mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-25 17:21:01 -07:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4163039f1f | |||
| 1112c0f966 | |||
| e4bc4a6cf5 | |||
| 6505ab8c6c | |||
| 3e87f8588e | |||
| fe4bd24f4e | |||
| fb60eecba0 | |||
| a42fdee8a8 | |||
| dae8d85a18 |
Generated
+43
-8
@@ -84,9 +84,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@a2a-js/sdk": {
|
||||
"version": "0.3.8",
|
||||
"resolved": "https://registry.npmjs.org/@a2a-js/sdk/-/sdk-0.3.8.tgz",
|
||||
"integrity": "sha512-vAg6JQbhOnHTzApsB7nGzCQ9r7PuY4GMr8gt88dIR8Wc8G8RSqVTyTmFeMurgzcYrtHYXS3ru2rnDoGj9UDeSw==",
|
||||
"version": "0.3.10",
|
||||
"resolved": "https://registry.npmjs.org/@a2a-js/sdk/-/sdk-0.3.10.tgz",
|
||||
"integrity": "sha512-t6w5ctnwJkSOMRl6M9rn95C1FTHCPqixxMR0yWXtzhZXEnF6mF1NAK0CfKlG3cz+tcwTxkmn287QZC3t9XPgrA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"uuid": "^11.1.0"
|
||||
@@ -95,9 +95,17 @@
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@bufbuild/protobuf": "^2.10.2",
|
||||
"@grpc/grpc-js": "^1.11.0",
|
||||
"express": "^4.21.2 || ^5.1.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@bufbuild/protobuf": {
|
||||
"optional": true
|
||||
},
|
||||
"@grpc/grpc-js": {
|
||||
"optional": true
|
||||
},
|
||||
"express": {
|
||||
"optional": true
|
||||
}
|
||||
@@ -515,6 +523,12 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@bufbuild/protobuf": {
|
||||
"version": "2.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.11.0.tgz",
|
||||
"integrity": "sha512-sBXGT13cpmPR5BMgHE6UEEfEaShh5Ror6rfN3yEK5si7QVrtZg8LEPQb0VVhiLRUslD2yLnXtnRzG035J/mZXQ==",
|
||||
"license": "(Apache-2.0 AND BSD-3-Clause)"
|
||||
},
|
||||
"node_modules/@bundled-es-modules/cookie": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@bundled-es-modules/cookie/-/cookie-2.0.1.tgz",
|
||||
@@ -1582,18 +1596,36 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@grpc/grpc-js": {
|
||||
"version": "1.13.4",
|
||||
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.13.4.tgz",
|
||||
"integrity": "sha512-GsFaMXCkMqkKIvwCQjCrwH+GHbPKBjhwo/8ZuUkWHqbI73Kky9I+pQltrlT0+MWpedCoosda53lgjYfyEPgxBg==",
|
||||
"version": "1.14.3",
|
||||
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz",
|
||||
"integrity": "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@grpc/proto-loader": "^0.7.13",
|
||||
"@grpc/proto-loader": "^0.8.0",
|
||||
"@js-sdsl/ordered-map": "^4.4.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@grpc/grpc-js/node_modules/@grpc/proto-loader": {
|
||||
"version": "0.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.0.tgz",
|
||||
"integrity": "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"lodash.camelcase": "^4.3.0",
|
||||
"long": "^5.0.0",
|
||||
"protobufjs": "^7.5.3",
|
||||
"yargs": "^17.7.2"
|
||||
},
|
||||
"bin": {
|
||||
"proto-loader-gen-types": "build/bin/proto-loader-gen-types.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/@grpc/proto-loader": {
|
||||
"version": "0.7.15",
|
||||
"resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz",
|
||||
@@ -17447,11 +17479,13 @@
|
||||
"version": "0.34.0-nightly.20260304.28af4e127",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@a2a-js/sdk": "^0.3.8",
|
||||
"@a2a-js/sdk": "^0.3.10",
|
||||
"@bufbuild/protobuf": "^2.11.0",
|
||||
"@google-cloud/logging": "^11.2.1",
|
||||
"@google-cloud/opentelemetry-cloud-monitoring-exporter": "^0.21.0",
|
||||
"@google-cloud/opentelemetry-cloud-trace-exporter": "^3.0.0",
|
||||
"@google/genai": "1.41.0",
|
||||
"@grpc/grpc-js": "^1.14.3",
|
||||
"@iarna/toml": "^2.2.5",
|
||||
"@joshua.litt/get-ripgrep": "^0.0.3",
|
||||
"@modelcontextprotocol/sdk": "^1.23.0",
|
||||
@@ -17489,6 +17523,7 @@
|
||||
"html-to-text": "^9.0.5",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"ignore": "^7.0.0",
|
||||
"ipaddr.js": "^1.9.1",
|
||||
"js-yaml": "^4.1.1",
|
||||
"marked": "^15.0.12",
|
||||
"mime": "4.0.7",
|
||||
|
||||
@@ -316,6 +316,35 @@ describe('loadConfig', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass agent settings to Config', async () => {
|
||||
const settings: Settings = {
|
||||
experimental: {
|
||||
enableAgents: false,
|
||||
},
|
||||
agents: {
|
||||
overrides: {
|
||||
test_agent: { enabled: true },
|
||||
},
|
||||
},
|
||||
};
|
||||
await loadConfig(settings, mockExtensionLoader, taskId);
|
||||
expect(Config).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
enableAgents: false,
|
||||
agents: settings.agents,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should default enableAgents to true if not specified', async () => {
|
||||
await loadConfig(mockSettings, mockExtensionLoader, taskId);
|
||||
expect(Config).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
enableAgents: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
describe('interactivity', () => {
|
||||
it('should set interactive true when not headless', async () => {
|
||||
vi.mocked(isHeadlessMode).mockReturnValue(false);
|
||||
|
||||
@@ -109,6 +109,8 @@ export async function loadConfig(
|
||||
interactive: !isHeadlessMode(),
|
||||
enableInteractiveShell: !isHeadlessMode(),
|
||||
ptyInfo: 'auto',
|
||||
enableAgents: settings.experimental?.enableAgents ?? true,
|
||||
agents: settings.agents,
|
||||
};
|
||||
|
||||
const fileService = new FileDiscoveryService(workspaceDir, {
|
||||
|
||||
@@ -112,6 +112,24 @@ describe('loadSettings', () => {
|
||||
expect(result.fileFiltering?.respectGitIgnore).toBe(true);
|
||||
});
|
||||
|
||||
it('should load experimental and agents settings correctly', () => {
|
||||
const settings = {
|
||||
experimental: {
|
||||
enableAgents: true,
|
||||
},
|
||||
agents: {
|
||||
overrides: {
|
||||
test_agent: { enabled: false },
|
||||
},
|
||||
},
|
||||
};
|
||||
fs.writeFileSync(USER_SETTINGS_PATH, JSON.stringify(settings));
|
||||
|
||||
const result = loadSettings(mockWorkspaceDir);
|
||||
expect(result.experimental?.enableAgents).toBe(true);
|
||||
expect(result.agents?.overrides?.['test_agent']?.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('should overwrite top-level settings from workspace (shallow merge)', () => {
|
||||
const userSettings = {
|
||||
showMemoryUsage: false,
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
getErrorMessage,
|
||||
type TelemetrySettings,
|
||||
homedir,
|
||||
type AgentSettings,
|
||||
} from '@google/gemini-cli-core';
|
||||
import stripJsonComments from 'strip-json-comments';
|
||||
|
||||
@@ -45,6 +46,10 @@ export interface Settings {
|
||||
enableRecursiveFileSearch?: boolean;
|
||||
customIgnoreFilePaths?: string[];
|
||||
};
|
||||
experimental?: {
|
||||
enableAgents?: boolean;
|
||||
};
|
||||
agents?: AgentSettings;
|
||||
}
|
||||
|
||||
export interface SettingsError {
|
||||
|
||||
@@ -21,11 +21,13 @@
|
||||
"dist"
|
||||
],
|
||||
"dependencies": {
|
||||
"@a2a-js/sdk": "^0.3.8",
|
||||
"@a2a-js/sdk": "^0.3.10",
|
||||
"@bufbuild/protobuf": "^2.11.0",
|
||||
"@google-cloud/logging": "^11.2.1",
|
||||
"@google-cloud/opentelemetry-cloud-monitoring-exporter": "^0.21.0",
|
||||
"@google-cloud/opentelemetry-cloud-trace-exporter": "^3.0.0",
|
||||
"@google/genai": "1.41.0",
|
||||
"@grpc/grpc-js": "^1.14.3",
|
||||
"@iarna/toml": "^2.2.5",
|
||||
"@joshua.litt/get-ripgrep": "^0.0.3",
|
||||
"@modelcontextprotocol/sdk": "^1.23.0",
|
||||
@@ -63,6 +65,7 @@
|
||||
"html-to-text": "^9.0.5",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"ignore": "^7.0.0",
|
||||
"ipaddr.js": "^1.9.1",
|
||||
"js-yaml": "^4.1.1",
|
||||
"marked": "^15.0.12",
|
||||
"mime": "4.0.7",
|
||||
|
||||
@@ -5,96 +5,119 @@
|
||||
*/
|
||||
|
||||
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
A2AClientManager,
|
||||
type SendMessageResult,
|
||||
} from './a2a-client-manager.js';
|
||||
import type { AgentCard, Task } from '@a2a-js/sdk';
|
||||
import {
|
||||
ClientFactory,
|
||||
DefaultAgentCardResolver,
|
||||
createAuthenticatingFetchWithRetry,
|
||||
ClientFactoryOptions,
|
||||
type AuthenticationHandler,
|
||||
type Client,
|
||||
} from '@a2a-js/sdk/client';
|
||||
import { A2AClientManager } from './a2a-client-manager.js';
|
||||
import type { AgentCard } from '@a2a-js/sdk';
|
||||
import * as sdkClient from '@a2a-js/sdk/client';
|
||||
import * as dnsPromises from 'node:dns/promises';
|
||||
import type { LookupOptions } from 'node:dns';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
|
||||
interface MockClient {
|
||||
sendMessageStream: ReturnType<typeof vi.fn>;
|
||||
getTask: ReturnType<typeof vi.fn>;
|
||||
cancelTask: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
vi.mock('@a2a-js/sdk/client', async (importOriginal) => {
|
||||
const actual = await importOriginal();
|
||||
return {
|
||||
...(actual as Record<string, unknown>),
|
||||
createAuthenticatingFetchWithRetry: vi.fn(),
|
||||
ClientFactory: vi.fn(),
|
||||
DefaultAgentCardResolver: vi.fn(),
|
||||
ClientFactoryOptions: {
|
||||
createFrom: vi.fn(),
|
||||
default: {},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../utils/debugLogger.js', () => ({
|
||||
debugLogger: {
|
||||
debug: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@a2a-js/sdk/client', () => {
|
||||
const ClientFactory = vi.fn();
|
||||
const DefaultAgentCardResolver = vi.fn();
|
||||
const RestTransportFactory = vi.fn();
|
||||
const JsonRpcTransportFactory = vi.fn();
|
||||
const ClientFactoryOptions = {
|
||||
default: {},
|
||||
createFrom: vi.fn(),
|
||||
};
|
||||
const createAuthenticatingFetchWithRetry = vi.fn();
|
||||
|
||||
DefaultAgentCardResolver.prototype.resolve = vi.fn();
|
||||
ClientFactory.prototype.createFromUrl = vi.fn();
|
||||
|
||||
return {
|
||||
ClientFactory,
|
||||
ClientFactoryOptions,
|
||||
DefaultAgentCardResolver,
|
||||
RestTransportFactory,
|
||||
JsonRpcTransportFactory,
|
||||
createAuthenticatingFetchWithRetry,
|
||||
};
|
||||
});
|
||||
vi.mock('node:dns/promises', () => ({
|
||||
lookup: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('A2AClientManager', () => {
|
||||
let manager: A2AClientManager;
|
||||
const mockAgentCard: AgentCard = {
|
||||
name: 'test-agent',
|
||||
description: 'A test agent',
|
||||
url: 'http://test.agent',
|
||||
version: '1.0.0',
|
||||
protocolVersion: '0.1.0',
|
||||
capabilities: {},
|
||||
skills: [],
|
||||
defaultInputModes: [],
|
||||
defaultOutputModes: [],
|
||||
};
|
||||
|
||||
const mockClient: MockClient = {
|
||||
sendMessageStream: vi.fn(),
|
||||
getTask: vi.fn(),
|
||||
cancelTask: vi.fn(),
|
||||
};
|
||||
|
||||
// Stable mocks initialized once
|
||||
const sendMessageStreamMock = vi.fn();
|
||||
const getTaskMock = vi.fn();
|
||||
const cancelTaskMock = vi.fn();
|
||||
const getAgentCardMock = vi.fn();
|
||||
const authFetchMock = vi.fn();
|
||||
|
||||
const mockClient = {
|
||||
sendMessageStream: sendMessageStreamMock,
|
||||
getTask: getTaskMock,
|
||||
cancelTask: cancelTaskMock,
|
||||
getAgentCard: getAgentCardMock,
|
||||
} as unknown as Client;
|
||||
|
||||
const mockAgentCard: Partial<AgentCard> = { name: 'TestAgent' };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
A2AClientManager.resetInstanceForTesting();
|
||||
manager = A2AClientManager.getInstance();
|
||||
manager.clearCache();
|
||||
|
||||
// Default mock implementations
|
||||
getAgentCardMock.mockResolvedValue({
|
||||
// Default DNS mock: resolve to public IP.
|
||||
// Use any cast only for return value due to complex multi-signature overloads.
|
||||
vi.mocked(dnsPromises.lookup).mockImplementation(
|
||||
async (_h: string, options?: LookupOptions | number) => {
|
||||
const addr = { address: '93.184.216.34', family: 4 };
|
||||
const isAll = typeof options === 'object' && options?.all;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return (isAll ? [addr] : addr) as any;
|
||||
},
|
||||
);
|
||||
|
||||
// Re-create the instances as plain objects that can be spied on
|
||||
const factoryInstance = {
|
||||
createFromUrl: vi.fn(),
|
||||
createFromAgentCard: vi.fn(),
|
||||
};
|
||||
const resolverInstance = {
|
||||
resolve: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mocked(sdkClient.ClientFactory).mockReturnValue(
|
||||
factoryInstance as unknown as sdkClient.ClientFactory,
|
||||
);
|
||||
vi.mocked(sdkClient.DefaultAgentCardResolver).mockReturnValue(
|
||||
resolverInstance as unknown as sdkClient.DefaultAgentCardResolver,
|
||||
);
|
||||
|
||||
vi.spyOn(factoryInstance, 'createFromUrl').mockResolvedValue(
|
||||
mockClient as unknown as sdkClient.Client,
|
||||
);
|
||||
vi.spyOn(factoryInstance, 'createFromAgentCard').mockResolvedValue(
|
||||
mockClient as unknown as sdkClient.Client,
|
||||
);
|
||||
vi.spyOn(resolverInstance, 'resolve').mockResolvedValue({
|
||||
...mockAgentCard,
|
||||
url: 'http://test.agent/real/endpoint',
|
||||
} as AgentCard);
|
||||
|
||||
vi.mocked(ClientFactory.prototype.createFromUrl).mockResolvedValue(
|
||||
mockClient,
|
||||
vi.spyOn(sdkClient.ClientFactoryOptions, 'createFrom').mockImplementation(
|
||||
(_defaults, overrides) =>
|
||||
overrides as unknown as sdkClient.ClientFactoryOptions,
|
||||
);
|
||||
|
||||
vi.mocked(DefaultAgentCardResolver.prototype.resolve).mockResolvedValue({
|
||||
...mockAgentCard,
|
||||
url: 'http://test.agent/real/endpoint',
|
||||
} as AgentCard);
|
||||
|
||||
vi.mocked(ClientFactoryOptions.createFrom).mockImplementation(
|
||||
(_defaults, overrides) => overrides as ClientFactoryOptions,
|
||||
);
|
||||
|
||||
vi.mocked(createAuthenticatingFetchWithRetry).mockReturnValue(
|
||||
authFetchMock,
|
||||
vi.mocked(sdkClient.createAuthenticatingFetchWithRetry).mockImplementation(
|
||||
() =>
|
||||
authFetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({}),
|
||||
} as Response),
|
||||
);
|
||||
|
||||
vi.stubGlobal(
|
||||
@@ -123,137 +146,194 @@ describe('A2AClientManager', () => {
|
||||
'TestAgent',
|
||||
'http://test.agent/card',
|
||||
);
|
||||
expect(agentCard).toMatchObject(mockAgentCard);
|
||||
expect(manager.getAgentCard('TestAgent')).toBe(agentCard);
|
||||
expect(manager.getClient('TestAgent')).toBeDefined();
|
||||
});
|
||||
|
||||
it('should configure ClientFactory with REST, JSON-RPC, and gRPC transports', async () => {
|
||||
await manager.loadAgent('TestAgent', 'http://test.agent/card');
|
||||
expect(sdkClient.ClientFactoryOptions.createFrom).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw an error if an agent with the same name is already loaded', async () => {
|
||||
await manager.loadAgent('TestAgent', 'http://test.agent/card');
|
||||
await expect(
|
||||
manager.loadAgent('TestAgent', 'http://another.agent/card'),
|
||||
manager.loadAgent('TestAgent', 'http://test.agent/card'),
|
||||
).rejects.toThrow("Agent with name 'TestAgent' is already loaded.");
|
||||
});
|
||||
|
||||
it('should use native fetch by default', async () => {
|
||||
await manager.loadAgent('TestAgent', 'http://test.agent/card');
|
||||
expect(createAuthenticatingFetchWithRetry).not.toHaveBeenCalled();
|
||||
expect(
|
||||
sdkClient.createAuthenticatingFetchWithRetry,
|
||||
).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should use provided custom authentication handler', async () => {
|
||||
const customAuthHandler = {
|
||||
headers: vi.fn(),
|
||||
shouldRetryWithHeaders: vi.fn(),
|
||||
const authHandler: sdkClient.AuthenticationHandler = {
|
||||
headers: async () => ({}),
|
||||
shouldRetryWithHeaders: async () => undefined,
|
||||
};
|
||||
await manager.loadAgent(
|
||||
'CustomAuthAgent',
|
||||
'http://custom.agent/card',
|
||||
customAuthHandler as unknown as AuthenticationHandler,
|
||||
);
|
||||
|
||||
expect(createAuthenticatingFetchWithRetry).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
customAuthHandler,
|
||||
'TestAgent',
|
||||
'http://test.agent/card',
|
||||
authHandler,
|
||||
);
|
||||
expect(sdkClient.createAuthenticatingFetchWithRetry).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should log a debug message upon loading an agent', async () => {
|
||||
await manager.loadAgent('TestAgent', 'http://test.agent/card');
|
||||
expect(debugLogger.debug).toHaveBeenCalledWith(
|
||||
"[A2AClientManager] Loaded agent 'TestAgent' from http://test.agent/card",
|
||||
expect.stringContaining("Loaded agent 'TestAgent'"),
|
||||
);
|
||||
});
|
||||
|
||||
it('should clear the cache', async () => {
|
||||
await manager.loadAgent('TestAgent', 'http://test.agent/card');
|
||||
expect(manager.getAgentCard('TestAgent')).toBeDefined();
|
||||
expect(manager.getClient('TestAgent')).toBeDefined();
|
||||
|
||||
manager.clearCache();
|
||||
|
||||
expect(manager.getAgentCard('TestAgent')).toBeUndefined();
|
||||
expect(manager.getClient('TestAgent')).toBeUndefined();
|
||||
expect(debugLogger.debug).toHaveBeenCalledWith(
|
||||
'[A2AClientManager] Cache cleared.',
|
||||
});
|
||||
|
||||
it('should throw if resolveAgentCard fails', async () => {
|
||||
const resolverInstance = {
|
||||
resolve: vi.fn().mockRejectedValue(new Error('Resolution failed')),
|
||||
};
|
||||
vi.mocked(sdkClient.DefaultAgentCardResolver).mockReturnValue(
|
||||
resolverInstance as unknown as sdkClient.DefaultAgentCardResolver,
|
||||
);
|
||||
|
||||
await expect(
|
||||
manager.loadAgent('FailAgent', 'http://fail.agent'),
|
||||
).rejects.toThrow('Resolution failed');
|
||||
});
|
||||
|
||||
it('should throw if factory.createFromAgentCard fails', async () => {
|
||||
const factoryInstance = {
|
||||
createFromAgentCard: vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error('Factory failed')),
|
||||
};
|
||||
vi.mocked(sdkClient.ClientFactory).mockReturnValue(
|
||||
factoryInstance as unknown as sdkClient.ClientFactory,
|
||||
);
|
||||
|
||||
await expect(
|
||||
manager.loadAgent('FailAgent', 'http://fail.agent'),
|
||||
).rejects.toThrow('Factory failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAgentCard and getClient', () => {
|
||||
it('should return undefined if agent is not found', () => {
|
||||
expect(manager.getAgentCard('Unknown')).toBeUndefined();
|
||||
expect(manager.getClient('Unknown')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('sendMessageStream', () => {
|
||||
beforeEach(async () => {
|
||||
await manager.loadAgent('TestAgent', 'http://test.agent');
|
||||
await manager.loadAgent('TestAgent', 'http://test.agent/card');
|
||||
});
|
||||
|
||||
it('should send a message and return a stream', async () => {
|
||||
const mockResult = {
|
||||
kind: 'message',
|
||||
messageId: 'a',
|
||||
parts: [],
|
||||
role: 'agent',
|
||||
} as SendMessageResult;
|
||||
|
||||
sendMessageStreamMock.mockReturnValue(
|
||||
mockClient.sendMessageStream.mockReturnValue(
|
||||
(async function* () {
|
||||
yield mockResult;
|
||||
yield { kind: 'message' };
|
||||
})(),
|
||||
);
|
||||
|
||||
const stream = manager.sendMessageStream('TestAgent', 'Hello');
|
||||
const results = [];
|
||||
for await (const res of stream) {
|
||||
results.push(res);
|
||||
for await (const result of stream) {
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
expect(results).toEqual([mockResult]);
|
||||
expect(sendMessageStreamMock).toHaveBeenCalledWith(
|
||||
expect(results).toHaveLength(1);
|
||||
expect(mockClient.sendMessageStream).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should use contextId and taskId when provided', async () => {
|
||||
mockClient.sendMessageStream.mockReturnValue(
|
||||
(async function* () {
|
||||
yield { kind: 'message' };
|
||||
})(),
|
||||
);
|
||||
|
||||
const stream = manager.sendMessageStream('TestAgent', 'Hello', {
|
||||
contextId: 'ctx123',
|
||||
taskId: 'task456',
|
||||
});
|
||||
// trigger execution
|
||||
for await (const _ of stream) {
|
||||
break;
|
||||
}
|
||||
|
||||
expect(mockClient.sendMessageStream).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: expect.anything(),
|
||||
message: expect.objectContaining({
|
||||
contextId: 'ctx123',
|
||||
taskId: 'task456',
|
||||
}),
|
||||
}),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('should use contextId and taskId when provided', async () => {
|
||||
sendMessageStreamMock.mockReturnValue(
|
||||
it('should correctly propagate AbortSignal to the stream', async () => {
|
||||
mockClient.sendMessageStream.mockReturnValue(
|
||||
(async function* () {
|
||||
yield {
|
||||
kind: 'message',
|
||||
messageId: 'a',
|
||||
parts: [],
|
||||
role: 'agent',
|
||||
} as SendMessageResult;
|
||||
yield { kind: 'message' };
|
||||
})(),
|
||||
);
|
||||
|
||||
const expectedContextId = 'user-context-id';
|
||||
const expectedTaskId = 'user-task-id';
|
||||
|
||||
const controller = new AbortController();
|
||||
const stream = manager.sendMessageStream('TestAgent', 'Hello', {
|
||||
contextId: expectedContextId,
|
||||
taskId: expectedTaskId,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
// trigger execution
|
||||
for await (const _ of stream) {
|
||||
// consume stream
|
||||
break;
|
||||
}
|
||||
|
||||
const call = sendMessageStreamMock.mock.calls[0][0];
|
||||
expect(call.message.contextId).toBe(expectedContextId);
|
||||
expect(call.message.taskId).toBe(expectedTaskId);
|
||||
expect(mockClient.sendMessageStream).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
expect.objectContaining({ signal: controller.signal }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle a multi-chunk stream with different event types', async () => {
|
||||
mockClient.sendMessageStream.mockReturnValue(
|
||||
(async function* () {
|
||||
yield { kind: 'message', messageId: 'm1' };
|
||||
yield { kind: 'status-update', taskId: 't1' };
|
||||
})(),
|
||||
);
|
||||
|
||||
const stream = manager.sendMessageStream('TestAgent', 'Hello');
|
||||
const results = [];
|
||||
for await (const result of stream) {
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results[0].kind).toBe('message');
|
||||
expect(results[1].kind).toBe('status-update');
|
||||
});
|
||||
|
||||
it('should throw prefixed error on failure', async () => {
|
||||
sendMessageStreamMock.mockImplementationOnce(() => {
|
||||
throw new Error('Network error');
|
||||
mockClient.sendMessageStream.mockImplementation(() => {
|
||||
throw new Error('Network failure');
|
||||
});
|
||||
|
||||
const stream = manager.sendMessageStream('TestAgent', 'Hello');
|
||||
await expect(async () => {
|
||||
for await (const _ of stream) {
|
||||
// consume
|
||||
// empty
|
||||
}
|
||||
}).rejects.toThrow(
|
||||
'[A2AClientManager] sendMessageStream Error [TestAgent]: Network error',
|
||||
'[A2AClientManager] sendMessageStream Error [TestAgent]: Network failure',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -261,7 +341,7 @@ describe('A2AClientManager', () => {
|
||||
const stream = manager.sendMessageStream('NonExistentAgent', 'Hello');
|
||||
await expect(async () => {
|
||||
for await (const _ of stream) {
|
||||
// consume
|
||||
// empty
|
||||
}
|
||||
}).rejects.toThrow("Agent 'NonExistentAgent' not found.");
|
||||
});
|
||||
@@ -269,28 +349,23 @@ describe('A2AClientManager', () => {
|
||||
|
||||
describe('getTask', () => {
|
||||
beforeEach(async () => {
|
||||
await manager.loadAgent('TestAgent', 'http://test.agent');
|
||||
await manager.loadAgent('TestAgent', 'http://test.agent/card');
|
||||
});
|
||||
|
||||
it('should get a task from the correct agent', async () => {
|
||||
getTaskMock.mockResolvedValue({
|
||||
id: 'task123',
|
||||
contextId: 'a',
|
||||
kind: 'task',
|
||||
status: { state: 'completed' },
|
||||
} as Task);
|
||||
const mockTask = { id: 'task123', kind: 'task' };
|
||||
mockClient.getTask.mockResolvedValue(mockTask);
|
||||
|
||||
await manager.getTask('TestAgent', 'task123');
|
||||
expect(getTaskMock).toHaveBeenCalledWith({
|
||||
id: 'task123',
|
||||
});
|
||||
const result = await manager.getTask('TestAgent', 'task123');
|
||||
expect(result).toBe(mockTask);
|
||||
expect(mockClient.getTask).toHaveBeenCalledWith({ id: 'task123' });
|
||||
});
|
||||
|
||||
it('should throw prefixed error on failure', async () => {
|
||||
getTaskMock.mockRejectedValueOnce(new Error('Network error'));
|
||||
mockClient.getTask.mockRejectedValue(new Error('Not found'));
|
||||
|
||||
await expect(manager.getTask('TestAgent', 'task123')).rejects.toThrow(
|
||||
'A2AClient getTask Error [TestAgent]: Network error',
|
||||
'A2AClient getTask Error [TestAgent]: Not found',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -303,28 +378,23 @@ describe('A2AClientManager', () => {
|
||||
|
||||
describe('cancelTask', () => {
|
||||
beforeEach(async () => {
|
||||
await manager.loadAgent('TestAgent', 'http://test.agent');
|
||||
await manager.loadAgent('TestAgent', 'http://test.agent/card');
|
||||
});
|
||||
|
||||
it('should cancel a task on the correct agent', async () => {
|
||||
cancelTaskMock.mockResolvedValue({
|
||||
id: 'task123',
|
||||
contextId: 'a',
|
||||
kind: 'task',
|
||||
status: { state: 'canceled' },
|
||||
} as Task);
|
||||
const mockTask = { id: 'task123', kind: 'task' };
|
||||
mockClient.cancelTask.mockResolvedValue(mockTask);
|
||||
|
||||
await manager.cancelTask('TestAgent', 'task123');
|
||||
expect(cancelTaskMock).toHaveBeenCalledWith({
|
||||
id: 'task123',
|
||||
});
|
||||
const result = await manager.cancelTask('TestAgent', 'task123');
|
||||
expect(result).toBe(mockTask);
|
||||
expect(mockClient.cancelTask).toHaveBeenCalledWith({ id: 'task123' });
|
||||
});
|
||||
|
||||
it('should throw prefixed error on failure', async () => {
|
||||
cancelTaskMock.mockRejectedValueOnce(new Error('Network error'));
|
||||
mockClient.cancelTask.mockRejectedValue(new Error('Cannot cancel'));
|
||||
|
||||
await expect(manager.cancelTask('TestAgent', 'task123')).rejects.toThrow(
|
||||
'A2AClient cancelTask Error [TestAgent]: Network error',
|
||||
'A2AClient cancelTask Error [TestAgent]: Cannot cancel',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -334,4 +404,82 @@ describe('A2AClientManager', () => {
|
||||
).rejects.toThrow("Agent 'NonExistentAgent' not found.");
|
||||
});
|
||||
});
|
||||
|
||||
describe('Protocol Routing & URL Logic', () => {
|
||||
it('should correctly split URLs to prevent .well-known doubling', async () => {
|
||||
const fullUrl = 'http://localhost:9001/.well-known/agent-card.json';
|
||||
const resolverInstance = {
|
||||
resolve: vi.fn().mockResolvedValue({ name: 'test' } as AgentCard),
|
||||
};
|
||||
vi.mocked(sdkClient.DefaultAgentCardResolver).mockReturnValue(
|
||||
resolverInstance as unknown as sdkClient.DefaultAgentCardResolver,
|
||||
);
|
||||
|
||||
await manager.loadAgent('test-doubling', fullUrl);
|
||||
|
||||
expect(resolverInstance.resolve).toHaveBeenCalledWith(
|
||||
'http://localhost:9001/',
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw if a remote agent uses a private IP (SSRF protection)', async () => {
|
||||
const privateUrl = 'http://169.254.169.254/.well-known/agent-card.json';
|
||||
await expect(manager.loadAgent('ssrf-agent', privateUrl)).rejects.toThrow(
|
||||
/Refusing to load agent 'ssrf-agent' from private IP range/,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw if a domain resolves to a private IP (DNS SSRF protection)', async () => {
|
||||
const maliciousDomainUrl =
|
||||
'http://malicious.com/.well-known/agent-card.json';
|
||||
|
||||
vi.mocked(dnsPromises.lookup).mockImplementationOnce(
|
||||
async (_h: string, options?: LookupOptions | number) => {
|
||||
const addr = { address: '10.0.0.1', family: 4 };
|
||||
const isAll = typeof options === 'object' && options?.all;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return (isAll ? [addr] : addr) as any;
|
||||
},
|
||||
);
|
||||
|
||||
await expect(
|
||||
manager.loadAgent('dns-ssrf-agent', maliciousDomainUrl),
|
||||
).rejects.toThrow(/private IP range/);
|
||||
});
|
||||
|
||||
it('should throw if a public agent card contains a private transport URL (Deep SSRF protection)', async () => {
|
||||
const publicUrl = 'https://public.agent.com/card.json';
|
||||
const resolverInstance = {
|
||||
resolve: vi.fn().mockResolvedValue({
|
||||
...mockAgentCard,
|
||||
url: 'http://192.168.1.1/api', // Malicious private transport in public card
|
||||
} as AgentCard),
|
||||
};
|
||||
vi.mocked(sdkClient.DefaultAgentCardResolver).mockReturnValue(
|
||||
resolverInstance as unknown as sdkClient.DefaultAgentCardResolver,
|
||||
);
|
||||
|
||||
// DNS for public.agent.com is public
|
||||
vi.mocked(dnsPromises.lookup).mockImplementation(
|
||||
async (hostname: string, options?: LookupOptions | number) => {
|
||||
const isAll = typeof options === 'object' && options?.all;
|
||||
if (hostname === 'public.agent.com') {
|
||||
const addr = { address: '1.1.1.1', family: 4 };
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return (isAll ? [addr] : addr) as any;
|
||||
}
|
||||
const addr = { address: '192.168.1.1', family: 4 };
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return (isAll ? [addr] : addr) as any;
|
||||
},
|
||||
);
|
||||
|
||||
await expect(
|
||||
manager.loadAgent('malicious-agent', publicUrl),
|
||||
).rejects.toThrow(
|
||||
/contains transport URL pointing to private IP range: http:\/\/192.168.1.1\/api/,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,40 +12,66 @@ import type {
|
||||
TaskStatusUpdateEvent,
|
||||
TaskArtifactUpdateEvent,
|
||||
} from '@a2a-js/sdk';
|
||||
import type { AuthenticationHandler, Client } from '@a2a-js/sdk/client';
|
||||
import {
|
||||
type Client,
|
||||
ClientFactory,
|
||||
ClientFactoryOptions,
|
||||
DefaultAgentCardResolver,
|
||||
RestTransportFactory,
|
||||
JsonRpcTransportFactory,
|
||||
type AuthenticationHandler,
|
||||
RestTransportFactory,
|
||||
createAuthenticatingFetchWithRetry,
|
||||
} from '@a2a-js/sdk/client';
|
||||
import { GrpcTransportFactory } from '@a2a-js/sdk/client/grpc';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { Agent as UndiciAgent } from 'undici';
|
||||
import {
|
||||
getGrpcChannelOptions,
|
||||
getGrpcCredentials,
|
||||
normalizeAgentCard,
|
||||
pinUrlToIp,
|
||||
splitAgentCardUrl,
|
||||
} from './a2aUtils.js';
|
||||
import {
|
||||
isPrivateIpAsync,
|
||||
safeLookup,
|
||||
isLoopbackHost,
|
||||
} from '../utils/fetch.js';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
|
||||
/**
|
||||
* Result of sending a message, which can be a full message, a task,
|
||||
* or an incremental status/artifact update.
|
||||
*/
|
||||
export type SendMessageResult =
|
||||
| Message
|
||||
| Task
|
||||
| TaskStatusUpdateEvent
|
||||
| TaskArtifactUpdateEvent;
|
||||
|
||||
// Local extension of RequestInit to support Node.js/undici dispatcher
|
||||
interface NodeFetchInit extends RequestInit {
|
||||
dispatcher?: UndiciAgent;
|
||||
}
|
||||
|
||||
// Remote agents can take 10+ minutes (e.g. Deep Research).
|
||||
// Use a dedicated dispatcher so the global 5-min timeout isn't affected.
|
||||
const A2A_TIMEOUT = 1800000; // 30 minutes
|
||||
const a2aDispatcher = new UndiciAgent({
|
||||
headersTimeout: A2A_TIMEOUT,
|
||||
bodyTimeout: A2A_TIMEOUT,
|
||||
connect: {
|
||||
// SSRF protection at the connection level (mitigates DNS rebinding)
|
||||
lookup: safeLookup,
|
||||
},
|
||||
});
|
||||
const a2aFetch: typeof fetch = (input, init) =>
|
||||
// @ts-expect-error The `dispatcher` property is a Node.js extension to fetch not present in standard types.
|
||||
fetch(input, { ...init, dispatcher: a2aDispatcher });
|
||||
|
||||
export type SendMessageResult =
|
||||
| Message
|
||||
| Task
|
||||
| TaskStatusUpdateEvent
|
||||
| TaskArtifactUpdateEvent;
|
||||
const a2aFetch: typeof fetch = (input, init) => {
|
||||
const nodeInit: NodeFetchInit = { ...init, dispatcher: a2aDispatcher };
|
||||
return fetch(input, nodeInit as RequestInit);
|
||||
};
|
||||
|
||||
/**
|
||||
* Manages A2A clients and caches loaded agent information.
|
||||
* Follows a singleton pattern to ensure a single client instance.
|
||||
* Orchestrates communication with remote A2A agents.
|
||||
* Manages protocol negotiation, authentication, and transport selection.
|
||||
*/
|
||||
export class A2AClientManager {
|
||||
private static instance: A2AClientManager;
|
||||
@@ -66,19 +92,10 @@ export class A2AClientManager {
|
||||
return A2AClientManager.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the singleton instance. Only for testing purposes.
|
||||
* @internal
|
||||
*/
|
||||
static resetInstanceForTesting() {
|
||||
// @ts-expect-error - Resetting singleton for testing
|
||||
A2AClientManager.instance = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads an agent by fetching its AgentCard and caches the client.
|
||||
* @param name The name to assign to the agent.
|
||||
* @param agentCardUrl The full URL to the agent's card.
|
||||
* @param agentCardUrl {string} The full URL to the agent's card.
|
||||
* @param authHandler Optional authentication handler to use for this agent.
|
||||
* @returns The loaded AgentCard.
|
||||
*/
|
||||
@@ -91,27 +108,51 @@ export class A2AClientManager {
|
||||
throw new Error(`Agent with name '${name}' is already loaded.`);
|
||||
}
|
||||
|
||||
let fetchImpl: typeof fetch = a2aFetch;
|
||||
if (authHandler) {
|
||||
fetchImpl = createAuthenticatingFetchWithRetry(a2aFetch, authHandler);
|
||||
}
|
||||
|
||||
const fetchImpl = this.getFetchImpl(authHandler);
|
||||
const resolver = new DefaultAgentCardResolver({ fetchImpl });
|
||||
const agentCard = await this.resolveAgentCard(name, agentCardUrl, resolver);
|
||||
|
||||
const options = ClientFactoryOptions.createFrom(
|
||||
// Pin URL to IP to prevent DNS rebinding for gRPC (connection-level SSRF protection)
|
||||
const grpcInterface = agentCard.additionalInterfaces?.find(
|
||||
(i) => i.transport === 'GRPC',
|
||||
);
|
||||
const urlToPin = grpcInterface?.url ?? agentCard.url;
|
||||
const { pinnedUrl, hostname } = await pinUrlToIp(urlToPin, name);
|
||||
|
||||
// Prepare base gRPC options
|
||||
const baseGrpcOptions: ConstructorParameters<
|
||||
typeof GrpcTransportFactory
|
||||
>[0] = {
|
||||
grpcChannelCredentials: getGrpcCredentials(urlToPin),
|
||||
};
|
||||
|
||||
// Include extension properties required for SSRF pinning using object spread.
|
||||
// This allows us to pass additional properties that the SDK uses internally
|
||||
// without triggering narrow-to-broad type assertion warnings.
|
||||
const fullGrpcOptions = {
|
||||
...baseGrpcOptions,
|
||||
target: pinnedUrl,
|
||||
grpcChannelOptions: getGrpcChannelOptions(hostname),
|
||||
};
|
||||
|
||||
// Configure standard SDK client for tool registration and discovery
|
||||
const clientOptions = ClientFactoryOptions.createFrom(
|
||||
ClientFactoryOptions.default,
|
||||
{
|
||||
transports: [
|
||||
new RestTransportFactory({ fetchImpl }),
|
||||
new JsonRpcTransportFactory({ fetchImpl }),
|
||||
new GrpcTransportFactory(
|
||||
fullGrpcOptions as ConstructorParameters<
|
||||
typeof GrpcTransportFactory
|
||||
>[0],
|
||||
),
|
||||
],
|
||||
cardResolver: resolver,
|
||||
},
|
||||
);
|
||||
|
||||
const factory = new ClientFactory(options);
|
||||
const client = await factory.createFromUrl(agentCardUrl, '');
|
||||
const agentCard = await client.getAgentCard();
|
||||
const factory = new ClientFactory(clientOptions);
|
||||
const client = await factory.createFromAgentCard(agentCard);
|
||||
|
||||
this.clients.set(name, client);
|
||||
this.agentCards.set(name, agentCard);
|
||||
@@ -146,9 +187,7 @@ export class A2AClientManager {
|
||||
options?: { contextId?: string; taskId?: string; signal?: AbortSignal },
|
||||
): AsyncIterable<SendMessageResult> {
|
||||
const client = this.clients.get(agentName);
|
||||
if (!client) {
|
||||
throw new Error(`Agent '${agentName}' not found.`);
|
||||
}
|
||||
if (!client) throw new Error(`Agent '${agentName}' not found.`);
|
||||
|
||||
const messageParams: MessageSendParams = {
|
||||
message: {
|
||||
@@ -164,7 +203,7 @@ export class A2AClientManager {
|
||||
try {
|
||||
yield* client.sendMessageStream(messageParams, {
|
||||
signal: options?.signal,
|
||||
});
|
||||
}) as AsyncIterable<SendMessageResult>;
|
||||
} catch (error: unknown) {
|
||||
const prefix = `[A2AClientManager] sendMessageStream Error [${agentName}]`;
|
||||
if (error instanceof Error) {
|
||||
@@ -202,9 +241,7 @@ export class A2AClientManager {
|
||||
*/
|
||||
async getTask(agentName: string, taskId: string): Promise<Task> {
|
||||
const client = this.clients.get(agentName);
|
||||
if (!client) {
|
||||
throw new Error(`Agent '${agentName}' not found.`);
|
||||
}
|
||||
if (!client) throw new Error(`Agent '${agentName}' not found.`);
|
||||
try {
|
||||
return await client.getTask({ id: taskId });
|
||||
} catch (error: unknown) {
|
||||
@@ -224,9 +261,7 @@ export class A2AClientManager {
|
||||
*/
|
||||
async cancelTask(agentName: string, taskId: string): Promise<Task> {
|
||||
const client = this.clients.get(agentName);
|
||||
if (!client) {
|
||||
throw new Error(`Agent '${agentName}' not found.`);
|
||||
}
|
||||
if (!client) throw new Error(`Agent '${agentName}' not found.`);
|
||||
try {
|
||||
return await client.cancelTask({ id: taskId });
|
||||
} catch (error: unknown) {
|
||||
@@ -237,4 +272,75 @@ export class A2AClientManager {
|
||||
throw new Error(`${prefix}: Unexpected error: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the appropriate fetch implementation for an agent.
|
||||
*/
|
||||
private getFetchImpl(authHandler?: AuthenticationHandler): typeof fetch {
|
||||
return authHandler
|
||||
? createAuthenticatingFetchWithRetry(a2aFetch, authHandler)
|
||||
: a2aFetch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves and normalizes an agent card from a given URL.
|
||||
* Handles splitting the URL if it already contains the standard .well-known path.
|
||||
* Also performs basic SSRF validation to prevent internal IP access.
|
||||
*/
|
||||
private async resolveAgentCard(
|
||||
agentName: string,
|
||||
url: string,
|
||||
resolver: DefaultAgentCardResolver,
|
||||
): Promise<AgentCard> {
|
||||
// Validate URL to prevent SSRF (with DNS resolution)
|
||||
if (await isPrivateIpAsync(url)) {
|
||||
// Local/private IPs are allowed ONLY for localhost for testing.
|
||||
const parsed = new URL(url);
|
||||
if (!isLoopbackHost(parsed.hostname)) {
|
||||
throw new Error(
|
||||
`Refusing to load agent '${agentName}' from private IP range: ${url}. Remote agents must use public URLs.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const { baseUrl, path } = splitAgentCardUrl(url);
|
||||
const rawCard = await resolver.resolve(baseUrl, path);
|
||||
const agentCard = normalizeAgentCard(rawCard);
|
||||
|
||||
// Deep validation of all transport URLs within the card to prevent SSRF
|
||||
await this.validateAgentCardUrls(agentName, agentCard);
|
||||
|
||||
return agentCard;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates all URLs (top-level and interfaces) within an AgentCard for SSRF.
|
||||
*/
|
||||
private async validateAgentCardUrls(
|
||||
agentName: string,
|
||||
card: AgentCard,
|
||||
): Promise<void> {
|
||||
const urlsToValidate = [card.url];
|
||||
if (card.additionalInterfaces) {
|
||||
for (const intf of card.additionalInterfaces) {
|
||||
if (intf.url) urlsToValidate.push(intf.url);
|
||||
}
|
||||
}
|
||||
|
||||
for (const url of urlsToValidate) {
|
||||
if (!url) continue;
|
||||
|
||||
// Ensure URL has a scheme for the parser (gRPC often provides raw IP:port)
|
||||
const validationUrl = url.includes('://') ? url : `http://${url}`;
|
||||
|
||||
if (await isPrivateIpAsync(validationUrl)) {
|
||||
const parsed = new URL(validationUrl);
|
||||
if (!isLoopbackHost(parsed.hostname)) {
|
||||
throw new Error(
|
||||
`Refusing to load agent '${agentName}': contains transport URL pointing to private IP range: ${url}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,13 +4,17 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import {
|
||||
extractMessageText,
|
||||
extractIdsFromResponse,
|
||||
isTerminalState,
|
||||
A2AResultReassembler,
|
||||
AUTH_REQUIRED_MSG,
|
||||
normalizeAgentCard,
|
||||
getGrpcCredentials,
|
||||
pinUrlToIp,
|
||||
splitAgentCardUrl,
|
||||
} from './a2aUtils.js';
|
||||
import type { SendMessageResult } from './a2a-client-manager.js';
|
||||
import type {
|
||||
@@ -22,8 +26,92 @@ import type {
|
||||
TaskStatusUpdateEvent,
|
||||
TaskArtifactUpdateEvent,
|
||||
} from '@a2a-js/sdk';
|
||||
import * as dnsPromises from 'node:dns/promises';
|
||||
|
||||
vi.mock('node:dns/promises', () => ({
|
||||
lookup: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('a2aUtils', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('getGrpcCredentials', () => {
|
||||
it('should return secure credentials for https', () => {
|
||||
const credentials = getGrpcCredentials('https://test.agent');
|
||||
expect(credentials).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return insecure credentials for http', () => {
|
||||
const credentials = getGrpcCredentials('http://test.agent');
|
||||
expect(credentials).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('pinUrlToIp', () => {
|
||||
it('should resolve and pin hostname to IP', async () => {
|
||||
vi.mocked(dnsPromises.lookup).mockResolvedValue([
|
||||
{ address: '93.184.216.34', family: 4 },
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
] as any);
|
||||
|
||||
const { pinnedUrl, hostname } = await pinUrlToIp(
|
||||
'http://example.com:9000',
|
||||
'test-agent',
|
||||
);
|
||||
expect(hostname).toBe('example.com');
|
||||
expect(pinnedUrl).toBe('http://93.184.216.34:9000/');
|
||||
});
|
||||
|
||||
it('should handle raw host:port strings (standard for gRPC)', async () => {
|
||||
vi.mocked(dnsPromises.lookup).mockResolvedValue([
|
||||
{ address: '93.184.216.34', family: 4 },
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
] as any);
|
||||
|
||||
const { pinnedUrl, hostname } = await pinUrlToIp(
|
||||
'example.com:9000',
|
||||
'test-agent',
|
||||
);
|
||||
expect(hostname).toBe('example.com');
|
||||
expect(pinnedUrl).toBe('93.184.216.34:9000');
|
||||
});
|
||||
|
||||
it('should throw error if resolution fails (fail closed)', async () => {
|
||||
vi.mocked(dnsPromises.lookup).mockRejectedValue(new Error('DNS Error'));
|
||||
|
||||
await expect(
|
||||
pinUrlToIp('http://unreachable.com', 'test-agent'),
|
||||
).rejects.toThrow("Failed to resolve host for agent 'test-agent'");
|
||||
});
|
||||
|
||||
it('should throw error if resolved to private IP', async () => {
|
||||
vi.mocked(dnsPromises.lookup).mockResolvedValue([
|
||||
{ address: '10.0.0.1', family: 4 },
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
] as any);
|
||||
|
||||
await expect(
|
||||
pinUrlToIp('http://malicious.com', 'test-agent'),
|
||||
).rejects.toThrow('resolves to private IP range');
|
||||
});
|
||||
|
||||
it('should allow localhost/127.0.0.1/::1 exceptions', async () => {
|
||||
vi.mocked(dnsPromises.lookup).mockResolvedValue([
|
||||
{ address: '127.0.0.1', family: 4 },
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
] as any);
|
||||
|
||||
const { pinnedUrl, hostname } = await pinUrlToIp(
|
||||
'http://localhost:9000',
|
||||
'test-agent',
|
||||
);
|
||||
expect(hostname).toBe('localhost');
|
||||
expect(pinnedUrl).toBe('http://127.0.0.1:9000/');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isTerminalState', () => {
|
||||
it('should return true for completed, failed, canceled, and rejected', () => {
|
||||
expect(isTerminalState('completed')).toBe(true);
|
||||
@@ -223,6 +311,173 @@ describe('a2aUtils', () => {
|
||||
} as Message),
|
||||
).toBe('');
|
||||
});
|
||||
|
||||
it('should handle file parts with neither name nor uri', () => {
|
||||
const message: Message = {
|
||||
kind: 'message',
|
||||
role: 'user',
|
||||
messageId: '1',
|
||||
parts: [
|
||||
{
|
||||
kind: 'file',
|
||||
file: {
|
||||
mimeType: 'text/plain',
|
||||
},
|
||||
} as FilePart,
|
||||
],
|
||||
};
|
||||
expect(extractMessageText(message)).toBe('File: [binary/unnamed]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeAgentCard', () => {
|
||||
it('should throw if input is not an object', () => {
|
||||
expect(() => normalizeAgentCard(null)).toThrow('Agent card is missing.');
|
||||
expect(() => normalizeAgentCard(undefined)).toThrow(
|
||||
'Agent card is missing.',
|
||||
);
|
||||
expect(() => normalizeAgentCard('not an object')).toThrow(
|
||||
'Agent card is missing.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should preserve unknown fields while providing defaults for mandatory ones', () => {
|
||||
const raw = {
|
||||
name: 'my-agent',
|
||||
customField: 'keep-me',
|
||||
};
|
||||
|
||||
const normalized = normalizeAgentCard(raw);
|
||||
|
||||
expect(normalized.name).toBe('my-agent');
|
||||
// @ts-expect-error - testing dynamic preservation
|
||||
expect(normalized.customField).toBe('keep-me');
|
||||
expect(normalized.description).toBe('');
|
||||
expect(normalized.skills).toEqual([]);
|
||||
expect(normalized.defaultInputModes).toEqual([]);
|
||||
});
|
||||
|
||||
it('should normalize and synchronize interfaces while preserving other fields', () => {
|
||||
const raw = {
|
||||
name: 'test',
|
||||
supportedInterfaces: [
|
||||
{
|
||||
url: 'grpc://test',
|
||||
protocolBinding: 'GRPC',
|
||||
protocolVersion: '1.0',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const normalized = normalizeAgentCard(raw);
|
||||
|
||||
// Should exist in both fields
|
||||
expect(normalized.additionalInterfaces).toHaveLength(1);
|
||||
expect(
|
||||
(normalized as unknown as Record<string, unknown>)[
|
||||
'supportedInterfaces'
|
||||
],
|
||||
).toHaveLength(1);
|
||||
|
||||
const intf = normalized.additionalInterfaces?.[0] as unknown as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
|
||||
expect(intf['transport']).toBe('GRPC');
|
||||
expect(intf['url']).toBe('grpc://test');
|
||||
|
||||
// Should fallback top-level url
|
||||
expect(normalized.url).toBe('grpc://test');
|
||||
});
|
||||
|
||||
it('should preserve existing top-level url if present', () => {
|
||||
const raw = {
|
||||
name: 'test',
|
||||
url: 'http://existing',
|
||||
supportedInterfaces: [{ url: 'http://other', transport: 'REST' }],
|
||||
};
|
||||
|
||||
const normalized = normalizeAgentCard(raw);
|
||||
expect(normalized.url).toBe('http://existing');
|
||||
});
|
||||
|
||||
it('should NOT prepend http:// scheme to raw IP:port strings for gRPC interfaces', () => {
|
||||
const raw = {
|
||||
name: 'raw-ip-grpc',
|
||||
supportedInterfaces: [{ url: '127.0.0.1:9000', transport: 'GRPC' }],
|
||||
};
|
||||
|
||||
const normalized = normalizeAgentCard(raw);
|
||||
expect(normalized.additionalInterfaces?.[0].url).toBe('127.0.0.1:9000');
|
||||
expect(normalized.url).toBe('127.0.0.1:9000');
|
||||
});
|
||||
|
||||
it('should prepend http:// scheme to raw IP:port strings for REST interfaces', () => {
|
||||
const raw = {
|
||||
name: 'raw-ip-rest',
|
||||
supportedInterfaces: [{ url: '127.0.0.1:8080', transport: 'REST' }],
|
||||
};
|
||||
|
||||
const normalized = normalizeAgentCard(raw);
|
||||
expect(normalized.additionalInterfaces?.[0].url).toBe(
|
||||
'http://127.0.0.1:8080',
|
||||
);
|
||||
});
|
||||
|
||||
it('should NOT override existing transport if protocolBinding is also present', () => {
|
||||
const raw = {
|
||||
name: 'priority-test',
|
||||
supportedInterfaces: [
|
||||
{ url: 'foo', transport: 'GRPC', protocolBinding: 'REST' },
|
||||
],
|
||||
};
|
||||
const normalized = normalizeAgentCard(raw);
|
||||
expect(normalized.additionalInterfaces?.[0].transport).toBe('GRPC');
|
||||
});
|
||||
});
|
||||
|
||||
describe('splitAgentCardUrl', () => {
|
||||
const standard = '.well-known/agent-card.json';
|
||||
|
||||
it('should return baseUrl as-is if it does not end with standard path', () => {
|
||||
const url = 'http://localhost:9001/custom/path';
|
||||
expect(splitAgentCardUrl(url)).toEqual({ baseUrl: url });
|
||||
});
|
||||
|
||||
it('should split correctly if URL ends with standard path', () => {
|
||||
const url = `http://localhost:9001/${standard}`;
|
||||
expect(splitAgentCardUrl(url)).toEqual({
|
||||
baseUrl: 'http://localhost:9001/',
|
||||
path: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle trailing slash in baseUrl when splitting', () => {
|
||||
const url = `http://example.com/api/${standard}`;
|
||||
expect(splitAgentCardUrl(url)).toEqual({
|
||||
baseUrl: 'http://example.com/api/',
|
||||
path: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should ignore hashes and query params when splitting', () => {
|
||||
const url = `http://localhost:9001/${standard}?foo=bar#baz`;
|
||||
expect(splitAgentCardUrl(url)).toEqual({
|
||||
baseUrl: 'http://localhost:9001/',
|
||||
path: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return original URL if parsing fails', () => {
|
||||
const url = 'not-a-url';
|
||||
expect(splitAgentCardUrl(url)).toEqual({ baseUrl: url });
|
||||
});
|
||||
|
||||
it('should handle standard path appearing earlier in the path', () => {
|
||||
const url = `http://localhost:9001/${standard}/something-else`;
|
||||
expect(splitAgentCardUrl(url)).toEqual({ baseUrl: url });
|
||||
});
|
||||
});
|
||||
|
||||
describe('A2AResultReassembler', () => {
|
||||
@@ -233,6 +488,7 @@ describe('a2aUtils', () => {
|
||||
reassembler.update({
|
||||
kind: 'status-update',
|
||||
taskId: 't1',
|
||||
contextId: 'ctx1',
|
||||
status: {
|
||||
state: 'working',
|
||||
message: {
|
||||
@@ -247,6 +503,7 @@ describe('a2aUtils', () => {
|
||||
reassembler.update({
|
||||
kind: 'artifact-update',
|
||||
taskId: 't1',
|
||||
contextId: 'ctx1',
|
||||
append: false,
|
||||
artifact: {
|
||||
artifactId: 'a1',
|
||||
@@ -259,6 +516,7 @@ describe('a2aUtils', () => {
|
||||
reassembler.update({
|
||||
kind: 'status-update',
|
||||
taskId: 't1',
|
||||
contextId: 'ctx1',
|
||||
status: {
|
||||
state: 'working',
|
||||
message: {
|
||||
@@ -273,6 +531,7 @@ describe('a2aUtils', () => {
|
||||
reassembler.update({
|
||||
kind: 'artifact-update',
|
||||
taskId: 't1',
|
||||
contextId: 'ctx1',
|
||||
append: true,
|
||||
artifact: {
|
||||
artifactId: 'a1',
|
||||
@@ -291,6 +550,7 @@ describe('a2aUtils', () => {
|
||||
|
||||
reassembler.update({
|
||||
kind: 'status-update',
|
||||
contextId: 'ctx1',
|
||||
status: {
|
||||
state: 'auth-required',
|
||||
message: {
|
||||
@@ -310,6 +570,7 @@ describe('a2aUtils', () => {
|
||||
|
||||
reassembler.update({
|
||||
kind: 'status-update',
|
||||
contextId: 'ctx1',
|
||||
status: {
|
||||
state: 'auth-required',
|
||||
},
|
||||
@@ -323,6 +584,7 @@ describe('a2aUtils', () => {
|
||||
|
||||
const chunk = {
|
||||
kind: 'status-update',
|
||||
contextId: 'ctx1',
|
||||
status: {
|
||||
state: 'auth-required',
|
||||
message: {
|
||||
@@ -351,6 +613,8 @@ describe('a2aUtils', () => {
|
||||
|
||||
reassembler.update({
|
||||
kind: 'task',
|
||||
id: 'task-1',
|
||||
contextId: 'ctx1',
|
||||
status: { state: 'completed' },
|
||||
history: [
|
||||
{
|
||||
@@ -369,6 +633,8 @@ describe('a2aUtils', () => {
|
||||
|
||||
reassembler.update({
|
||||
kind: 'task',
|
||||
id: 'task-1',
|
||||
contextId: 'ctx1',
|
||||
status: { state: 'working' },
|
||||
history: [
|
||||
{
|
||||
@@ -387,6 +653,8 @@ describe('a2aUtils', () => {
|
||||
|
||||
reassembler.update({
|
||||
kind: 'task',
|
||||
id: 'task-1',
|
||||
contextId: 'ctx1',
|
||||
status: { state: 'completed' },
|
||||
artifacts: [
|
||||
{
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as grpc from '@grpc/grpc-js';
|
||||
import { lookup } from 'node:dns/promises';
|
||||
import type {
|
||||
Message,
|
||||
Part,
|
||||
@@ -13,7 +15,12 @@ import type {
|
||||
Artifact,
|
||||
TaskState,
|
||||
TaskStatusUpdateEvent,
|
||||
TaskArtifactUpdateEvent,
|
||||
AgentCard,
|
||||
AgentInterface,
|
||||
Task,
|
||||
} from '@a2a-js/sdk';
|
||||
import { isAddressPrivate } from '../utils/fetch.js';
|
||||
import type { SendMessageResult } from './a2a-client-manager.js';
|
||||
|
||||
export const AUTH_REQUIRED_MSG = `[Authorization Required] The agent has indicated it requires authorization to proceed. Please follow the agent's instructions.`;
|
||||
@@ -33,80 +40,68 @@ export class A2AResultReassembler {
|
||||
update(chunk: SendMessageResult) {
|
||||
if (!('kind' in chunk)) return;
|
||||
|
||||
switch (chunk.kind) {
|
||||
case 'status-update':
|
||||
this.appendStateInstructions(chunk.status?.state);
|
||||
this.pushMessage(chunk.status?.message);
|
||||
break;
|
||||
if (isStatusUpdateEvent(chunk)) {
|
||||
this.appendStateInstructions(chunk.status?.state);
|
||||
this.pushMessage(chunk.status?.message);
|
||||
} else if (isArtifactUpdateEvent(chunk)) {
|
||||
if (chunk.artifact) {
|
||||
const id = chunk.artifact.artifactId;
|
||||
const existing = this.artifacts.get(id);
|
||||
|
||||
case 'artifact-update':
|
||||
if (chunk.artifact) {
|
||||
const id = chunk.artifact.artifactId;
|
||||
const existing = this.artifacts.get(id);
|
||||
|
||||
if (chunk.append && existing) {
|
||||
for (const part of chunk.artifact.parts) {
|
||||
existing.parts.push(structuredClone(part));
|
||||
}
|
||||
} else {
|
||||
this.artifacts.set(id, structuredClone(chunk.artifact));
|
||||
}
|
||||
|
||||
const newText = extractPartsText(chunk.artifact.parts, '');
|
||||
let chunks = this.artifactChunks.get(id);
|
||||
if (!chunks) {
|
||||
chunks = [];
|
||||
this.artifactChunks.set(id, chunks);
|
||||
}
|
||||
if (chunk.append) {
|
||||
chunks.push(newText);
|
||||
} else {
|
||||
chunks.length = 0;
|
||||
chunks.push(newText);
|
||||
if (chunk.append && existing) {
|
||||
for (const part of chunk.artifact.parts) {
|
||||
existing.parts.push(structuredClone(part));
|
||||
}
|
||||
} else {
|
||||
this.artifacts.set(id, structuredClone(chunk.artifact));
|
||||
}
|
||||
break;
|
||||
|
||||
case 'task':
|
||||
this.appendStateInstructions(chunk.status?.state);
|
||||
this.pushMessage(chunk.status?.message);
|
||||
if (chunk.artifacts) {
|
||||
for (const art of chunk.artifacts) {
|
||||
this.artifacts.set(art.artifactId, structuredClone(art));
|
||||
this.artifactChunks.set(art.artifactId, [
|
||||
extractPartsText(art.parts, ''),
|
||||
]);
|
||||
}
|
||||
const newText = extractPartsText(chunk.artifact.parts, '');
|
||||
let chunks = this.artifactChunks.get(id);
|
||||
if (!chunks) {
|
||||
chunks = [];
|
||||
this.artifactChunks.set(id, chunks);
|
||||
}
|
||||
// History Fallback: Some agent implementations do not populate the
|
||||
// status.message in their final terminal response, instead archiving
|
||||
// the final answer in the task's history array. To ensure we don't
|
||||
// present an empty result, we fallback to the most recent agent message
|
||||
// in the history only when the task is terminal and no other content
|
||||
// (message log or artifacts) has been reassembled.
|
||||
if (
|
||||
isTerminalState(chunk.status?.state) &&
|
||||
this.messageLog.length === 0 &&
|
||||
this.artifacts.size === 0 &&
|
||||
chunk.history &&
|
||||
chunk.history.length > 0
|
||||
) {
|
||||
const lastAgentMsg = [...chunk.history]
|
||||
.reverse()
|
||||
.find((m) => m.role?.toLowerCase().includes('agent'));
|
||||
if (lastAgentMsg) {
|
||||
this.pushMessage(lastAgentMsg);
|
||||
}
|
||||
if (chunk.append) {
|
||||
chunks.push(newText);
|
||||
} else {
|
||||
chunks.length = 0;
|
||||
chunks.push(newText);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'message': {
|
||||
this.pushMessage(chunk);
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
break;
|
||||
} else if (isTask(chunk)) {
|
||||
this.appendStateInstructions(chunk.status?.state);
|
||||
this.pushMessage(chunk.status?.message);
|
||||
if (chunk.artifacts) {
|
||||
for (const art of chunk.artifacts) {
|
||||
this.artifacts.set(art.artifactId, structuredClone(art));
|
||||
this.artifactChunks.set(art.artifactId, [
|
||||
extractPartsText(art.parts, ''),
|
||||
]);
|
||||
}
|
||||
}
|
||||
// History Fallback: Some agent implementations do not populate the
|
||||
// status.message in their final terminal response, instead archiving
|
||||
// the final answer in the task's history array. To ensure we don't
|
||||
// present an empty result, we fallback to the most recent agent message
|
||||
// in the history only when the task is terminal and no other content
|
||||
// (message log or artifacts) has been reassembled.
|
||||
if (
|
||||
isTerminalState(chunk.status?.state) &&
|
||||
this.messageLog.length === 0 &&
|
||||
this.artifacts.size === 0 &&
|
||||
chunk.history &&
|
||||
chunk.history.length > 0
|
||||
) {
|
||||
const lastAgentMsg = [...chunk.history]
|
||||
.reverse()
|
||||
.find((m) => m.role?.toLowerCase().includes('agent'));
|
||||
if (lastAgentMsg) {
|
||||
this.pushMessage(lastAgentMsg);
|
||||
}
|
||||
}
|
||||
} else if (isMessage(chunk)) {
|
||||
this.pushMessage(chunk);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,36 +205,168 @@ function extractPartText(part: Part): string {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Type Guards
|
||||
/**
|
||||
* Normalizes an agent card by ensuring it has the required properties
|
||||
* and resolving any inconsistencies between protocol versions.
|
||||
*/
|
||||
export function normalizeAgentCard(card: unknown): AgentCard {
|
||||
if (!isObject(card)) {
|
||||
throw new Error('Agent card is missing.');
|
||||
}
|
||||
|
||||
function isTextPart(part: Part): part is TextPart {
|
||||
return part.kind === 'text';
|
||||
}
|
||||
// Double-cast to bypass strict linter while bootstrapping the object.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const result = { ...card } as unknown as AgentCard;
|
||||
|
||||
function isDataPart(part: Part): part is DataPart {
|
||||
return part.kind === 'data';
|
||||
}
|
||||
// Ensure mandatory fields exist with safe defaults.
|
||||
if (typeof result.name !== 'string') result.name = 'unknown';
|
||||
if (typeof result.description !== 'string') result.description = '';
|
||||
if (typeof result.url !== 'string') result.url = '';
|
||||
if (typeof result.version !== 'string') result.version = '';
|
||||
if (typeof result.protocolVersion !== 'string') result.protocolVersion = '';
|
||||
if (!isObject(result.capabilities)) result.capabilities = {};
|
||||
if (!Array.isArray(result.skills)) result.skills = [];
|
||||
if (!Array.isArray(result.defaultInputModes)) result.defaultInputModes = [];
|
||||
if (!Array.isArray(result.defaultOutputModes)) result.defaultOutputModes = [];
|
||||
|
||||
function isFilePart(part: Part): part is FilePart {
|
||||
return part.kind === 'file';
|
||||
}
|
||||
// Normalize interfaces and synchronize both interface fields.
|
||||
const normalizedInterfaces = extractNormalizedInterfaces(card);
|
||||
result.additionalInterfaces = normalizedInterfaces;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
(result as unknown as Record<string, AgentInterface[]>)[
|
||||
'supportedInterfaces'
|
||||
] = normalizedInterfaces;
|
||||
|
||||
function isStatusUpdateEvent(
|
||||
result: SendMessageResult,
|
||||
): result is TaskStatusUpdateEvent {
|
||||
return result.kind === 'status-update';
|
||||
// Fallback preferredTransport: If not specified, default to GRPC if available.
|
||||
if (
|
||||
!result.preferredTransport &&
|
||||
normalizedInterfaces.some((i) => i.transport === 'GRPC')
|
||||
) {
|
||||
result.preferredTransport = 'GRPC';
|
||||
}
|
||||
|
||||
// Fallback: If top-level URL is missing, use the first interface's URL.
|
||||
if (result.url === '' && normalizedInterfaces.length > 0) {
|
||||
result.url = normalizedInterfaces[0].url;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the given state is a terminal state for a task.
|
||||
* Returns gRPC channel credentials based on the URL scheme.
|
||||
*/
|
||||
export function isTerminalState(state: TaskState | undefined): boolean {
|
||||
return (
|
||||
state === 'completed' ||
|
||||
state === 'failed' ||
|
||||
state === 'canceled' ||
|
||||
state === 'rejected'
|
||||
);
|
||||
export function getGrpcCredentials(url: string): grpc.ChannelCredentials {
|
||||
return url.startsWith('https://')
|
||||
? grpc.credentials.createSsl()
|
||||
: grpc.credentials.createInsecure();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns gRPC channel options to ensure SSL/authority matches the original hostname
|
||||
* when connecting via a pinned IP address.
|
||||
*/
|
||||
export function getGrpcChannelOptions(
|
||||
hostname: string,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
'grpc.default_authority': hostname,
|
||||
'grpc.ssl_target_name_override': hostname,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a hostname to its IP address and validates it against SSRF.
|
||||
* Returns the pinned IP-based URL and the original hostname.
|
||||
*/
|
||||
export async function pinUrlToIp(
|
||||
url: string,
|
||||
agentName: string,
|
||||
): Promise<{ pinnedUrl: string; hostname: string }> {
|
||||
if (!url) return { pinnedUrl: url, hostname: '' };
|
||||
|
||||
// gRPC URLs in A2A can be 'host:port' or 'dns:///host:port' or have schemes.
|
||||
// We normalize to host:port for resolution.
|
||||
const hasScheme = url.includes('://');
|
||||
const normalizedUrl = hasScheme ? url : `http://${url}`;
|
||||
|
||||
try {
|
||||
const parsed = new URL(normalizedUrl);
|
||||
const hostname = parsed.hostname;
|
||||
|
||||
const sanitizedHost =
|
||||
hostname.startsWith('[') && hostname.endsWith(']')
|
||||
? hostname.slice(1, -1)
|
||||
: hostname;
|
||||
|
||||
// Resolve DNS to check the actual target IP and pin it
|
||||
const addresses = await lookup(hostname, { all: true });
|
||||
const publicAddresses = addresses.filter(
|
||||
(addr) =>
|
||||
!isAddressPrivate(addr.address) ||
|
||||
sanitizedHost === 'localhost' ||
|
||||
sanitizedHost === '127.0.0.1' ||
|
||||
sanitizedHost === '::1',
|
||||
);
|
||||
|
||||
if (publicAddresses.length === 0 && addresses.length > 0) {
|
||||
throw new Error(
|
||||
`Refusing to load agent '${agentName}': transport URL '${url}' resolves to private IP range.`,
|
||||
);
|
||||
}
|
||||
|
||||
const pinnedIp = publicAddresses[0].address;
|
||||
const pinnedHostname = pinnedIp.includes(':') ? `[${pinnedIp}]` : pinnedIp;
|
||||
|
||||
// Reconstruct URL with IP
|
||||
parsed.hostname = pinnedHostname;
|
||||
let pinnedUrl = parsed.toString();
|
||||
|
||||
// If original didn't have scheme, remove it (standard for gRPC targets)
|
||||
if (!hasScheme) {
|
||||
pinnedUrl = pinnedUrl.replace(/^http:\/\//, '');
|
||||
// URL.toString() might append a trailing slash
|
||||
if (pinnedUrl.endsWith('/') && !url.endsWith('/')) {
|
||||
pinnedUrl = pinnedUrl.slice(0, -1);
|
||||
}
|
||||
}
|
||||
|
||||
return { pinnedUrl, hostname };
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message.includes('Refusing')) throw e;
|
||||
throw new Error(`Failed to resolve host for agent '${agentName}': ${url}`, {
|
||||
cause: e,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Splts an agent card URL into a baseUrl and a standard path if it already
|
||||
* contains '.well-known/agent-card.json'.
|
||||
*/
|
||||
export function splitAgentCardUrl(url: string): {
|
||||
baseUrl: string;
|
||||
path?: string;
|
||||
} {
|
||||
const standardPath = '.well-known/agent-card.json';
|
||||
try {
|
||||
const parsedUrl = new URL(url);
|
||||
if (parsedUrl.pathname.endsWith(standardPath)) {
|
||||
// Reconstruct baseUrl from parsed components to avoid issues with hashes or query params.
|
||||
parsedUrl.pathname = parsedUrl.pathname.substring(
|
||||
0,
|
||||
parsedUrl.pathname.lastIndexOf(standardPath),
|
||||
);
|
||||
parsedUrl.search = '';
|
||||
parsedUrl.hash = '';
|
||||
// We return undefined for path if it's the standard one,
|
||||
// because the SDK's DefaultAgentCardResolver appends it automatically.
|
||||
return { baseUrl: parsedUrl.toString(), path: undefined };
|
||||
}
|
||||
} catch (_e) {
|
||||
// Ignore URL parsing errors here, let the resolver handle them.
|
||||
}
|
||||
return { baseUrl: url };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -257,7 +384,7 @@ export function extractIdsFromResponse(result: SendMessageResult): {
|
||||
|
||||
if ('kind' in result) {
|
||||
const kind = result.kind;
|
||||
if (kind === 'message' || kind === 'artifact-update') {
|
||||
if (kind === 'message' || isArtifactUpdateEvent(result)) {
|
||||
taskId = result.taskId;
|
||||
contextId = result.contextId;
|
||||
} else if (kind === 'task') {
|
||||
@@ -279,3 +406,121 @@ export function extractIdsFromResponse(result: SendMessageResult): {
|
||||
|
||||
return { contextId, taskId, clearTaskId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts and normalizes interfaces from the card, handling protocol version fallbacks.
|
||||
* Preserves all original fields to maintain SDK compatibility.
|
||||
*/
|
||||
function extractNormalizedInterfaces(
|
||||
card: Record<string, unknown>,
|
||||
): AgentInterface[] {
|
||||
const rawInterfaces =
|
||||
getArray(card, 'additionalInterfaces') ||
|
||||
getArray(card, 'supportedInterfaces');
|
||||
|
||||
if (!rawInterfaces) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const mapped: AgentInterface[] = [];
|
||||
for (const i of rawInterfaces) {
|
||||
if (isObject(i)) {
|
||||
// Create a copy to preserve all original fields.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const normalized = { ...i } as unknown as AgentInterface & {
|
||||
protocolBinding?: string;
|
||||
};
|
||||
|
||||
// Ensure 'url' exists
|
||||
if (typeof normalized.url !== 'string') {
|
||||
normalized.url = '';
|
||||
}
|
||||
|
||||
// Normalize 'transport' from 'protocolBinding'
|
||||
const transport = normalized.transport || normalized.protocolBinding;
|
||||
if (transport) {
|
||||
normalized.transport = transport;
|
||||
}
|
||||
|
||||
// Robust URL: Ensure the URL has a scheme (except for gRPC).
|
||||
// Some agent implementations (like a2a-go samples) may provide raw IP:port strings.
|
||||
// gRPC targets MUST NOT have a scheme (e.g. 'http://'), or they will fail name resolution.
|
||||
if (
|
||||
normalized.url &&
|
||||
!normalized.url.includes('://') &&
|
||||
!normalized.url.startsWith('/') &&
|
||||
normalized.transport !== 'GRPC'
|
||||
) {
|
||||
// Default to http:// for insecure REST/JSON-RPC if scheme is missing.
|
||||
normalized.url = `http://${normalized.url}`;
|
||||
}
|
||||
|
||||
mapped.push(normalized);
|
||||
}
|
||||
}
|
||||
return mapped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely extracts an array property from an object.
|
||||
*/
|
||||
function getArray(
|
||||
obj: Record<string, unknown>,
|
||||
key: string,
|
||||
): unknown[] | undefined {
|
||||
const val = obj[key];
|
||||
return Array.isArray(val) ? val : undefined;
|
||||
}
|
||||
|
||||
// Type Guards
|
||||
|
||||
function isTextPart(part: Part): part is TextPart {
|
||||
return part.kind === 'text';
|
||||
}
|
||||
|
||||
function isDataPart(part: Part): part is DataPart {
|
||||
return part.kind === 'data';
|
||||
}
|
||||
|
||||
function isFilePart(part: Part): part is FilePart {
|
||||
return part.kind === 'file';
|
||||
}
|
||||
|
||||
function isStatusUpdateEvent(
|
||||
result: SendMessageResult,
|
||||
): result is TaskStatusUpdateEvent {
|
||||
return result.kind === 'status-update';
|
||||
}
|
||||
|
||||
function isArtifactUpdateEvent(
|
||||
result: SendMessageResult,
|
||||
): result is TaskArtifactUpdateEvent {
|
||||
return result.kind === 'artifact-update';
|
||||
}
|
||||
|
||||
function isMessage(result: SendMessageResult): result is Message {
|
||||
return result.kind === 'message';
|
||||
}
|
||||
|
||||
function isTask(result: SendMessageResult): result is Task {
|
||||
return result.kind === 'task';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the given state is a terminal state for a task.
|
||||
*/
|
||||
export function isTerminalState(state: TaskState | undefined): boolean {
|
||||
return (
|
||||
state === 'completed' ||
|
||||
state === 'failed' ||
|
||||
state === 'canceled' ||
|
||||
state === 'rejected'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if a value is a non-array object.
|
||||
*/
|
||||
function isObject(val: unknown): val is Record<string, unknown> {
|
||||
return typeof val === 'object' && val !== null && !Array.isArray(val);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import * as crypto from 'node:crypto';
|
||||
import { AgentRegistry, getModelConfigAlias } from './registry.js';
|
||||
import { makeFakeConfig } from '../test-utils/config.js';
|
||||
import type { AgentDefinition, LocalAgentDefinition } from './types.js';
|
||||
@@ -29,10 +30,23 @@ import { SimpleExtensionLoader } from '../utils/extensionLoader.js';
|
||||
import type { ToolRegistry } from '../tools/tool-registry.js';
|
||||
import { ThinkingLevel } from '@google/genai';
|
||||
import type { AcknowledgedAgentsService } from './acknowledgedAgents.js';
|
||||
import * as sdkClient from '@a2a-js/sdk/client';
|
||||
import { safeFetch } from '../utils/fetch.js';
|
||||
import { PolicyDecision } from '../policy/types.js';
|
||||
import { A2AAuthProviderFactory } from './auth-provider/factory.js';
|
||||
import type { A2AAuthProvider } from './auth-provider/types.js';
|
||||
|
||||
vi.mock('@a2a-js/sdk/client', async (importOriginal) => {
|
||||
const actual = await importOriginal();
|
||||
return {
|
||||
...(actual as Record<string, unknown>),
|
||||
DefaultAgentCardResolver: vi.fn().mockImplementation((options) => ({
|
||||
fetchImpl: options?.fetchImpl,
|
||||
resolve: vi.fn().mockResolvedValue({ name: 'RemoteAgent' }),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('./agentLoader.js', () => ({
|
||||
loadAgentsFromDirectory: vi
|
||||
.fn()
|
||||
@@ -417,7 +431,7 @@ describe('AgentRegistry', () => {
|
||||
expect(registry.getDefinition('extension-agent')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should use agentCardUrl as hash for acknowledgement of remote agents', async () => {
|
||||
it('should use agentCardUrl and content-based hash for acknowledgement of remote agents', async () => {
|
||||
mockConfig = makeMockedConfig({ enableAgents: true });
|
||||
// Trust the folder so it attempts to load project agents
|
||||
vi.spyOn(mockConfig, 'isTrustedFolder').mockReturnValue(true);
|
||||
@@ -453,21 +467,75 @@ describe('AgentRegistry', () => {
|
||||
clearCache: vi.fn(),
|
||||
} as unknown as A2AClientManager);
|
||||
|
||||
// Mock the resolver to return a consistent card content for hashing
|
||||
const mockCardContent = { name: 'RemoteAgent' };
|
||||
const expectedContentHash = crypto
|
||||
.createHash('sha256')
|
||||
.update(JSON.stringify(mockCardContent))
|
||||
.digest('hex');
|
||||
const expectedHash = `https://example.com/card#${expectedContentHash}`;
|
||||
|
||||
vi.mocked(sdkClient.DefaultAgentCardResolver).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
resolve: vi.fn().mockResolvedValue(mockCardContent),
|
||||
}) as unknown as sdkClient.DefaultAgentCardResolver,
|
||||
);
|
||||
|
||||
await registry.initialize();
|
||||
|
||||
// Verify ackService was called with the URL, not the file hash
|
||||
// Verify ackService was called with the content-based hash
|
||||
expect(ackService.isAcknowledged).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'RemoteAgent',
|
||||
'https://example.com/card',
|
||||
expectedHash,
|
||||
);
|
||||
|
||||
// Also verify that the agent's metadata was updated to use the URL as hash
|
||||
// Use getDefinition because registerAgent might have been called
|
||||
// Also verify that the agent's metadata was updated to use the content-based hash
|
||||
expect(registry.getDefinition('RemoteAgent')?.metadata?.hash).toBe(
|
||||
'https://example.com/card',
|
||||
expectedHash,
|
||||
);
|
||||
});
|
||||
|
||||
it('should use safeFetch in DefaultAgentCardResolver during initialization', async () => {
|
||||
mockConfig = makeMockedConfig({ enableAgents: true });
|
||||
vi.spyOn(mockConfig, 'isTrustedFolder').mockReturnValue(true);
|
||||
vi.spyOn(mockConfig, 'getFolderTrust').mockReturnValue(true);
|
||||
|
||||
const registry = new TestableAgentRegistry(mockConfig);
|
||||
|
||||
const remoteAgent: AgentDefinition = {
|
||||
kind: 'remote',
|
||||
name: 'RemoteAgent',
|
||||
description: 'A remote agent',
|
||||
agentCardUrl: 'https://example.com/card',
|
||||
inputConfig: { inputSchema: { type: 'object' } },
|
||||
};
|
||||
|
||||
vi.mocked(tomlLoader.loadAgentsFromDirectory).mockResolvedValue({
|
||||
agents: [remoteAgent],
|
||||
errors: [],
|
||||
});
|
||||
|
||||
// Track constructor calls
|
||||
const resolverMock = vi.mocked(sdkClient.DefaultAgentCardResolver);
|
||||
|
||||
await registry.initialize();
|
||||
|
||||
// Find the call for our remote agent
|
||||
const call = resolverMock.mock.calls.find((args) => {
|
||||
const options = args[0] as { fetchImpl?: typeof fetch };
|
||||
// We look for a call that was provided with a fetch implementation.
|
||||
// In our current implementation, we wrap safeFetch.
|
||||
return typeof options?.fetchImpl === 'function';
|
||||
});
|
||||
|
||||
expect(call).toBeDefined();
|
||||
const options = call?.[0] as { fetchImpl?: typeof fetch };
|
||||
|
||||
// We passed safeFetch directly
|
||||
expect(options?.fetchImpl).toBe(safeFetch);
|
||||
});
|
||||
});
|
||||
|
||||
describe('registration logic', () => {
|
||||
@@ -874,6 +942,17 @@ describe('AgentRegistry', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should maintain registration under canonical name', async () => {
|
||||
const originalName = 'my-agent';
|
||||
const definition = { ...MOCK_AGENT_V1, name: originalName };
|
||||
|
||||
await registry.testRegisterAgent(definition);
|
||||
|
||||
const registered = registry.getDefinition(originalName);
|
||||
expect(registered).toBeDefined();
|
||||
expect(registry.getAllAgentNames()).toEqual([originalName]);
|
||||
});
|
||||
|
||||
it('should reject an agent definition missing a name', async () => {
|
||||
const invalidAgent = { ...MOCK_AGENT_V1, name: '' };
|
||||
const debugWarnSpy = vi
|
||||
|
||||
@@ -9,14 +9,18 @@ import { CoreEvent, coreEvents } from '../utils/events.js';
|
||||
import type { AgentOverride, Config } from '../config/config.js';
|
||||
import type { AgentDefinition, LocalAgentDefinition } from './types.js';
|
||||
import { loadAgentsFromDirectory } from './agentLoader.js';
|
||||
import { splitAgentCardUrl } from './a2aUtils.js';
|
||||
import { CodebaseInvestigatorAgent } from './codebase-investigator.js';
|
||||
import { CliHelpAgent } from './cli-help-agent.js';
|
||||
import { GeneralistAgent } from './generalist-agent.js';
|
||||
import { BrowserAgentDefinition } from './browser/browserAgentDefinition.js';
|
||||
import { A2AClientManager } from './a2a-client-manager.js';
|
||||
import { A2AAuthProviderFactory } from './auth-provider/factory.js';
|
||||
import { DefaultAgentCardResolver } from '@a2a-js/sdk/client';
|
||||
import { safeFetch } from '../utils/fetch.js';
|
||||
import type { AuthenticationHandler } from '@a2a-js/sdk/client';
|
||||
import { type z } from 'zod';
|
||||
import * as crypto from 'node:crypto';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
import { isAutoModel } from '../config/models.js';
|
||||
import {
|
||||
@@ -155,13 +159,38 @@ export class AgentRegistry {
|
||||
const agentsToRegister: AgentDefinition[] = [];
|
||||
|
||||
for (const agent of projectAgents.agents) {
|
||||
// If it's a remote agent, use the agentCardUrl as the hash.
|
||||
// This allows multiple remote agents in a single file to be tracked independently.
|
||||
// For remote agents, we must use a content-based hash of the AgentCard
|
||||
// to prevent Indirect Prompt Injection if the remote card is modified.
|
||||
if (agent.kind === 'remote') {
|
||||
if (!agent.metadata) {
|
||||
agent.metadata = {};
|
||||
try {
|
||||
// We use a dedicated resolver here to fetch the card for hashing.
|
||||
// This is separate from loadAgent to keep hashing logic isolated.
|
||||
// We provide safeFetch to ensure SSRF and DNS rebinding protection.
|
||||
const resolver = new DefaultAgentCardResolver({
|
||||
fetchImpl: safeFetch,
|
||||
});
|
||||
const { baseUrl, path } = splitAgentCardUrl(agent.agentCardUrl);
|
||||
const rawCard = await resolver.resolve(baseUrl, path);
|
||||
const cardContent = JSON.stringify(rawCard);
|
||||
const contentHash = crypto
|
||||
.createHash('sha256')
|
||||
.update(cardContent)
|
||||
.digest('hex');
|
||||
|
||||
if (!agent.metadata) {
|
||||
agent.metadata = {};
|
||||
}
|
||||
// Combining URL and content hash ensures we track specific card versions at specific locations.
|
||||
agent.metadata.hash = `${agent.agentCardUrl}#${contentHash}`;
|
||||
} catch (e) {
|
||||
debugLogger.warn(
|
||||
`[AgentRegistry] Could not fetch remote card for hashing "${agent.name}":`,
|
||||
e,
|
||||
);
|
||||
// If we can't fetch the card, we can't verify its acknowledgement securely.
|
||||
unacknowledgedAgents.push(agent);
|
||||
continue;
|
||||
}
|
||||
agent.metadata.hash = agent.agentCardUrl;
|
||||
}
|
||||
|
||||
if (!agent.metadata?.hash) {
|
||||
@@ -178,6 +207,10 @@ export class AgentRegistry {
|
||||
if (isAcknowledged) {
|
||||
agentsToRegister.push(agent);
|
||||
} else {
|
||||
// Register unacknowledged agents so they are visible to the LLM.
|
||||
// They will be registered with ASK_USER policy, triggering the
|
||||
// acknowledgement flow when the LLM tries to call them.
|
||||
agentsToRegister.push(agent);
|
||||
unacknowledgedAgents.push(agent);
|
||||
}
|
||||
}
|
||||
@@ -312,6 +345,17 @@ export class AgentRegistry {
|
||||
}
|
||||
|
||||
const mergedDefinition = this.applyOverrides(definition, settingsOverrides);
|
||||
|
||||
// Ensure we don't accidentally overwrite an existing agent with a different canonical name
|
||||
if (
|
||||
mergedDefinition.name !== definition.name &&
|
||||
this.agents.has(mergedDefinition.name)
|
||||
) {
|
||||
throw new Error(
|
||||
`Cannot register agent '${definition.name}' as '${mergedDefinition.name}': Name collision with an already registered agent.`,
|
||||
);
|
||||
}
|
||||
|
||||
this.agents.set(mergedDefinition.name, mergedDefinition);
|
||||
|
||||
this.registerModelConfigs(mergedDefinition);
|
||||
|
||||
@@ -103,12 +103,13 @@ describe('AgentRegistry Acknowledgement', () => {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should not register unacknowledged project agents and emit event', async () => {
|
||||
it('should register unacknowledged project agents and emit event', async () => {
|
||||
const emitSpy = vi.spyOn(coreEvents, 'emitAgentsDiscovered');
|
||||
|
||||
await registry.initialize();
|
||||
|
||||
expect(registry.getDefinition('ProjectAgent')).toBeUndefined();
|
||||
// Now unacknowledged agents ARE registered (but with ASK_USER policy)
|
||||
expect(registry.getDefinition('ProjectAgent')).toBeDefined();
|
||||
expect(emitSpy).toHaveBeenCalledWith([MOCK_AGENT_WITH_HASH]);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest';
|
||||
import {
|
||||
isPrivateIp,
|
||||
isPrivateIpAsync,
|
||||
isAddressPrivate,
|
||||
safeLookup,
|
||||
safeFetch,
|
||||
} from './fetch.js';
|
||||
import * as dnsPromises from 'node:dns/promises';
|
||||
import * as dns from 'node:dns';
|
||||
|
||||
vi.mock('node:dns/promises', () => ({
|
||||
lookup: vi.fn(),
|
||||
}));
|
||||
|
||||
// We need to mock node:dns for safeLookup since it uses the callback API
|
||||
vi.mock('node:dns', () => ({
|
||||
lookup: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock global fetch
|
||||
const originalFetch = global.fetch;
|
||||
global.fetch = vi.fn();
|
||||
|
||||
describe('fetch utils', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
describe('isAddressPrivate', () => {
|
||||
it('should identify private IPv4 addresses', () => {
|
||||
expect(isAddressPrivate('10.0.0.1')).toBe(true);
|
||||
expect(isAddressPrivate('127.0.0.1')).toBe(true);
|
||||
expect(isAddressPrivate('172.16.0.1')).toBe(true);
|
||||
expect(isAddressPrivate('192.168.1.1')).toBe(true);
|
||||
});
|
||||
|
||||
it('should identify non-routable and reserved IPv4 addresses (RFC 6890)', () => {
|
||||
expect(isAddressPrivate('0.0.0.0')).toBe(true);
|
||||
expect(isAddressPrivate('100.64.0.1')).toBe(true);
|
||||
expect(isAddressPrivate('192.0.0.1')).toBe(true);
|
||||
expect(isAddressPrivate('192.0.2.1')).toBe(true);
|
||||
expect(isAddressPrivate('192.88.99.1')).toBe(true);
|
||||
// Benchmark range (198.18.0.0/15)
|
||||
expect(isAddressPrivate('198.18.0.0')).toBe(true);
|
||||
expect(isAddressPrivate('198.18.0.1')).toBe(true);
|
||||
expect(isAddressPrivate('198.19.255.255')).toBe(true);
|
||||
expect(isAddressPrivate('198.51.100.1')).toBe(true);
|
||||
expect(isAddressPrivate('203.0.113.1')).toBe(true);
|
||||
expect(isAddressPrivate('224.0.0.1')).toBe(true);
|
||||
expect(isAddressPrivate('240.0.0.1')).toBe(true);
|
||||
});
|
||||
|
||||
it('should identify private IPv6 addresses', () => {
|
||||
expect(isAddressPrivate('::1')).toBe(true);
|
||||
expect(isAddressPrivate('fc00::')).toBe(true);
|
||||
expect(isAddressPrivate('fd00::')).toBe(true);
|
||||
expect(isAddressPrivate('fe80::')).toBe(true);
|
||||
expect(isAddressPrivate('febf::')).toBe(true);
|
||||
});
|
||||
|
||||
it('should identify special local addresses', () => {
|
||||
expect(isAddressPrivate('0.0.0.0')).toBe(true);
|
||||
expect(isAddressPrivate('::')).toBe(true);
|
||||
expect(isAddressPrivate('localhost')).toBe(true);
|
||||
});
|
||||
|
||||
it('should identify link-local addresses', () => {
|
||||
expect(isAddressPrivate('169.254.169.254')).toBe(true);
|
||||
});
|
||||
|
||||
it('should identify IPv4-mapped IPv6 private addresses', () => {
|
||||
expect(isAddressPrivate('::ffff:127.0.0.1')).toBe(true);
|
||||
expect(isAddressPrivate('::ffff:10.0.0.1')).toBe(true);
|
||||
expect(isAddressPrivate('::ffff:169.254.169.254')).toBe(true);
|
||||
expect(isAddressPrivate('::ffff:192.168.1.1')).toBe(true);
|
||||
expect(isAddressPrivate('::ffff:172.16.0.1')).toBe(true);
|
||||
expect(isAddressPrivate('::ffff:0.0.0.0')).toBe(true);
|
||||
expect(isAddressPrivate('::ffff:100.64.0.1')).toBe(true);
|
||||
expect(isAddressPrivate('::ffff:a9fe:101')).toBe(true); // 169.254.1.1
|
||||
});
|
||||
|
||||
it('should identify public addresses as non-private', () => {
|
||||
expect(isAddressPrivate('8.8.8.8')).toBe(false);
|
||||
expect(isAddressPrivate('93.184.216.34')).toBe(false);
|
||||
expect(isAddressPrivate('2001:4860:4860::8888')).toBe(false);
|
||||
expect(isAddressPrivate('::ffff:8.8.8.8')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPrivateIp', () => {
|
||||
it('should identify private IPs in URLs', () => {
|
||||
expect(isPrivateIp('http://10.0.0.1/')).toBe(true);
|
||||
expect(isPrivateIp('https://127.0.0.1:8080/')).toBe(true);
|
||||
expect(isPrivateIp('http://localhost/')).toBe(true);
|
||||
expect(isPrivateIp('http://[::1]/')).toBe(true);
|
||||
});
|
||||
|
||||
it('should identify public IPs in URLs as non-private', () => {
|
||||
expect(isPrivateIp('http://8.8.8.8/')).toBe(false);
|
||||
expect(isPrivateIp('https://google.com/')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPrivateIpAsync', () => {
|
||||
it('should identify private IPs directly', async () => {
|
||||
expect(await isPrivateIpAsync('http://10.0.0.1/')).toBe(true);
|
||||
});
|
||||
|
||||
it('should identify domains resolving to private IPs', async () => {
|
||||
vi.mocked(dnsPromises.lookup).mockImplementation(
|
||||
async () =>
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
[{ address: '10.0.0.1', family: 4 }] as any,
|
||||
);
|
||||
expect(await isPrivateIpAsync('http://malicious.com/')).toBe(true);
|
||||
});
|
||||
|
||||
it('should identify domains resolving to public IPs as non-private', async () => {
|
||||
vi.mocked(dnsPromises.lookup).mockImplementation(
|
||||
async () =>
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
[{ address: '8.8.8.8', family: 4 }] as any,
|
||||
);
|
||||
expect(await isPrivateIpAsync('http://google.com/')).toBe(false);
|
||||
});
|
||||
|
||||
it('should throw error if DNS resolution fails (fail closed)', async () => {
|
||||
vi.mocked(dnsPromises.lookup).mockRejectedValue(new Error('DNS Error'));
|
||||
await expect(isPrivateIpAsync('http://unreachable.com/')).rejects.toThrow(
|
||||
'Failed to verify if URL resolves to private IP',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return false for invalid URLs instead of throwing verification error', async () => {
|
||||
expect(await isPrivateIpAsync('not-a-url')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('safeLookup', () => {
|
||||
it('should filter out private IPs', async () => {
|
||||
const addresses = [
|
||||
{ address: '8.8.8.8', family: 4 },
|
||||
{ address: '10.0.0.1', family: 4 },
|
||||
];
|
||||
|
||||
vi.mocked(dns.lookup).mockImplementation(((
|
||||
_h: string,
|
||||
_o: dns.LookupOptions,
|
||||
cb: (
|
||||
err: Error | null,
|
||||
addr: Array<{ address: string; family: number }>,
|
||||
) => void,
|
||||
) => {
|
||||
cb(null, addresses);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
}) as any);
|
||||
|
||||
const result = await new Promise<
|
||||
Array<{ address: string; family: number }>
|
||||
>((resolve, reject) => {
|
||||
safeLookup('example.com', { all: true }, (err, filtered) => {
|
||||
if (err) reject(err);
|
||||
else resolve(filtered);
|
||||
});
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].address).toBe('8.8.8.8');
|
||||
});
|
||||
|
||||
it('should allow explicit localhost', async () => {
|
||||
const addresses = [{ address: '127.0.0.1', family: 4 }];
|
||||
|
||||
vi.mocked(dns.lookup).mockImplementation(((
|
||||
_h: string,
|
||||
_o: dns.LookupOptions,
|
||||
cb: (
|
||||
err: Error | null,
|
||||
addr: Array<{ address: string; family: number }>,
|
||||
) => void,
|
||||
) => {
|
||||
cb(null, addresses);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
}) as any);
|
||||
|
||||
const result = await new Promise<
|
||||
Array<{ address: string; family: number }>
|
||||
>((resolve, reject) => {
|
||||
safeLookup('localhost', { all: true }, (err, filtered) => {
|
||||
if (err) reject(err);
|
||||
else resolve(filtered);
|
||||
});
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].address).toBe('127.0.0.1');
|
||||
});
|
||||
|
||||
it('should error if all resolved IPs are private', async () => {
|
||||
const addresses = [{ address: '10.0.0.1', family: 4 }];
|
||||
|
||||
vi.mocked(dns.lookup).mockImplementation(((
|
||||
_h: string,
|
||||
_o: dns.LookupOptions,
|
||||
cb: (
|
||||
err: Error | null,
|
||||
addr: Array<{ address: string; family: number }>,
|
||||
) => void,
|
||||
) => {
|
||||
cb(null, addresses);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
}) as any);
|
||||
|
||||
await expect(
|
||||
new Promise((resolve, reject) => {
|
||||
safeLookup('malicious.com', { all: true }, (err, filtered) => {
|
||||
if (err) reject(err);
|
||||
else resolve(filtered);
|
||||
});
|
||||
}),
|
||||
).rejects.toThrow('Refusing to connect to private IP address');
|
||||
});
|
||||
});
|
||||
|
||||
describe('safeFetch', () => {
|
||||
it('should forward to fetch with dispatcher', async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValue(new Response('ok'));
|
||||
|
||||
const response = await safeFetch('https://example.com');
|
||||
expect(response.status).toBe(200);
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'https://example.com',
|
||||
expect.objectContaining({
|
||||
dispatcher: expect.any(Object),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle Refusing to connect errors', async () => {
|
||||
vi.mocked(global.fetch).mockRejectedValue(
|
||||
new Error('Refusing to connect to private IP address'),
|
||||
);
|
||||
|
||||
await expect(safeFetch('http://10.0.0.1')).rejects.toThrow(
|
||||
'Access to private network is blocked',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,10 @@
|
||||
|
||||
import { getErrorMessage, isNodeError } from './errors.js';
|
||||
import { URL } from 'node:url';
|
||||
import * as dns from 'node:dns';
|
||||
import { lookup } from 'node:dns/promises';
|
||||
import { Agent, ProxyAgent, setGlobalDispatcher } from 'undici';
|
||||
import ipaddr from 'ipaddr.js';
|
||||
|
||||
const DEFAULT_HEADERS_TIMEOUT = 300000; // 5 minutes
|
||||
const DEFAULT_BODY_TIMEOUT = 300000; // 5 minutes
|
||||
@@ -19,15 +22,10 @@ setGlobalDispatcher(
|
||||
}),
|
||||
);
|
||||
|
||||
const PRIVATE_IP_RANGES = [
|
||||
/^10\./,
|
||||
/^127\./,
|
||||
/^172\.(1[6-9]|2[0-9]|3[0-1])\./,
|
||||
/^192\.168\./,
|
||||
/^::1$/,
|
||||
/^fc00:/,
|
||||
/^fe80:/,
|
||||
];
|
||||
// Local extension of RequestInit to support Node.js/undici dispatcher
|
||||
interface NodeFetchInit extends RequestInit {
|
||||
dispatcher?: Agent | ProxyAgent;
|
||||
}
|
||||
|
||||
export class FetchError extends Error {
|
||||
constructor(
|
||||
@@ -40,15 +38,210 @@ export class FetchError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes a hostname by stripping IPv6 brackets if present.
|
||||
*/
|
||||
export function sanitizeHostname(hostname: string): string {
|
||||
return hostname.startsWith('[') && hostname.endsWith(']')
|
||||
? hostname.slice(1, -1)
|
||||
: hostname;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a hostname is a local loopback address allowed for development/testing.
|
||||
*/
|
||||
export function isLoopbackHost(hostname: string): boolean {
|
||||
const sanitized = sanitizeHostname(hostname);
|
||||
return (
|
||||
sanitized === 'localhost' ||
|
||||
sanitized === '127.0.0.1' ||
|
||||
sanitized === '::1'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A custom DNS lookup implementation for undici agents that prevents
|
||||
* connection to private IP ranges (SSRF protection).
|
||||
*/
|
||||
export function safeLookup(
|
||||
hostname: string,
|
||||
options: dns.LookupOptions | number | null | undefined,
|
||||
callback: (
|
||||
err: Error | null,
|
||||
addresses: Array<{ address: string; family: number }>,
|
||||
) => void,
|
||||
): void {
|
||||
// Use the callback-based dns.lookup to match undici's expected signature.
|
||||
// We explicitly handle the 'all' option to ensure we get an array of addresses.
|
||||
const lookupOptions =
|
||||
typeof options === 'number' ? { family: options } : { ...options };
|
||||
const finalOptions = { ...lookupOptions, all: true };
|
||||
|
||||
dns.lookup(hostname, finalOptions, (err, addresses) => {
|
||||
if (err) {
|
||||
callback(err, []);
|
||||
return;
|
||||
}
|
||||
|
||||
const addressArray = Array.isArray(addresses) ? addresses : [];
|
||||
const filtered = addressArray.filter(
|
||||
(addr) => !isAddressPrivate(addr.address) || isLoopbackHost(hostname),
|
||||
);
|
||||
|
||||
if (filtered.length === 0 && addressArray.length > 0) {
|
||||
callback(new Error(`Refusing to connect to private IP address`), []);
|
||||
return;
|
||||
}
|
||||
|
||||
callback(null, filtered);
|
||||
});
|
||||
}
|
||||
|
||||
// Dedicated dispatcher with connection-level SSRF protection (safeLookup)
|
||||
const safeDispatcher = new Agent({
|
||||
headersTimeout: DEFAULT_HEADERS_TIMEOUT,
|
||||
bodyTimeout: DEFAULT_BODY_TIMEOUT,
|
||||
connect: {
|
||||
lookup: safeLookup,
|
||||
},
|
||||
});
|
||||
|
||||
export function isPrivateIp(url: string): boolean {
|
||||
try {
|
||||
const hostname = new URL(url).hostname;
|
||||
return PRIVATE_IP_RANGES.some((range) => range.test(hostname));
|
||||
return isAddressPrivate(hostname);
|
||||
} catch (_e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a URL resolves to a private IP address.
|
||||
* Performs DNS resolution to prevent DNS rebinding/SSRF bypasses.
|
||||
*/
|
||||
export async function isPrivateIpAsync(url: string): Promise<boolean> {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
const hostname = parsed.hostname;
|
||||
|
||||
// Fast check for literal IPs or localhost
|
||||
if (isAddressPrivate(hostname)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Resolve DNS to check the actual target IP
|
||||
const addresses = await lookup(hostname, { all: true });
|
||||
return addresses.some((addr) => isAddressPrivate(addr.address));
|
||||
} catch (e) {
|
||||
if (
|
||||
e instanceof Error &&
|
||||
e.name === 'TypeError' &&
|
||||
e.message.includes('Invalid URL')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
throw new Error(`Failed to verify if URL resolves to private IP: ${url}`, {
|
||||
cause: e,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal helper to check if an IP address string is in a private or reserved range.
|
||||
*/
|
||||
export function isAddressPrivate(address: string): boolean {
|
||||
const sanitized = sanitizeHostname(address);
|
||||
|
||||
if (sanitized === 'localhost') {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!ipaddr.isValid(sanitized)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const addr = ipaddr.parse(sanitized);
|
||||
const range = addr.range();
|
||||
|
||||
// Special handling for IPv4-mapped IPv6 (::ffff:x.x.x.x)
|
||||
// We unmap it and check the underlying IPv4 address.
|
||||
if (addr instanceof ipaddr.IPv6 && addr.isIPv4MappedAddress()) {
|
||||
return isAddressPrivate(addr.toIPv4Address().toString());
|
||||
}
|
||||
|
||||
// Explicitly block 198.18.0.0/15 (Benchmark testing) which ipaddr.js
|
||||
// classifies as unicast, but is not public internet.
|
||||
if (addr instanceof ipaddr.IPv4) {
|
||||
const [r, bits] = ipaddr.parseCIDR('198.18.0.0/15');
|
||||
if (r instanceof ipaddr.IPv4 && addr.match(r, bits)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return range !== 'unicast';
|
||||
} catch (_e) {
|
||||
// If parsing fails despite isValid(), we treat it as potentially unsafe.
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhanced fetch with SSRF protection.
|
||||
* Prevents access to private/internal networks at the connection level.
|
||||
*/
|
||||
export async function safeFetch(
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
): Promise<Response> {
|
||||
const nodeInit: NodeFetchInit = {
|
||||
...init,
|
||||
dispatcher: safeDispatcher,
|
||||
};
|
||||
|
||||
try {
|
||||
return await fetch(input, nodeInit);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
// Re-map refusing to connect errors to standard FetchError
|
||||
if (error.message.includes('Refusing to connect to private IP address')) {
|
||||
const urlString =
|
||||
input instanceof Request
|
||||
? input.url
|
||||
: typeof input === 'string'
|
||||
? input
|
||||
: input.toString();
|
||||
throw new FetchError(
|
||||
`Access to private network is blocked: ${urlString}`,
|
||||
'ERR_PRIVATE_NETWORK',
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
throw new FetchError(
|
||||
getErrorMessage(error),
|
||||
isNodeError(error) ? error.code : undefined,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
throw new FetchError(String(error));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an undici ProxyAgent that incorporates safe DNS lookup.
|
||||
*/
|
||||
export function createSafeProxyAgent(proxyUrl: string): ProxyAgent {
|
||||
return new ProxyAgent({
|
||||
uri: proxyUrl,
|
||||
connect: {
|
||||
lookup: safeLookup,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs a fetch with a specified timeout and connection-level SSRF protection.
|
||||
*/
|
||||
export async function fetchWithTimeout(
|
||||
url: string,
|
||||
timeout: number,
|
||||
@@ -67,16 +260,29 @@ export async function fetchWithTimeout(
|
||||
}
|
||||
}
|
||||
|
||||
const nodeInit: NodeFetchInit = {
|
||||
...options,
|
||||
signal: controller.signal,
|
||||
dispatcher: safeDispatcher,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
signal: controller.signal,
|
||||
});
|
||||
const response = await fetch(url, nodeInit);
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (isNodeError(error) && error.code === 'ABORT_ERR') {
|
||||
throw new FetchError(`Request timed out after ${timeout}ms`, 'ETIMEDOUT');
|
||||
}
|
||||
if (
|
||||
error instanceof Error &&
|
||||
error.message.includes('Refusing to connect to private IP address')
|
||||
) {
|
||||
throw new FetchError(
|
||||
`Access to private network is blocked: ${url}`,
|
||||
'ERR_PRIVATE_NETWORK',
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
throw new FetchError(getErrorMessage(error), undefined, { cause: error });
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
Reference in New Issue
Block a user