From 4138667bae4259169114d4911fafa8496cc42a36 Mon Sep 17 00:00:00 2001 From: Adam Weidman <65992621+adamfweidman@users.noreply.github.com> Date: Wed, 11 Feb 2026 16:23:28 -0500 Subject: [PATCH] feat(a2a): add value-resolver for auth credential resolution (#18653) --- packages/core/src/agents/agentLoader.test.ts | 167 ++++++++++++++++++ packages/core/src/agents/agentLoader.ts | 153 ++++++++++++++++ .../src/agents/auth-provider/base-provider.ts | 29 ++- .../auth-provider/value-resolver.test.ts | 136 ++++++++++++++ .../agents/auth-provider/value-resolver.ts | 102 +++++++++++ 5 files changed, 583 insertions(+), 4 deletions(-) create mode 100644 packages/core/src/agents/auth-provider/value-resolver.test.ts create mode 100644 packages/core/src/agents/auth-provider/value-resolver.ts diff --git a/packages/core/src/agents/agentLoader.test.ts b/packages/core/src/agents/agentLoader.test.ts index 3649558b64..a54626b637 100644 --- a/packages/core/src/agents/agentLoader.test.ts +++ b/packages/core/src/agents/agentLoader.test.ts @@ -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, + }, + }); + }); + }); }); diff --git a/packages/core/src/agents/agentLoader.ts b/packages/core/src/agents/agentLoader.ts index 8d5e44b93c..cb2a605779 100644 --- a/packages/core/src/agents/agentLoader.ts +++ b/packages/core/src/agents/agentLoader.ts @@ -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'; @@ -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; + // 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 = @@ -95,6 +114,66 @@ 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'), @@ -102,6 +181,7 @@ const remoteAgentSchema = z description: z.string().optional(), display_name: z.string().optional(), agent_card_url: z.string().url(), + auth: authConfigSchema.optional(), }) .strict(); @@ -238,6 +318,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}`); + } + } +} + /** * Converts a FrontmatterAgentDefinition DTO to the internal AgentDefinition structure. * @@ -270,6 +420,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, }; diff --git a/packages/core/src/agents/auth-provider/base-provider.ts b/packages/core/src/agents/auth-provider/base-provider.ts index 7b21853a09..7fb2e61acc 100644 --- a/packages/core/src/agents/auth-provider/base-provider.ts +++ b/packages/core/src/agents/auth-provider/base-provider.ts @@ -9,17 +9,33 @@ import type { A2AAuthProvider, A2AAuthProviderType } from './types.js'; /** * Abstract base class for A2A authentication providers. + * Provides default implementations for optional methods. */ export abstract class BaseA2AAuthProvider implements A2AAuthProvider { + /** + * The type of authentication provider. + */ abstract readonly type: A2AAuthProviderType; + + /** + * Get the HTTP headers to include in requests. + * Subclasses must implement this method. + */ abstract headers(): Promise; private static readonly MAX_AUTH_RETRIES = 2; private authRetryCount = 0; /** - * Default: retry on 401/403 with fresh headers. - * Subclasses with cached tokens must override to force-refresh to avoid infinite retries. + * Check if a request should be retried with new headers. + * + * The default implementation checks for 401/403 status codes and + * returns fresh headers for retry. Subclasses can override for + * custom retry logic. + * + * @param _req The original request init + * @param res The response from the server + * @returns New headers for retry, or undefined if no retry should be made */ async shouldRetryWithHeaders( _req: RequestInit, @@ -32,10 +48,15 @@ export abstract class BaseA2AAuthProvider implements A2AAuthProvider { this.authRetryCount++; return this.headers(); } - // Reset on success + // Reset count if not an auth error this.authRetryCount = 0; return undefined; } - async initialize(): Promise {} + /** + * Initialize the provider. Override in subclasses that need async setup. + */ + async initialize(): Promise { + // Default: no-op + } } diff --git a/packages/core/src/agents/auth-provider/value-resolver.test.ts b/packages/core/src/agents/auth-provider/value-resolver.test.ts new file mode 100644 index 0000000000..58aa84c077 --- /dev/null +++ b/packages/core/src/agents/auth-provider/value-resolver.test.ts @@ -0,0 +1,136 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { + resolveAuthValue, + needsResolution, + maskSensitiveValue, +} from './value-resolver.js'; + +describe('value-resolver', () => { + describe('resolveAuthValue', () => { + describe('environment variables', () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('should resolve environment variable with $ prefix', async () => { + vi.stubEnv('TEST_API_KEY', 'secret-key-123'); + const result = await resolveAuthValue('$TEST_API_KEY'); + expect(result).toBe('secret-key-123'); + }); + + it('should throw error for unset environment variable', async () => { + await expect(resolveAuthValue('$UNSET_VAR_12345')).rejects.toThrow( + "Environment variable 'UNSET_VAR_12345' is not set or is empty", + ); + }); + + it('should throw error for empty environment variable', async () => { + vi.stubEnv('EMPTY_VAR', ''); + await expect(resolveAuthValue('$EMPTY_VAR')).rejects.toThrow( + "Environment variable 'EMPTY_VAR' is not set or is empty", + ); + }); + }); + + describe('shell commands', () => { + it('should execute shell command with ! prefix', async () => { + const result = await resolveAuthValue('!echo hello'); + expect(result).toBe('hello'); + }); + + it('should trim whitespace from command output', async () => { + const result = await resolveAuthValue('!echo " hello "'); + expect(result).toBe('hello'); + }); + + it('should throw error for empty command', async () => { + await expect(resolveAuthValue('!')).rejects.toThrow( + 'Empty command in auth value', + ); + }); + + it('should throw error for command that returns empty output', async () => { + await expect(resolveAuthValue('!echo -n ""')).rejects.toThrow( + 'returned empty output', + ); + }); + + it('should throw error for failed command', async () => { + await expect( + resolveAuthValue('!nonexistent-command-12345'), + ).rejects.toThrow(/Command.*failed/); + }); + }); + + describe('literal values', () => { + it('should return literal value as-is', async () => { + const result = await resolveAuthValue('literal-api-key'); + expect(result).toBe('literal-api-key'); + }); + + it('should return empty string as-is', async () => { + const result = await resolveAuthValue(''); + expect(result).toBe(''); + }); + + it('should not treat values starting with other characters as special', async () => { + const result = await resolveAuthValue('api-key-123'); + expect(result).toBe('api-key-123'); + }); + }); + + describe('escaped literals', () => { + it('should return $ literal when value starts with $$', async () => { + const result = await resolveAuthValue('$$LITERAL'); + expect(result).toBe('$LITERAL'); + }); + + it('should return ! literal when value starts with !!', async () => { + const result = await resolveAuthValue('!!not-a-command'); + expect(result).toBe('!not-a-command'); + }); + }); + }); + + describe('needsResolution', () => { + it('should return true for environment variable reference', () => { + expect(needsResolution('$ENV_VAR')).toBe(true); + }); + + it('should return true for command reference', () => { + expect(needsResolution('!command')).toBe(true); + }); + + it('should return false for literal value', () => { + expect(needsResolution('literal')).toBe(false); + }); + + it('should return false for empty string', () => { + expect(needsResolution('')).toBe(false); + }); + }); + + describe('maskSensitiveValue', () => { + it('should mask value longer than 12 characters', () => { + expect(maskSensitiveValue('1234567890abcd')).toBe('12****cd'); + }); + + it('should return **** for short values', () => { + expect(maskSensitiveValue('short')).toBe('****'); + }); + + it('should return **** for exactly 12 characters', () => { + expect(maskSensitiveValue('123456789012')).toBe('****'); + }); + + it('should return **** for empty string', () => { + expect(maskSensitiveValue('')).toBe('****'); + }); + }); +}); diff --git a/packages/core/src/agents/auth-provider/value-resolver.ts b/packages/core/src/agents/auth-provider/value-resolver.ts new file mode 100644 index 0000000000..c349a57498 --- /dev/null +++ b/packages/core/src/agents/auth-provider/value-resolver.ts @@ -0,0 +1,102 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { debugLogger } from '../../utils/debugLogger.js'; +import { getShellConfiguration, spawnAsync } from '../../utils/shell-utils.js'; + +const COMMAND_TIMEOUT_MS = 60_000; + +/** + * Resolves a value that may be an environment variable reference, + * a shell command, or a literal value. + * + * Supported formats: + * - `$ENV_VAR`: Read from environment variable + * - `!command`: Execute shell command and use output (trimmed) + * - `$$` or `!!`: Escape prefix, returns rest as literal + * - Any other string: Use as literal value + * + * @param value The value to resolve + * @returns The resolved value + * @throws Error if environment variable is not set or command fails + */ +export async function resolveAuthValue(value: string): Promise { + // Support escaping with double prefix (e.g. $$ or !!). + // Strips one prefix char: $$FOO → $FOO, !!cmd → !cmd (literal, not resolved). + if (value.startsWith('$$') || value.startsWith('!!')) { + return value.slice(1); + } + + // Environment variable: $MY_VAR + if (value.startsWith('$')) { + const envVar = value.slice(1); + const resolved = process.env[envVar]; + if (resolved === undefined || resolved === '') { + throw new Error( + `Environment variable '${envVar}' is not set or is empty. ` + + `Please set it before using this agent.`, + ); + } + debugLogger.debug(`[AuthValueResolver] Resolved env var: ${envVar}`); + return resolved; + } + + // Shell command: !command arg1 arg2 + if (value.startsWith('!')) { + const command = value.slice(1).trim(); + if (!command) { + throw new Error('Empty command in auth value. Expected format: !command'); + } + + debugLogger.debug(`[AuthValueResolver] Executing command for auth value`); + + const shellConfig = getShellConfiguration(); + try { + const { stdout } = await spawnAsync( + shellConfig.executable, + [...shellConfig.argsPrefix, command], + { + signal: AbortSignal.timeout(COMMAND_TIMEOUT_MS), + windowsHide: true, + }, + ); + + const trimmed = stdout.trim(); + if (!trimmed) { + throw new Error(`Command '${command}' returned empty output`); + } + return trimmed; + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + throw new Error( + `Command '${command}' timed out after ${COMMAND_TIMEOUT_MS / 1000} seconds`, + ); + } + throw error; + } + } + + // Literal value - return as-is + return value; +} + +/** + * Check if a value needs resolution (is an env var or command reference). + */ +export function needsResolution(value: string): boolean { + return value.startsWith('$') || value.startsWith('!'); +} + +/** + * Mask a sensitive value for logging purposes. + * Shows the first and last 2 characters with asterisks in between. + */ +export function maskSensitiveValue(value: string): string { + if (value.length <= 12) { + return '****'; + } + return `${value.slice(0, 2)}****${value.slice(-2)}`; +}