mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-08-03 05:31:02 -07:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1d2ba62564 | |||
| 1390a02b10 | |||
| 7f2ab59753 | |||
| 7b226a1fa0 | |||
| 16c5c0b1cf | |||
| 72e0cd5112 | |||
| bdda2f4d59 | |||
| a110442703 | |||
| 75e6d0cf6c | |||
| 670bdf2674 |
@@ -35,11 +35,6 @@ const commonRestrictedSyntaxRules = [
|
||||
message:
|
||||
'Do not throw string literals or non-Error objects. Throw new Error("...") instead.',
|
||||
},
|
||||
{
|
||||
selector: 'CallExpression[callee.name="fetch"]',
|
||||
message:
|
||||
'Use safeFetch() from "@/utils/fetch" instead of the global fetch() to ensure SSRF protection. If you are implementing a custom security layer, use an eslint-disable comment and explain why.',
|
||||
},
|
||||
];
|
||||
|
||||
export default tseslint.config(
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
getPersistedState,
|
||||
setPersistedState,
|
||||
type StateChange,
|
||||
type AgentSettings,
|
||||
type AgentSettings as CoderAgentSettings,
|
||||
type PersistedStateMetadata,
|
||||
getContextIdFromMetadata,
|
||||
getAgentSettingsFromMetadata,
|
||||
@@ -44,9 +44,9 @@ import { pushTaskStateFailed } from '../utils/executor_utils.js';
|
||||
*/
|
||||
class TaskWrapper {
|
||||
task: Task;
|
||||
agentSettings: AgentSettings;
|
||||
agentSettings: CoderAgentSettings;
|
||||
|
||||
constructor(task: Task, agentSettings: AgentSettings) {
|
||||
constructor(task: Task, agentSettings: CoderAgentSettings) {
|
||||
this.task = task;
|
||||
this.agentSettings = agentSettings;
|
||||
}
|
||||
@@ -89,14 +89,18 @@ export class CoderAgentExecutor implements AgentExecutor {
|
||||
constructor(private taskStore?: TaskStore) {}
|
||||
|
||||
private async getConfig(
|
||||
agentSettings: AgentSettings,
|
||||
agentSettings: CoderAgentSettings,
|
||||
taskId: string,
|
||||
): Promise<Config> {
|
||||
const workspaceRoot = setTargetDir(agentSettings);
|
||||
loadEnvironment(); // Will override any global env with workspace envs
|
||||
const settings = loadSettings(workspaceRoot);
|
||||
const loadedSettings = loadSettings(workspaceRoot);
|
||||
const extensions = loadExtensions(workspaceRoot);
|
||||
return loadConfig(settings, new SimpleExtensionLoader(extensions), taskId);
|
||||
return loadConfig(
|
||||
loadedSettings,
|
||||
new SimpleExtensionLoader(extensions),
|
||||
taskId,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -138,10 +142,10 @@ export class CoderAgentExecutor implements AgentExecutor {
|
||||
async createTask(
|
||||
taskId: string,
|
||||
contextId: string,
|
||||
agentSettingsInput?: AgentSettings,
|
||||
agentSettingsInput?: CoderAgentSettings,
|
||||
eventBus?: ExecutionEventBus,
|
||||
): Promise<TaskWrapper> {
|
||||
const agentSettings: AgentSettings = agentSettingsInput || {
|
||||
const agentSettings: CoderAgentSettings = agentSettingsInput || {
|
||||
kind: CoderAgentEvent.StateAgentSettingsEvent,
|
||||
workspacePath: process.cwd(),
|
||||
};
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import * as path from 'node:path';
|
||||
import { loadConfig } from './config.js';
|
||||
import type { Settings } from './settings.js';
|
||||
import type { LoadedSettings } from './settings.js';
|
||||
import {
|
||||
type ExtensionLoader,
|
||||
FileDiscoveryService,
|
||||
@@ -40,6 +40,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
},
|
||||
},
|
||||
}),
|
||||
isTrustedFolder: vi.fn().mockReturnValue(true),
|
||||
getRemoteAdminSettings: vi.fn(),
|
||||
setRemoteAdminSettings: vi.fn(),
|
||||
};
|
||||
@@ -72,13 +73,35 @@ vi.mock('../utils/logger.js', () => ({
|
||||
}));
|
||||
|
||||
describe('loadConfig', () => {
|
||||
const mockSettings = {} as Settings;
|
||||
const mockLoadedSettings: LoadedSettings = {
|
||||
userSettings: {},
|
||||
workspaceSettings: {},
|
||||
};
|
||||
const mockExtensionLoader = {} as ExtensionLoader;
|
||||
const taskId = 'test-task-id';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubEnv('GEMINI_API_KEY', 'test-key');
|
||||
|
||||
// Default mock implementation for Config to include isTrustedFolder
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(Config as any).mockImplementation((params: any) => ({
|
||||
...params,
|
||||
initialize: vi.fn(),
|
||||
waitForMcpInit: vi.fn(),
|
||||
refreshAuth: vi.fn(),
|
||||
getExperiments: vi.fn().mockReturnValue({
|
||||
flags: {
|
||||
[ExperimentFlags.ENABLE_ADMIN_CONTROLS]: {
|
||||
boolValue: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
isTrustedFolder: vi.fn().mockReturnValue(true),
|
||||
getRemoteAdminSettings: vi.fn(),
|
||||
setRemoteAdminSettings: vi.fn(),
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -87,12 +110,12 @@ describe('loadConfig', () => {
|
||||
|
||||
describe('admin settings overrides', () => {
|
||||
it('should not fetch admin controls if experiment is disabled', async () => {
|
||||
await loadConfig(mockSettings, mockExtensionLoader, taskId);
|
||||
await loadConfig(mockLoadedSettings, mockExtensionLoader, taskId);
|
||||
expect(fetchAdminControlsOnce).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should pass clientName as a2a-server to Config', async () => {
|
||||
await loadConfig(mockSettings, mockExtensionLoader, taskId);
|
||||
await loadConfig(mockLoadedSettings, mockExtensionLoader, taskId);
|
||||
expect(Config).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
clientName: 'a2a-server',
|
||||
@@ -117,6 +140,7 @@ describe('loadConfig', () => {
|
||||
},
|
||||
},
|
||||
}),
|
||||
isTrustedFolder: vi.fn().mockReturnValue(true),
|
||||
getRemoteAdminSettings: vi.fn().mockReturnValue({}),
|
||||
setRemoteAdminSettings: vi.fn(),
|
||||
};
|
||||
@@ -138,7 +162,7 @@ describe('loadConfig', () => {
|
||||
};
|
||||
vi.mocked(fetchAdminControlsOnce).mockResolvedValue(mockAdminSettings);
|
||||
|
||||
await loadConfig(mockSettings, mockExtensionLoader, taskId);
|
||||
await loadConfig(mockLoadedSettings, mockExtensionLoader, taskId);
|
||||
|
||||
expect(Config).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -159,7 +183,7 @@ describe('loadConfig', () => {
|
||||
};
|
||||
vi.mocked(fetchAdminControlsOnce).mockResolvedValue(mockAdminSettings);
|
||||
|
||||
await loadConfig(mockSettings, mockExtensionLoader, taskId);
|
||||
await loadConfig(mockLoadedSettings, mockExtensionLoader, taskId);
|
||||
|
||||
expect(Config).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -174,7 +198,7 @@ describe('loadConfig', () => {
|
||||
const mockAdminSettings: FetchAdminControlsResponse = {};
|
||||
vi.mocked(fetchAdminControlsOnce).mockResolvedValue(mockAdminSettings);
|
||||
|
||||
await loadConfig(mockSettings, mockExtensionLoader, taskId);
|
||||
await loadConfig(mockLoadedSettings, mockExtensionLoader, taskId);
|
||||
|
||||
expect(Config).toHaveBeenLastCalledWith(expect.objectContaining({}));
|
||||
});
|
||||
@@ -193,7 +217,7 @@ describe('loadConfig', () => {
|
||||
);
|
||||
vi.mocked(fetchAdminControlsOnce).mockResolvedValue(mockAdminSettings);
|
||||
|
||||
await loadConfig(mockSettings, mockExtensionLoader, taskId);
|
||||
await loadConfig(mockLoadedSettings, mockExtensionLoader, taskId);
|
||||
|
||||
expect(fetchAdminControlsOnce).toHaveBeenCalledWith(
|
||||
mockCodeAssistServer,
|
||||
@@ -213,7 +237,11 @@ describe('loadConfig', () => {
|
||||
it('should set customIgnoreFilePaths when CUSTOM_IGNORE_FILE_PATHS env var is present', async () => {
|
||||
const testPath = '/tmp/ignore';
|
||||
vi.stubEnv('CUSTOM_IGNORE_FILE_PATHS', testPath);
|
||||
const config = await loadConfig(mockSettings, mockExtensionLoader, taskId);
|
||||
const config = await loadConfig(
|
||||
mockLoadedSettings,
|
||||
mockExtensionLoader,
|
||||
taskId,
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
expect((config as any).fileFiltering.customIgnoreFilePaths).toEqual([
|
||||
testPath,
|
||||
@@ -222,12 +250,19 @@ describe('loadConfig', () => {
|
||||
|
||||
it('should set customIgnoreFilePaths when settings.fileFiltering.customIgnoreFilePaths is present', async () => {
|
||||
const testPath = '/settings/ignore';
|
||||
const settings: Settings = {
|
||||
fileFiltering: {
|
||||
customIgnoreFilePaths: [testPath],
|
||||
const loadedSettings: LoadedSettings = {
|
||||
userSettings: {
|
||||
fileFiltering: {
|
||||
customIgnoreFilePaths: [testPath],
|
||||
},
|
||||
},
|
||||
workspaceSettings: {},
|
||||
};
|
||||
const config = await loadConfig(settings, mockExtensionLoader, taskId);
|
||||
const config = await loadConfig(
|
||||
loadedSettings,
|
||||
mockExtensionLoader,
|
||||
taskId,
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
expect((config as any).fileFiltering.customIgnoreFilePaths).toEqual([
|
||||
testPath,
|
||||
@@ -238,12 +273,19 @@ describe('loadConfig', () => {
|
||||
const envPath = '/env/ignore';
|
||||
const settingsPath = '/settings/ignore';
|
||||
vi.stubEnv('CUSTOM_IGNORE_FILE_PATHS', envPath);
|
||||
const settings: Settings = {
|
||||
fileFiltering: {
|
||||
customIgnoreFilePaths: [settingsPath],
|
||||
const loadedSettings: LoadedSettings = {
|
||||
userSettings: {
|
||||
fileFiltering: {
|
||||
customIgnoreFilePaths: [settingsPath],
|
||||
},
|
||||
},
|
||||
workspaceSettings: {},
|
||||
};
|
||||
const config = await loadConfig(settings, mockExtensionLoader, taskId);
|
||||
const config = await loadConfig(
|
||||
loadedSettings,
|
||||
mockExtensionLoader,
|
||||
taskId,
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
expect((config as any).fileFiltering.customIgnoreFilePaths).toEqual([
|
||||
settingsPath,
|
||||
@@ -254,13 +296,21 @@ describe('loadConfig', () => {
|
||||
it('should split CUSTOM_IGNORE_FILE_PATHS using system delimiter', async () => {
|
||||
const paths = ['/path/one', '/path/two'];
|
||||
vi.stubEnv('CUSTOM_IGNORE_FILE_PATHS', paths.join(path.delimiter));
|
||||
const config = await loadConfig(mockSettings, mockExtensionLoader, taskId);
|
||||
const config = await loadConfig(
|
||||
mockLoadedSettings,
|
||||
mockExtensionLoader,
|
||||
taskId,
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
expect((config as any).fileFiltering.customIgnoreFilePaths).toEqual(paths);
|
||||
});
|
||||
|
||||
it('should have empty customIgnoreFilePaths when both are missing', async () => {
|
||||
const config = await loadConfig(mockSettings, mockExtensionLoader, taskId);
|
||||
const config = await loadConfig(
|
||||
mockLoadedSettings,
|
||||
mockExtensionLoader,
|
||||
taskId,
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
expect((config as any).fileFiltering.customIgnoreFilePaths).toEqual([]);
|
||||
});
|
||||
@@ -268,13 +318,16 @@ describe('loadConfig', () => {
|
||||
it('should initialize FileDiscoveryService with correct options', async () => {
|
||||
const testPath = '/tmp/ignore';
|
||||
vi.stubEnv('CUSTOM_IGNORE_FILE_PATHS', testPath);
|
||||
const settings: Settings = {
|
||||
fileFiltering: {
|
||||
respectGitIgnore: false,
|
||||
const loadedSettings: LoadedSettings = {
|
||||
userSettings: {
|
||||
fileFiltering: {
|
||||
respectGitIgnore: false,
|
||||
},
|
||||
},
|
||||
workspaceSettings: {},
|
||||
};
|
||||
|
||||
await loadConfig(settings, mockExtensionLoader, taskId);
|
||||
await loadConfig(loadedSettings, mockExtensionLoader, taskId);
|
||||
|
||||
expect(FileDiscoveryService).toHaveBeenCalledWith(expect.any(String), {
|
||||
respectGitIgnore: false,
|
||||
@@ -285,10 +338,13 @@ describe('loadConfig', () => {
|
||||
|
||||
describe('tool configuration', () => {
|
||||
it('should pass V1 allowedTools to Config properly', async () => {
|
||||
const settings: Settings = {
|
||||
allowedTools: ['shell', 'edit'],
|
||||
const loadedSettings: LoadedSettings = {
|
||||
userSettings: {
|
||||
allowedTools: ['shell', 'edit'],
|
||||
},
|
||||
workspaceSettings: {},
|
||||
};
|
||||
await loadConfig(settings, mockExtensionLoader, taskId);
|
||||
await loadConfig(loadedSettings, mockExtensionLoader, taskId);
|
||||
expect(Config).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
allowedTools: ['shell', 'edit'],
|
||||
@@ -297,12 +353,15 @@ describe('loadConfig', () => {
|
||||
});
|
||||
|
||||
it('should pass V2 tools.allowed to Config properly', async () => {
|
||||
const settings: Settings = {
|
||||
tools: {
|
||||
allowed: ['shell', 'fetch'],
|
||||
const loadedSettings: LoadedSettings = {
|
||||
userSettings: {
|
||||
tools: {
|
||||
allowed: ['shell', 'fetch'],
|
||||
},
|
||||
},
|
||||
workspaceSettings: {},
|
||||
};
|
||||
await loadConfig(settings, mockExtensionLoader, taskId);
|
||||
await loadConfig(loadedSettings, mockExtensionLoader, taskId);
|
||||
expect(Config).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
allowedTools: ['shell', 'fetch'],
|
||||
@@ -311,13 +370,16 @@ describe('loadConfig', () => {
|
||||
});
|
||||
|
||||
it('should prefer V1 allowedTools over V2 tools.allowed if both present', async () => {
|
||||
const settings: Settings = {
|
||||
allowedTools: ['v1-tool'],
|
||||
tools: {
|
||||
allowed: ['v2-tool'],
|
||||
const loadedSettings: LoadedSettings = {
|
||||
userSettings: {
|
||||
allowedTools: ['v1-tool'],
|
||||
tools: {
|
||||
allowed: ['v2-tool'],
|
||||
},
|
||||
},
|
||||
workspaceSettings: {},
|
||||
};
|
||||
await loadConfig(settings, mockExtensionLoader, taskId);
|
||||
await loadConfig(loadedSettings, mockExtensionLoader, taskId);
|
||||
expect(Config).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
allowedTools: ['v1-tool'],
|
||||
@@ -325,10 +387,42 @@ describe('loadConfig', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass agent settings to Config', async () => {
|
||||
const loadedSettings: LoadedSettings = {
|
||||
userSettings: {
|
||||
experimental: {
|
||||
enableAgents: false,
|
||||
},
|
||||
agents: {
|
||||
overrides: {
|
||||
test_agent: { enabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
workspaceSettings: {},
|
||||
};
|
||||
await loadConfig(loadedSettings, mockExtensionLoader, taskId);
|
||||
expect(Config).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
enableAgents: false,
|
||||
agents: loadedSettings.userSettings.agents,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should default enableAgents to false if not specified (secure default)', async () => {
|
||||
await loadConfig(mockLoadedSettings, mockExtensionLoader, taskId);
|
||||
expect(Config).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
enableAgents: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
describe('interactivity', () => {
|
||||
it('should set interactive true when not headless', async () => {
|
||||
vi.mocked(isHeadlessMode).mockReturnValue(false);
|
||||
await loadConfig(mockSettings, mockExtensionLoader, taskId);
|
||||
await loadConfig(mockLoadedSettings, mockExtensionLoader, taskId);
|
||||
expect(Config).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
interactive: true,
|
||||
@@ -339,7 +433,7 @@ describe('loadConfig', () => {
|
||||
|
||||
it('should set interactive false when headless', async () => {
|
||||
vi.mocked(isHeadlessMode).mockReturnValue(true);
|
||||
await loadConfig(mockSettings, mockExtensionLoader, taskId);
|
||||
await loadConfig(mockLoadedSettings, mockExtensionLoader, taskId);
|
||||
expect(Config).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
interactive: false,
|
||||
@@ -378,12 +472,13 @@ describe('loadConfig', () => {
|
||||
waitForMcpInit: vi.fn(),
|
||||
refreshAuth: refreshAuthMock,
|
||||
getExperiments: vi.fn().mockReturnValue({ flags: {} }),
|
||||
isTrustedFolder: vi.fn().mockReturnValue(true),
|
||||
getRemoteAdminSettings: vi.fn(),
|
||||
setRemoteAdminSettings: vi.fn(),
|
||||
}) as unknown as Config,
|
||||
);
|
||||
|
||||
await loadConfig(mockSettings, mockExtensionLoader, taskId);
|
||||
await loadConfig(mockLoadedSettings, mockExtensionLoader, taskId);
|
||||
|
||||
expect(refreshAuthMock).toHaveBeenCalledWith(
|
||||
AuthType.LOGIN_WITH_GOOGLE,
|
||||
@@ -408,13 +503,14 @@ describe('loadConfig', () => {
|
||||
waitForMcpInit: vi.fn(),
|
||||
refreshAuth: refreshAuthMock,
|
||||
getExperiments: vi.fn().mockReturnValue({ flags: {} }),
|
||||
isTrustedFolder: vi.fn().mockReturnValue(true),
|
||||
getRemoteAdminSettings: vi.fn(),
|
||||
setRemoteAdminSettings: vi.fn(),
|
||||
}) as unknown as Config,
|
||||
);
|
||||
|
||||
await expect(
|
||||
loadConfig(mockSettings, mockExtensionLoader, taskId),
|
||||
loadConfig(mockLoadedSettings, mockExtensionLoader, taskId),
|
||||
).rejects.toThrow('Non-interactive session');
|
||||
|
||||
expect(refreshAuthMock).toHaveBeenCalledWith(
|
||||
@@ -437,12 +533,13 @@ describe('loadConfig', () => {
|
||||
waitForMcpInit: vi.fn(),
|
||||
refreshAuth: refreshAuthMock,
|
||||
getExperiments: vi.fn().mockReturnValue({ flags: {} }),
|
||||
isTrustedFolder: vi.fn().mockReturnValue(true),
|
||||
getRemoteAdminSettings: vi.fn(),
|
||||
setRemoteAdminSettings: vi.fn(),
|
||||
}) as unknown as Config,
|
||||
);
|
||||
|
||||
await loadConfig(mockSettings, mockExtensionLoader, taskId);
|
||||
await loadConfig(mockLoadedSettings, mockExtensionLoader, taskId);
|
||||
|
||||
expect(refreshAuthMock).not.toHaveBeenCalledWith(
|
||||
AuthType.LOGIN_WITH_GOOGLE,
|
||||
@@ -464,12 +561,13 @@ describe('loadConfig', () => {
|
||||
waitForMcpInit: vi.fn(),
|
||||
refreshAuth: refreshAuthMock,
|
||||
getExperiments: vi.fn().mockReturnValue({ flags: {} }),
|
||||
isTrustedFolder: vi.fn().mockReturnValue(true),
|
||||
getRemoteAdminSettings: vi.fn(),
|
||||
setRemoteAdminSettings: vi.fn(),
|
||||
}) as unknown as Config,
|
||||
);
|
||||
|
||||
await loadConfig(mockSettings, mockExtensionLoader, taskId);
|
||||
await loadConfig(mockLoadedSettings, mockExtensionLoader, taskId);
|
||||
|
||||
expect(refreshAuthMock).not.toHaveBeenCalledWith(
|
||||
AuthType.LOGIN_WITH_GOOGLE,
|
||||
@@ -490,13 +588,14 @@ describe('loadConfig', () => {
|
||||
waitForMcpInit: vi.fn(),
|
||||
refreshAuth: refreshAuthMock,
|
||||
getExperiments: vi.fn().mockReturnValue({ flags: {} }),
|
||||
isTrustedFolder: vi.fn().mockReturnValue(true),
|
||||
getRemoteAdminSettings: vi.fn(),
|
||||
setRemoteAdminSettings: vi.fn(),
|
||||
}) as unknown as Config,
|
||||
);
|
||||
|
||||
await expect(
|
||||
loadConfig(mockSettings, mockExtensionLoader, taskId),
|
||||
loadConfig(mockLoadedSettings, mockExtensionLoader, taskId),
|
||||
).rejects.toThrow(
|
||||
'Interactive terminal required for LOGIN_WITH_GOOGLE. Run in an interactive terminal or set GEMINI_CLI_USE_COMPUTE_ADC=true to use Application Default Credentials.',
|
||||
);
|
||||
@@ -526,13 +625,14 @@ describe('loadConfig', () => {
|
||||
waitForMcpInit: vi.fn(),
|
||||
refreshAuth: refreshAuthMock,
|
||||
getExperiments: vi.fn().mockReturnValue({ flags: {} }),
|
||||
isTrustedFolder: vi.fn().mockReturnValue(true),
|
||||
getRemoteAdminSettings: vi.fn(),
|
||||
setRemoteAdminSettings: vi.fn(),
|
||||
}) as unknown as Config,
|
||||
);
|
||||
|
||||
await expect(
|
||||
loadConfig(mockSettings, mockExtensionLoader, taskId),
|
||||
loadConfig(mockLoadedSettings, mockExtensionLoader, taskId),
|
||||
).rejects.toThrow(
|
||||
'OAuth failed. Fallback to COMPUTE_ADC also failed: ADC failed',
|
||||
);
|
||||
|
||||
@@ -32,17 +32,49 @@ import {
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
import { logger } from '../utils/logger.js';
|
||||
import type { Settings } from './settings.js';
|
||||
import { type AgentSettings, CoderAgentEvent } from '../types.js';
|
||||
import {
|
||||
type Settings,
|
||||
type LoadedSettings,
|
||||
resolveEnvVarsInObject,
|
||||
} from './settings.js';
|
||||
import {
|
||||
type AgentSettings as CoderAgentSettings,
|
||||
CoderAgentEvent,
|
||||
} from '../types.js';
|
||||
|
||||
export async function loadConfig(
|
||||
settings: Settings,
|
||||
loadedSettings: LoadedSettings,
|
||||
extensionLoader: ExtensionLoader,
|
||||
taskId: string,
|
||||
): Promise<Config> {
|
||||
const workspaceDir = process.cwd();
|
||||
const adcFilePath = process.env['GOOGLE_APPLICATION_CREDENTIALS'];
|
||||
|
||||
const { userSettings, workspaceSettings } = loadedSettings;
|
||||
|
||||
// Set an initial config to use to determine trust and get a code assist server.
|
||||
// This is needed to fetch admin controls and safely resolve environment variables.
|
||||
const initialConfigParams: ConfigParameters = {
|
||||
sessionId: taskId,
|
||||
targetDir: workspaceDir,
|
||||
cwd: workspaceDir,
|
||||
extensionLoader,
|
||||
debugMode: process.env['DEBUG'] === 'true' || false,
|
||||
model: PREVIEW_GEMINI_MODEL,
|
||||
};
|
||||
const tempConfig = new Config(initialConfigParams);
|
||||
|
||||
// Securely merge settings: only expand workspace variables if the user trusts this folder.
|
||||
// If there are overlapping keys, the values of workspaceSettings will
|
||||
// override values from userSettings.
|
||||
const isTrusted = tempConfig.isTrustedFolder();
|
||||
const settings: Settings = {
|
||||
...userSettings,
|
||||
...(isTrusted
|
||||
? resolveEnvVarsInObject(workspaceSettings)
|
||||
: workspaceSettings),
|
||||
};
|
||||
|
||||
const folderTrust =
|
||||
settings.folderTrust === true ||
|
||||
process.env['GEMINI_FOLDER_TRUST'] === 'true';
|
||||
@@ -110,6 +142,8 @@ export async function loadConfig(
|
||||
interactive: !isHeadlessMode(),
|
||||
enableInteractiveShell: !isHeadlessMode(),
|
||||
ptyInfo: 'auto',
|
||||
enableAgents: settings.experimental?.enableAgents ?? false,
|
||||
agents: settings.agents,
|
||||
};
|
||||
|
||||
const fileService = new FileDiscoveryService(workspaceDir, {
|
||||
@@ -129,16 +163,11 @@ export async function loadConfig(
|
||||
configParams.geminiMdFileCount = fileCount;
|
||||
configParams.geminiMdFilePaths = filePaths;
|
||||
|
||||
// Set an initial config to use to get a code assist server.
|
||||
// This is needed to fetch admin controls.
|
||||
const initialConfig = new Config({
|
||||
...configParams,
|
||||
});
|
||||
|
||||
const codeAssistServer = getCodeAssistServer(initialConfig);
|
||||
// Use initialConfig to fetch admin controls.
|
||||
const codeAssistServer = getCodeAssistServer(tempConfig);
|
||||
|
||||
const adminControlsEnabled =
|
||||
initialConfig.getExperiments()?.flags[ExperimentFlags.ENABLE_ADMIN_CONTROLS]
|
||||
tempConfig.getExperiments()?.flags[ExperimentFlags.ENABLE_ADMIN_CONTROLS]
|
||||
?.boolValue ?? false;
|
||||
|
||||
// Initialize final config parameters to the previous parameters.
|
||||
@@ -178,7 +207,9 @@ export async function loadConfig(
|
||||
return config;
|
||||
}
|
||||
|
||||
export function setTargetDir(agentSettings: AgentSettings | undefined): string {
|
||||
export function setTargetDir(
|
||||
agentSettings: CoderAgentSettings | undefined,
|
||||
): string {
|
||||
const originalCWD = process.cwd();
|
||||
const targetDir =
|
||||
process.env['CODER_AGENT_WORKSPACE_PATH'] ??
|
||||
|
||||
@@ -106,27 +106,41 @@ describe('loadSettings', () => {
|
||||
fs.writeFileSync(USER_SETTINGS_PATH, JSON.stringify(settings));
|
||||
|
||||
const result = loadSettings(mockWorkspaceDir);
|
||||
expect(result.showMemoryUsage).toBe(true);
|
||||
expect(result.coreTools).toEqual(['tool1', 'tool2']);
|
||||
expect(result.mcpServers).toHaveProperty('server1');
|
||||
expect(result.fileFiltering?.respectGitIgnore).toBe(true);
|
||||
expect(result.userSettings.showMemoryUsage).toBe(true);
|
||||
expect(result.userSettings.coreTools).toEqual(['tool1', 'tool2']);
|
||||
expect(result.userSettings.mcpServers).toHaveProperty('server1');
|
||||
expect(result.userSettings.fileFiltering?.respectGitIgnore).toBe(true);
|
||||
});
|
||||
|
||||
it('should overwrite top-level settings from workspace (shallow merge)', () => {
|
||||
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.userSettings.experimental?.enableAgents).toBe(true);
|
||||
expect(result.userSettings.agents?.overrides?.['test_agent']?.enabled).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return separate user and raw workspace settings', () => {
|
||||
const userSettings = {
|
||||
showMemoryUsage: false,
|
||||
fileFiltering: {
|
||||
respectGitIgnore: true,
|
||||
enableRecursiveFileSearch: true,
|
||||
},
|
||||
};
|
||||
fs.writeFileSync(USER_SETTINGS_PATH, JSON.stringify(userSettings));
|
||||
|
||||
const workspaceSettings = {
|
||||
showMemoryUsage: true,
|
||||
fileFiltering: {
|
||||
respectGitIgnore: false,
|
||||
},
|
||||
coreTools: ['${VAR}'],
|
||||
};
|
||||
const workspaceSettingsPath = path.join(
|
||||
mockGeminiWorkspaceDir,
|
||||
@@ -135,11 +149,9 @@ describe('loadSettings', () => {
|
||||
fs.writeFileSync(workspaceSettingsPath, JSON.stringify(workspaceSettings));
|
||||
|
||||
const result = loadSettings(mockWorkspaceDir);
|
||||
// Primitive value overwritten
|
||||
expect(result.showMemoryUsage).toBe(true);
|
||||
|
||||
// Object value completely replaced (shallow merge behavior)
|
||||
expect(result.fileFiltering?.respectGitIgnore).toBe(false);
|
||||
expect(result.fileFiltering?.enableRecursiveFileSearch).toBeUndefined();
|
||||
expect(result.userSettings.showMemoryUsage).toBe(false);
|
||||
expect(result.workspaceSettings.showMemoryUsage).toBe(true);
|
||||
// Workspace settings must be RAW (no expansion)
|
||||
expect(result.workspaceSettings.coreTools).toEqual(['${VAR}']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
getErrorMessage,
|
||||
type TelemetrySettings,
|
||||
homedir,
|
||||
type AgentSettings,
|
||||
} from '@google/gemini-cli-core';
|
||||
import stripJsonComments from 'strip-json-comments';
|
||||
|
||||
@@ -48,6 +49,11 @@ export interface Settings {
|
||||
enableRecursiveFileSearch?: boolean;
|
||||
customIgnoreFilePaths?: string[];
|
||||
};
|
||||
experimental?: {
|
||||
enableEventDrivenScheduler?: boolean;
|
||||
enableAgents?: boolean;
|
||||
};
|
||||
agents?: AgentSettings;
|
||||
}
|
||||
|
||||
export interface SettingsError {
|
||||
@@ -60,19 +66,25 @@ export interface CheckpointingSettings {
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads settings from user and workspace directories.
|
||||
* Project settings override user settings.
|
||||
*
|
||||
* How is it different to gemini-cli/cli: Returns already merged settings rather
|
||||
* than `LoadedSettings` (unnecessary since we are not modifying users
|
||||
* settings.json).
|
||||
* The result of loading settings, containing both user-level (expanded)
|
||||
* and workspace-level (raw) configurations.
|
||||
*/
|
||||
export function loadSettings(workspaceDir: string): Settings {
|
||||
export interface LoadedSettings {
|
||||
userSettings: Settings;
|
||||
workspaceSettings: Settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads settings from user and workspace directories.
|
||||
* Workspace settings are returned RAW (without env var expansion) to prevent
|
||||
* secret leakage from untrusted repositories.
|
||||
*/
|
||||
export function loadSettings(workspaceDir: string): LoadedSettings {
|
||||
let userSettings: Settings = {};
|
||||
let workspaceSettings: Settings = {};
|
||||
const settingsErrors: SettingsError[] = [];
|
||||
|
||||
// Load user settings
|
||||
// Load user settings (Safe to expand)
|
||||
try {
|
||||
if (fs.existsSync(USER_SETTINGS_PATH)) {
|
||||
const userContent = fs.readFileSync(USER_SETTINGS_PATH, 'utf-8');
|
||||
@@ -95,15 +107,14 @@ export function loadSettings(workspaceDir: string): Settings {
|
||||
'settings.json',
|
||||
);
|
||||
|
||||
// Load workspace settings
|
||||
// Load workspace settings (RAW - NO EXPANSION)
|
||||
try {
|
||||
if (fs.existsSync(workspaceSettingsPath)) {
|
||||
const projectContent = fs.readFileSync(workspaceSettingsPath, 'utf-8');
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const parsedWorkspaceSettings = JSON.parse(
|
||||
workspaceSettings = JSON.parse(
|
||||
stripJsonComments(projectContent),
|
||||
) as Settings;
|
||||
workspaceSettings = resolveEnvVarsInObject(parsedWorkspaceSettings);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
settingsErrors.push({
|
||||
@@ -120,11 +131,9 @@ export function loadSettings(workspaceDir: string): Settings {
|
||||
}
|
||||
}
|
||||
|
||||
// If there are overlapping keys, the values of workspaceSettings will
|
||||
// override values from userSettings
|
||||
return {
|
||||
...userSettings,
|
||||
...workspaceSettings,
|
||||
userSettings,
|
||||
workspaceSettings,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -140,7 +149,7 @@ function resolveEnvVarsInString(value: string): string {
|
||||
});
|
||||
}
|
||||
|
||||
function resolveEnvVarsInObject<T>(obj: T): T {
|
||||
export function resolveEnvVarsInObject<T>(obj: T): T {
|
||||
if (
|
||||
obj === null ||
|
||||
obj === undefined ||
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
import { A2AExpressApp, type UserBuilder } from '@a2a-js/sdk/server/express'; // Import server components
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { logger } from '../utils/logger.js';
|
||||
import type { AgentSettings } from '../types.js';
|
||||
import { type AgentSettings as CoderAgentSettings } from '../types.js';
|
||||
import { GCSTaskStore, NoOpTaskStore } from '../persistence/gcs.js';
|
||||
import { CoderAgentExecutor } from '../agent/executor.js';
|
||||
import { requestStorage } from './requestStorage.js';
|
||||
@@ -197,10 +197,10 @@ export async function createApp() {
|
||||
// Load the server configuration once on startup.
|
||||
const workspaceRoot = setTargetDir(undefined);
|
||||
loadEnvironment();
|
||||
const settings = loadSettings(workspaceRoot);
|
||||
const loadedSettings = loadSettings(workspaceRoot);
|
||||
const extensions = loadExtensions(workspaceRoot);
|
||||
const config = await loadConfig(
|
||||
settings,
|
||||
loadedSettings,
|
||||
new SimpleExtensionLoader(extensions),
|
||||
'a2a-server',
|
||||
);
|
||||
@@ -252,7 +252,7 @@ export async function createApp() {
|
||||
const taskId = uuidv4();
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const agentSettings = req.body.agentSettings as
|
||||
| AgentSettings
|
||||
| CoderAgentSettings
|
||||
| undefined;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const contextId = req.body.contextId || uuidv4();
|
||||
|
||||
@@ -123,7 +123,6 @@ async function downloadFiles({
|
||||
downloads.push(
|
||||
(async () => {
|
||||
const endpoint = `${REPO_DOWNLOAD_URL}/refs/tags/${releaseTag}/${SOURCE_DIR}/${fileBasename}`;
|
||||
// eslint-disable-next-line no-restricted-syntax -- TODO: Migrate to safeFetch for SSRF protection
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'GET',
|
||||
dispatcher: proxy ? new ProxyAgent(proxy) : undefined,
|
||||
|
||||
@@ -61,7 +61,6 @@ export const getLatestGitHubRelease = async (
|
||||
|
||||
const endpoint = `https://api.github.com/repos/google-github-actions/run-gemini-cli/releases/latest`;
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax -- TODO: Migrate to safeFetch for SSRF protection
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
|
||||
@@ -5,96 +5,102 @@
|
||||
*/
|
||||
|
||||
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 { 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,
|
||||
};
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
// Default mock implementations
|
||||
getAgentCardMock.mockResolvedValue({
|
||||
// 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,21 +129,27 @@ 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 for transports only', async () => {
|
||||
@@ -146,21 +158,13 @@ describe('A2AClientManager', () => {
|
||||
shouldRetryWithHeaders: vi.fn(),
|
||||
};
|
||||
await manager.loadAgent(
|
||||
'CustomAuthAgent',
|
||||
'http://custom.agent/card',
|
||||
customAuthHandler as unknown as AuthenticationHandler,
|
||||
);
|
||||
|
||||
expect(createAuthenticatingFetchWithRetry).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
customAuthHandler,
|
||||
'TestAgent',
|
||||
'http://test.agent/card',
|
||||
customAuthHandler as unknown as sdkClient.AuthenticationHandler,
|
||||
);
|
||||
|
||||
// Card resolver should NOT use the authenticated fetch by default.
|
||||
const resolverInstance = vi.mocked(DefaultAgentCardResolver).mock
|
||||
.instances[0];
|
||||
expect(resolverInstance).toBeDefined();
|
||||
const resolverOptions = vi.mocked(DefaultAgentCardResolver).mock
|
||||
const resolverOptions = vi.mocked(sdkClient.DefaultAgentCardResolver).mock
|
||||
.calls[0][0];
|
||||
expect(resolverOptions?.fetchImpl).not.toBe(authFetchMock);
|
||||
});
|
||||
@@ -173,10 +177,10 @@ describe('A2AClientManager', () => {
|
||||
await manager.loadAgent(
|
||||
'AuthCardAgent',
|
||||
'http://authcard.agent/card',
|
||||
customAuthHandler as unknown as AuthenticationHandler,
|
||||
customAuthHandler as unknown as sdkClient.AuthenticationHandler,
|
||||
);
|
||||
|
||||
const resolverOptions = vi.mocked(DefaultAgentCardResolver).mock
|
||||
const resolverOptions = vi.mocked(sdkClient.DefaultAgentCardResolver).mock
|
||||
.calls[0][0];
|
||||
const cardFetch = resolverOptions?.fetchImpl as typeof fetch;
|
||||
|
||||
@@ -204,10 +208,10 @@ describe('A2AClientManager', () => {
|
||||
await manager.loadAgent(
|
||||
'AuthCardAgent401',
|
||||
'http://authcard.agent/card',
|
||||
customAuthHandler as unknown as AuthenticationHandler,
|
||||
customAuthHandler as unknown as sdkClient.AuthenticationHandler,
|
||||
);
|
||||
|
||||
const resolverOptions = vi.mocked(DefaultAgentCardResolver).mock
|
||||
const resolverOptions = vi.mocked(sdkClient.DefaultAgentCardResolver).mock
|
||||
.calls[0][0];
|
||||
const cardFetch = resolverOptions?.fetchImpl as typeof fetch;
|
||||
|
||||
@@ -220,106 +224,163 @@ describe('A2AClientManager', () => {
|
||||
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 propagate the original error on failure', async () => {
|
||||
sendMessageStreamMock.mockImplementationOnce(() => {
|
||||
throw new Error('Network error');
|
||||
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 () => {
|
||||
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('Network error');
|
||||
}).rejects.toThrow(
|
||||
'[A2AClientManager] sendMessageStream Error [TestAgent]: Network failure',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an error if the agent is not found', async () => {
|
||||
const stream = manager.sendMessageStream('NonExistentAgent', 'Hello');
|
||||
await expect(async () => {
|
||||
for await (const _ of stream) {
|
||||
// consume
|
||||
// empty
|
||||
}
|
||||
}).rejects.toThrow("Agent 'NonExistentAgent' not found.");
|
||||
});
|
||||
@@ -327,28 +388,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',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -361,28 +417,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',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -12,45 +12,47 @@ 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 * as grpc from '@grpc/grpc-js';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { Agent as UndiciAgent } from 'undici';
|
||||
import { normalizeAgentCard } from './a2aUtils.js';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
import { safeLookup } from '../utils/fetch.js';
|
||||
import { classifyAgentError } from './a2a-errors.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;
|
||||
|
||||
// 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: {
|
||||
lookup: safeLookup, // SSRF protection at connection level
|
||||
},
|
||||
});
|
||||
const a2aFetch: typeof fetch = (input, init) =>
|
||||
// eslint-disable-next-line no-restricted-syntax -- TODO: Migrate to safeFetch for SSRF protection
|
||||
fetch(input, { ...init, dispatcher: a2aDispatcher } as RequestInit);
|
||||
|
||||
export type SendMessageResult =
|
||||
| Message
|
||||
| Task
|
||||
| TaskStatusUpdateEvent
|
||||
| TaskArtifactUpdateEvent;
|
||||
// @ts-expect-error The `dispatcher` property is a Node.js extension to fetch not present in standard types.
|
||||
fetch(input, { ...init, dispatcher: a2aDispatcher });
|
||||
|
||||
/**
|
||||
* 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;
|
||||
@@ -120,22 +122,35 @@ export class A2AClientManager {
|
||||
};
|
||||
|
||||
const resolver = new DefaultAgentCardResolver({ fetchImpl: cardFetch });
|
||||
const rawCard = await resolver.resolve(agentCardUrl, '');
|
||||
// TODO: Remove normalizeAgentCard once @a2a-js/sdk handles
|
||||
// proto field name aliases (supportedInterfaces → additionalInterfaces,
|
||||
// protocolBinding → transport).
|
||||
const agentCard = normalizeAgentCard(rawCard);
|
||||
|
||||
const options = ClientFactoryOptions.createFrom(
|
||||
const grpcUrl =
|
||||
agentCard.additionalInterfaces?.find((i) => i.transport === 'GRPC')
|
||||
?.url ?? agentCard.url;
|
||||
|
||||
const clientOptions = ClientFactoryOptions.createFrom(
|
||||
ClientFactoryOptions.default,
|
||||
{
|
||||
transports: [
|
||||
new RestTransportFactory({ fetchImpl: authFetch }),
|
||||
new JsonRpcTransportFactory({ fetchImpl: authFetch }),
|
||||
new GrpcTransportFactory({
|
||||
grpcChannelCredentials: grpcUrl.startsWith('https://')
|
||||
? grpc.credentials.createSsl()
|
||||
: grpc.credentials.createInsecure(),
|
||||
}),
|
||||
],
|
||||
cardResolver: resolver,
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
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);
|
||||
@@ -173,9 +188,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: {
|
||||
@@ -188,9 +201,19 @@ export class A2AClientManager {
|
||||
},
|
||||
};
|
||||
|
||||
yield* client.sendMessageStream(messageParams, {
|
||||
signal: options?.signal,
|
||||
});
|
||||
try {
|
||||
yield* client.sendMessageStream(messageParams, {
|
||||
signal: options?.signal,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const prefix = `[A2AClientManager] sendMessageStream Error [${agentName}]`;
|
||||
if (error instanceof Error) {
|
||||
throw new Error(`${prefix}: ${error.message}`, { cause: error });
|
||||
}
|
||||
throw new Error(
|
||||
`${prefix}: Unexpected error during sendMessageStream: ${String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -219,9 +242,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) {
|
||||
@@ -241,9 +262,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) {
|
||||
|
||||
@@ -12,9 +12,6 @@ import {
|
||||
A2AResultReassembler,
|
||||
AUTH_REQUIRED_MSG,
|
||||
normalizeAgentCard,
|
||||
getGrpcCredentials,
|
||||
pinUrlToIp,
|
||||
splitAgentCardUrl,
|
||||
} from './a2aUtils.js';
|
||||
import type { SendMessageResult } from './a2a-client-manager.js';
|
||||
import type {
|
||||
@@ -26,12 +23,6 @@ import type {
|
||||
TaskStatusUpdateEvent,
|
||||
TaskArtifactUpdateEvent,
|
||||
} from '@a2a-js/sdk';
|
||||
import * as dnsPromises from 'node:dns/promises';
|
||||
import type { LookupAddress } from 'node:dns';
|
||||
|
||||
vi.mock('node:dns/promises', () => ({
|
||||
lookup: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('a2aUtils', () => {
|
||||
beforeEach(() => {
|
||||
@@ -42,89 +33,6 @@ describe('a2aUtils', () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
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 as unknown as (
|
||||
hostname: string,
|
||||
options: { all: true },
|
||||
) => Promise<LookupAddress[]>,
|
||||
).mockResolvedValue([{ address: '93.184.216.34', family: 4 }]);
|
||||
|
||||
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 as unknown as (
|
||||
hostname: string,
|
||||
options: { all: true },
|
||||
) => Promise<LookupAddress[]>,
|
||||
).mockResolvedValue([{ address: '93.184.216.34', family: 4 }]);
|
||||
|
||||
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 as unknown as (
|
||||
hostname: string,
|
||||
options: { all: true },
|
||||
) => Promise<LookupAddress[]>,
|
||||
).mockResolvedValue([{ address: '10.0.0.1', family: 4 }]);
|
||||
|
||||
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 as unknown as (
|
||||
hostname: string,
|
||||
options: { all: true },
|
||||
) => Promise<LookupAddress[]>,
|
||||
).mockResolvedValue([{ address: '127.0.0.1', family: 4 }]);
|
||||
|
||||
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);
|
||||
@@ -365,12 +273,12 @@ describe('a2aUtils', () => {
|
||||
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([]);
|
||||
expect(normalized.description).toBeUndefined();
|
||||
expect(normalized.skills).toBeUndefined();
|
||||
expect(normalized.defaultInputModes).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should normalize and synchronize interfaces while preserving other fields', () => {
|
||||
it('should map supportedInterfaces to additionalInterfaces with protocolBinding → transport', () => {
|
||||
const raw = {
|
||||
name: 'test',
|
||||
supportedInterfaces: [
|
||||
@@ -384,13 +292,7 @@ describe('a2aUtils', () => {
|
||||
|
||||
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,
|
||||
@@ -399,43 +301,18 @@ describe('a2aUtils', () => {
|
||||
|
||||
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', () => {
|
||||
it('should not overwrite additionalInterfaces if already present', () => {
|
||||
const raw = {
|
||||
name: 'test',
|
||||
url: 'http://existing',
|
||||
additionalInterfaces: [{ url: 'http://grpc', transport: 'GRPC' }],
|
||||
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',
|
||||
);
|
||||
expect(normalized.additionalInterfaces).toHaveLength(1);
|
||||
expect(normalized.additionalInterfaces?.[0].url).toBe('http://grpc');
|
||||
});
|
||||
|
||||
it('should NOT override existing transport if protocolBinding is also present', () => {
|
||||
@@ -448,48 +325,20 @@ describe('a2aUtils', () => {
|
||||
const normalized = normalizeAgentCard(raw);
|
||||
expect(normalized.additionalInterfaces?.[0].transport).toBe('GRPC');
|
||||
});
|
||||
});
|
||||
|
||||
describe('splitAgentCardUrl', () => {
|
||||
const standard = '.well-known/agent-card.json';
|
||||
it('should not mutate the original card object', () => {
|
||||
const raw = {
|
||||
name: 'test',
|
||||
supportedInterfaces: [{ url: 'grpc://test', protocolBinding: 'GRPC' }],
|
||||
};
|
||||
|
||||
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 });
|
||||
const normalized = normalizeAgentCard(raw);
|
||||
expect(normalized).not.toBe(raw);
|
||||
expect(normalized.additionalInterfaces).toBeDefined();
|
||||
// Original should not have additionalInterfaces added
|
||||
expect(
|
||||
(raw as Record<string, unknown>)['additionalInterfaces'],
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -4,9 +4,6 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as grpc from '@grpc/grpc-js';
|
||||
import { lookup } from 'node:dns/promises';
|
||||
import { z } from 'zod';
|
||||
import type {
|
||||
Message,
|
||||
Part,
|
||||
@@ -18,37 +15,10 @@ import type {
|
||||
AgentCard,
|
||||
AgentInterface,
|
||||
} 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.`;
|
||||
|
||||
const AgentInterfaceSchema = z
|
||||
.object({
|
||||
url: z.string().default(''),
|
||||
transport: z.string().optional(),
|
||||
protocolBinding: z.string().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const AgentCardSchema = z
|
||||
.object({
|
||||
name: z.string().default('unknown'),
|
||||
description: z.string().default(''),
|
||||
url: z.string().default(''),
|
||||
version: z.string().default(''),
|
||||
protocolVersion: z.string().default(''),
|
||||
capabilities: z.record(z.unknown()).default({}),
|
||||
skills: z.array(z.union([z.string(), z.record(z.unknown())])).default([]),
|
||||
defaultInputModes: z.array(z.string()).default([]),
|
||||
defaultOutputModes: z.array(z.string()).default([]),
|
||||
|
||||
additionalInterfaces: z.array(AgentInterfaceSchema).optional(),
|
||||
supportedInterfaces: z.array(AgentInterfaceSchema).optional(),
|
||||
preferredTransport: z.string().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
/**
|
||||
* Reassembles incremental A2A streaming updates into a coherent result.
|
||||
* Shows sequential status/messages followed by all reassembled artifacts.
|
||||
@@ -241,166 +211,45 @@ function extractPartText(part: Part): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes an agent card by ensuring it has the required properties
|
||||
* and resolving any inconsistencies between protocol versions.
|
||||
* Normalizes proto field name aliases that the SDK doesn't handle yet.
|
||||
* The A2A proto spec uses `supported_interfaces` and `protocol_binding`,
|
||||
* while the SDK expects `additionalInterfaces` and `transport`.
|
||||
* TODO: Remove once @a2a-js/sdk handles these aliases natively.
|
||||
*/
|
||||
export function normalizeAgentCard(card: unknown): AgentCard {
|
||||
if (!isObject(card)) {
|
||||
throw new Error('Agent card is missing.');
|
||||
}
|
||||
|
||||
// Use Zod to validate and parse the card, ensuring safe defaults and narrowing types.
|
||||
const parsed = AgentCardSchema.parse(card);
|
||||
// Narrowing to AgentCard interface after runtime validation.
|
||||
// Shallow-copy to avoid mutating the SDK's cached object.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const result = parsed as unknown as AgentCard;
|
||||
const result = { ...card } as unknown as AgentCard;
|
||||
|
||||
// Normalize interfaces and synchronize both interface fields.
|
||||
const normalizedInterfaces = extractNormalizedInterfaces(parsed);
|
||||
result.additionalInterfaces = normalizedInterfaces;
|
||||
|
||||
// Sync supportedInterfaces for backward compatibility.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const legacyResult = result as unknown as Record<string, AgentInterface[]>;
|
||||
legacyResult['supportedInterfaces'] = normalizedInterfaces;
|
||||
|
||||
// Fallback preferredTransport: If not specified, default to GRPC if available.
|
||||
if (
|
||||
!result.preferredTransport &&
|
||||
normalizedInterfaces.some((i) => i.transport === 'GRPC')
|
||||
) {
|
||||
result.preferredTransport = 'GRPC';
|
||||
// Map supportedInterfaces → additionalInterfaces if needed
|
||||
if (!result.additionalInterfaces) {
|
||||
const raw = card;
|
||||
if (Array.isArray(raw['supportedInterfaces'])) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
result.additionalInterfaces = raw[
|
||||
'supportedInterfaces'
|
||||
] as AgentInterface[];
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: If top-level URL is missing, use the first interface's URL.
|
||||
if (result.url === '' && normalizedInterfaces.length > 0) {
|
||||
result.url = normalizedInterfaces[0].url;
|
||||
// Map protocolBinding → transport on each interface
|
||||
for (const intf of result.additionalInterfaces ?? []) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const raw = intf as unknown as Record<string, unknown>;
|
||||
const binding = raw['protocolBinding'];
|
||||
|
||||
if (!intf.transport && typeof binding === 'string') {
|
||||
intf.transport = binding;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns gRPC channel credentials based on the URL scheme.
|
||||
*/
|
||||
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) {
|
||||
if (addresses.length > 0) {
|
||||
throw new Error(
|
||||
`Refusing to load agent '${agentName}': transport URL '${url}' resolves to private IP range.`,
|
||||
);
|
||||
}
|
||||
throw new Error(
|
||||
`Failed to resolve any public IP addresses for host: ${hostname}`,
|
||||
);
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts contextId and taskId from a Message, Task, or Update response.
|
||||
* Follows the pattern from the A2A CLI sample to maintain conversational continuity.
|
||||
@@ -446,65 +295,6 @@ 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)) {
|
||||
// Use schema to validate interface object.
|
||||
const parsed = AgentInterfaceSchema.parse(i);
|
||||
// Narrowing to AgentInterface after runtime validation.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const normalized = parsed as unknown as AgentInterface & {
|
||||
protocolBinding?: string;
|
||||
};
|
||||
|
||||
// Normalize 'transport' from 'protocolBinding' if missing.
|
||||
if (!normalized.transport && normalized.protocolBinding) {
|
||||
normalized.transport = normalized.protocolBinding;
|
||||
}
|
||||
|
||||
// Robust URL: Ensure the URL has a scheme (except for gRPC).
|
||||
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 as AgentInterface);
|
||||
}
|
||||
}
|
||||
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 {
|
||||
|
||||
@@ -66,6 +66,18 @@ export class AcknowledgedAgentsService {
|
||||
hash: string,
|
||||
): Promise<boolean> {
|
||||
await this.load();
|
||||
return this.isAcknowledgedSync(projectPath, agentName, hash);
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous check for acknowledgment.
|
||||
* Note: Assumes load() has already been called and awaited (e.g. during registry init).
|
||||
*/
|
||||
isAcknowledgedSync(
|
||||
projectPath: string,
|
||||
agentName: string,
|
||||
hash: string,
|
||||
): boolean {
|
||||
const projectAgents = this.acknowledgedAgents[projectPath];
|
||||
if (!projectAgents) return false;
|
||||
return projectAgents[agentName] === hash;
|
||||
|
||||
@@ -29,7 +29,7 @@ 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 { PolicyDecision } from '../policy/types.js';
|
||||
import { PolicyDecision, ApprovalMode } from '../policy/types.js';
|
||||
import { A2AAuthProviderFactory } from './auth-provider/factory.js';
|
||||
import type { A2AAuthProvider } from './auth-provider/types.js';
|
||||
|
||||
@@ -1171,6 +1171,37 @@ describe('AgentRegistry', () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should register remote agents with ALLOW decision in YOLO mode', async () => {
|
||||
const remoteAgent: AgentDefinition = {
|
||||
kind: 'remote',
|
||||
name: 'YoloAgent',
|
||||
description: 'A remote agent in YOLO mode',
|
||||
agentCardUrl: 'https://example.com/card',
|
||||
inputConfig: { inputSchema: { type: 'object' } },
|
||||
};
|
||||
|
||||
vi.mocked(A2AClientManager.getInstance).mockReturnValue({
|
||||
loadAgent: vi.fn().mockResolvedValue({ name: 'YoloAgent' }),
|
||||
} as unknown as A2AClientManager);
|
||||
|
||||
const policyEngine = mockConfig.getPolicyEngine();
|
||||
vi.spyOn(mockConfig, 'getApprovalMode').mockReturnValue(
|
||||
ApprovalMode.YOLO,
|
||||
);
|
||||
const addRuleSpy = vi.spyOn(policyEngine, 'addRule');
|
||||
|
||||
await registry.testRegisterAgent(remoteAgent);
|
||||
|
||||
// In YOLO mode, even remote agents should be registered with ALLOW.
|
||||
expect(addRuleSpy).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
toolName: 'YoloAgent',
|
||||
decision: PolicyDecision.ALLOW,
|
||||
source: 'AgentRegistry (Dynamic)',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reload', () => {
|
||||
|
||||
@@ -23,7 +23,11 @@ import {
|
||||
type ModelConfig,
|
||||
ModelConfigService,
|
||||
} from '../services/modelConfigService.js';
|
||||
import { PolicyDecision, PRIORITY_SUBAGENT_TOOL } from '../policy/types.js';
|
||||
import {
|
||||
PolicyDecision,
|
||||
PRIORITY_SUBAGENT_TOOL,
|
||||
ApprovalMode,
|
||||
} from '../policy/types.js';
|
||||
import { A2AAgentError, AgentAuthConfigMissingError } from './a2a-errors.js';
|
||||
|
||||
/**
|
||||
@@ -176,9 +180,8 @@ export class AgentRegistry {
|
||||
agent.metadata.hash,
|
||||
);
|
||||
|
||||
if (isAcknowledged) {
|
||||
agentsToRegister.push(agent);
|
||||
} else {
|
||||
agentsToRegister.push(agent);
|
||||
if (!isAcknowledged) {
|
||||
unacknowledgedAgents.push(agent);
|
||||
}
|
||||
}
|
||||
@@ -340,10 +343,23 @@ export class AgentRegistry {
|
||||
policyEngine.removeRulesForTool(definition.name, 'AgentRegistry (Dynamic)');
|
||||
|
||||
// Add the new dynamic policy
|
||||
const isYolo = this.config.getApprovalMode() === ApprovalMode.YOLO;
|
||||
const isAcknowledged =
|
||||
definition.kind === 'local' &&
|
||||
(!definition.metadata?.hash ||
|
||||
(this.config.getProjectRoot() &&
|
||||
this.config
|
||||
.getAcknowledgedAgentsService()
|
||||
?.isAcknowledgedSync?.(
|
||||
this.config.getProjectRoot(),
|
||||
definition.name,
|
||||
definition.metadata.hash,
|
||||
)));
|
||||
|
||||
policyEngine.addRule({
|
||||
toolName: definition.name,
|
||||
decision:
|
||||
definition.kind === 'local'
|
||||
isAcknowledged || isYolo
|
||||
? PolicyDecision.ALLOW
|
||||
: PolicyDecision.ASK_USER,
|
||||
priority: PRIORITY_SUBAGENT_TOOL,
|
||||
|
||||
@@ -12,6 +12,7 @@ import { coreEvents } from '../utils/events.js';
|
||||
import * as tomlLoader from './agentLoader.js';
|
||||
import { type Config } from '../config/config.js';
|
||||
import { AcknowledgedAgentsService } from './acknowledgedAgents.js';
|
||||
import { PolicyDecision } from '../policy/types.js';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
@@ -103,13 +104,22 @@ 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]);
|
||||
|
||||
// Verify policy
|
||||
const policyEngine = config.getPolicyEngine();
|
||||
expect(
|
||||
await policyEngine?.check({ name: 'ProjectAgent', args: {} }, undefined),
|
||||
).toMatchObject({
|
||||
decision: PolicyDecision.ASK_USER,
|
||||
});
|
||||
});
|
||||
|
||||
it('should register acknowledged project agents', async () => {
|
||||
@@ -134,6 +144,14 @@ describe('AgentRegistry Acknowledgement', () => {
|
||||
|
||||
expect(registry.getDefinition('ProjectAgent')).toBeDefined();
|
||||
expect(emitSpy).not.toHaveBeenCalled();
|
||||
|
||||
// Verify policy is ALLOW for acknowledged agent
|
||||
const policyEngine = config.getPolicyEngine();
|
||||
expect(
|
||||
await policyEngine?.check({ name: 'ProjectAgent', args: {} }, undefined),
|
||||
).toMatchObject({
|
||||
decision: PolicyDecision.ALLOW,
|
||||
});
|
||||
});
|
||||
|
||||
it('should register agents without hash (legacy/safe?)', async () => {
|
||||
|
||||
@@ -700,7 +700,6 @@ async function fetchAndCacheUserInfo(client: OAuth2Client): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax -- TODO: Migrate to safeFetch for SSRF protection
|
||||
const response = await fetch(
|
||||
'https://www.googleapis.com/oauth2/v2/userinfo',
|
||||
{
|
||||
|
||||
@@ -111,7 +111,6 @@ export class MCPOAuthProvider {
|
||||
scope: config.scopes?.join(' ') || '',
|
||||
};
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax -- TODO: Migrate to safeFetch for SSRF protection
|
||||
const response = await fetch(registrationUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -301,7 +300,6 @@ export class MCPOAuthProvider {
|
||||
? { Accept: 'text/event-stream' }
|
||||
: { Accept: 'application/json' };
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax -- TODO: Migrate to safeFetch for SSRF protection
|
||||
const response = await fetch(mcpServerUrl, {
|
||||
method: 'HEAD',
|
||||
headers,
|
||||
|
||||
@@ -97,7 +97,6 @@ export class OAuthUtils {
|
||||
resourceMetadataUrl: string,
|
||||
): Promise<OAuthProtectedResourceMetadata | null> {
|
||||
try {
|
||||
// eslint-disable-next-line no-restricted-syntax -- TODO: Migrate to safeFetch for SSRF protection
|
||||
const response = await fetch(resourceMetadataUrl);
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
@@ -122,7 +121,6 @@ export class OAuthUtils {
|
||||
authServerMetadataUrl: string,
|
||||
): Promise<OAuthAuthorizationServerMetadata | null> {
|
||||
try {
|
||||
// eslint-disable-next-line no-restricted-syntax -- TODO: Migrate to safeFetch for SSRF protection
|
||||
const response = await fetch(authServerMetadataUrl);
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
|
||||
@@ -546,7 +546,6 @@ export class ClearcutLogger {
|
||||
let result: LogResponse = {};
|
||||
|
||||
try {
|
||||
// eslint-disable-next-line no-restricted-syntax -- TODO: Migrate to safeFetch for SSRF protection
|
||||
const response = await fetch(CLEARCUT_URL, {
|
||||
method: 'POST',
|
||||
body: safeJsonStringify(request),
|
||||
|
||||
@@ -1903,7 +1903,6 @@ export async function connectToMcpServer(
|
||||
acceptHeader = 'application/json';
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax -- TODO: Migrate to safeFetch for SSRF protection
|
||||
const response = await fetch(urlToFetch, {
|
||||
method: 'HEAD',
|
||||
headers: {
|
||||
|
||||
@@ -5,27 +5,12 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest';
|
||||
import {
|
||||
isPrivateIp,
|
||||
isPrivateIpAsync,
|
||||
isAddressPrivate,
|
||||
safeLookup,
|
||||
safeFetch,
|
||||
fetchWithTimeout,
|
||||
PrivateIpError,
|
||||
} from './fetch.js';
|
||||
import * as dnsPromises from 'node:dns/promises';
|
||||
import * as dns from 'node:dns';
|
||||
import { isPrivateIp, isAddressPrivate, fetchWithTimeout } from './fetch.js';
|
||||
|
||||
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();
|
||||
@@ -114,150 +99,6 @@ describe('fetch utils', () => {
|
||||
});
|
||||
});
|
||||
|
||||
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(PrivateIpError);
|
||||
});
|
||||
});
|
||||
|
||||
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 PrivateIpError());
|
||||
|
||||
await expect(safeFetch('http://10.0.0.1')).rejects.toThrow(
|
||||
'Access to private network is blocked',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchWithTimeout', () => {
|
||||
it('should handle timeouts', async () => {
|
||||
vi.mocked(global.fetch).mockImplementation(
|
||||
@@ -279,13 +120,5 @@ describe('fetch utils', () => {
|
||||
'Request timed out after 50ms',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle private IP errors via handleFetchError', async () => {
|
||||
vi.mocked(global.fetch).mockRejectedValue(new PrivateIpError());
|
||||
|
||||
await expect(fetchWithTimeout('http://10.0.0.1', 1000)).rejects.toThrow(
|
||||
'Access to private network is blocked: http://10.0.0.1',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,37 +6,12 @@
|
||||
|
||||
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
|
||||
|
||||
// Configure default global dispatcher with higher timeouts
|
||||
setGlobalDispatcher(
|
||||
new Agent({
|
||||
headersTimeout: DEFAULT_HEADERS_TIMEOUT,
|
||||
bodyTimeout: DEFAULT_BODY_TIMEOUT,
|
||||
}),
|
||||
);
|
||||
|
||||
// Local extension of RequestInit to support Node.js/undici dispatcher
|
||||
interface NodeFetchInit extends RequestInit {
|
||||
dispatcher?: Agent | ProxyAgent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown when a connection to a private IP address is blocked for security reasons.
|
||||
*/
|
||||
export class PrivateIpError extends Error {
|
||||
constructor(message = 'Refusing to connect to private IP address') {
|
||||
super(message);
|
||||
this.name = 'PrivateIpError';
|
||||
}
|
||||
}
|
||||
|
||||
export class FetchError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
@@ -48,6 +23,14 @@ export class FetchError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
// Configure default global dispatcher with higher timeouts
|
||||
setGlobalDispatcher(
|
||||
new Agent({
|
||||
headersTimeout: DEFAULT_HEADERS_TIMEOUT,
|
||||
bodyTimeout: DEFAULT_BODY_TIMEOUT,
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* Sanitizes a hostname by stripping IPv6 brackets if present.
|
||||
*/
|
||||
@@ -69,53 +52,6 @@ export function isLoopbackHost(hostname: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 PrivateIpError(), []);
|
||||
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;
|
||||
@@ -125,37 +61,6 @@ export function isPrivateIp(url: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* IANA Benchmark Testing Range (198.18.0.0/15).
|
||||
* Classified as 'unicast' by ipaddr.js but is reserved and should not be
|
||||
@@ -210,72 +115,15 @@ export function isAddressPrivate(address: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal helper to map varied fetch errors to a standardized FetchError.
|
||||
* Centralizes security-related error mapping (e.g. PrivateIpError).
|
||||
*/
|
||||
function handleFetchError(error: unknown, url: string): never {
|
||||
if (error instanceof PrivateIpError) {
|
||||
throw new FetchError(
|
||||
`Access to private network is blocked: ${url}`,
|
||||
'ERR_PRIVATE_NETWORK',
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
|
||||
if (error instanceof FetchError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new FetchError(
|
||||
getErrorMessage(error),
|
||||
isNodeError(error) ? error.code : undefined,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
return await fetch(input, nodeInit);
|
||||
} catch (error) {
|
||||
const url =
|
||||
input instanceof Request
|
||||
? input.url
|
||||
: typeof input === 'string'
|
||||
? input
|
||||
: input.toString();
|
||||
handleFetchError(error, url);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
@@ -294,21 +142,17 @@ export async function fetchWithTimeout(
|
||||
}
|
||||
}
|
||||
|
||||
const nodeInit: NodeFetchInit = {
|
||||
...options,
|
||||
signal: controller.signal,
|
||||
dispatcher: safeDispatcher,
|
||||
};
|
||||
|
||||
try {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
const response = await fetch(url, nodeInit);
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
signal: controller.signal,
|
||||
});
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (isNodeError(error) && error.code === 'ABORT_ERR') {
|
||||
throw new FetchError(`Request timed out after ${timeout}ms`, 'ETIMEDOUT');
|
||||
}
|
||||
handleFetchError(error, url.toString());
|
||||
throw new FetchError(getErrorMessage(error), undefined, { cause: error });
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
@@ -454,7 +454,6 @@ export async function exchangeCodeForToken(
|
||||
params.append('resource', resource);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax -- TODO: Migrate to safeFetch for SSRF protection
|
||||
const response = await fetch(config.tokenUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -508,7 +507,6 @@ export async function refreshAccessToken(
|
||||
params.append('resource', resource);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax -- TODO: Migrate to safeFetch for SSRF protection
|
||||
const response = await fetch(tokenUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
|
||||
@@ -42,7 +42,6 @@ async function checkForUpdates(
|
||||
const currentVersion = context.extension.packageJSON.version;
|
||||
|
||||
// Fetch extension details from the VSCode Marketplace.
|
||||
// eslint-disable-next-line no-restricted-syntax -- TODO: Migrate to safeFetch for SSRF protection
|
||||
const response = await fetch(
|
||||
'https://marketplace.visualstudio.com/_apis/public/gallery/extensionquery',
|
||||
{
|
||||
|
||||
@@ -356,7 +356,6 @@ describe('IDEServer', () => {
|
||||
});
|
||||
|
||||
it('should reject request without auth token', async () => {
|
||||
// eslint-disable-next-line no-restricted-syntax -- TODO: Migrate to safeFetch for SSRF protection
|
||||
const response = await fetch(`http://localhost:${port}/mcp`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -371,7 +370,6 @@ describe('IDEServer', () => {
|
||||
});
|
||||
|
||||
it('should allow request with valid auth token', async () => {
|
||||
// eslint-disable-next-line no-restricted-syntax -- TODO: Migrate to safeFetch for SSRF protection
|
||||
const response = await fetch(`http://localhost:${port}/mcp`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -389,7 +387,6 @@ describe('IDEServer', () => {
|
||||
});
|
||||
|
||||
it('should reject request with invalid auth token', async () => {
|
||||
// eslint-disable-next-line no-restricted-syntax -- TODO: Migrate to safeFetch for SSRF protection
|
||||
const response = await fetch(`http://localhost:${port}/mcp`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -416,7 +413,6 @@ describe('IDEServer', () => {
|
||||
];
|
||||
|
||||
for (const header of malformedHeaders) {
|
||||
// eslint-disable-next-line no-restricted-syntax -- TODO: Migrate to safeFetch for SSRF protection
|
||||
const response = await fetch(`http://localhost:${port}/mcp`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
|
||||
Reference in New Issue
Block a user