mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-13 05:12:55 -07:00
feat(a2a): add value-resolver for auth credential resolution (#18653)
This commit is contained in:
@@ -363,4 +363,171 @@ Hidden`,
|
|||||||
expect(result.errors).toHaveLength(1);
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
DEFAULT_MAX_TURNS,
|
DEFAULT_MAX_TURNS,
|
||||||
DEFAULT_MAX_TIME_MINUTES,
|
DEFAULT_MAX_TIME_MINUTES,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
|
import type { A2AAuthConfig } from './auth-provider/types.js';
|
||||||
import { isValidToolName } from '../tools/tool-names.js';
|
import { isValidToolName } from '../tools/tool-names.js';
|
||||||
import { FRONTMATTER_REGEX } from '../skills/skillLoader.js';
|
import { FRONTMATTER_REGEX } from '../skills/skillLoader.js';
|
||||||
import { getErrorMessage } from '../utils/errors.js';
|
import { getErrorMessage } from '../utils/errors.js';
|
||||||
@@ -39,11 +40,29 @@ interface FrontmatterLocalAgentDefinition
|
|||||||
timeout_mins?: number;
|
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
|
interface FrontmatterRemoteAgentDefinition
|
||||||
extends FrontmatterBaseAgentDefinition {
|
extends FrontmatterBaseAgentDefinition {
|
||||||
kind: 'remote';
|
kind: 'remote';
|
||||||
description?: string;
|
description?: string;
|
||||||
agent_card_url: string;
|
agent_card_url: string;
|
||||||
|
auth?: FrontmatterAuthConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
type FrontmatterAgentDefinition =
|
type FrontmatterAgentDefinition =
|
||||||
@@ -95,6 +114,66 @@ const localAgentSchema = z
|
|||||||
})
|
})
|
||||||
.strict();
|
.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
|
const remoteAgentSchema = z
|
||||||
.object({
|
.object({
|
||||||
kind: z.literal('remote').optional().default('remote'),
|
kind: z.literal('remote').optional().default('remote'),
|
||||||
@@ -102,6 +181,7 @@ const remoteAgentSchema = z
|
|||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
display_name: z.string().optional(),
|
display_name: z.string().optional(),
|
||||||
agent_card_url: z.string().url(),
|
agent_card_url: z.string().url(),
|
||||||
|
auth: authConfigSchema.optional(),
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
@@ -238,6 +318,76 @@ export async function parseAgentMarkdown(
|
|||||||
return [agentDef];
|
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.
|
* Converts a FrontmatterAgentDefinition DTO to the internal AgentDefinition structure.
|
||||||
*
|
*
|
||||||
@@ -270,6 +420,9 @@ export function markdownToAgentDefinition(
|
|||||||
description: markdown.description || '(Loading description...)',
|
description: markdown.description || '(Loading description...)',
|
||||||
displayName: markdown.display_name,
|
displayName: markdown.display_name,
|
||||||
agentCardUrl: markdown.agent_card_url,
|
agentCardUrl: markdown.agent_card_url,
|
||||||
|
auth: markdown.auth
|
||||||
|
? convertFrontmatterAuthToConfig(markdown.auth)
|
||||||
|
: undefined,
|
||||||
inputConfig,
|
inputConfig,
|
||||||
metadata,
|
metadata,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,17 +9,33 @@ import type { A2AAuthProvider, A2AAuthProviderType } from './types.js';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Abstract base class for A2A authentication providers.
|
* Abstract base class for A2A authentication providers.
|
||||||
|
* Provides default implementations for optional methods.
|
||||||
*/
|
*/
|
||||||
export abstract class BaseA2AAuthProvider implements A2AAuthProvider {
|
export abstract class BaseA2AAuthProvider implements A2AAuthProvider {
|
||||||
|
/**
|
||||||
|
* The type of authentication provider.
|
||||||
|
*/
|
||||||
abstract readonly type: A2AAuthProviderType;
|
abstract readonly type: A2AAuthProviderType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the HTTP headers to include in requests.
|
||||||
|
* Subclasses must implement this method.
|
||||||
|
*/
|
||||||
abstract headers(): Promise<HttpHeaders>;
|
abstract headers(): Promise<HttpHeaders>;
|
||||||
|
|
||||||
private static readonly MAX_AUTH_RETRIES = 2;
|
private static readonly MAX_AUTH_RETRIES = 2;
|
||||||
private authRetryCount = 0;
|
private authRetryCount = 0;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Default: retry on 401/403 with fresh headers.
|
* Check if a request should be retried with new headers.
|
||||||
* Subclasses with cached tokens must override to force-refresh to avoid infinite retries.
|
*
|
||||||
|
* 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(
|
async shouldRetryWithHeaders(
|
||||||
_req: RequestInit,
|
_req: RequestInit,
|
||||||
@@ -32,10 +48,15 @@ export abstract class BaseA2AAuthProvider implements A2AAuthProvider {
|
|||||||
this.authRetryCount++;
|
this.authRetryCount++;
|
||||||
return this.headers();
|
return this.headers();
|
||||||
}
|
}
|
||||||
// Reset on success
|
// Reset count if not an auth error
|
||||||
this.authRetryCount = 0;
|
this.authRetryCount = 0;
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
async initialize(): Promise<void> {}
|
/**
|
||||||
|
* Initialize the provider. Override in subclasses that need async setup.
|
||||||
|
*/
|
||||||
|
async initialize(): Promise<void> {
|
||||||
|
// Default: no-op
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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('****');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string> {
|
||||||
|
// 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)}`;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user