Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
167 changes: 167 additions & 0 deletions packages/core/src/agents/agentLoader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -363,4 +363,171 @@ Hidden`,
expect(result.errors).toHaveLength(1);
});
});

describe('remote agent auth configuration', () => {
it('should parse remote agent with apiKey auth', async () => {
const filePath = await writeAgentMarkdown(`---
kind: remote
name: api-key-agent
agent_card_url: https://example.com/card
auth:
type: apiKey
key: $MY_API_KEY
in: header
name: X-Custom-Key
---
`);
const result = await parseAgentMarkdown(filePath);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
kind: 'remote',
name: 'api-key-agent',
auth: {
type: 'apiKey',
key: '$MY_API_KEY',
in: 'header',
name: 'X-Custom-Key',
},
});
});

it('should parse remote agent with http Bearer auth', async () => {
const filePath = await writeAgentMarkdown(`---
kind: remote
name: bearer-agent
agent_card_url: https://example.com/card
auth:
type: http
scheme: Bearer
token: $BEARER_TOKEN
---
`);
const result = await parseAgentMarkdown(filePath);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
kind: 'remote',
name: 'bearer-agent',
auth: {
type: 'http',
scheme: 'Bearer',
token: '$BEARER_TOKEN',
},
});
});

it('should parse remote agent with http Basic auth', async () => {
const filePath = await writeAgentMarkdown(`---
kind: remote
name: basic-agent
agent_card_url: https://example.com/card
auth:
type: http
scheme: Basic
username: $AUTH_USER
password: $AUTH_PASS
---
`);
const result = await parseAgentMarkdown(filePath);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
kind: 'remote',
name: 'basic-agent',
auth: {
type: 'http',
scheme: 'Basic',
username: '$AUTH_USER',
password: '$AUTH_PASS',
},
});
});

it('should throw error for Bearer auth without token', async () => {
const filePath = await writeAgentMarkdown(`---
kind: remote
name: invalid-bearer
agent_card_url: https://example.com/card
auth:
type: http
scheme: Bearer
---
`);
await expect(parseAgentMarkdown(filePath)).rejects.toThrow(
/Bearer scheme requires "token"/,
);
});

it('should throw error for Basic auth without credentials', async () => {
const filePath = await writeAgentMarkdown(`---
kind: remote
name: invalid-basic
agent_card_url: https://example.com/card
auth:
type: http
scheme: Basic
username: user
---
`);
await expect(parseAgentMarkdown(filePath)).rejects.toThrow(
/Basic scheme requires "username" and "password"/,
);
});

it('should throw error for apiKey auth without key', async () => {
const filePath = await writeAgentMarkdown(`---
kind: remote
name: invalid-apikey
agent_card_url: https://example.com/card
auth:
type: apiKey
---
`);
await expect(parseAgentMarkdown(filePath)).rejects.toThrow(
/auth\.key.*Required/,
);
});

it('should convert auth config in markdownToAgentDefinition', () => {
const markdown = {
kind: 'remote' as const,
name: 'auth-agent',
agent_card_url: 'https://example.com/card',
auth: {
type: 'apiKey' as const,
key: '$API_KEY',
in: 'header' as const,
},
};

const result = markdownToAgentDefinition(markdown);
expect(result).toMatchObject({
kind: 'remote',
name: 'auth-agent',
auth: {
type: 'apiKey',
key: '$API_KEY',
location: 'header',
},
});
});

it('should parse auth with agent_card_requires_auth flag', async () => {
const filePath = await writeAgentMarkdown(`---
kind: remote
name: protected-card-agent
agent_card_url: https://example.com/card
auth:
type: apiKey
key: $MY_API_KEY
agent_card_requires_auth: true
---
`);
const result = await parseAgentMarkdown(filePath);
expect(result[0]).toMatchObject({
auth: {
type: 'apiKey',
agent_card_requires_auth: true,
},
});
});
});
});
153 changes: 153 additions & 0 deletions packages/core/src/agents/agentLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
DEFAULT_MAX_TURNS,
DEFAULT_MAX_TIME_MINUTES,
} from './types.js';
import type { A2AAuthConfig } from './auth-provider/types.js';
import { isValidToolName } from '../tools/tool-names.js';
import { FRONTMATTER_REGEX } from '../skills/skillLoader.js';
import { getErrorMessage } from '../utils/errors.js';
Expand All @@ -39,11 +40,29 @@ interface FrontmatterLocalAgentDefinition
timeout_mins?: number;
}

/**
* Authentication configuration for remote agents in frontmatter format.
*/
interface FrontmatterAuthConfig {
type: 'apiKey' | 'http';
agent_card_requires_auth?: boolean;
Comment thread
adamfweidman marked this conversation as resolved.
// API Key
key?: string;
in?: 'header' | 'query' | 'cookie';
name?: string;
// HTTP
scheme?: 'Bearer' | 'Basic';
token?: string;
username?: string;
password?: string;
}

interface FrontmatterRemoteAgentDefinition
extends FrontmatterBaseAgentDefinition {
kind: 'remote';
description?: string;
agent_card_url: string;
auth?: FrontmatterAuthConfig;
}

type FrontmatterAgentDefinition =
Expand Down Expand Up @@ -95,13 +114,74 @@ const localAgentSchema = z
})
.strict();

/**
* Base fields shared by all auth configs.
*/
const baseAuthFields = {
agent_card_requires_auth: z.boolean().optional(),
};

/**
* API Key auth schema.
* Supports sending key in header, query parameter, or cookie.
*/
const apiKeyAuthSchema = z.object({
...baseAuthFields,
type: z.literal('apiKey'),
key: z.string().min(1, 'API key is required'),
in: z.enum(['header', 'query', 'cookie']).optional(),
name: z.string().optional(),
});

/**
* HTTP auth schema (Bearer or Basic).
* Note: Validation for scheme-specific fields is applied in authConfigSchema
* since discriminatedUnion doesn't support refined schemas directly.
*/
const httpAuthSchemaBase = z.object({
...baseAuthFields,
type: z.literal('http'),
scheme: z.enum(['Bearer', 'Basic']),
token: z.string().optional(),
username: z.string().optional(),
password: z.string().optional(),
});

/**
* Combined auth schema - discriminated union of all auth types.
* Note: We use the base schema for discriminatedUnion, then apply refinements
* via superRefine since discriminatedUnion doesn't support refined schemas directly.
*/
const authConfigSchema = z
.discriminatedUnion('type', [apiKeyAuthSchema, httpAuthSchemaBase])
.superRefine((data, ctx) => {
// Apply HTTP auth validation after union parsing
if (data.type === 'http') {
if (data.scheme === 'Bearer' && !data.token) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Bearer scheme requires "token"',
path: ['token'],
});
}
if (data.scheme === 'Basic' && (!data.username || !data.password)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Basic scheme requires "username" and "password"',
path: data.username ? ['password'] : ['username'],
});
}
}
});

const remoteAgentSchema = z
.object({
kind: z.literal('remote').optional().default('remote'),
name: nameSchema,
description: z.string().optional(),
display_name: z.string().optional(),
agent_card_url: z.string().url(),
auth: authConfigSchema.optional(),
})
.strict();

Expand Down Expand Up @@ -237,6 +317,76 @@ export async function parseAgentMarkdown(
return [agentDef];
}

/**
* Converts frontmatter auth config to the internal A2AAuthConfig type.
* This handles the mapping from snake_case YAML to the internal type structure.
*/
function convertFrontmatterAuthToConfig(
frontmatter: FrontmatterAuthConfig,
): A2AAuthConfig {
const base = {
agent_card_requires_auth: frontmatter.agent_card_requires_auth,
};

switch (frontmatter.type) {
case 'apiKey':
if (!frontmatter.key) {
throw new Error('Internal error: API key missing after validation.');
}
return {
...base,
type: 'apiKey',
key: frontmatter.key,
location: frontmatter.in,
name: frontmatter.name,
};

case 'http': {
if (!frontmatter.scheme) {
throw new Error(
'Internal error: HTTP scheme missing after validation.',
);
}
switch (frontmatter.scheme) {
case 'Bearer':
if (!frontmatter.token) {
throw new Error(
'Internal error: Bearer token missing after validation.',
);
}
return {
...base,
type: 'http',
scheme: 'Bearer',
token: frontmatter.token,
};
case 'Basic':
if (!frontmatter.username || !frontmatter.password) {
throw new Error(
'Internal error: Basic auth credentials missing after validation.',
);
}
return {
...base,
type: 'http',
scheme: 'Basic',
username: frontmatter.username,
password: frontmatter.password,
};
default: {
const exhaustive: never = frontmatter.scheme;
throw new Error(`Unknown HTTP scheme: ${exhaustive}`);
}
}
}

default: {
const exhaustive: never = frontmatter.type;
throw new Error(`Unknown auth type: ${exhaustive}`);
}
}
}
Comment thread
adamfweidman marked this conversation as resolved.

/**
* Converts a FrontmatterAgentDefinition DTO to the internal AgentDefinition structure.
*
Expand Down Expand Up @@ -269,6 +419,9 @@ export function markdownToAgentDefinition(
description: markdown.description || '(Loading description...)',
displayName: markdown.display_name,
agentCardUrl: markdown.agent_card_url,
auth: markdown.auth
? convertFrontmatterAuthToConfig(markdown.auth)
: undefined,
inputConfig,
metadata,
};
Expand Down
Loading
Loading