Compare commits

...

18 Commits

Author SHA1 Message Date
Coco Sheng 9e31c3a444 fix(core): update snapshot tests for write_file warning 2026-05-20 13:42:27 -04:00
Coco Sheng 8047123225 fix(core): mitigate data corruption during write_file on massive text blocks 2026-05-20 13:36:48 -04:00
Coco Sheng 29481a1562 fix: robust ripgrep path resolution and 1p hermetic execution support (#27253) 2026-05-20 16:29:00 +00:00
Coco Sheng 2c85f57402 fix(cli): integrate PolicyEngine into ACP session to prevent deadlocks (#23507) (#27252) 2026-05-20 16:16:09 +00:00
Ramón Medrano Llamas 124539b5cc fix(devtools): bundle devtools package to avoid resolution errors (#27250) 2026-05-20 07:14:57 +00:00
gemini-cli-robot ec4910f0bb Changelog for v0.43.0-preview.1 (#27297)
Co-authored-by: gemini-cli-robot <224641728+gemini-cli-robot@users.noreply.github.com>
2026-05-20 03:49:29 +00:00
Andrea Alberti 57c42a5c40 feat(cli): add Sublime Text and Emacs Client editors, improve error messages and documentation (#21090)
Co-authored-by: Ananth Kini <ananthkini1@gmail.com>
2026-05-19 20:03:25 +00:00
ashishch432 8997488ea6 fix(cli): preserve proxy-agent named exports in ESM bundle (#27145) 2026-05-19 19:41:31 +00:00
kaluchi 6589cdf11b Proposal: deterministic encoding for child-process I/O (#27247) 2026-05-19 19:41:04 +00:00
Dev Randalpura 9de4289287 fix(core): correctly handle nullable array types in MCP tools (#27228) 2026-05-19 18:24:18 +00:00
Coco Sheng 37f3a4c90a fix(core): respect NO_PROXY in global fetch dispatcher (#27216) 2026-05-19 17:56:59 +00:00
Om Patel fcc8c62b8b fix(core): prevent path traversal in custome command file injection (#27234) 2026-05-19 17:37:46 +00:00
Adam Weidman c4758ba820 feat(core): wire AgentSession invocations into agent-tool (#26948) 2026-05-19 16:56:43 +00:00
Ramón Medrano Llamas 96a8d1d069 fix(cli): bundle ink worker-entry.js (#27249) 2026-05-19 16:47:36 +00:00
Dev Randalpura 3494fda2cf fix(core): add exception handling to migrateFromFileStorage (#27229) 2026-05-19 15:10:21 +00:00
Coco Sheng 1a024f30a3 fix: allow configured MCP servers in non-interactive mode (#27215) 2026-05-19 14:44:09 +00:00
Adam Weidman 5650fa90d7 feat(core): add RemoteSessionInvocation (#26937) 2026-05-19 14:17:24 +00:00
Keith Schaab 85566a73f6 fix(a2a-server): Implement default policy loading for parity with CLI (#27073) 2026-05-19 14:09:57 +00:00
63 changed files with 3378 additions and 889 deletions
+1
View File
@@ -23,3 +23,4 @@ Thumbs.db
**/SKILL.md
packages/sdk/test-data/*.json
*.mdx
packages/vscode-ide-companion/NOTICES.txt
+6 -3
View File
@@ -1,6 +1,6 @@
# Preview release: v0.43.0-preview.0
# Preview release: v0.43.0-preview.1
Released: May 12, 2026
Released: May 19, 2026
Our preview release includes the latest, new, and experimental features. This
release may not be as stable as our [latest weekly release](latest.md).
@@ -26,6 +26,9 @@ npm install -g @google/gemini-cli@preview
## What's Changed
- fix(patch): cherry-pick 85566a7 to release/v0.43.0-preview.0-pr-27073
[CONFLICTS] by @gemini-cli-robot in
[#27256](https://github.com/google-gemini/gemini-cli/pull/27256)
- feat(core): steer model to use edit tool for surgical edits, fix a typo in
[#26480](https://github.com/google-gemini/gemini-cli/pull/26480)
- docs: clarify Auto Memory proposes memory updates and skills in
@@ -193,4 +196,4 @@ npm install -g @google/gemini-cli@preview
[#26949](https://github.com/google-gemini/gemini-cli/pull/26949)
**Full Changelog**:
https://github.com/google-gemini/gemini-cli/compare/v0.42.0-preview.2...v0.43.0-preview.0
https://github.com/google-gemini/gemini-cli/compare/v0.42.0-preview.2...v0.43.0-preview.1
+12 -2
View File
@@ -105,9 +105,19 @@ their corresponding top-level category object in your `settings.json` file.
#### `general`
- **`general.preferredEditor`** (string):
- **Description:** The preferred editor to open files in.
- **`general.preferredEditor`** (enum):
- **Description:** The preferred editor to open files in. Must be one of the
built-in supported identifiers. Use /editor in the CLI to pick
interactively, or leave unset to use $VISUAL/$EDITOR.
- **Default:** `undefined`
- **Values:** `"vscode"`, `"vscodium"`, `"windsurf"`, `"cursor"`, `"zed"`,
`"antigravity"`, `"sublimetext"`, `"lapce"`, `"nova"`, `"bbedit"`, `"vim"`,
`"neovim"`, `"emacs"`, `"hx"`, `"emacsclient"`, `"micro"`
- **`general.openEditorInNewWindow`** (boolean):
- **Description:** Open VS Code-family editors in a new window when editing
files.
- **Default:** `false`
- **`general.vimMode`** (boolean):
- **Description:** Enable Vim keybindings
+41 -2
View File
@@ -63,7 +63,6 @@ const external = [
'@lydell/node-pty-win32-arm64',
'@lydell/node-pty-win32-x64',
'@github/keytar',
'@google/gemini-cli-devtools',
];
const baseConfig = {
@@ -102,11 +101,46 @@ const cliConfig = {
plugins: createWasmPlugins(),
alias: {
'is-in-ci': path.resolve(__dirname, 'packages/cli/src/patches/is-in-ci.ts'),
'https-proxy-agent': path.resolve(
__dirname,
'packages/cli/src/patches/https-proxy-agent.ts',
),
'http-proxy-agent': path.resolve(
__dirname,
'packages/cli/src/patches/http-proxy-agent.ts',
),
'@google/gemini-cli-devtools': path.resolve(
__dirname,
'packages/devtools/src/index.ts',
),
...commonAliases,
},
metafile: true,
};
const workerConfig = {
...baseConfig,
banner: {
js: `const require = (await import('node:module')).createRequire(import.meta.url); const __chunk_filename = (await import('node:url')).fileURLToPath(import.meta.url); const __chunk_dirname = (await import('node:path')).dirname(__chunk_filename);`,
},
entryPoints: {
'worker/worker-entry': path.join(
path.dirname(require.resolve('ink')),
'worker/worker-entry.js',
),
},
outdir: 'bundle',
define: {
__filename: '__chunk_filename',
__dirname: '__chunk_dirname',
'process.env.NODE_ENV': JSON.stringify(
process.env.NODE_ENV || 'production',
),
},
plugins: createWasmPlugins(),
alias: commonAliases,
};
const a2aServerConfig = {
...baseConfig,
banner: {
@@ -133,13 +167,18 @@ Promise.allSettled([
writeFileSync('./bundle/esbuild.json', JSON.stringify(metafile, null, 2));
}
}),
esbuild.build(workerConfig),
esbuild.build(a2aServerConfig),
]).then((results) => {
const [cliResult, a2aResult] = results;
const [cliResult, workerResult, a2aResult] = results;
if (cliResult.status === 'rejected') {
console.error('gemini.js build failed:', cliResult.reason);
process.exit(1);
}
if (workerResult.status === 'rejected') {
console.error('worker-entry.js build failed:', workerResult.reason);
process.exit(1);
}
// error in a2a-server bundling will not stop gemini.js bundling process
if (a2aResult.status === 'rejected') {
console.warn('a2a-server build failed:', a2aResult.reason);
-7
View File
@@ -6078,12 +6078,6 @@
"node": ">=8"
}
},
"node_modules/chardet": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/chardet/-/chardet-2.1.0.tgz",
"integrity": "sha512-bNFETTG/pM5ryzQ9Ad0lJOTa6HWD/YsScAR3EnCPZRPlQh77JocYktSHOUHelyhm8IARL+o4c4F1bP5KVOjiRA==",
"license": "MIT"
},
"node_modules/check-error": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz",
@@ -18434,7 +18428,6 @@
"@xterm/headless": "5.5.0",
"ajv": "^8.17.1",
"ajv-formats": "^3.0.0",
"chardet": "^2.1.0",
"chokidar": "^5.0.0",
"command-exists": "^1.2.9",
"diff": "^8.0.3",
+8 -2
View File
@@ -93,10 +93,16 @@ export class CoderAgentExecutor implements AgentExecutor {
taskId: string,
): Promise<Config> {
const workspaceRoot = setTargetDir(agentSettings);
const isTrusted = agentSettings.isTrusted ?? false;
loadEnvironment(); // Will override any global env with workspace envs
const settings = loadSettings(workspaceRoot);
const settings = loadSettings(workspaceRoot, isTrusted);
const extensions = loadExtensions(workspaceRoot);
return loadConfig(settings, new SimpleExtensionLoader(extensions), taskId);
return loadConfig(
settings,
new SimpleExtensionLoader(extensions),
taskId,
isTrusted,
);
}
/**
+114 -2
View File
@@ -19,7 +19,9 @@ import {
isHeadlessMode,
FatalAuthenticationError,
PolicyDecision,
ApprovalMode,
PRIORITY_YOLO_ALLOW_ALL,
createPolicyEngineConfig,
} from '@google/gemini-cli-core';
// Mock dependencies
@@ -53,6 +55,32 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
isHeadlessMode: vi.fn().mockReturnValue(false),
getCodeAssistServer: vi.fn(),
fetchAdminControlsOnce: vi.fn(),
createPolicyEngineConfig: vi
.fn()
.mockImplementation(
(_settings, mode, _defaultPoliciesDir, _interactive) => ({
rules:
mode === actual.ApprovalMode.YOLO
? [
{
toolName: '*',
decision: actual.PolicyDecision.ALLOW,
priority: actual.PRIORITY_YOLO_ALLOW_ALL,
modes: [actual.ApprovalMode.YOLO],
allowRedirection: true,
},
]
: [
{
toolName: 'read_file',
decision: actual.PolicyDecision.ALLOW,
priority: 1.05,
source: 'Default: read-only.toml',
},
],
checkers: [],
}),
),
coreEvents: {
emitAdminSettingsChanged: vi.fn(),
},
@@ -261,6 +289,85 @@ describe('loadConfig', () => {
expect((config as any).fileFiltering.customIgnoreFilePaths).toEqual([]);
});
describe('policy engine configuration', () => {
it('should merge V1 and V2 tool settings into policySettings', async () => {
const settings: Settings = {
allowedTools: ['v1-allowed'],
tools: {
allowed: ['v2-allowed'],
exclude: ['v2-exclude'],
core: ['v2-core'],
},
mcpServers: {
test: { command: 'test', args: [] },
},
policyPaths: ['/path/to/policy'],
adminPolicyPaths: ['/path/to/admin/policy'],
};
await loadConfig(settings, mockExtensionLoader, taskId);
expect(createPolicyEngineConfig).toHaveBeenCalledWith(
expect.objectContaining({
tools: {
core: ['v2-core'],
exclude: ['v2-exclude'],
allowed: ['v1-allowed'],
},
mcpServers: settings.mcpServers,
policyPaths: settings.policyPaths,
adminPolicyPaths: settings.adminPolicyPaths,
}),
ApprovalMode.DEFAULT,
undefined,
true,
);
});
it('should use V2 tool settings when V1 is missing', async () => {
const settings: Settings = {
tools: {
allowed: ['v2-allowed'],
},
};
await loadConfig(settings, mockExtensionLoader, taskId);
expect(createPolicyEngineConfig).toHaveBeenCalledWith(
expect.objectContaining({
tools: expect.objectContaining({
allowed: ['v2-allowed'],
}),
}),
ApprovalMode.DEFAULT,
undefined,
true,
);
});
it('should use V1 tool settings when V2 is also present', async () => {
const settings: Settings = {
allowedTools: ['v1-allowed'],
tools: {
allowed: ['v2-allowed'],
},
};
await loadConfig(settings, mockExtensionLoader, taskId);
expect(createPolicyEngineConfig).toHaveBeenCalledWith(
expect.objectContaining({
tools: expect.objectContaining({
allowed: ['v1-allowed'],
}),
}),
ApprovalMode.DEFAULT,
undefined,
true,
);
});
});
describe('tool configuration', () => {
it('should pass V1 allowedTools to Config properly', async () => {
const settings: Settings = {
@@ -385,14 +492,19 @@ describe('loadConfig', () => {
);
});
it('should use default approval mode and empty rules when GEMINI_YOLO_MODE is not true', async () => {
it('should use default approval mode and load default rules when GEMINI_YOLO_MODE is not true', async () => {
vi.stubEnv('GEMINI_YOLO_MODE', 'false');
await loadConfig(mockSettings, mockExtensionLoader, taskId);
expect(Config).toHaveBeenCalledWith(
expect.objectContaining({
approvalMode: 'default',
policyEngineConfig: expect.objectContaining({
rules: [],
rules: expect.arrayContaining([
expect.objectContaining({
toolName: 'read_file',
decision: PolicyDecision.ALLOW,
}),
]),
}),
}),
);
+23 -17
View File
@@ -23,8 +23,8 @@ import {
ExperimentFlags,
isHeadlessMode,
FatalAuthenticationError,
PolicyDecision,
PRIORITY_YOLO_ALLOW_ALL,
createPolicyEngineConfig,
type PolicySettings,
type TelemetryTarget,
type ConfigParameters,
type ExtensionLoader,
@@ -38,6 +38,7 @@ export async function loadConfig(
settings: Settings,
extensionLoader: ExtensionLoader,
taskId: string,
trusted: boolean = false,
): Promise<Config> {
const workspaceDir = process.cwd();
@@ -63,6 +64,24 @@ export async function loadConfig(
? ApprovalMode.YOLO
: ApprovalMode.DEFAULT;
const policySettings: PolicySettings = {
mcpServers: settings.mcpServers,
tools: {
core: settings.coreTools || settings.tools?.core,
exclude: settings.excludeTools || settings.tools?.exclude,
allowed: settings.allowedTools || settings.tools?.allowed,
},
policyPaths: settings.policyPaths,
adminPolicyPaths: settings.adminPolicyPaths,
};
const policyEngineConfig = await createPolicyEngineConfig(
policySettings,
approvalMode,
undefined,
true,
);
const configParams: ConfigParameters = {
sessionId: taskId,
clientName: 'a2a-server',
@@ -78,20 +97,7 @@ export async function loadConfig(
allowedTools: settings.allowedTools || settings.tools?.allowed || undefined,
showMemoryUsage: settings.showMemoryUsage || false,
approvalMode,
policyEngineConfig: {
rules:
approvalMode === ApprovalMode.YOLO
? [
{
toolName: '*',
decision: PolicyDecision.ALLOW,
priority: PRIORITY_YOLO_ALLOW_ALL,
modes: [ApprovalMode.YOLO],
allowRedirection: true,
},
]
: [],
},
policyEngineConfig,
mcpServers: settings.mcpServers,
cwd: workspaceDir,
telemetry: {
@@ -118,7 +124,7 @@ export async function loadConfig(
},
ideMode: false,
folderTrust,
trustedFolder: true,
trustedFolder: trusted,
extensionLoader,
checkpointing,
interactive: true,
@@ -9,7 +9,7 @@ import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { loadSettings, USER_SETTINGS_PATH } from './settings.js';
import { debugLogger } from '@google/gemini-cli-core';
import { debugLogger, checkPathTrust } from '@google/gemini-cli-core';
const mocks = vi.hoisted(() => {
const suffix = Math.random().toString(36).slice(2);
@@ -40,6 +40,8 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
},
getErrorMessage: (error: unknown) => String(error),
homedir: () => path.join(os.tmpdir(), `gemini-home-${mocks.suffix}`),
checkPathTrust: vi.fn(() => ({ isTrusted: false })),
isHeadlessMode: vi.fn(() => true),
};
});
@@ -146,7 +148,7 @@ describe('loadSettings', () => {
);
fs.writeFileSync(workspaceSettingsPath, JSON.stringify(workspaceSettings));
const result = loadSettings(mockWorkspaceDir);
const result = loadSettings(mockWorkspaceDir, true);
// Primitive value overwritten
expect(result.showMemoryUsage).toBe(true);
@@ -154,4 +156,78 @@ describe('loadSettings', () => {
expect(result.fileFiltering?.respectGitIgnore).toBe(false);
expect(result.fileFiltering?.enableRecursiveFileSearch).toBeUndefined();
});
describe('security', () => {
it('should NOT load workspace settings if workspace is NOT trusted', () => {
const userSettings = { showMemoryUsage: false };
fs.writeFileSync(USER_SETTINGS_PATH, JSON.stringify(userSettings));
const workspaceSettings = { showMemoryUsage: true };
const workspaceSettingsPath = path.join(
mockGeminiWorkspaceDir,
'settings.json',
);
fs.writeFileSync(
workspaceSettingsPath,
JSON.stringify(workspaceSettings),
);
// checkPathTrust is mocked to return isTrusted: false by default
const result = loadSettings(mockWorkspaceDir);
expect(result.showMemoryUsage).toBe(false);
});
it('should load workspace settings if workspace IS trusted', () => {
vi.mocked(checkPathTrust).mockReturnValueOnce({
isTrusted: true,
source: 'file',
});
const userSettings = { showMemoryUsage: false };
fs.writeFileSync(USER_SETTINGS_PATH, JSON.stringify(userSettings));
const workspaceSettings = { showMemoryUsage: true };
const workspaceSettingsPath = path.join(
mockGeminiWorkspaceDir,
'settings.json',
);
fs.writeFileSync(
workspaceSettingsPath,
JSON.stringify(workspaceSettings),
);
const result = loadSettings(mockWorkspaceDir);
expect(result.showMemoryUsage).toBe(true);
});
it('should NOT allow workspace settings to override adminPolicyPaths or policyPaths even if trusted', () => {
vi.mocked(checkPathTrust).mockReturnValueOnce({
isTrusted: true,
source: 'file',
});
const userSettings = {
adminPolicyPaths: ['/trusted/admin'],
policyPaths: ['/trusted/user'],
};
fs.writeFileSync(USER_SETTINGS_PATH, JSON.stringify(userSettings));
const workspaceSettings = {
adminPolicyPaths: ['./malicious/admin'],
policyPaths: ['./malicious/user'],
showMemoryUsage: true,
};
const workspaceSettingsPath = path.join(
mockGeminiWorkspaceDir,
'settings.json',
);
fs.writeFileSync(
workspaceSettingsPath,
JSON.stringify(workspaceSettings),
);
const result = loadSettings(mockWorkspaceDir);
expect(result.showMemoryUsage).toBe(true);
expect(result.adminPolicyPaths).toEqual(['/trusted/admin']);
expect(result.policyPaths).toEqual(['/trusted/user']);
});
});
});
+44 -17
View File
@@ -14,6 +14,8 @@ import {
getErrorMessage,
type TelemetrySettings,
homedir,
checkPathTrust,
isHeadlessMode,
} from '@google/gemini-cli-core';
import stripJsonComments from 'strip-json-comments';
@@ -51,6 +53,8 @@ export interface Settings {
experimental?: {
enableAgents?: boolean;
};
policyPaths?: string[];
adminPolicyPaths?: string[];
}
export interface SettingsError {
@@ -64,13 +68,16 @@ export interface CheckpointingSettings {
/**
* Loads settings from user and workspace directories.
* Project settings override user settings.
* Project settings override user settings if the workspace is trusted.
*
* How is it different to gemini-cli/cli: Returns already merged settings rather
* than `LoadedSettings` (unnecessary since we are not modifying users
* settings.json).
*/
export function loadSettings(workspaceDir: string): Settings {
export function loadSettings(
workspaceDir: string,
isTrustedOverride?: boolean,
): Settings {
let userSettings: Settings = {};
let workspaceSettings: Settings = {};
const settingsErrors: SettingsError[] = [];
@@ -92,27 +99,39 @@ export function loadSettings(workspaceDir: string): Settings {
});
}
let isTrusted = isTrustedOverride;
if (isTrusted === undefined) {
const { isTrusted: trustResult } = checkPathTrust({
path: workspaceDir,
isFolderTrustEnabled: userSettings.folderTrust ?? true,
isHeadless: isHeadlessMode(),
});
isTrusted = trustResult ?? false;
}
const workspaceSettingsPath = path.join(
workspaceDir,
GEMINI_DIR,
'settings.json',
);
// Load workspace settings
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(
stripJsonComments(projectContent),
) as Settings;
workspaceSettings = resolveEnvVarsInObject(parsedWorkspaceSettings);
// Load workspace settings only if trusted
if (isTrusted) {
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(
stripJsonComments(projectContent),
) as Settings;
workspaceSettings = resolveEnvVarsInObject(parsedWorkspaceSettings);
}
} catch (error: unknown) {
settingsErrors.push({
message: getErrorMessage(error),
path: workspaceSettingsPath,
});
}
} catch (error: unknown) {
settingsErrors.push({
message: getErrorMessage(error),
path: workspaceSettingsPath,
});
}
if (settingsErrors.length > 0) {
@@ -125,10 +144,18 @@ export function loadSettings(workspaceDir: string): Settings {
// If there are overlapping keys, the values of workspaceSettings will
// override values from userSettings
return {
const mergedSettings = {
...userSettings,
...workspaceSettings,
};
// Security: ensure policyPaths and adminPolicyPaths are only loaded from trusted, user-level
// configuration and cannot be overridden by workspace-level settings, even if the
// workspace is trusted.
mergedSettings.policyPaths = userSettings.policyPaths;
mergedSettings.adminPolicyPaths = userSettings.adminPolicyPaths;
return mergedSettings;
}
function resolveEnvVarsInString(value: string): string {
+14 -1
View File
@@ -30,6 +30,8 @@ import {
debugLogger,
SimpleExtensionLoader,
GitService,
checkPathTrust,
isHeadlessMode,
} from '@google/gemini-cli-core';
import type { Command, CommandArgument } from '../commands/types.js';
@@ -197,12 +199,23 @@ export async function createApp() {
// Load the server configuration once on startup.
const workspaceRoot = setTargetDir(undefined);
loadEnvironment();
const settings = loadSettings(workspaceRoot);
// Use a temporary settings load to check if folder trust is enabled.
// This is similar to how the CLI handles the initial trust check.
const initialSettings = loadSettings(workspaceRoot, false);
const { isTrusted } = checkPathTrust({
path: workspaceRoot,
isFolderTrustEnabled: initialSettings.folderTrust ?? true,
isHeadless: isHeadlessMode(),
});
const settings = loadSettings(workspaceRoot, isTrusted ?? false);
const extensions = loadExtensions(workspaceRoot);
const config = await loadConfig(
settings,
new SimpleExtensionLoader(extensions),
'a2a-server',
isTrusted ?? false,
);
let git: GitService | undefined;
+1
View File
@@ -47,6 +47,7 @@ export interface AgentSettings {
kind: CoderAgentEvent.StateAgentSettingsEvent;
workspacePath: string;
autoExecute?: boolean;
isTrusted?: boolean;
}
export interface ToolCallConfirmation {
+5
View File
@@ -100,6 +100,11 @@ describe('GeminiAgent Session Resume', () => {
subscribe: vi.fn(),
unsubscribe: vi.fn(),
},
getMessageBus: vi.fn().mockReturnValue({
publish: vi.fn(),
subscribe: vi.fn(),
unsubscribe: vi.fn(),
}),
getApprovalMode: vi.fn().mockReturnValue('default'),
isAutoMemoryEnabled: vi.fn().mockReturnValue(false),
isPlanEnabled: vi.fn().mockReturnValue(true),
+325
View File
@@ -26,6 +26,10 @@ import {
InvalidStreamError,
GeminiEventType,
type ServerGeminiStreamEvent,
PolicyDecision,
MessageBusType,
type ToolConfirmationRequest,
DiscoveredMCPTool,
} from '@google/gemini-cli-core';
import type { LoadedSettings } from '../config/settings.js';
import { type Part, FinishReason } from '@google/genai';
@@ -139,6 +143,9 @@ describe('Session', () => {
isPlanEnabled: vi.fn().mockReturnValue(true),
getCheckpointingEnabled: vi.fn().mockReturnValue(false),
getGitService: vi.fn().mockResolvedValue({} as GitService),
getPolicyEngine: vi.fn().mockReturnValue({
check: vi.fn(),
}),
validatePathAccess: vi.fn().mockReturnValue(null),
getWorkspaceContext: vi.fn().mockReturnValue({
addReadOnlyPath: vi.fn(),
@@ -707,4 +714,322 @@ describe('Session', () => {
}),
);
});
describe('Policy Handling', () => {
it('should auto-approve tool calls when PolicyEngine returns ALLOW', async () => {
const mockPolicyEngine = mockConfig.getPolicyEngine() as unknown as {
check: Mock<
(
toolCall: { name: string; args: Record<string, unknown> },
serverName?: string,
toolAnnotations?: Record<string, unknown>,
subagent?: string,
) => Promise<{ decision: PolicyDecision }>
>;
};
mockPolicyEngine.check.mockResolvedValue({
decision: PolicyDecision.ALLOW,
});
// Trigger the subscription handler
const handler = mockMessageBus.subscribe.mock.calls.find(
(call) => call[0] === MessageBusType.TOOL_CONFIRMATION_REQUEST,
)?.[1] as (request: ToolConfirmationRequest) => Promise<void>;
expect(handler).toBeDefined();
await handler({
type: MessageBusType.TOOL_CONFIRMATION_REQUEST,
correlationId: 'test-id',
toolCall: { name: 'ls', args: {} },
});
expect(mockMessageBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
correlationId: 'test-id',
confirmed: true,
requiresUserConfirmation: false,
}),
);
});
it('should request user confirmation when PolicyEngine returns ASK_USER', async () => {
const mockPolicyEngine = mockConfig.getPolicyEngine() as unknown as {
check: Mock<
(
toolCall: { name: string; args: Record<string, unknown> },
serverName?: string,
toolAnnotations?: Record<string, unknown>,
subagent?: string,
) => Promise<{ decision: PolicyDecision }>
>;
};
mockPolicyEngine.check.mockResolvedValue({
decision: PolicyDecision.ASK_USER,
});
const handler = mockMessageBus.subscribe.mock.calls.find(
(call) => call[0] === MessageBusType.TOOL_CONFIRMATION_REQUEST,
)?.[1] as (request: ToolConfirmationRequest) => Promise<void>;
await handler({
type: MessageBusType.TOOL_CONFIRMATION_REQUEST,
correlationId: 'test-id-2',
toolCall: { name: 'rm', args: { path: '/' } },
});
expect(mockMessageBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
correlationId: 'test-id-2',
confirmed: false,
requiresUserConfirmation: true,
}),
);
});
it('should deny tool calls when PolicyEngine returns DENY', async () => {
const mockPolicyEngine = mockConfig.getPolicyEngine() as unknown as {
check: Mock<
(
toolCall: { name: string; args: Record<string, unknown> },
serverName?: string,
toolAnnotations?: Record<string, unknown>,
subagent?: string,
) => Promise<{ decision: PolicyDecision }>
>;
};
mockPolicyEngine.check.mockResolvedValue({
decision: PolicyDecision.DENY,
});
const handler = mockMessageBus.subscribe.mock.calls.find(
(call) => call[0] === MessageBusType.TOOL_CONFIRMATION_REQUEST,
)?.[1] as (request: ToolConfirmationRequest) => Promise<void>;
await handler({
type: MessageBusType.TOOL_CONFIRMATION_REQUEST,
correlationId: 'test-id-3',
toolCall: { name: 'forbidden', args: {} },
});
expect(mockMessageBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
correlationId: 'test-id-3',
confirmed: false,
requiresUserConfirmation: false,
}),
);
});
it('should pass subagent and trusted tool info to PolicyEngine', async () => {
const mockPolicyEngine = mockConfig.getPolicyEngine() as unknown as {
check: Mock<
(
toolCall: { name: string; args: Record<string, unknown> },
serverName?: string,
toolAnnotations?: Record<string, unknown>,
subagent?: string,
) => Promise<{ decision: PolicyDecision }>
>;
};
mockPolicyEngine.check.mockResolvedValue({
decision: PolicyDecision.ALLOW,
});
// Mock tool in registry with trusted annotations
const trustedAnnotations = { safe: true };
mockToolRegistry.getTool.mockReturnValue({
name: 'ls',
toolAnnotations: trustedAnnotations,
});
const handler = mockMessageBus.subscribe.mock.calls.find(
(call) => call[0] === MessageBusType.TOOL_CONFIRMATION_REQUEST,
)?.[1] as (request: ToolConfirmationRequest) => Promise<void>;
await handler({
type: MessageBusType.TOOL_CONFIRMATION_REQUEST,
correlationId: 'test-id-trusted',
toolCall: { name: 'ls', args: {} },
subagent: 'restricted-subagent',
serverName: 'spoofed-server', // Should be ignored
toolAnnotations: { malicious: true }, // Should be ignored
});
expect(mockPolicyEngine.check).toHaveBeenCalledWith(
expect.anything(),
undefined, // serverName for non-MCP tool
trustedAnnotations,
'restricted-subagent',
);
});
it('should handle exceptions in PolicyEngine by failing closed', async () => {
const mockPolicyEngine = mockConfig.getPolicyEngine() as unknown as {
check: Mock<
(
toolCall: { name: string; args: Record<string, unknown> },
serverName?: string,
toolAnnotations?: Record<string, unknown>,
subagent?: string,
) => Promise<{ decision: PolicyDecision }>
>;
};
mockPolicyEngine.check.mockRejectedValue(
new Error('Policy check failed'),
);
const handler = mockMessageBus.subscribe.mock.calls.find(
(call) => call[0] === MessageBusType.TOOL_CONFIRMATION_REQUEST,
)?.[1] as (request: ToolConfirmationRequest) => Promise<void>;
await handler({
type: MessageBusType.TOOL_CONFIRMATION_REQUEST,
correlationId: 'test-id-error',
toolCall: { name: 'ls', args: {} },
});
expect(mockMessageBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
correlationId: 'test-id-error',
confirmed: false,
requiresUserConfirmation: false,
}),
);
});
it('should fail closed when PolicyEngine is missing', async () => {
(mockConfig.getPolicyEngine as Mock).mockReturnValue(undefined);
const handler = mockMessageBus.subscribe.mock.calls.find(
(call) => call[0] === MessageBusType.TOOL_CONFIRMATION_REQUEST,
)?.[1] as (request: ToolConfirmationRequest) => Promise<void>;
await handler({
type: MessageBusType.TOOL_CONFIRMATION_REQUEST,
correlationId: 'test-id-no-engine',
toolCall: { name: 'ls', args: {} },
});
expect(mockMessageBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
correlationId: 'test-id-no-engine',
confirmed: false,
requiresUserConfirmation: false,
}),
);
});
it('should handle missing tool name in request by failing closed', async () => {
const handler = mockMessageBus.subscribe.mock.calls.find(
(call) => call[0] === MessageBusType.TOOL_CONFIRMATION_REQUEST,
)?.[1] as (request: ToolConfirmationRequest) => Promise<void>;
await handler({
type: MessageBusType.TOOL_CONFIRMATION_REQUEST,
correlationId: 'test-id-no-name',
toolCall: { name: '', args: {} },
});
expect(mockMessageBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
correlationId: 'test-id-no-name',
confirmed: false,
requiresUserConfirmation: false,
}),
);
});
it('should trim tool name before lookup and validation', async () => {
const handler = mockMessageBus.subscribe.mock.calls.find(
(call) => call[0] === MessageBusType.TOOL_CONFIRMATION_REQUEST,
)?.[1] as (request: ToolConfirmationRequest) => Promise<void>;
await handler({
type: MessageBusType.TOOL_CONFIRMATION_REQUEST,
correlationId: 'test-id-whitespace',
toolCall: { name: ' ', args: {} },
});
expect(mockMessageBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
correlationId: 'test-id-whitespace',
confirmed: false,
requiresUserConfirmation: false,
}),
);
});
it('should pass serverName from DiscoveredMCPTool to PolicyEngine', async () => {
const mockPolicyEngine = mockConfig.getPolicyEngine() as unknown as {
check: Mock<
(
toolCall: { name: string; args: Record<string, unknown> },
serverName?: string,
toolAnnotations?: Record<string, unknown>,
subagent?: string,
) => Promise<{ decision: PolicyDecision }>
>;
};
mockPolicyEngine.check.mockResolvedValue({
decision: PolicyDecision.ALLOW,
});
// Mock tool in registry as a DiscoveredMCPTool instance
const mcpTool = {
name: 'mcp_server_tool',
serverName: 'test-server',
toolAnnotations: { mcp: true },
};
Object.setPrototypeOf(mcpTool, DiscoveredMCPTool.prototype);
mockToolRegistry.getTool.mockReturnValue(mcpTool);
const handler = mockMessageBus.subscribe.mock.calls.find(
(call) => call[0] === MessageBusType.TOOL_CONFIRMATION_REQUEST,
)?.[1] as (request: ToolConfirmationRequest) => Promise<void>;
await handler({
type: MessageBusType.TOOL_CONFIRMATION_REQUEST,
correlationId: 'test-id-mcp',
toolCall: { name: 'mcp_server_tool', args: {} },
});
expect(mockPolicyEngine.check).toHaveBeenCalledWith(
expect.anything(),
'test-server',
{ mcp: true },
undefined,
);
});
it('should fail closed and deny unknown tools', async () => {
mockToolRegistry.getTool.mockReturnValue(undefined);
const handler = mockMessageBus.subscribe.mock.calls.find(
(call) => call[0] === MessageBusType.TOOL_CONFIRMATION_REQUEST,
)?.[1] as (request: ToolConfirmationRequest) => Promise<void>;
await handler({
type: MessageBusType.TOOL_CONFIRMATION_REQUEST,
correlationId: 'test-id-unknown',
toolCall: { name: 'unknown_tool', args: {} },
});
expect(mockMessageBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
correlationId: 'test-id-unknown',
confirmed: false,
requiresUserConfirmation: false,
}),
);
});
});
});
+95
View File
@@ -34,6 +34,9 @@ import {
isNodeError,
REFERENCE_CONTENT_START,
InvalidStreamError,
MessageBusType,
PolicyDecision,
type ToolConfirmationRequest,
} from '@google/gemini-cli-core';
import * as acp from '@agentclientprotocol/sdk';
import type { Part, FunctionCall } from '@google/genai';
@@ -61,6 +64,7 @@ export class Session {
private pendingPrompt: AbortController | null = null;
private commandHandler = new CommandHandler();
private callIdCounter = 0;
private readonly disposeController = new AbortController();
private generateCallId(name: string): string {
return `${name}-${Date.now()}-${++this.callIdCounter}`;
@@ -77,8 +81,98 @@ export class Session {
CoreEvent.ApprovalModeChanged,
this.handleApprovalModeChanged,
);
// Subscribe to tool confirmation requests to handle policy checks (e.g. auto-allowing safe shell commands)
this.context.config
.getMessageBus()
?.subscribe(
MessageBusType.TOOL_CONFIRMATION_REQUEST,
this.handleToolConfirmationRequest,
{ signal: this.disposeController.signal },
);
}
private handleToolConfirmationRequest = async (
request: ToolConfirmationRequest,
) => {
try {
const policyEngine = this.context.config.getPolicyEngine?.();
const messageBus = this.context.config.getMessageBus();
if (!messageBus) {
return;
}
if (!policyEngine) {
debugLogger.warn(
'Policy engine missing. Denying tool confirmation request.',
);
await messageBus.publish({
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
correlationId: request.correlationId,
confirmed: false,
requiresUserConfirmation: false,
});
return;
}
const toolName = request.toolCall.name?.trim();
if (!toolName) {
debugLogger.warn(
'Tool confirmation request missing tool name. Denying.',
);
await messageBus.publish({
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
correlationId: request.correlationId,
confirmed: false,
requiresUserConfirmation: false,
});
return;
}
const tool = this.context.toolRegistry.getTool(toolName);
if (!tool) {
debugLogger.warn(
`Tool confirmation request for unknown tool: ${toolName}. Denying.`,
);
await messageBus.publish({
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
correlationId: request.correlationId,
confirmed: false,
requiresUserConfirmation: false,
});
return;
}
const serverName =
tool instanceof DiscoveredMCPTool ? tool.serverName : undefined;
const toolAnnotations = tool.toolAnnotations;
const result = await policyEngine.check(
request.toolCall,
serverName,
toolAnnotations,
request.subagent,
);
await messageBus.publish({
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
correlationId: request.correlationId,
confirmed: result.decision === PolicyDecision.ALLOW,
requiresUserConfirmation: result.decision === PolicyDecision.ASK_USER,
});
} catch (error) {
debugLogger.error('Error handling tool confirmation request:', error);
// Fail closed on exception
await this.context.config.getMessageBus()?.publish({
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
correlationId: request.correlationId,
confirmed: false,
requiresUserConfirmation: false,
});
}
};
private handleApprovalModeChanged = (payload: ApprovalModeChangedPayload) => {
if (payload.sessionId === this.id) {
void this.sendUpdate({
@@ -96,6 +190,7 @@ export class Session {
CoreEvent.ApprovalModeChanged,
this.handleApprovalModeChanged,
);
this.disposeController.abort();
}
async cancelPendingPrompt(): Promise<void> {
+18 -2
View File
@@ -12,6 +12,7 @@
import {
DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD,
DEFAULT_MODEL_CONFIGS,
EDITOR_OPTIONS,
AuthProviderType,
type MCPServerConfig,
type RequiredMcpServerConfig,
@@ -192,12 +193,27 @@ const SETTINGS_SCHEMA = {
showInDialog: false,
properties: {
preferredEditor: {
type: 'string',
type: 'enum',
label: 'Preferred Editor',
category: 'General',
requiresRestart: false,
default: undefined as string | undefined,
description: 'The preferred editor to open files in.',
description: oneLine`
The preferred editor to open files in. Must be one of the built-in
supported identifiers. Use /editor in the CLI to pick interactively,
or leave unset to use $VISUAL/$EDITOR.
`,
showInDialog: false,
options: EDITOR_OPTIONS,
},
openEditorInNewWindow: {
type: 'boolean',
label: 'Open Editor in New Window',
category: 'General',
requiresRestart: false,
default: false,
description:
'Open VS Code-family editors in a new window when editing files.',
showInDialog: false,
},
vimMode: {
@@ -0,0 +1,8 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
// eslint-disable-next-line import/no-relative-packages
export { HttpProxyAgent } from '../../../../node_modules/http-proxy-agent/dist/index.js';
@@ -0,0 +1,8 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
// eslint-disable-next-line import/no-relative-packages
export { HttpsProxyAgent } from '../../../../node_modules/https-proxy-agent/dist/index.js';
+5 -6
View File
@@ -47,7 +47,6 @@ import { MouseProvider } from './contexts/MouseContext.js';
import { ScrollProvider } from './contexts/ScrollProvider.js';
import {
type StartupWarning,
type EditorType,
type Config,
type IdeInfo,
type IdeContext,
@@ -68,6 +67,7 @@ import {
ShellExecutionService,
saveApiKey,
debugLogger,
isValidEditorType,
coreEvents,
CoreEvent,
flattenMemory,
@@ -609,11 +609,10 @@ export const AppContainer = (props: AppContainerProps) => {
const staticAreaMaxItemHeight = Math.max(terminalHeight * 4, 100);
const getPreferredEditor = useCallback(
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
() => settings.merged.general.preferredEditor as EditorType,
[settings.merged.general.preferredEditor],
);
const getPreferredEditor = useCallback(() => {
const val = settings.merged.general.preferredEditor;
return isValidEditorType(val) ? val : undefined;
}, [settings.merged.general.preferredEditor]);
const buffer = useTextBuffer({
initialText: '',
@@ -22,7 +22,6 @@ import {
type EditorType,
isEditorAvailable,
EDITOR_DISPLAY_NAMES,
coreEvents,
} from '@google/gemini-cli-core';
import { useKeypress } from '../hooks/useKeypress.js';
@@ -72,10 +71,6 @@ export function EditorSettingsDialog({
)
: 0;
if (editorIndex === -1) {
coreEvents.emitFeedback(
'error',
`Editor is not supported: ${currentPreference}`,
);
editorIndex = 0;
}
@@ -131,10 +126,7 @@ export function EditorSettingsDialog({
isEditorAvailable(settings.merged.general.preferredEditor)
) {
mergedEditorName =
EDITOR_DISPLAY_NAMES[
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
settings.merged.general.preferredEditor as EditorType
];
EDITOR_DISPLAY_NAMES[settings.merged.general.preferredEditor];
}
return (
@@ -161,6 +153,7 @@ export function EditorSettingsDialog({
onSelect={handleEditorSelect}
isFocused={focusedSection === 'editor'}
key={selectedScope}
maxItemsToShow={editorItems.length}
/>
<Box marginTop={1} flexDirection="column">
@@ -9,6 +9,17 @@ import { renderHook } from '../../../test-utils/render.js';
import { useTextBuffer } from './text-buffer.js';
import { parseInputForHighlighting } from '../../utils/highlight.js';
vi.mock('../../contexts/SettingsContext.js', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../../contexts/SettingsContext.js')>();
return {
...actual,
useSettings: () => ({
merged: { general: { openEditorInNewWindow: false } },
}),
};
});
describe('text-buffer performance', () => {
afterEach(() => {
vi.restoreAllMocks();
@@ -44,6 +44,17 @@ import { cpLen } from '../../utils/textUtils.js';
import { type Key } from '../../hooks/useKeypress.js';
import { escapePath } from '@google/gemini-cli-core';
vi.mock('../../contexts/SettingsContext.js', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../../contexts/SettingsContext.js')>();
return {
...actual,
useSettings: () => ({
merged: { general: { openEditorInNewWindow: false } },
}),
};
});
const defaultVisualLayout: VisualLayout = {
visualLines: [''],
logicalToVisualMap: [[[0, 0]]],
@@ -13,6 +13,7 @@ import { LRUCache } from 'mnemonist';
import {
coreEvents,
debugLogger,
getErrorMessage,
unescapePath,
type EditorType,
} from '@google/gemini-cli-core';
@@ -30,6 +31,7 @@ import type { VimAction } from './vim-buffer-actions.js';
import { handleVimAction } from './vim-buffer-actions.js';
import { LRU_BUFFER_PERF_CACHE_LIMIT } from '../../constants.js';
import { openFileInEditor } from '../../utils/editorUtils.js';
import { useSettings } from '../../contexts/SettingsContext.js';
import { useKeyMatchers } from '../../hooks/useKeyMatchers.js';
export const LARGE_PASTE_LINE_THRESHOLD = 5;
@@ -2840,6 +2842,7 @@ export function useTextBuffer({
singleLine = false,
getPreferredEditor,
}: UseTextBufferProps): TextBuffer {
const settings = useSettings();
const keyMatchers = useKeyMatchers();
const initialState = useMemo((): TextBufferState => {
const lines = initialText.split('\n');
@@ -3325,6 +3328,7 @@ export function useTextBuffer({
stdin,
setRawMode,
getPreferredEditor?.(),
settings.merged.general.openEditorInNewWindow,
);
let newText = fs.readFileSync(filePath, 'utf8');
@@ -3342,11 +3346,7 @@ export function useTextBuffer({
dispatch({ type: 'set_text', payload: newText, pushToUndo: false });
} catch (err) {
coreEvents.emitFeedback(
'error',
'[useTextBuffer] external editor error',
err,
);
coreEvents.emitFeedback('error', getErrorMessage(err), err);
} finally {
try {
fs.unlinkSync(filePath);
@@ -3359,7 +3359,14 @@ export function useTextBuffer({
/* ignore */
}
}
}, [text, pastedContent, stdin, setRawMode, getPreferredEditor]);
}, [
text,
pastedContent,
stdin,
setRawMode,
getPreferredEditor,
settings.merged.general.openEditorInNewWindow,
]);
const handleInput = useCallback(
(key: Key): boolean => {
+112 -53
View File
@@ -7,14 +7,33 @@
import { spawn, spawnSync } from 'node:child_process';
import type { ReadStream } from 'node:tty';
import {
coreEvents,
ALL_EDITORS,
CoreEvent,
coreEvents,
type EditorType,
getEditorCommand,
getEditorExtraArgs,
getEditorWaitFlag,
isGuiEditor,
isTerminalEditor,
isValidEditorType,
resolveEditorTypeFromCommand,
} from '@google/gemini-cli-core';
/**
* Command name substrings used to guess whether an unknown $VISUAL/$EDITOR
* value is a GUI editor. This is a fallback for editors not in the registry;
* registered editors are detected via resolveEditorTypeFromCommand instead.
*/
const HEURISTIC_GUI_COMMANDS = [
'code',
'cursor',
'subl',
'zed',
'atom',
'agy',
] as const;
/**
* Opens a file in an external editor and waits for it to close.
* Handles raw mode switching to ensure the editor can interact with the terminal.
@@ -23,36 +42,65 @@ import {
* @param stdin The stdin stream from Ink/Node
* @param setRawMode Function to toggle raw mode
* @param preferredEditorType The user's preferred editor from config
* @param openInNewWindow Whether to open VS Code-family editors in a new window
*/
export async function openFileInEditor(
filePath: string,
stdin: ReadStream | null | undefined,
setRawMode: ((mode: boolean) => void) | undefined,
preferredEditorType?: EditorType,
openInNewWindow?: boolean,
): Promise<void> {
let command: string | undefined = undefined;
const args = [filePath];
// Extra args that come before the file path (e.g. -nw for emacsclient)
const extraArgs: string[] = [];
if (preferredEditorType) {
if (!isValidEditorType(preferredEditorType)) {
coreEvents.emitFeedback(
'error',
`Editor '${preferredEditorType}' is not a recognized editor identifier. ` +
`Supported editors: ${ALL_EDITORS.join(', ')}. ` +
`Use /editor to select one, or set the $VISUAL or $EDITOR environment variable.`,
);
return;
}
command = getEditorCommand(preferredEditorType);
if (isGuiEditor(preferredEditorType)) {
args.unshift('--wait');
args.unshift(getEditorWaitFlag(preferredEditorType));
}
extraArgs.push(
...getEditorExtraArgs(preferredEditorType, {
newWindow: openInNewWindow,
}),
);
}
if (!command) {
command = process.env['VISUAL'] ?? process.env['EDITOR'];
if (command) {
const lowerCommand = command.toLowerCase();
const isGui = ['code', 'cursor', 'subl', 'zed', 'atom'].some((gui) =>
lowerCommand.includes(gui),
);
if (
isGui &&
!lowerCommand.includes('--wait') &&
!lowerCommand.includes('-w')
) {
args.unshift(lowerCommand.includes('subl') ? '-w' : '--wait');
const envCommand = process.env['VISUAL'] ?? process.env['EDITOR'];
if (envCommand) {
command = envCommand;
const [envExecutable = ''] = envCommand.split(' ');
const resolvedType = resolveEditorTypeFromCommand(envExecutable);
if (resolvedType) {
if (
isGuiEditor(resolvedType) &&
!envCommand.includes('--wait') &&
!envCommand.includes('-w')
) {
args.unshift(getEditorWaitFlag(resolvedType));
}
extraArgs.push(
...getEditorExtraArgs(resolvedType, { newWindow: openInNewWindow }),
);
} else {
// Heuristic fallback for commands not in the registry
const lower = envCommand.toLowerCase();
const isGui = HEURISTIC_GUI_COMMANDS.some((g) => lower.includes(g));
if (isGui && !lower.includes('--wait') && !lower.includes('-w')) {
args.unshift(lower.includes('subl') ? '-w' : '--wait');
}
}
}
}
@@ -66,7 +114,16 @@ export async function openFileInEditor(
// Determine if we should use sync or async based on the command/editor type.
// If we have a preferredEditorType, we can check if it's a terminal editor.
// Otherwise, we guess based on the command name.
const terminalEditors = ['vi', 'vim', 'nvim', 'emacs', 'hx', 'nano'];
const terminalEditors = [
'vi',
'vim',
'nvim',
'emacs',
'emacsclient',
'hx',
'nano',
'micro',
];
const isTerminal = preferredEditorType
? isTerminalEditor(preferredEditorType)
: terminalEditors.some((te) => executable.toLowerCase().includes(te));
@@ -86,58 +143,60 @@ export async function openFileInEditor(
try {
if (isTerminal) {
const result = spawnSync(executable, [...initialArgs, ...args], {
stdio: 'inherit',
shell: process.platform === 'win32',
});
if (result.error) {
coreEvents.emitFeedback(
'error',
'[editorUtils] external terminal editor error',
result.error,
);
throw result.error;
}
if (typeof result.status === 'number' && result.status !== 0) {
const err = new Error(
`External editor exited with status ${result.status}`,
);
coreEvents.emitFeedback(
'error',
'[editorUtils] external editor error',
err,
);
throw err;
}
} else {
await new Promise<void>((resolve, reject) => {
const child = spawn(executable, [...initialArgs, ...args], {
const result = spawnSync(
executable,
[...initialArgs, ...extraArgs, ...args],
{
stdio: 'inherit',
shell: process.platform === 'win32',
});
},
);
if (result.error) {
const spawnErr = result.error as NodeJS.ErrnoException;
coreEvents.emitFeedback(
'error',
spawnErr.code === 'ENOENT'
? `Editor command '${executable}' was not found in PATH. Install it or use /editor to choose another editor.`
: (spawnErr.message ?? String(spawnErr)),
);
return;
}
if (typeof result.status === 'number' && result.status !== 0) {
coreEvents.emitFeedback(
'error',
`External editor exited with status ${result.status}`,
);
return;
}
} else {
await new Promise<void>((resolve) => {
const child = spawn(
executable,
[...initialArgs, ...extraArgs, ...args],
{
stdio: 'inherit',
shell: process.platform === 'win32',
},
);
child.on('error', (err) => {
const spawnErr = err as NodeJS.ErrnoException;
resolve();
coreEvents.emitFeedback(
'error',
'[editorUtils] external editor spawn error',
err,
spawnErr.code === 'ENOENT'
? `Editor command '${executable}' was not found in PATH. Install it or use /editor to choose another editor.`
: (spawnErr.message ?? String(spawnErr)),
);
reject(err);
});
child.on('close', (status) => {
resolve();
if (typeof status === 'number' && status !== 0) {
const err = new Error(
`External editor exited with status ${status}`,
);
coreEvents.emitFeedback(
'error',
'[editorUtils] external editor error',
err,
`External editor exited with status ${status}`,
);
reject(err);
} else {
resolve();
}
});
});
-1
View File
@@ -54,7 +54,6 @@
"@xterm/headless": "5.5.0",
"ajv": "^8.17.1",
"ajv-formats": "^3.0.0",
"chardet": "^2.1.0",
"chokidar": "^5.0.0",
"command-exists": "^1.2.9",
"diff": "^8.0.3",
+122
View File
@@ -12,6 +12,8 @@ import type { Config } from '../config/config.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
import { LocalSubagentInvocation } from './local-invocation.js';
import { RemoteAgentInvocation } from './remote-invocation.js';
import { LocalSessionInvocation } from './local-session-invocation.js';
import { RemoteSessionInvocation } from './remote-session-invocation.js';
import { BrowserAgentInvocation } from './browser/browserAgentInvocation.js';
import { BROWSER_AGENT_NAME } from './browser/browserAgentDefinition.js';
import { AgentRegistry } from './registry.js';
@@ -19,6 +21,8 @@ import type { LocalAgentDefinition, RemoteAgentDefinition } from './types.js';
vi.mock('./local-invocation.js');
vi.mock('./remote-invocation.js');
vi.mock('./local-session-invocation.js');
vi.mock('./remote-session-invocation.js');
vi.mock('./browser/browserAgentInvocation.js');
describe('AgentTool', () => {
@@ -141,4 +145,122 @@ describe('AgentTool', () => {
'Invoke Browser Agent',
);
});
describe('agentSessionSubagentEnabled feature flag', () => {
it('should use LocalSessionInvocation when flag is enabled for local agent', async () => {
vi.spyOn(mockConfig, 'isAgentSessionSubagentEnabled').mockReturnValue(
true,
);
tool = new AgentTool(mockConfig, mockMessageBus);
const params = {
agent_name: 'TestLocalAgent',
prompt: 'Do something',
};
const invocation = tool['createInvocation'](params, mockMessageBus);
await invocation.shouldConfirmExecute(new AbortController().signal);
expect(LocalSessionInvocation).toHaveBeenCalledWith(
testLocalDefinition,
mockConfig,
{ objective: 'Do something' },
mockMessageBus,
undefined,
);
expect(LocalSubagentInvocation).not.toHaveBeenCalled();
});
it('should use RemoteSessionInvocation when flag is enabled for remote agent', async () => {
vi.spyOn(mockConfig, 'isAgentSessionSubagentEnabled').mockReturnValue(
true,
);
tool = new AgentTool(mockConfig, mockMessageBus);
const params = {
agent_name: 'TestRemoteAgent',
prompt: 'Search something',
};
const invocation = tool['createInvocation'](params, mockMessageBus);
await invocation.shouldConfirmExecute(new AbortController().signal);
expect(RemoteSessionInvocation).toHaveBeenCalledWith(
testRemoteDefinition,
mockConfig,
{ query: 'Search something' },
mockMessageBus,
undefined,
);
expect(RemoteAgentInvocation).not.toHaveBeenCalled();
});
it('should use legacy invocations when flag is disabled (default)', async () => {
vi.spyOn(mockConfig, 'isAgentSessionSubagentEnabled').mockReturnValue(
false,
);
tool = new AgentTool(mockConfig, mockMessageBus);
const localParams = {
agent_name: 'TestLocalAgent',
prompt: 'Do something',
};
const localInv = tool['createInvocation'](localParams, mockMessageBus);
await localInv.shouldConfirmExecute(new AbortController().signal);
expect(LocalSubagentInvocation).toHaveBeenCalled();
expect(LocalSessionInvocation).not.toHaveBeenCalled();
vi.clearAllMocks();
const remoteParams = {
agent_name: 'TestRemoteAgent',
prompt: 'Search',
};
const remoteInv = tool['createInvocation'](remoteParams, mockMessageBus);
await remoteInv.shouldConfirmExecute(new AbortController().signal);
expect(RemoteAgentInvocation).toHaveBeenCalled();
expect(RemoteSessionInvocation).not.toHaveBeenCalled();
});
it('should thread onAgentEvent to session invocations', async () => {
vi.spyOn(mockConfig, 'isAgentSessionSubagentEnabled').mockReturnValue(
true,
);
const onEvent = vi.fn();
tool = new AgentTool(mockConfig, mockMessageBus, onEvent);
const params = {
agent_name: 'TestLocalAgent',
prompt: 'Do something',
};
const invocation = tool['createInvocation'](params, mockMessageBus);
await invocation.shouldConfirmExecute(new AbortController().signal);
expect(LocalSessionInvocation).toHaveBeenCalledWith(
testLocalDefinition,
mockConfig,
{ objective: 'Do something' },
mockMessageBus,
{ onAgentEvent: onEvent },
);
});
it('should always use BrowserAgentInvocation for browser agent regardless of flag', async () => {
vi.spyOn(mockConfig, 'isAgentSessionSubagentEnabled').mockReturnValue(
true,
);
tool = new AgentTool(mockConfig, mockMessageBus);
const params = {
agent_name: BROWSER_AGENT_NAME,
prompt: 'Open page',
};
const invocation = tool['createInvocation'](params, mockMessageBus);
await invocation.shouldConfirmExecute(new AbortController().signal);
expect(BrowserAgentInvocation).toHaveBeenCalled();
expect(LocalSessionInvocation).not.toHaveBeenCalled();
expect(RemoteSessionInvocation).not.toHaveBeenCalled();
});
});
});
+29
View File
@@ -18,8 +18,11 @@ import type { MessageBus } from '../confirmation-bus/message-bus.js';
import type { AgentDefinition, AgentInputs } from './types.js';
import { LocalSubagentInvocation } from './local-invocation.js';
import { RemoteAgentInvocation } from './remote-invocation.js';
import { LocalSessionInvocation } from './local-session-invocation.js';
import { RemoteSessionInvocation } from './remote-session-invocation.js';
import { BROWSER_AGENT_NAME } from './browser/browserAgentDefinition.js';
import { BrowserAgentInvocation } from './browser/browserAgentInvocation.js';
import type { AgentEvent } from '../agent/types.js';
import { formatUserHintsForModel } from '../utils/fastAckHelper.js';
import { isRecord } from '../utils/markdownUtils.js';
import { runInDevTraceSpan } from '../telemetry/trace.js';
@@ -46,6 +49,7 @@ export class AgentTool extends BaseDeclarativeTool<
constructor(
private readonly context: AgentLoopContext,
messageBus: MessageBus,
private readonly onAgentEvent?: (event: AgentEvent) => void,
) {
super(
AGENT_TOOL_NAME,
@@ -100,6 +104,7 @@ export class AgentTool extends BaseDeclarativeTool<
this.context,
_toolName,
_toolDisplayName,
this.onAgentEvent,
);
}
@@ -133,6 +138,7 @@ class DelegateInvocation extends BaseToolInvocation<
private readonly context: AgentLoopContext,
_toolName?: string,
_toolDisplayName?: string,
private readonly onAgentEvent?: (event: AgentEvent) => void,
) {
super(
params,
@@ -160,7 +166,21 @@ class DelegateInvocation extends BaseToolInvocation<
);
}
const useSession = this.context.config.isAgentSessionSubagentEnabled();
const options = this.onAgentEvent
? { onAgentEvent: this.onAgentEvent }
: undefined;
if (this.definition.kind === 'remote') {
if (useSession) {
return new RemoteSessionInvocation(
this.definition,
this.context,
agentArgs,
this.messageBus,
options,
);
}
return new RemoteAgentInvocation(
this.definition,
this.context,
@@ -168,6 +188,15 @@ class DelegateInvocation extends BaseToolInvocation<
this.messageBus,
);
} else {
if (useSession) {
return new LocalSessionInvocation(
this.definition,
this.context,
agentArgs,
this.messageBus,
options,
);
}
return new LocalSubagentInvocation(
this.definition,
this.context,
@@ -0,0 +1,666 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { RemoteSessionInvocation } from './remote-session-invocation.js';
import { RemoteSubagentSession } from './remote-subagent-protocol.js';
import {
type RemoteAgentDefinition,
type SubagentProgress,
SubagentState,
} from './types.js';
import { createMockMessageBus } from '../test-utils/mock-message-bus.js';
import type { AgentLoopContext } from '../config/agent-loop-context.js';
import type { Config } from '../config/config.js';
import type { ToolResult } from '../tools/tools.js';
import type { AgentEvent } from '../agent/types.js';
vi.mock('./remote-subagent-protocol.js');
const mockDefinition: RemoteAgentDefinition = {
name: 'test-agent',
kind: 'remote',
agentCardUrl: 'http://test-agent/card',
displayName: 'Test Agent',
description: 'A test agent',
inputConfig: { inputSchema: { type: 'object' } },
};
const mockMessageBus = createMockMessageBus();
interface MockSessionSetupOptions {
result?: ToolResult;
error?: Error;
progress?: SubagentProgress;
sessionState?: { contextId?: string; taskId?: string };
}
function setupMockSession(options: MockSessionSetupOptions = {}) {
const {
result = {
llmContent: [{ text: 'done' }],
returnDisplay: {
isSubagentProgress: true,
agentName: 'Test Agent',
state: SubagentState.COMPLETED,
result: 'done',
recentActivity: [],
} satisfies SubagentProgress,
},
error,
progress,
sessionState = {},
} = options;
const subscriberCallbacks: Array<(event: AgentEvent) => void> = [];
const mockSession = {
send: vi.fn().mockResolvedValue({ streamId: 'stream-1' }),
getResult: error
? vi.fn().mockRejectedValue(error)
: vi.fn().mockResolvedValue(result),
getLatestProgress: vi.fn().mockReturnValue(progress),
getSessionState: vi.fn().mockReturnValue(sessionState),
subscribe: vi.fn((cb: (event: AgentEvent) => void) => {
subscriberCallbacks.push(cb);
return vi.fn(); // unsubscribe
}),
abort: vi.fn(),
};
vi.mocked(RemoteSubagentSession).mockImplementation(
() => mockSession as unknown as RemoteSubagentSession,
);
return {
mockSession,
subscriberCallbacks,
/** Fire a message event through all subscribed callbacks. */
emitEvent(event: AgentEvent) {
for (const cb of subscriberCallbacks) {
cb(event);
}
},
};
}
describe('RemoteSessionInvocation', () => {
let mockContext: AgentLoopContext;
beforeEach(() => {
vi.clearAllMocks();
const mockConfig = {
getA2AClientManager: vi.fn().mockReturnValue({}),
injectionService: {
getLatestInjectionIndex: vi.fn().mockReturnValue(0),
},
} as unknown as Config;
mockContext = { config: mockConfig } as unknown as AgentLoopContext;
// Clear the static sessionState map between tests
(
RemoteSessionInvocation as unknown as {
sessionState?: Map<string, unknown>;
}
).sessionState?.clear();
});
afterEach(() => {
vi.restoreAllMocks();
});
// ---------------------------------------------------------------------------
// Constructor Validation
// ---------------------------------------------------------------------------
describe('Constructor Validation', () => {
it('accepts valid input with string query', () => {
expect(() => {
new RemoteSessionInvocation(
mockDefinition,
mockContext,
{ query: 'hello' },
mockMessageBus,
);
}).not.toThrow();
});
it('accepts missing query (defaults to "Get Started!")', () => {
expect(() => {
new RemoteSessionInvocation(
mockDefinition,
mockContext,
{},
mockMessageBus,
);
}).not.toThrow();
});
it('throws if query is not a string', () => {
expect(() => {
new RemoteSessionInvocation(
mockDefinition,
mockContext,
{ query: 123 },
mockMessageBus,
);
}).toThrow("requires a string 'query' input");
});
it('throws if A2AClientManager is not available', () => {
const noA2AConfig = {
getA2AClientManager: vi.fn().mockReturnValue(undefined),
injectionService: {
getLatestInjectionIndex: vi.fn().mockReturnValue(0),
},
} as unknown as Config;
const noA2AContext = {
config: noA2AConfig,
} as unknown as AgentLoopContext;
expect(() => {
new RemoteSessionInvocation(
mockDefinition,
noA2AContext,
{ query: 'hi' },
mockMessageBus,
);
}).toThrow('A2AClientManager is not available');
});
});
// ---------------------------------------------------------------------------
// Execution Logic
// ---------------------------------------------------------------------------
describe('Execution Logic', () => {
it('should create session and return result', async () => {
const completedProgress: SubagentProgress = {
isSubagentProgress: true,
agentName: 'Test Agent',
state: SubagentState.COMPLETED,
result: 'Agent output',
recentActivity: [],
};
const expectedResult: ToolResult = {
llmContent: [{ text: 'Agent output' }],
returnDisplay: completedProgress,
};
setupMockSession({
result: expectedResult,
progress: completedProgress,
});
const invocation = new RemoteSessionInvocation(
mockDefinition,
mockContext,
{ query: 'do stuff' },
mockMessageBus,
);
const result = await invocation.execute({
abortSignal: new AbortController().signal,
});
expect(RemoteSubagentSession).toHaveBeenCalledOnce();
expect(result).toBe(expectedResult);
});
it('should pass initial state from static map to session', async () => {
const priorState = { contextId: 'ctx-42', taskId: 'task-42' };
// Seed the static map before constructing the invocation
(
RemoteSessionInvocation as unknown as {
sessionState: Map<string, unknown>;
}
).sessionState.set('test-agent::http://test-agent/card', priorState);
setupMockSession();
const invocation = new RemoteSessionInvocation(
mockDefinition,
mockContext,
{ query: 'hi' },
mockMessageBus,
);
await invocation.execute({
abortSignal: new AbortController().signal,
});
// Verify the session constructor received the prior state
expect(RemoteSubagentSession).toHaveBeenCalledWith(
mockDefinition,
mockContext,
mockMessageBus,
priorState,
);
});
it('should persist session state in finally block', async () => {
const newState = { contextId: 'ctx-new', taskId: 'task-new' };
setupMockSession({ sessionState: newState });
const invocation = new RemoteSessionInvocation(
mockDefinition,
mockContext,
{ query: 'hi' },
mockMessageBus,
);
await invocation.execute({
abortSignal: new AbortController().signal,
});
// Verify the state was persisted in the static map
const storedState = (
RemoteSessionInvocation as unknown as {
sessionState: Map<string, { contextId?: string; taskId?: string }>;
}
).sessionState.get('test-agent::http://test-agent/card');
expect(storedState).toEqual(newState);
});
it('should persist session state across invocations', async () => {
// First invocation returns state
const firstState = { contextId: 'ctx-1', taskId: 'task-1' };
setupMockSession({ sessionState: firstState });
const invocation1 = new RemoteSessionInvocation(
mockDefinition,
mockContext,
{ query: 'first' },
mockMessageBus,
);
await invocation1.execute({
abortSignal: new AbortController().signal,
});
// Second invocation — the mock constructor should receive firstState
const secondState = { contextId: 'ctx-2', taskId: 'task-2' };
setupMockSession({ sessionState: secondState });
const invocation2 = new RemoteSessionInvocation(
mockDefinition,
mockContext,
{ query: 'second' },
mockMessageBus,
);
await invocation2.execute({
abortSignal: new AbortController().signal,
});
// The second invocation should have received the first's state
const secondCallArgs = vi.mocked(RemoteSubagentSession).mock.calls[1];
expect(secondCallArgs[3]).toEqual(firstState);
});
it('should subscribe for progress updates', async () => {
const completedProgress: SubagentProgress = {
isSubagentProgress: true,
agentName: 'Test Agent',
state: SubagentState.RUNNING,
result: 'partial',
recentActivity: [],
};
const { mockSession, emitEvent } = setupMockSession({
progress: completedProgress,
});
const updateOutput = vi.fn();
const invocation = new RemoteSessionInvocation(
mockDefinition,
mockContext,
{ query: 'hi' },
mockMessageBus,
);
// Override getResult to emit a message event mid-execution
mockSession.getResult.mockImplementation(async () => {
emitEvent({
type: 'message',
id: 'e1',
timestamp: new Date().toISOString(),
streamId: 's1',
role: 'agent',
content: [{ type: 'text', text: 'hello' }],
});
return {
llmContent: [{ text: 'done' }],
returnDisplay: completedProgress,
};
});
await invocation.execute({
abortSignal: new AbortController().signal,
updateOutput,
});
// subscribe should have been called (at least once for progress, possibly for parent)
expect(mockSession.subscribe).toHaveBeenCalled();
// updateOutput should have been called with the progress from getLatestProgress
expect(updateOutput).toHaveBeenCalledWith(
expect.objectContaining({
isSubagentProgress: true,
}),
);
});
it('should handle abort gracefully', async () => {
const controller = new AbortController();
const partialProgress: SubagentProgress = {
isSubagentProgress: true,
agentName: 'Test Agent',
state: SubagentState.RUNNING,
result: '',
recentActivity: [
{
id: 'a1',
type: 'thought',
content: 'Thinking...',
status: SubagentState.RUNNING,
},
],
};
const { mockSession } = setupMockSession({ progress: partialProgress });
// When getResult resolves, the signal will already be aborted
mockSession.getResult.mockImplementation(async () => {
controller.abort();
return {
llmContent: [{ text: '' }],
returnDisplay: '',
};
});
const updateOutput = vi.fn();
const invocation = new RemoteSessionInvocation(
mockDefinition,
mockContext,
{ query: 'hi' },
mockMessageBus,
);
const result = await invocation.execute({
abortSignal: controller.signal,
updateOutput,
});
expect(result.returnDisplay).toMatchObject({ state: 'cancelled' });
expect(
(result.returnDisplay as SubagentProgress).recentActivity[0].status,
).toBe(SubagentState.CANCELLED);
expect(result.llmContent).toEqual([
{ text: 'Operation cancelled by user' },
]);
});
});
// ---------------------------------------------------------------------------
// Error Handling
// ---------------------------------------------------------------------------
describe('Error Handling', () => {
it('should handle execution errors gracefully', async () => {
setupMockSession({ error: new Error('Network failure') });
const updateOutput = vi.fn();
const invocation = new RemoteSessionInvocation(
mockDefinition,
mockContext,
{ query: 'hi' },
mockMessageBus,
);
const result = await invocation.execute({
abortSignal: new AbortController().signal,
updateOutput,
});
expect(result.returnDisplay).toMatchObject({ state: 'error' });
expect((result.returnDisplay as SubagentProgress).result).toContain(
'Network failure',
);
// updateOutput should be called with error progress
expect(updateOutput).toHaveBeenCalledWith(
expect.objectContaining({ state: 'error' }),
);
});
it('should include partial output in error display', async () => {
const partialProgress: SubagentProgress = {
isSubagentProgress: true,
agentName: 'Test Agent',
state: SubagentState.RUNNING,
result: 'Partial work so far',
recentActivity: [
{
id: 'a1',
type: 'thought',
content: 'Thinking...',
status: SubagentState.RUNNING,
},
],
};
setupMockSession({
error: new Error('mid-stream error'),
progress: partialProgress,
});
const invocation = new RemoteSessionInvocation(
mockDefinition,
mockContext,
{ query: 'hi' },
mockMessageBus,
);
const result = await invocation.execute({
abortSignal: new AbortController().signal,
});
const display = result.returnDisplay as SubagentProgress;
// Should contain both the partial output and the error
expect(display.result).toContain('Partial work so far');
expect(display.result).toContain('mid-stream error');
// Should preserve and update partial activity status to ERROR
expect(display.recentActivity).toHaveLength(1);
expect(display.recentActivity[0].content).toBe('Thinking...');
expect(display.recentActivity[0].status).toBe(SubagentState.ERROR);
});
it('should clean up listeners in finally', async () => {
const { mockSession } = setupMockSession();
const controller = new AbortController();
const removeEventListenerSpy = vi.spyOn(
controller.signal,
'removeEventListener',
);
const onAgentEvent = vi.fn();
const invocation = new RemoteSessionInvocation(
mockDefinition,
mockContext,
{ query: 'hi' },
mockMessageBus,
{ onAgentEvent },
);
await invocation.execute({
abortSignal: controller.signal,
});
// removeEventListener should have been called for the abort listener
expect(removeEventListenerSpy).toHaveBeenCalledWith(
'abort',
expect.any(Function),
);
// All unsubscribe functions returned by subscribe during execute should be called
const postExecuteUnsubscribes = mockSession.subscribe.mock.results.map(
(r) => r.value,
);
for (const unsub of postExecuteUnsubscribes) {
expect(unsub).toHaveBeenCalled();
}
});
});
// ---------------------------------------------------------------------------
// SessionState Management
// ---------------------------------------------------------------------------
describe('SessionState Management', () => {
it('should use composite name::url as session state key', async () => {
const secondDefinition: RemoteAgentDefinition = {
...mockDefinition,
name: 'other-agent',
displayName: 'Other Agent',
agentCardUrl: 'http://other-agent/card',
};
// First agent
setupMockSession({
sessionState: { contextId: 'ctx-a' },
});
const inv1 = new RemoteSessionInvocation(
mockDefinition,
mockContext,
{ query: 'hi' },
mockMessageBus,
);
await inv1.execute({ abortSignal: new AbortController().signal });
// Second agent
setupMockSession({
sessionState: { contextId: 'ctx-b' },
});
const inv2 = new RemoteSessionInvocation(
secondDefinition,
mockContext,
{ query: 'hi' },
mockMessageBus,
);
await inv2.execute({ abortSignal: new AbortController().signal });
const stateMap = (
RemoteSessionInvocation as unknown as {
sessionState: Map<string, { contextId?: string; taskId?: string }>;
}
).sessionState;
// Each agent should have its own entry keyed by name::url
expect(stateMap.get('test-agent::http://test-agent/card')).toEqual({
contextId: 'ctx-a',
});
expect(stateMap.get('other-agent::http://other-agent/card')).toEqual({
contextId: 'ctx-b',
});
});
it('should isolate same-name agents with different URLs', async () => {
const defA: RemoteAgentDefinition = {
...mockDefinition,
agentCardUrl: 'http://host-a/card',
};
const defB: RemoteAgentDefinition = {
...mockDefinition,
agentCardUrl: 'http://host-b/card',
};
// Agent A
setupMockSession({ sessionState: { contextId: 'ctx-a' } });
const invA = new RemoteSessionInvocation(
defA,
mockContext,
{ query: 'hi' },
mockMessageBus,
);
await invA.execute({ abortSignal: new AbortController().signal });
// Agent B (same name, different URL)
setupMockSession({ sessionState: { contextId: 'ctx-b' } });
const invB = new RemoteSessionInvocation(
defB,
mockContext,
{ query: 'hi' },
mockMessageBus,
);
await invB.execute({ abortSignal: new AbortController().signal });
const stateMap = (
RemoteSessionInvocation as unknown as {
sessionState: Map<string, { contextId?: string; taskId?: string }>;
}
).sessionState;
expect(stateMap.get('test-agent::http://host-a/card')).toEqual({
contextId: 'ctx-a',
});
expect(stateMap.get('test-agent::http://host-b/card')).toEqual({
contextId: 'ctx-b',
});
});
it('should fall back to name-only key when URL is unavailable', async () => {
const noUrlDef: RemoteAgentDefinition = {
...mockDefinition,
agentCardUrl: undefined,
};
setupMockSession({ sessionState: { contextId: 'ctx-no-url' } });
const inv = new RemoteSessionInvocation(
noUrlDef,
mockContext,
{ query: 'hi' },
mockMessageBus,
);
await inv.execute({ abortSignal: new AbortController().signal });
const stateMap = (
RemoteSessionInvocation as unknown as {
sessionState: Map<string, { contextId?: string; taskId?: string }>;
}
).sessionState;
expect(stateMap.get('test-agent')).toEqual({ contextId: 'ctx-no-url' });
});
it('should persist state even on error', async () => {
const stateOnError = { contextId: 'ctx-err', taskId: 'task-err' };
setupMockSession({
error: new Error('boom'),
sessionState: stateOnError,
});
const invocation = new RemoteSessionInvocation(
mockDefinition,
mockContext,
{ query: 'hi' },
mockMessageBus,
);
await invocation.execute({
abortSignal: new AbortController().signal,
});
const stateMap = (
RemoteSessionInvocation as unknown as {
sessionState: Map<string, { contextId?: string; taskId?: string }>;
}
).sessionState;
expect(stateMap.get('test-agent::http://test-agent/card')).toEqual(
stateOnError,
);
});
});
});
@@ -0,0 +1,281 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
BaseToolInvocation,
type ToolConfirmationOutcome,
type ToolResult,
type ToolCallConfirmationDetails,
type ExecuteOptions,
} from '../tools/tools.js';
import {
DEFAULT_QUERY_STRING,
type RemoteAgentInputs,
type RemoteAgentDefinition,
type AgentInputs,
type SubagentProgress,
type SubagentActivityItem,
SubagentState,
getRemoteAgentTargetUrl,
} from './types.js';
import { type AgentLoopContext } from '../config/agent-loop-context.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
import { A2AAgentError } from './a2a-errors.js';
import { RemoteSubagentSession } from './remote-subagent-protocol.js';
import type { AgentEvent } from '../agent/types.js';
/** Optional configuration for remote agent invocations. */
export interface SubagentInvocationOptions {
toolName?: string;
toolDisplayName?: string;
onAgentEvent?: (event: AgentEvent) => void;
}
/**
* Session-based remote agent invocation.
*
* This implementation delegates execution to {@link RemoteSubagentSession},
* which wraps the A2A client streaming behind the AgentProtocol interface.
*
* Cross-invocation A2A session state (contextId/taskId) is persisted via a
* static map keyed by a composite of agent name and target URL. This ensures
* agents with the same name but different endpoints maintain independent state.
*/
export class RemoteSessionInvocation extends BaseToolInvocation<
RemoteAgentInputs,
ToolResult
> {
// Persist A2A conversation state across ephemeral invocation instances.
// Keyed by composite of name + target URL so agents with the same name
// but different endpoints don't share state.
private static readonly sessionState = new Map<
string,
{ contextId?: string; taskId?: string }
>();
/**
* Builds a composite key for the sessionState map.
* Format: `name::targetUrl` (or just `name` if no URL can be derived).
*/
private static sessionKey(definition: RemoteAgentDefinition): string {
const url = getRemoteAgentTargetUrl(definition);
return url ? `${definition.name}::${url}` : definition.name;
}
private readonly _onAgentEvent?: (event: AgentEvent) => void;
constructor(
private readonly definition: RemoteAgentDefinition,
private readonly context: AgentLoopContext,
params: AgentInputs,
messageBus: MessageBus,
options?: SubagentInvocationOptions,
) {
const query = params['query'] ?? DEFAULT_QUERY_STRING;
if (typeof query !== 'string') {
throw new Error(
`Remote agent '${definition.name}' requires a string 'query' input.`,
);
}
// Safe to pass strict object to super
super(
{ query },
messageBus,
options?.toolName ?? definition.name,
options?.toolDisplayName ?? definition.displayName,
);
this._onAgentEvent = options?.onAgentEvent;
// Validate that A2AClientManager is available at construction time
if (!this.context.config.getA2AClientManager()) {
throw new Error(
`Failed to initialize RemoteSessionInvocation for '${definition.name}': A2AClientManager is not available.`,
);
}
}
getDescription(): string {
return `Calling remote agent ${this.definition.displayName ?? this.definition.name}`;
}
protected override async getConfirmationDetails(
_abortSignal: AbortSignal,
): Promise<ToolCallConfirmationDetails | false> {
return {
type: 'info',
title: `Call Remote Agent: ${this.definition.displayName ?? this.definition.name}`,
prompt: `Calling remote agent: "${this.params.query}"`,
onConfirm: async (_outcome: ToolConfirmationOutcome) => {
// Policy updates are now handled centrally by the scheduler
},
};
}
async execute(options: ExecuteOptions): Promise<ToolResult> {
const { abortSignal: _signal, updateOutput } = options;
const agentName = this.definition.displayName ?? this.definition.name;
const emptyActivity: SubagentActivityItem[] = [];
// Seed session with prior A2A conversation state
const stateKey = RemoteSessionInvocation.sessionKey(this.definition);
const priorState = RemoteSessionInvocation.sessionState.get(stateKey);
const session = new RemoteSubagentSession(
this.definition,
this.context,
this.messageBus,
priorState,
);
// Wire external abort signal to session abort
const abortListener = () => void session.abort();
_signal?.addEventListener('abort', abortListener, { once: true });
// Subscribe for parent session observability
let unsubscribeParent: (() => void) | undefined;
if (this._onAgentEvent) {
unsubscribeParent = session.subscribe(this._onAgentEvent);
}
// Subscribe to message events for live SubagentProgress updates
const unsubscribeProgress = session.subscribe((event: AgentEvent) => {
if (event.type === 'message' && updateOutput) {
const currentProgress = session.getLatestProgress();
if (currentProgress) updateOutput(currentProgress);
}
});
try {
if (updateOutput) {
updateOutput({
isSubagentProgress: true,
agentName,
state: SubagentState.RUNNING,
recentActivity: [
{
id: 'pending',
type: 'thought',
content: 'Working...',
status: SubagentState.RUNNING,
},
],
});
}
await session.send({
message: { content: [{ type: 'text', text: this.params.query }] },
});
const result = await session.getResult();
// The protocol resolves aborts with an empty result rather than
// rejecting. Detect this and surface proper error state.
if (_signal?.aborted) {
const partialProgress = session.getLatestProgress();
const recentActivity = this.stopRunningActivities(
partialProgress?.recentActivity ?? emptyActivity,
SubagentState.CANCELLED,
);
const errorProgress: SubagentProgress = {
isSubagentProgress: true,
agentName,
state: SubagentState.CANCELLED,
result:
typeof partialProgress?.result === 'string'
? partialProgress.result
: '',
recentActivity,
};
if (updateOutput) updateOutput(errorProgress);
return {
llmContent: [{ text: 'Operation cancelled by user' }],
returnDisplay: errorProgress,
};
}
// Emit final completed progress
if (updateOutput) {
const finalProgress = session.getLatestProgress();
if (finalProgress) updateOutput(finalProgress);
}
return result;
} catch (error: unknown) {
const partialProgress = session.getLatestProgress();
const partialOutput =
typeof partialProgress?.result === 'string'
? partialProgress.result
: '';
const errorMessage = this.formatExecutionError(error);
const fullDisplay = partialOutput
? `${partialOutput}\n\n${errorMessage}`
: errorMessage;
const isAbort =
(error instanceof Error && error.name === 'AbortError') ||
errorMessage.includes('Aborted');
const status = isAbort ? SubagentState.CANCELLED : SubagentState.ERROR;
const recentActivity = this.stopRunningActivities(
partialProgress?.recentActivity ?? emptyActivity,
status,
);
const errorProgress: SubagentProgress = {
isSubagentProgress: true,
agentName,
state: status,
result: fullDisplay,
recentActivity,
};
if (updateOutput) {
updateOutput(errorProgress);
}
return {
llmContent: [{ text: fullDisplay }],
returnDisplay: errorProgress,
};
} finally {
// Persist A2A state for next invocation — even on abort/error
RemoteSessionInvocation.sessionState.set(
stateKey,
session.getSessionState(),
);
_signal?.removeEventListener('abort', abortListener);
unsubscribeProgress();
unsubscribeParent?.();
}
}
private stopRunningActivities(
activity: SubagentActivityItem[],
status: SubagentState,
): SubagentActivityItem[] {
const result: SubagentActivityItem[] = [];
for (const item of activity) {
result.push(
item.status === SubagentState.RUNNING ? { ...item, status } : item,
);
}
return result;
}
/**
* Formats an execution error into a user-friendly message.
* Recognizes typed A2AAgentError subclasses and falls back to
* a generic message for unknown errors.
*/
private formatExecutionError(error: unknown): string {
if (error instanceof A2AAgentError) {
return error.userMessage;
}
return `Error calling remote agent: ${
error instanceof Error ? error.message : String(error)
}`;
}
}
@@ -82,8 +82,21 @@ class RemoteSubagentProtocol implements AgentProtocol {
private readonly context: AgentLoopContext,
// Required for API parity across protocol constructors (local, remote, legacy)
_messageBus: MessageBus,
initialState?: { contextId?: string; taskId?: string },
) {
this._agentName = definition.displayName ?? definition.name;
if (initialState) {
this.contextId = initialState.contextId;
this.taskId = initialState.taskId;
}
}
/**
* Returns the current A2A conversation state.
* Used by the invocation layer to persist state across invocations.
*/
getSessionState(): { contextId?: string; taskId?: string } {
return { contextId: this.contextId, taskId: this.taskId };
}
// ---------------------------------------------------------------------------
@@ -394,11 +407,13 @@ export class RemoteSubagentSession extends AgentSession {
definition: RemoteAgentDefinition,
context: AgentLoopContext,
messageBus: MessageBus,
initialState?: { contextId?: string; taskId?: string },
) {
const protocol = new RemoteSubagentProtocol(
definition,
context,
messageBus,
initialState,
);
super(protocol);
this._remoteProtocol = protocol;
@@ -420,6 +435,14 @@ export class RemoteSubagentSession extends AgentSession {
return this._remoteProtocol.getLatestProgress();
}
/**
* Returns the current A2A conversation state (contextId/taskId).
* Used by the invocation layer to persist state across invocations.
*/
getSessionState(): { contextId?: string; taskId?: string } {
return this._remoteProtocol.getSessionState();
}
/**
* Convenience: start execution with a query string.
* Equivalent to send({message: {content: [{type:'text', text: query}]}}).
@@ -180,14 +180,18 @@ describe('OAuthCredentialStorage', () => {
expect(result).toEqual(mockCredentials);
});
it('should throw an error if the migration file contains invalid JSON', async () => {
it('should return null and log a warning if the migration file contains invalid JSON', async () => {
vi.spyOn(mockHybridTokenStorage, 'getCredentials').mockResolvedValue(
null,
);
vi.spyOn(fs, 'readFile').mockResolvedValue('invalid json');
await expect(OAuthCredentialStorage.loadCredentials()).rejects.toThrow(
'Failed to load OAuth credentials',
const result = await OAuthCredentialStorage.loadCredentials();
expect(result).toBeNull();
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
'warning',
expect.stringContaining('Corrupted OAuth credential file'),
);
});
@@ -129,8 +129,17 @@ export class OAuthCredentialStorage {
throw error;
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const credentials: Credentials = JSON.parse(credsJson);
let credentials: Credentials;
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
credentials = JSON.parse(credsJson);
} catch {
coreEvents.emitFeedback(
'warning',
`Corrupted OAuth credential file at ${oldFilePath}, skipping migration`,
);
return null;
}
// Save to new storage
await this.saveCredentials(credentials);
@@ -347,6 +347,47 @@ describe('MessageBus', () => {
}),
);
});
it('should strip sensitive metadata and enforce subagent identity on derived bus', async () => {
vi.spyOn(policyEngine, 'check').mockResolvedValue({
decision: PolicyDecision.ASK_USER,
});
const subagentName = 'attacker';
const subagentBus = messageBus.derive(subagentName);
const request: ToolConfirmationRequest = {
type: MessageBusType.TOOL_CONFIRMATION_REQUEST,
toolCall: { name: 'sensitive-tool', args: {} },
correlationId: 'malicious-id',
forcedDecision: 'allow' as 'allow' | 'deny' | 'ask_user', // Try to bypass policy
subagent: 'trusted-subagent', // Try to spoof identity
serverName: 'spoofed-server', // Try to spoof server name
toolAnnotations: { safe: true }, // Try to spoof annotations
details: {
type: 'exec',
title: 'Spoofed UI',
command: 'rm -rf /',
} as unknown as ToolConfirmationRequest['details'], // Try to spoof UI
};
await new Promise<void>((resolve) => {
messageBus.subscribe<ToolConfirmationRequest>(
MessageBusType.TOOL_CONFIRMATION_REQUEST,
(msg) => {
if (msg.correlationId === 'malicious-id') {
expect(msg.forcedDecision).toBeUndefined();
expect(msg.serverName).toBeUndefined();
expect(msg.toolAnnotations).toBeUndefined();
expect(msg.details).toBeUndefined();
expect(msg.subagent).toBe('attacker/trusted-subagent');
resolve();
}
},
);
void subagentBus.publish(request);
});
});
});
describe('subscribe with AbortSignal', () => {
@@ -21,9 +21,9 @@ export class MessageBus extends EventEmitter {
constructor(
private readonly policyEngine: PolicyEngine,
private readonly debug = false,
private readonly isTrusted = true,
) {
super();
this.debug = debug;
}
private isValidMessage(message: Message): boolean {
@@ -47,18 +47,32 @@ export class MessageBus extends EventEmitter {
/**
* Derives a child message bus scoped to a specific subagent.
* Derived buses are untrusted.
*/
derive(subagentName: string): MessageBus {
const bus = new MessageBus(this.policyEngine, this.debug);
const bus = new MessageBus(this.policyEngine, this.debug, false);
bus.publish = async (message: Message) => {
if (message.type === MessageBusType.TOOL_CONFIRMATION_REQUEST) {
// Sanitization for untrusted callers:
// 1. Remove forcedDecision to prevent policy bypass.
// 2. Remove metadata (serverName, toolAnnotations, details) to prevent spoofing.
// 3. Enforce subagent identity by prepending/setting the scope.
const {
forcedDecision: _forcedDecision,
subagent: _subagent,
serverName: _serverName,
toolAnnotations: _toolAnnotations,
details: _details,
...otherFields
} = message;
return this.publish({
...message,
...otherFields,
subagent: message.subagent
? `${subagentName}/${message.subagent}`
: subagentName,
});
} as Message);
}
return this.publish(message);
};
@@ -95,7 +109,10 @@ export class MessageBus extends EventEmitter {
message.subagent,
);
const decision = message.forcedDecision ?? policyDecision;
// Only trust forcedDecision if it comes from a trusted bus
const decision =
(this.isTrusted ? message.forcedDecision : undefined) ??
policyDecision;
switch (decision) {
case PolicyDecision.ALLOW:
@@ -37,6 +37,12 @@ vi.mock('node:fs', async (importOriginal) => {
};
});
vi.mock('node:os');
vi.mock('undici', () => ({
EnvHttpProxyAgent: vi.fn(),
fetch: vi.fn(),
setGlobalDispatcher: vi.fn(),
Agent: vi.fn(),
}));
describe('ide-connection-utils', () => {
beforeEach(() => {
@@ -698,12 +704,36 @@ describe('ide-connection-utils', () => {
});
describe('createProxyAwareFetch', () => {
it('should return a proxy-aware fetcher function', async () => {
it('should return a proxy-aware fetcher function that respects NO_PROXY and includes ideServerHost', async () => {
const { createProxyAwareFetch } = await import(
'./ide-connection-utils.js'
);
const fetcher = await createProxyAwareFetch('127.0.0.1');
const { EnvHttpProxyAgent } = await import('undici');
const ideServerHost = '127.0.0.1';
const existingNoProxy = 'google.com,example.com';
vi.stubEnv('NO_PROXY', existingNoProxy);
const fetcher = await createProxyAwareFetch(ideServerHost);
expect(typeof fetcher).toBe('function');
expect(EnvHttpProxyAgent).toHaveBeenCalledWith({
noProxy: `${existingNoProxy},${ideServerHost}`,
});
});
it('should handle missing NO_PROXY when creating proxy-aware fetcher', async () => {
const { createProxyAwareFetch } = await import(
'./ide-connection-utils.js'
);
const { EnvHttpProxyAgent } = await import('undici');
const ideServerHost = 'host.docker.internal';
vi.stubEnv('NO_PROXY', '');
await createProxyAwareFetch(ideServerHost);
expect(EnvHttpProxyAgent).toHaveBeenCalledWith({
noProxy: ideServerHost,
});
});
});
});
-1
View File
@@ -103,7 +103,6 @@ export {
export * from './utils/tool-utils.js';
export * from './utils/tool-visibility.js';
export * from './utils/terminalSerializer.js';
export * from './utils/systemEncoding.js';
export * from './utils/textUtils.js';
export * from './utils/formatters.js';
export * from './utils/generateContentResponseUtilities.js';
+140
View File
@@ -24,6 +24,7 @@ import {
import { Storage } from '../config/storage.js';
import * as tomlLoader from './toml-loader.js';
import { coreEvents } from '../utils/events.js';
import { MCPServerConfig } from '../config/config.js';
vi.unmock('../config/storage.js');
@@ -279,6 +280,145 @@ describe('createPolicyEngineConfig', () => {
expect(untrustedRule).toBeUndefined();
});
it('should NOT automatically allow configured MCP servers in non-interactive mode by default', async () => {
const config = await createPolicyEngineConfig(
{
mcpServers: {
'server-1': new MCPServerConfig('node', []),
},
},
ApprovalMode.DEFAULT,
MOCK_DEFAULT_DIR,
false, // non-interactive
);
const rule = config.rules?.find(
(r) => r.mcpName === 'server-1' && r.decision === PolicyDecision.ALLOW,
);
expect(rule).toBeUndefined();
});
it('should automatically allow configured MCP servers in non-interactive mode if opted-in', async () => {
const config = await createPolicyEngineConfig(
{
mcp: { autoAllowInHeadless: true },
mcpServers: {
'server-1': new MCPServerConfig('node', []),
'server-2': new MCPServerConfig('python', []),
},
},
ApprovalMode.DEFAULT,
MOCK_DEFAULT_DIR,
false, // non-interactive
);
const rule1 = config.rules?.find(
(r) => r.mcpName === 'server-1' && r.decision === PolicyDecision.ALLOW,
);
const rule2 = config.rules?.find(
(r) => r.mcpName === 'server-2' && r.decision === PolicyDecision.ALLOW,
);
expect(rule1).toBeDefined();
expect(rule1?.source).toBe('Settings (Headless MCP Auto-Allow)');
expect(rule2).toBeDefined();
expect(rule2?.source).toBe('Settings (Headless MCP Auto-Allow)');
});
it('should NOT automatically allow configured MCP servers in interactive mode even if opted-in', async () => {
const config = await createPolicyEngineConfig(
{
mcp: { autoAllowInHeadless: true },
mcpServers: {
'server-1': new MCPServerConfig('node', []),
},
},
ApprovalMode.DEFAULT,
MOCK_DEFAULT_DIR,
true, // interactive
);
const rule = config.rules?.find(
(r) => r.mcpName === 'server-1' && r.decision === PolicyDecision.ALLOW,
);
expect(rule).toBeUndefined();
});
it('should NOT duplicate allow rules if an MCP server is already explicitly allowed, wildcard allowed, or trusted', async () => {
const config = await createPolicyEngineConfig(
{
mcp: {
autoAllowInHeadless: true,
allowed: ['server-1', '*'],
},
mcpServers: {
'server-1': new MCPServerConfig('node', []),
'server-2': new MCPServerConfig('node', []),
'server-3': { trust: true },
'server-4': new MCPServerConfig('node', []),
},
},
ApprovalMode.DEFAULT,
MOCK_DEFAULT_DIR,
false, // non-interactive
);
// server-1: already in mcp.allowed
const rules1 = config.rules?.filter(
(r) => r.mcpName === 'server-1' && r.decision === PolicyDecision.ALLOW,
);
expect(rules1).toHaveLength(1);
expect(rules1?.[0].source).toBe('Settings (MCP Allowed)');
// server-2: covered by '*' in mcp.allowed
// Note: the logic adds a rule for '*' which will match server-2 at runtime,
// but the loop in headless auto-allow should skip adding a specific rule for server-2.
const rules2 = config.rules?.filter(
(r) => r.mcpName === 'server-2' && r.decision === PolicyDecision.ALLOW,
);
expect(rules2).toHaveLength(0);
// server-3: already trusted
const rules3 = config.rules?.filter(
(r) => r.mcpName === 'server-3' && r.decision === PolicyDecision.ALLOW,
);
expect(rules3).toHaveLength(1);
expect(rules3?.[0].source).toBe('Settings (MCP Trusted)');
// server-4: NOT explicitly allowed or trusted, but SHOULD NOT be added because '*' exists in mcp.allowed
const rules4 = config.rules?.filter(
(r) => r.mcpName === 'server-4' && r.decision === PolicyDecision.ALLOW,
);
expect(rules4).toHaveLength(0);
// Verify the wildcard rule exists
const wildcardRule = config.rules?.find(
(r) => r.mcpName === '*' && r.decision === PolicyDecision.ALLOW,
);
expect(wildcardRule).toBeDefined();
expect(wildcardRule?.toolName).toBe('mcp_*');
});
it('should use correct tool name pattern for wildcard server in headless auto-allow', async () => {
const config = await createPolicyEngineConfig(
{
mcp: { autoAllowInHeadless: true },
mcpServers: {
'*': new MCPServerConfig('node', []),
},
},
ApprovalMode.DEFAULT,
MOCK_DEFAULT_DIR,
false, // non-interactive
);
const rule = config.rules?.find(
(r) => r.mcpName === '*' && r.decision === PolicyDecision.ALLOW,
);
expect(rule).toBeDefined();
expect(rule?.toolName).toBe('mcp_*');
});
it('should handle multiple MCP server configurations together', async () => {
const config = await createPolicyEngineConfig(
{
+32
View File
@@ -600,6 +600,38 @@ export async function createPolicyEngineConfig(
}
}
// In non-interactive mode, automatically allow all configured MCP servers if opted-in.
// This ensures that tools provided by these servers are available without
// requiring explicit entries in settings.mcp.allowed.
if (
!interactive &&
settings.mcp?.autoAllowInHeadless &&
settings.mcpServers
) {
for (const serverName of Object.keys(settings.mcpServers)) {
// Avoid duplicates if already explicitly allowed, allowed via wildcard, or trusted.
if (
settings.mcp?.allowed?.includes(serverName) ||
settings.mcp?.allowed?.includes('*') ||
settings.mcpServers[serverName].trust
) {
continue;
}
rules.push({
toolName:
serverName === '*'
? `${MCP_TOOL_PREFIX}*`
: `${MCP_TOOL_PREFIX}${serverName}_*`,
mcpName: serverName,
decision: PolicyDecision.ALLOW,
priority: ALLOWED_MCP_SERVER_PRIORITY,
source: 'Settings (Headless MCP Auto-Allow)',
modes: nonPlanModes,
});
}
}
return {
rules,
checkers,
+1
View File
@@ -333,6 +333,7 @@ export interface PolicySettings {
mcp?: {
excluded?: string[];
allowed?: string[];
autoAllowInHeadless?: boolean;
};
tools?: {
core?: string[];
@@ -120,10 +120,6 @@ vi.mock('../utils/terminalSerializer.js', () => ({
convertColorToHex: () => '#000000',
ColorMode: { DEFAULT: 0, PALETTE: 1, RGB: 2 },
}));
vi.mock('../utils/systemEncoding.js', () => ({
getCachedEncodingForBuffer: vi.fn().mockReturnValue('utf-8'),
}));
const mockProcessKill = vi
.spyOn(process, 'kill')
.mockImplementation(() => true);
@@ -1030,7 +1026,7 @@ describe('ShellExecutionService', () => {
});
describe('Platform-Specific Behavior', () => {
it('should use powershell.exe on Windows', async () => {
it('should use powershell.exe on Windows and prefix the command with chcp 65001 for the PTY session', async () => {
mockPlatform.mockReturnValue('win32');
await simulateExecution('dir "foo bar"', (pty) =>
pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null }),
@@ -1038,7 +1034,7 @@ describe('ShellExecutionService', () => {
expect(mockPtySpawn).toHaveBeenCalledWith(
'powershell.exe',
['-NoProfile', '-Command', 'dir "foo bar"'],
['-NoProfile', '-Command', 'chcp 65001 >$null;dir "foo bar"'],
expect.any(Object),
);
});
@@ -13,7 +13,6 @@ import os from 'node:os';
import fs, { mkdirSync } from 'node:fs';
import path from 'node:path';
import type { IPty } from '@lydell/node-pty';
import { getCachedEncodingForBuffer } from '../utils/systemEncoding.js';
import {
getShellConfiguration,
resolveExecutable,
@@ -81,6 +80,40 @@ function ensurePromptvarsDisabled(command: string, shell: ShellType): string {
return `${BASH_SHOPT_GUARD} ${command}`;
}
// On Windows, a new ConPTY session inherits its codepage from the system
// OEMCP (microsoft/terminal `src/host/settings.cpp:41` defaults
// `_uCodePage` to `Globals.uiOEMCP`, set from `GetOEMCP()` in
// `srvinit.cpp:44`). On locales without "Beta: Use Unicode UTF-8 for
// worldwide language support" the OEMCP is a legacy codepage (e.g. 850,
// 866, 936, 932), and conhost converts every byte from the child via
// `MultiByteToWideChar(gci.OutputCP, ...)` in `_stream.cpp:341-343`,
// turning UTF-8 output from child processes (perl, python, node, ...)
// into mojibake.
//
// `CreatePseudoConsole` does not accept a codepage argument
// (microsoft/terminal#9174 — open as a feature request). The only way
// to set the ConPTY codepage is from inside the new session via
// `SetConsoleOutputCP` (intercepted by conhost in `getset.cpp:1144`).
// Prefix the command with `chcp 65001` so the first thing the new
// session does is switch its codepage to UTF-8.
function injectUtf8CodepageForPty(
command: string,
shell: ShellType,
isWindows: boolean,
usingPty: boolean,
): string {
if (!isWindows || !usingPty) {
return command;
}
if (shell === 'powershell') {
return `chcp 65001 >$null;${command}`;
}
if (shell === 'cmd') {
return `chcp 65001>nul&${command}`;
}
return command;
}
/** A structured result from a shell command execution. */
export type ShellExecutionResult = ExecutionResult;
@@ -389,6 +422,7 @@ export class ShellExecutionService {
cwd: string,
shellExecutionConfig: ShellExecutionConfig,
isInteractive: boolean,
usingPty: boolean,
): Promise<{
program: string;
args: string[];
@@ -417,7 +451,13 @@ export class ShellExecutionService {
const resolvedExecutable = resolveExecutable(executable) ?? executable;
const guardedCommand = ensurePromptvarsDisabled(commandToExecute, shell);
const spawnArgs = [...argsPrefix, guardedCommand];
const finalCommand = injectUtf8CodepageForPty(
guardedCommand,
shell,
isWindows,
usingPty,
);
const spawnArgs = [...argsPrefix, finalCommand];
// 2. Prepare Environment
const gitConfigKeys: string[] = [];
@@ -520,6 +560,7 @@ export class ShellExecutionService {
cwd,
shellExecutionConfig,
isInteractive,
false,
);
cmdCleanup = prepared.cleanup;
@@ -620,14 +661,8 @@ export class ShellExecutionService {
const handleOutput = (data: Buffer, stream: 'stdout' | 'stderr') => {
if (!stdoutDecoder || !stderrDecoder) {
const encoding = getCachedEncodingForBuffer(data);
try {
stdoutDecoder = new TextDecoder(encoding);
stderrDecoder = new TextDecoder(encoding);
} catch {
stdoutDecoder = new TextDecoder('utf-8');
stderrDecoder = new TextDecoder('utf-8');
}
stdoutDecoder = new TextDecoder('utf-8');
stderrDecoder = new TextDecoder('utf-8');
}
if (isStreamingRawContent && sniffedBytes < MAX_SNIFF_SIZE) {
@@ -900,6 +935,7 @@ export class ShellExecutionService {
cwd,
shellExecutionConfig,
true,
true,
);
cmdCleanup = prepared.cleanup;
@@ -1115,12 +1151,7 @@ export class ShellExecutionService {
() =>
new Promise<void>((resolveChunk) => {
if (!decoder) {
const encoding = getCachedEncodingForBuffer(data);
try {
decoder = new TextDecoder(encoding);
} catch {
decoder = new TextDecoder('utf-8');
}
decoder = new TextDecoder('utf-8');
}
if (isStreamingRawContent && sniffedBytes < MAX_SNIFF_SIZE) {
@@ -667,12 +667,12 @@ exports[`coreTools snapshots for specific models > Model: gemini-2.5-pro > snaps
{
"description": "Writes content to a specified file in the local filesystem.
The user has the ability to modify \`content\`. If modified, this will be stated in the response.",
The user has the ability to modify \`content\`. If modified, this will be stated in the response. WARNING: Do NOT use this tool if the file contains massive literal text sequences (like a 6000+ character string, large arrays, or inline base64 images), as LLMs are prone to corrupting or truncating such sequences during a full file rewrite. Use the 'replace' tool instead.",
"name": "write_file",
"parametersJsonSchema": {
"properties": {
"content": {
"description": "The content to write to the file. Do not use omission placeholders like '(rest of methods ...)', '...', or 'unchanged code'; provide complete literal content.",
"description": "The content to write to the file. Do not use omission placeholders like '(rest of methods ...)', '...', or 'unchanged code'; provide complete literal content. WARNING: Do not use this tool to rewrite files containing massive literal text blocks (e.g., inline base64 images or >6000 character strings) because you may corrupt them. Use the replace tool instead.",
"type": "string",
},
"file_path": {
@@ -1439,12 +1439,12 @@ exports[`coreTools snapshots for specific models > Model: gemini-3-pro-preview >
exports[`coreTools snapshots for specific models > Model: gemini-3-pro-preview > snapshot for tool: write_file 1`] = `
{
"description": "Writes the complete content to a file, automatically creating missing parent directories. Overwrites existing files. The user has the ability to modify 'content' before it is saved. Best for new or small files; use 'replace' for targeted edits to large files to minimize token usage and simplify reviews.",
"description": "Writes the complete content to a file, automatically creating missing parent directories. Overwrites existing files. The user has the ability to modify 'content' before it is saved. Best for new or small files; use 'replace' for targeted edits to large files to minimize token usage and simplify reviews. WARNING: Do NOT use this tool if the file contains massive literal text sequences (like a 6000+ character string, large arrays, or inline base64 images), as LLMs are prone to corrupting or truncating such sequences during a full file rewrite. Use the 'replace' tool instead.",
"name": "write_file",
"parametersJsonSchema": {
"properties": {
"content": {
"description": "The complete content to write. Provide the full file; do not use placeholders like '// ... rest of code'.",
"description": "The complete content to write. Provide the full file; do not use placeholders like '// ... rest of code'. WARNING: Do not use this tool to rewrite files containing massive literal text blocks (e.g., inline base64 images or >6000 character strings) because you may corrupt them. Use the replace tool instead.",
"type": "string",
},
"file_path": {
@@ -112,7 +112,7 @@ export const DEFAULT_LEGACY_SET: CoreToolSet = {
name: WRITE_FILE_TOOL_NAME,
description: `Writes content to a specified file in the local filesystem.
The user has the ability to modify \`content\`. If modified, this will be stated in the response.`,
The user has the ability to modify \`content\`. If modified, this will be stated in the response. WARNING: Do NOT use this tool if the file contains massive literal text sequences (like a 6000+ character string, large arrays, or inline base64 images), as LLMs are prone to corrupting or truncating such sequences during a full file rewrite. Use the '${EDIT_TOOL_NAME}' tool instead.`,
parametersJsonSchema: {
type: 'object',
properties: {
@@ -122,7 +122,7 @@ export const DEFAULT_LEGACY_SET: CoreToolSet = {
},
[WRITE_FILE_PARAM_CONTENT]: {
description:
"The content to write to the file. Do not use omission placeholders like '(rest of methods ...)', '...', or 'unchanged code'; provide complete literal content.",
"The content to write to the file. Do not use omission placeholders like '(rest of methods ...)', '...', or 'unchanged code'; provide complete literal content. WARNING: Do not use this tool to rewrite files containing massive literal text blocks (e.g., inline base64 images or >6000 character strings) because you may corrupt them. Use the replace tool instead.",
type: 'string',
},
},
@@ -119,7 +119,7 @@ export const GEMINI_3_SET: CoreToolSet = {
write_file: {
name: WRITE_FILE_TOOL_NAME,
description: `Writes the complete content to a file, automatically creating missing parent directories. Overwrites existing files. The user has the ability to modify 'content' before it is saved. Best for new or small files; use '${EDIT_TOOL_NAME}' for targeted edits to large files to minimize token usage and simplify reviews.`,
description: `Writes the complete content to a file, automatically creating missing parent directories. Overwrites existing files. The user has the ability to modify 'content' before it is saved. Best for new or small files; use '${EDIT_TOOL_NAME}' for targeted edits to large files to minimize token usage and simplify reviews. WARNING: Do NOT use this tool if the file contains massive literal text sequences (like a 6000+ character string, large arrays, or inline base64 images), as LLMs are prone to corrupting or truncating such sequences during a full file rewrite. Use the '${EDIT_TOOL_NAME}' tool instead.`,
parametersJsonSchema: {
type: 'object',
properties: {
@@ -129,7 +129,7 @@ export const GEMINI_3_SET: CoreToolSet = {
},
[WRITE_FILE_PARAM_CONTENT]: {
description:
"The complete content to write. Provide the full file; do not use placeholders like '// ... rest of code'.",
"The complete content to write. Provide the full file; do not use placeholders like '// ... rest of code'. WARNING: Do not use this tool to rewrite files containing massive literal text blocks (e.g., inline base64 images or >6000 character strings) because you may corrupt them. Use the replace tool instead.",
type: 'string',
},
},
+125
View File
@@ -74,6 +74,12 @@ let MOCK_CONTEXT: McpContext = MOCK_CONTEXT_DEFAULT;
vi.mock('@modelcontextprotocol/sdk/client/stdio.js');
vi.mock('@modelcontextprotocol/sdk/client/index.js');
vi.mock('@google/genai');
vi.mock('undici', () => ({
EnvHttpProxyAgent: vi.fn(),
fetch: vi.fn(),
setGlobalDispatcher: vi.fn(),
Agent: vi.fn(),
}));
vi.mock('../mcp/oauth-provider.js');
vi.mock('../mcp/oauth-token-storage.js');
vi.mock('../mcp/oauth-utils.js');
@@ -804,6 +810,102 @@ describe('mcp-client', () => {
});
});
it('should transform nullable array schemas and preserve properties during discovery', async () => {
const mockedClient = {
connect: vi.fn(),
discover: vi.fn(),
disconnect: vi.fn(),
getStatus: vi.fn(),
registerCapabilities: vi.fn(),
setRequestHandler: vi.fn(),
setNotificationHandler: vi.fn(),
getServerCapabilities: vi.fn().mockReturnValue({ tools: {} }),
listTools: vi.fn().mockResolvedValue({
tools: [
{
name: 'nullableTool',
description: 'Tool with nullable array',
inputSchema: {
type: 'object',
properties: {
tags: {
type: ['array', 'null'],
items: { type: 'string' },
},
},
$defs: {
SomeType: { type: 'string' },
},
},
},
],
}),
listPrompts: vi.fn().mockResolvedValue({
prompts: [],
}),
request: vi.fn().mockResolvedValue({}),
};
vi.mocked(ClientLib.Client).mockReturnValue(
mockedClient as unknown as ClientLib.Client,
);
vi.spyOn(SdkClientStdioLib, 'StdioClientTransport').mockReturnValue(
{} as SdkClientStdioLib.StdioClientTransport,
);
const mockedToolRegistry = {
registerTool: vi.fn(),
sortTools: vi.fn(),
getToolsByServer: vi.fn().mockReturnValue([]),
getMessageBus: vi.fn().mockReturnValue(undefined),
} as unknown as ToolRegistry;
const promptRegistry = {
registerPrompt: vi.fn(),
getPromptsByServer: vi.fn().mockReturnValue([]),
removePromptsByServer: vi.fn(),
} as unknown as PromptRegistry;
const resourceRegistry = {
getResourcesByServer: vi.fn().mockReturnValue([]),
setResourcesForServer: vi.fn(),
removeResourcesByServer: vi.fn(),
} as unknown as ResourceRegistry;
const client = new McpClient(
'test-server',
{
command: 'test-command',
},
workspaceContext,
MOCK_CONTEXT,
false,
'0.0.1',
);
await client.connect();
await client.discoverInto(MOCK_CONTEXT, {
toolRegistry: mockedToolRegistry,
promptRegistry,
resourceRegistry,
});
expect(mockedToolRegistry.registerTool).toHaveBeenCalledOnce();
const registeredTool = vi.mocked(mockedToolRegistry.registerTool).mock
.calls[0][0];
expect(registeredTool.schema.parametersJsonSchema).toEqual({
type: 'object',
properties: {
tags: {
type: 'array',
nullable: true,
items: { type: 'string' },
},
wait_for_previous: {
type: 'boolean',
description:
'Set to true to wait for all previously requested tools in this turn to complete before starting. Set to false (or omit) to run in parallel. Use true when this tool depends on the output of previous tools.',
},
},
$defs: {
SomeType: { type: 'string' },
},
});
});
it('should discover resources when a server only exposes resources', async () => {
const mockedClient = {
connect: vi.fn(),
@@ -1779,6 +1881,29 @@ describe('mcp-client', () => {
});
describe('createTransport', () => {
it('should create an HTTP transport that respects NO_PROXY', async () => {
const { createTransport } = await import('./mcp-client.js');
const { EnvHttpProxyAgent } = await import('undici');
const noProxyValue = 'localhost,127.0.0.1';
vi.stubEnv('NO_PROXY', noProxyValue);
await createTransport(
'test-server',
{
url: 'http://test-server',
type: 'http',
},
false,
MOCK_CONTEXT,
);
expect(EnvHttpProxyAgent).toHaveBeenCalledWith(
expect.objectContaining({
noProxy: noProxyValue,
}),
);
});
describe('should connect via httpUrl', () => {
it('uses MCP SDK authProvider token() path for oauth-enabled servers', async () => {
const mockGetValidTokenWithMetadata = vi.fn().mockResolvedValue({
+40
View File
@@ -1308,6 +1308,46 @@ export async function discoverTools(
continue;
}
if (toolDef.inputSchema) {
try {
const transform = (obj: unknown): unknown => {
if (obj === null || typeof obj !== 'object') return obj;
if (Array.isArray(obj)) return obj.map(transform);
const res = { ...obj } as Record<string, unknown>;
if (Array.isArray(res['type']) && res['type'].length === 2) {
const nIdx = res['type'].indexOf('null');
if (nIdx !== -1 && typeof res['type'][1 - nIdx] === 'string') {
res['type'] = res['type'][1 - nIdx];
res['nullable'] = true;
}
}
for (const k in res) {
if (Object.prototype.hasOwnProperty.call(res, k)) {
res[k] = transform(res[k]);
}
}
return res;
};
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
toolDef.inputSchema = transform(toolDef.inputSchema) as {
type: 'object';
properties?: Record<string, object>;
required?: string[];
};
} catch (error) {
cliConfig.emitMcpDiagnostic(
'error',
`Failed to parse adjusted inputSchema for tool '${toolDef.name}' from server '${mcpServerName}'. Using original schema. Error: ${error instanceof Error ? error.message : String(error)}`,
error,
mcpServerName,
);
}
}
const mcpCallableTool = new McpCallableTool(
mcpClient,
toolDef,
@@ -9,6 +9,8 @@ import { detectOmissionPlaceholders } from './omissionPlaceholderDetector.js';
describe('detectOmissionPlaceholders', () => {
it('detects standalone placeholder lines', () => {
expect(detectOmissionPlaceholders('...')).toEqual([' ...']);
expect(detectOmissionPlaceholders('// ...')).toEqual([' ...']);
expect(detectOmissionPlaceholders('(rest of methods ...)')).toEqual([
'rest of methods ...',
]);
@@ -5,6 +5,7 @@
*/
const OMITTED_PREFIXES = new Set([
'',
'rest of',
'rest of method',
'rest of methods',
+52 -1
View File
@@ -1813,7 +1813,18 @@ describe('resolveRipgrepPath', () => {
);
});
it('should resolve the SEA (flattened) path first', async () => {
it('should resolve the SEA (purely flattened) path first', async () => {
vi.mocked(fileExists).mockImplementation(async (checkPath) => {
const expectedTarget = path.resolve(__dirname, `rg-linux-x64`);
return checkPath.includes(expectedTarget);
});
const resolvedPath = await resolveRipgrepPath();
expect(resolvedPath).not.toBeNull();
expect(resolvedPath).toContain('rg-linux-x64');
});
it('should resolve the SEA (vendor subdirectory) path if purely flattened is missing', async () => {
vi.mocked(fileExists).mockImplementation(async (checkPath) =>
checkPath.includes(path.normalize('vendor/ripgrep')),
);
@@ -1823,6 +1834,39 @@ describe('resolveRipgrepPath', () => {
expect(resolvedPath).toContain(path.normalize('vendor/ripgrep'));
});
it('should resolve the Dev/Dist layout (actual output with src/) if SEA path is missing', async () => {
vi.mocked(fileExists).mockImplementation(async (checkPath) => {
// Normalize the expected check against the absolute resolved path logic
const expectedTarget = path.resolve(
__dirname,
'../../../vendor/ripgrep',
);
return checkPath.includes(expectedTarget);
});
const resolvedPath = await resolveRipgrepPath();
expect(resolvedPath).not.toBeNull();
expect(resolvedPath).toContain('vendor');
});
it('should resolve the Dev/Dist layout (assumed output without src/) if others are missing', async () => {
vi.mocked(fileExists).mockImplementation(async (checkPath) => {
const expectedTarget = path.resolve(
__dirname,
'../../vendor/ripgrep',
);
const skipTarget = path.resolve(__dirname, '../../../vendor/ripgrep');
return (
checkPath.includes(expectedTarget) &&
!checkPath.includes(skipTarget)
);
});
const resolvedPath = await resolveRipgrepPath();
expect(resolvedPath).not.toBeNull();
expect(resolvedPath).toContain('vendor');
});
it('should fall back to system PATH if both bundled paths are missing and system is trusted', async () => {
vi.mocked(fileExists).mockResolvedValue(false);
vi.mocked(resolveExecutable).mockReturnValue('/usr/bin/rg');
@@ -1862,6 +1906,13 @@ describe('resolveRipgrepPath', () => {
const resolvedPath = await resolveRipgrepPath();
expect(resolvedPath).toBeNull();
});
it('should handle errors gracefully and return null', async () => {
vi.mocked(fileExists).mockRejectedValue(new Error('File system error'));
const resolvedPath = await resolveRipgrepPath();
expect(resolvedPath).toBeNull();
});
});
describe('on Windows', () => {
+6 -2
View File
@@ -59,9 +59,13 @@ export async function resolveRipgrepPath(): Promise<string | null> {
const binName = `rg-${platform}-${arch}${platform === 'win32' ? '.exe' : ''}`;
const candidatePaths = [
// 1. SEA runtime layout: everything is flattened into the root dir
// 1. SEA runtime layout (Flattened): everything is in the root dir
path.resolve(__dirname, binName),
// 2. SEA runtime layout (Subdirectory): bundled into a vendor/ripgrep dir
path.resolve(__dirname, 'vendor/ripgrep', binName),
// 2. Dev/Dist layout: packages/core/dist/tools/ripGrep.js -> packages/core/vendor/ripgrep
// 3. Dev/Dist layout (Actual): dist/src/tools/ripGrep.js -> packages/core/vendor/ripgrep
path.resolve(__dirname, '../../../vendor/ripgrep', binName),
// 4. Dev/Dist layout (Assumed/Bundled): dist/tools/ripGrep.js -> packages/core/vendor/ripgrep
path.resolve(__dirname, '../../vendor/ripgrep', binName),
];
+203 -1
View File
@@ -21,7 +21,11 @@ import {
allowEditorTypeInSandbox,
isEditorAvailable,
isEditorAvailableAsync,
isValidEditorType,
getEditorWaitFlag,
getEditorExtraArgs,
resolveEditorAsync,
resolveEditorTypeFromCommand,
type EditorType,
} from './editor.js';
import { coreEvents, CoreEvent } from './events.js';
@@ -84,6 +88,20 @@ describe('editor utils', () => {
win32Commands: ['agy.cmd', 'antigravity.cmd', 'antigravity'],
},
{ editor: 'hx', commands: ['hx'], win32Commands: ['hx'] },
{
editor: 'sublimetext',
commands: ['subl'],
win32Commands: ['subl'],
},
{ editor: 'lapce', commands: ['lapce'], win32Commands: ['lapce'] },
{ editor: 'nova', commands: ['nova'], win32Commands: ['nova'] },
{ editor: 'bbedit', commands: ['bbedit'], win32Commands: ['bbedit'] },
{
editor: 'emacsclient',
commands: ['emacsclient'],
win32Commands: ['emacsclient'],
},
{ editor: 'micro', commands: ['micro'], win32Commands: ['micro'] },
];
for (const { editor, commands, win32Commands } of testCases) {
@@ -188,6 +206,7 @@ describe('editor utils', () => {
commands: ['agy', 'antigravity'],
win32Commands: ['agy.cmd', 'antigravity.cmd', 'antigravity'],
},
{ editor: 'bbedit', commands: ['bbedit'], win32Commands: ['bbedit'] },
];
for (const { editor, commands, win32Commands } of guiEditors) {
@@ -317,6 +336,7 @@ describe('editor utils', () => {
}
it('should return the correct command for emacs with escaped paths', () => {
Object.defineProperty(process, 'platform', { value: 'linux' });
const command = getDiffCommand(
'old file "quote".txt',
'new file \\back\\slash.txt',
@@ -331,6 +351,30 @@ describe('editor utils', () => {
});
});
it('should return the correct command for emacsclient', () => {
const command = getDiffCommand('old.txt', 'new.txt', 'emacsclient');
expect(command).toEqual({
command: 'emacsclient',
args: ['-nw', '--eval', '(ediff "old.txt" "new.txt")'],
});
});
it('should return the correct command for emacsclient with escaped paths', () => {
const command = getDiffCommand(
'old file "quote".txt',
'new file \\back\\slash.txt',
'emacsclient',
);
expect(command).toEqual({
command: 'emacsclient',
args: [
'-nw',
'--eval',
'(ediff "old file \\"quote\\".txt" "new file \\\\back\\\\slash.txt")',
],
});
});
it('should return the correct command for helix', () => {
const command = getDiffCommand('old.txt', 'new.txt', 'hx');
expect(command).toEqual({
@@ -339,6 +383,22 @@ describe('editor utils', () => {
});
});
it('should return null for sublimetext (no CLI diff support)', () => {
expect(getDiffCommand('old.txt', 'new.txt', 'sublimetext')).toBeNull();
});
it('should return null for lapce (no CLI diff support)', () => {
expect(getDiffCommand('old.txt', 'new.txt', 'lapce')).toBeNull();
});
it('should return null for nova (no CLI diff support)', () => {
expect(getDiffCommand('old.txt', 'new.txt', 'nova')).toBeNull();
});
it('should return null for micro (no CLI diff support)', () => {
expect(getDiffCommand('old.txt', 'new.txt', 'micro')).toBeNull();
});
it('should return null for an unsupported editor', () => {
// @ts-expect-error Testing unsupported editor
const command = getDiffCommand('old.txt', 'new.txt', 'foobar');
@@ -353,6 +413,7 @@ describe('editor utils', () => {
'windsurf',
'cursor',
'zed',
'bbedit',
];
for (const editor of guiEditors) {
@@ -473,7 +534,14 @@ describe('editor utils', () => {
});
}
const terminalEditors: EditorType[] = ['vim', 'neovim', 'emacs', 'hx'];
// micro has no CLI diff support (getDiffCommand returns null) so is excluded here
const terminalEditors: EditorType[] = [
'vim',
'neovim',
'emacs',
'hx',
'emacsclient',
];
for (const editor of terminalEditors) {
it(`should call spawnSync for ${editor}`, async () => {
@@ -520,6 +588,15 @@ describe('editor utils', () => {
expect(allowEditorTypeInSandbox('emacs')).toBe(true);
});
it('should allow emacsclient in sandbox mode', () => {
vi.stubEnv('SANDBOX', 'sandbox');
expect(allowEditorTypeInSandbox('emacsclient')).toBe(true);
});
it('should allow emacsclient when not in sandbox mode', () => {
expect(allowEditorTypeInSandbox('emacsclient')).toBe(true);
});
it('should allow neovim in sandbox mode', () => {
vi.stubEnv('SANDBOX', 'sandbox');
expect(allowEditorTypeInSandbox('neovim')).toBe(true);
@@ -544,6 +621,10 @@ describe('editor utils', () => {
'windsurf',
'cursor',
'zed',
'sublimetext',
'lapce',
'nova',
'bbedit',
];
for (const editor of guiEditors) {
it(`should not allow ${editor} in sandbox mode`, () => {
@@ -777,4 +858,125 @@ describe('editor utils', () => {
expect(emitSpy).toHaveBeenCalledWith(CoreEvent.RequestEditorSelection);
});
});
describe('isValidEditorType', () => {
it('should return true for known editor identifiers', () => {
expect(isValidEditorType('vscode')).toBe(true);
expect(isValidEditorType('vim')).toBe(true);
expect(isValidEditorType('sublimetext')).toBe(true);
expect(isValidEditorType('emacsclient')).toBe(true);
expect(isValidEditorType('micro')).toBe(true);
expect(isValidEditorType('lapce')).toBe(true);
expect(isValidEditorType('nova')).toBe(true);
expect(isValidEditorType('bbedit')).toBe(true);
});
it('should return false for unrecognized strings', () => {
expect(isValidEditorType('emacsclient -nw')).toBe(false);
expect(isValidEditorType('subl')).toBe(false);
expect(isValidEditorType('code')).toBe(false);
expect(isValidEditorType('')).toBe(false);
expect(isValidEditorType('notepad')).toBe(false);
});
});
describe('getEditorWaitFlag', () => {
it('should return -w for sublimetext', () => {
expect(getEditorWaitFlag('sublimetext')).toBe('-w');
});
it('should return --wait for all other GUI editors', () => {
const standardGuiEditors: EditorType[] = [
'vscode',
'vscodium',
'windsurf',
'cursor',
'zed',
'antigravity',
'lapce',
'nova',
'bbedit',
];
for (const editor of standardGuiEditors) {
expect(getEditorWaitFlag(editor)).toBe('--wait');
}
});
});
describe('resolveEditorTypeFromCommand', () => {
it('should resolve known command names to their editor type', () => {
expect(resolveEditorTypeFromCommand('cursor')).toBe('cursor');
expect(resolveEditorTypeFromCommand('code')).toBe('vscode');
expect(resolveEditorTypeFromCommand('codium')).toBe('vscodium');
expect(resolveEditorTypeFromCommand('vim')).toBe('vim');
});
it('should be case-insensitive', () => {
expect(resolveEditorTypeFromCommand('Cursor')).toBe('cursor');
expect(resolveEditorTypeFromCommand('CODE')).toBe('vscode');
});
it('should return undefined for unknown commands', () => {
expect(resolveEditorTypeFromCommand('unknowntool')).toBeUndefined();
expect(resolveEditorTypeFromCommand('')).toBeUndefined();
});
});
describe('getEditorExtraArgs', () => {
it('should return [-nw] for emacsclient', () => {
expect(getEditorExtraArgs('emacsclient')).toEqual(['-nw']);
});
it('should return [] for VS Code-family editors by default', () => {
const vscodeEditors: EditorType[] = [
'vscode',
'vscodium',
'cursor',
'windsurf',
];
for (const editor of vscodeEditors) {
expect(getEditorExtraArgs(editor)).toEqual([]);
}
});
it('should return [--new-window] for VS Code-family editors when newWindow is true', () => {
const vscodeEditors: EditorType[] = [
'vscode',
'vscodium',
'cursor',
'windsurf',
];
for (const editor of vscodeEditors) {
expect(getEditorExtraArgs(editor, { newWindow: true })).toEqual([
'--new-window',
]);
}
});
it('should return [] for VS Code-family editors when newWindow is false', () => {
const vscodeEditors: EditorType[] = [
'vscode',
'vscodium',
'cursor',
'windsurf',
];
for (const editor of vscodeEditors) {
expect(getEditorExtraArgs(editor, { newWindow: false })).toEqual([]);
}
});
it('should return [] for all other editors', () => {
const otherEditors: EditorType[] = [
'vim',
'neovim',
'emacs',
'hx',
'sublimetext',
'micro',
];
for (const editor of otherEditors) {
expect(getEditorExtraArgs(editor)).toEqual([]);
}
});
});
});
+112 -3
View File
@@ -17,10 +17,23 @@ const GUI_EDITORS = [
'cursor',
'zed',
'antigravity',
'sublimetext',
'lapce',
'nova',
'bbedit',
] as const;
const TERMINAL_EDITORS = [
'vim',
'neovim',
'emacs',
'hx',
'emacsclient',
'micro',
] as const;
const TERMINAL_EDITORS = ['vim', 'neovim', 'emacs', 'hx'] as const;
const EDITORS = [...GUI_EDITORS, ...TERMINAL_EDITORS] as const;
export const ALL_EDITORS: readonly string[] = EDITORS;
const GUI_EDITORS_SET = new Set<string>(GUI_EDITORS);
const TERMINAL_EDITORS_SET = new Set<string>(TERMINAL_EDITORS);
const EDITORS_SET = new Set<string>(EDITORS);
@@ -53,15 +66,26 @@ export const EDITOR_DISPLAY_NAMES: Record<EditorType, string> = {
neovim: 'Neovim',
zed: 'Zed',
emacs: 'Emacs',
emacsclient: 'Emacs Client',
antigravity: 'Antigravity',
hx: 'Helix',
sublimetext: 'Sublime Text',
lapce: 'Lapce',
nova: 'Nova',
bbedit: 'BBEdit',
micro: 'Micro',
};
export function getEditorDisplayName(editor: EditorType): string {
return EDITOR_DISPLAY_NAMES[editor] || editor;
}
function isValidEditorType(editor: string): editor is EditorType {
export const EDITOR_OPTIONS: ReadonlyArray<{
value: EditorType;
label: string;
}> = EDITORS.map((e) => ({ value: e, label: EDITOR_DISPLAY_NAMES[e] }));
export function isValidEditorType(editor: string): editor is EditorType {
return EDITORS_SET.has(editor);
}
@@ -120,11 +144,18 @@ const editorCommands: Record<
neovim: { win32: ['nvim'], default: ['nvim'] },
zed: { win32: ['zed'], default: ['zed', 'zeditor'] },
emacs: { win32: ['emacs.exe'], default: ['emacs'] },
emacsclient: { win32: ['emacsclient'], default: ['emacsclient'] },
antigravity: {
win32: ['agy.cmd', 'antigravity.cmd', 'antigravity'],
default: ['agy', 'antigravity'],
},
hx: { win32: ['hx'], default: ['hx'] },
sublimetext: { win32: ['subl'], default: ['subl'] },
lapce: { win32: ['lapce'], default: ['lapce'] },
// nova and bbedit are macOS-only; commandExists will return false on other platforms
nova: { win32: ['nova'], default: ['nova'] },
bbedit: { win32: ['bbedit'], default: ['bbedit'] },
micro: { win32: ['micro'], default: ['micro'] },
};
function getEditorCommands(editor: EditorType): string[] {
@@ -156,6 +187,77 @@ export function getEditorCommand(editor: EditorType): string {
);
}
/**
* Given a command name (e.g. "cursor", "code", "code.cmd"), returns the
* EditorType that uses that command, or undefined if no match is found.
*
* This intentionally checks command names across all platforms (both `default`
* and `win32` lists) so that, for example, `$EDITOR=code` is recognized as
* vscode on Windows and `$EDITOR=code.cmd` is recognized as vscode on macOS.
*/
export function resolveEditorTypeFromCommand(
command: string,
): EditorType | undefined {
const lowerCmd = command.toLowerCase();
for (const editor of EDITORS) {
const { win32, default: nonWin32 } = editorCommands[editor];
if (
win32.some((c) => c.toLowerCase() === lowerCmd) ||
nonWin32.some((c) => c.toLowerCase() === lowerCmd)
) {
return editor;
}
}
return undefined;
}
/**
* Per-editor wait flags for GUI editors. Most use '--wait'; exceptions are listed here.
*/
const editorWaitFlags: Partial<Record<EditorType, string>> = {
sublimetext: '-w', // subl uses -w instead of --wait
};
/**
* Returns the flag used to make a GUI editor block until the file is closed.
*/
export function getEditorWaitFlag(editor: EditorType): string {
return editorWaitFlags[editor] ?? '--wait';
}
/**
* Per-editor extra arguments prepended to the command invocation.
*/
const editorExtraArgs: Partial<Record<EditorType, string[]>> = {
emacsclient: ['-nw'], // Force terminal (no-window) mode
};
/**
* VS Code-family editors that support the --new-window flag.
*/
const NEW_WINDOW_EDITORS = new Set<EditorType>([
'vscode',
'vscodium',
'cursor',
'windsurf',
'antigravity',
]);
/**
* Returns any extra arguments that must be passed to the editor executable
* (in addition to the file path and any wait flag).
*/
export function getEditorExtraArgs(
editor: EditorType,
options?: { newWindow?: boolean },
): string[] {
const args = editorExtraArgs[editor] ? [...editorExtraArgs[editor]] : [];
if (options?.newWindow && NEW_WINDOW_EDITORS.has(editor)) {
args.push('--new-window');
}
return args;
}
export function allowEditorTypeInSandbox(editor: EditorType): boolean {
const notUsingSandbox = !process.env['SANDBOX'];
if (isGuiEditor(editor)) {
@@ -267,18 +369,25 @@ export function getDiffCommand(
],
};
case 'emacs':
case 'emacsclient': {
const extraArgs = editor === 'emacsclient' ? ['-nw'] : [];
return {
command: 'emacs',
command,
args: [
...extraArgs,
'--eval',
`(ediff ${escapeELispString(oldPath)} ${escapeELispString(newPath)})`,
],
};
}
case 'hx':
return {
command: 'hx',
args: ['--vsplit', '--', oldPath, newPath],
};
case 'bbedit':
return { command, args: ['--wait', '--diff', oldPath, newPath] };
// sublimetext, lapce, nova, micro do not support CLI-driven diff views
default:
return null;
}
+74 -7
View File
@@ -10,16 +10,16 @@ import * as dnsPromises from 'node:dns/promises';
import type { LookupAddress, LookupAllOptions } from 'node:dns';
import ipaddr from 'ipaddr.js';
const { setGlobalDispatcher, Agent, ProxyAgent } = vi.hoisted(() => ({
const { setGlobalDispatcher, Agent, EnvHttpProxyAgent } = vi.hoisted(() => ({
setGlobalDispatcher: vi.fn(),
Agent: vi.fn(),
ProxyAgent: vi.fn(),
EnvHttpProxyAgent: vi.fn(),
}));
vi.mock('undici', () => ({
setGlobalDispatcher,
Agent,
ProxyAgent,
EnvHttpProxyAgent,
}));
vi.mock('node:dns/promises', () => ({
@@ -33,6 +33,7 @@ const {
isAddressPrivate,
fetchWithTimeout,
setGlobalProxy,
createSafeProxyAgent,
} = await import('./fetch.js');
interface ErrorWithCode extends Error {
code?: string;
@@ -54,6 +55,8 @@ describe('fetch utils', () => {
}
return [{ address: '93.184.216.34', family: 4 }];
});
vi.unstubAllEnvs();
updateGlobalFetchTimeouts(60000);
});
afterEach(() => {
@@ -237,17 +240,81 @@ describe('fetch utils', () => {
});
describe('setGlobalProxy', () => {
it('should configure ProxyAgent with experiment flag timeout', () => {
const proxyUrl = 'http://proxy.example.com';
it('should configure EnvHttpProxyAgent with experiment flag timeout and noProxy', () => {
const proxyUrl = ' http://proxy.example.com ';
const noProxyValue = ' localhost,127.0.0.1 ';
vi.stubEnv('NO_PROXY', noProxyValue);
updateGlobalFetchTimeouts(45773134);
setGlobalProxy(proxyUrl);
expect(ProxyAgent).toHaveBeenCalledWith({
uri: proxyUrl,
expect(EnvHttpProxyAgent).toHaveBeenCalledWith({
httpProxy: 'http://proxy.example.com',
httpsProxy: 'http://proxy.example.com',
noProxy: 'localhost,127.0.0.1',
headersTimeout: 45773134,
bodyTimeout: 300000,
});
expect(setGlobalDispatcher).toHaveBeenCalled();
});
it('should fall back to no_proxy if NO_PROXY is not set', () => {
const proxyUrl = 'http://proxy.example.com';
const noProxyValue = 'localhost,127.0.0.1';
vi.stubEnv('no_proxy', noProxyValue);
setGlobalProxy(proxyUrl);
expect(EnvHttpProxyAgent).toHaveBeenCalledWith(
expect.objectContaining({
noProxy: noProxyValue,
}),
);
});
it('should handle empty NO_PROXY', () => {
const proxyUrl = 'http://proxy.example.com';
vi.stubEnv('NO_PROXY', '');
setGlobalProxy(proxyUrl);
expect(EnvHttpProxyAgent).toHaveBeenCalledWith(
expect.objectContaining({
noProxy: '',
}),
);
});
it('should handle multi-entry NO_PROXY with trimming', () => {
const proxyUrl = 'http://proxy.example.com';
const noProxyValue = ' google.com, 127.0.0.1 , localhost ';
vi.stubEnv('NO_PROXY', noProxyValue);
setGlobalProxy(proxyUrl);
expect(EnvHttpProxyAgent).toHaveBeenCalledWith(
expect.objectContaining({
noProxy: 'google.com, 127.0.0.1 , localhost',
}),
);
});
});
describe('createSafeProxyAgent', () => {
it('should create an EnvHttpProxyAgent with trimmed values and default timeouts', () => {
const proxyUrl = ' http://proxy.example.com ';
const noProxyValue = ' localhost,127.0.0.1 ';
vi.stubEnv('NO_PROXY', noProxyValue);
createSafeProxyAgent(proxyUrl);
expect(EnvHttpProxyAgent).toHaveBeenCalledWith({
httpProxy: 'http://proxy.example.com',
httpsProxy: 'http://proxy.example.com',
noProxy: 'localhost,127.0.0.1',
headersTimeout: 60000,
bodyTimeout: 300000,
});
});
});
});
+26 -8
View File
@@ -6,7 +6,7 @@
import { getErrorMessage, isAbortError } from './errors.js';
import { URL } from 'node:url';
import { Agent, ProxyAgent, setGlobalDispatcher } from 'undici';
import { Agent, EnvHttpProxyAgent, setGlobalDispatcher } from 'undici';
import ipaddr from 'ipaddr.js';
import { lookup } from 'node:dns/promises';
@@ -169,11 +169,21 @@ export async function isPrivateIpAsync(url: string): Promise<boolean> {
}
/**
* Creates an undici ProxyAgent that incorporates safe DNS lookup.
* Creates an undici EnvHttpProxyAgent that incorporates safe DNS lookup.
*/
export function createSafeProxyAgent(proxyUrl: string): ProxyAgent {
return new ProxyAgent({
uri: proxyUrl,
export function createSafeProxyAgent(proxyUrl: string): EnvHttpProxyAgent {
const trimmedProxy = proxyUrl.trim();
const noProxy = (
process.env['NO_PROXY'] ??
process.env['no_proxy'] ??
''
)?.trim();
return new EnvHttpProxyAgent({
httpProxy: trimmedProxy,
httpsProxy: trimmedProxy,
noProxy,
headersTimeout: defaultHeadersTimeout,
bodyTimeout: defaultBodyTimeout,
});
}
@@ -220,10 +230,18 @@ export async function fetchWithTimeout(
}
export function setGlobalProxy(proxy: string) {
currentProxy = proxy;
const trimmedProxy = proxy.trim();
currentProxy = trimmedProxy;
const noProxy = (
process.env['NO_PROXY'] ??
process.env['no_proxy'] ??
''
)?.trim();
setGlobalDispatcher(
new ProxyAgent({
uri: proxy,
new EnvHttpProxyAgent({
httpProxy: trimmedProxy,
httpsProxy: trimmedProxy,
noProxy,
headersTimeout: defaultHeadersTimeout,
bodyTimeout: defaultBodyTimeout,
}),
+107
View File
@@ -413,6 +413,113 @@ describe('readPathFromWorkspace', () => {
).rejects.toThrow('Path not found in workspace: not-found.txt');
});
it('should prevent path traversal outside the workspace via relative paths', async () => {
mock({
[CWD]: {},
[OUTSIDE_DIR]: {
'secret.txt': 'secrets',
},
});
const config = createMockConfig(CWD);
// Attempt to traverse out of CWD to OUTSIDE_DIR
const relativeTraversal = path.join('..', 'outside', 'secret.txt');
await expect(
readPathFromWorkspace(relativeTraversal, config),
).rejects.toThrow(`Path not found in workspace: ${relativeTraversal}`);
});
it('should prevent symlink escape outside the workspace', async () => {
mock({
[CWD]: {
'malicious-link': mock.symlink({
path: path.join(OUTSIDE_DIR, 'secret.txt'),
}),
},
[OUTSIDE_DIR]: {
'secret.txt': 'secrets',
},
});
const config = createMockConfig(CWD);
// Even if the link is in the workspace, its target is not.
await expect(
readPathFromWorkspace('malicious-link', config),
).rejects.toThrow('Path not found in workspace: malicious-link');
});
it('should block symlink escape inside a directory expansion (defense-in-depth)', async () => {
mock({
[CWD]: {
'allowed-dir': {
'legit.txt': 'legit content',
'malicious-link.txt': mock.symlink({
path: path.join(OUTSIDE_DIR, 'secret.txt'),
}),
},
},
[OUTSIDE_DIR]: {
'secret.txt': 'secrets',
},
});
const mockFileService = {
filterFiles: vi.fn((files) => files),
} as unknown as FileDiscoveryService;
const config = createMockConfig(CWD, [], mockFileService);
const result = await readPathFromWorkspace('allowed-dir', config);
const resultText = result
.map((p) => {
if (typeof p === 'string') return p;
if (typeof p === 'object' && p && 'text' in p) return p.text;
return '';
})
.join('');
// Legit content should be there
expect(resultText).toContain('legit content');
// Secret content should NOT be there, but a skip message SHOULD be
expect(resultText).not.toContain('secrets');
expect(resultText).toContain(
'--- Skipped malicious-link.txt: traverses outside workspace ---',
);
});
it('should push multiple skip messages if multiple traversals are found in a directory', async () => {
mock({
[CWD]: {
'bad-dir': {
'link1.txt': mock.symlink({ path: path.join(OUTSIDE_DIR, 's1.txt') }),
'link2.txt': mock.symlink({ path: path.join(OUTSIDE_DIR, 's2.txt') }),
'good.txt': 'good content',
},
},
[OUTSIDE_DIR]: {
's1.txt': 'secret1',
's2.txt': 'secret2',
},
});
const mockFileService = {
filterFiles: vi.fn((files) => files),
} as unknown as FileDiscoveryService;
const config = createMockConfig(CWD, [], mockFileService);
const result = await readPathFromWorkspace('bad-dir', config);
const resultText = result
.map((p) => {
if (typeof p === 'string') return p;
if (typeof p === 'object' && p && 'text' in p) return p.text;
return '';
})
.join('');
expect(resultText).toContain('good content');
expect(resultText).toContain(
'--- Skipped link1.txt: traverses outside workspace ---',
);
expect(resultText).toContain(
'--- Skipped link2.txt: traverses outside workspace ---',
);
expect(resultText).not.toContain('secret1');
expect(resultText).not.toContain('secret2');
});
// mock-fs permission simulation is unreliable on Windows.
it.skipIf(process.platform === 'win32')(
'should return an error string if reading a file with no permissions',
+15
View File
@@ -40,6 +40,12 @@ export async function readPathFromWorkspace(
const searchDirs = workspace.getDirectories();
for (const dir of searchDirs) {
const potentialPath = path.resolve(dir, pathStr);
// Security check: ensure the resolved path is actually within the workspace.
if (!workspace.isPathWithinWorkspace(potentialPath)) {
continue;
}
try {
await fs.access(potentialPath);
absolutePath = potentialPath;
@@ -81,6 +87,15 @@ export async function readPathFromWorkspace(
);
for (const filePath of finalFiles) {
// Defense in depth: validate each file found within the directory.
if (!workspace.isPathWithinWorkspace(filePath)) {
const relativePathForDisplay = path.relative(absolutePath, filePath);
allParts.push({
text: `--- Skipped ${relativePathForDisplay}: traverses outside workspace ---\n\n`,
});
continue;
}
const relativePathForDisplay = path.relative(absolutePath, filePath);
allParts.push({ text: `--- ${relativePathForDisplay} ---\n` });
const result = await processSingleFileContent(
+63
View File
@@ -811,6 +811,24 @@ describe('normalizePath', () => {
expect(isTrustedSystemPath(cwd)).toBe(false);
});
it('should not reject paths if the current working directory is the root directory', () => {
mockPlatform('linux');
const originalCwd = process.cwd;
process.cwd = vi.fn().mockReturnValue('/');
expect(isTrustedSystemPath('/usr/bin/rg')).toBe(true);
process.cwd = originalCwd;
});
it('should not reject paths if the current working directory is a Windows root directory', () => {
mockPlatform('win32');
vi.stubEnv('SystemRoot', 'C:\\Windows');
const originalCwd = process.cwd;
process.cwd = vi.fn().mockReturnValue('C:\\');
expect(isTrustedSystemPath('C:\\Windows\\System32\\rg.exe')).toBe(true);
process.cwd = originalCwd;
vi.unstubAllEnvs();
});
it('should allow trusted paths on Windows', () => {
mockPlatform('win32');
vi.stubEnv('SystemRoot', 'C:\\Windows');
@@ -854,5 +872,50 @@ describe('normalizePath', () => {
expect(isTrustedSystemPath('/tmp/rg')).toBe(false);
expect(isTrustedSystemPath('/Library/rg')).toBe(false);
});
it('should allow 1P internal hermetic execution paths', () => {
mockPlatform('linux');
expect(isTrustedSystemPath('/google/bin/rg')).toBe(true);
expect(
isTrustedSystemPath(
'/google/src/cloud/user/workspace/bazel-out/k8-fastbuild/bin/rg',
),
).toBe(true);
expect(
isTrustedSystemPath(
'/google/src/cloud/user/workspace/blaze-out/k8-opt/bin/rg',
),
).toBe(true);
});
describe('in secure hermetic environments', () => {
const originalCwd = process.cwd;
const cwd = '/sandbox';
beforeEach(() => {
mockPlatform('linux');
process.cwd = vi.fn().mockReturnValue(cwd);
});
afterEach(() => {
process.cwd = originalCwd;
vi.unstubAllEnvs();
});
it('should reject paths in the CWD by default', () => {
expect(isTrustedSystemPath(path.join(cwd, 'bin/rg'))).toBe(false);
});
it.each([
['TEST_SRCDIR', '/mock/runfiles'],
['BAZEL_TEST', '1'],
['TEST_WORKSPACE', 'my_workspace'],
['RUNFILES_DIR', '/mock/runfiles'],
])('should bypass CWD rejection when %s is set', (envVar, value) => {
vi.stubEnv(envVar, value);
expect(isTrustedSystemPath(path.join(cwd, 'bin/rg'))).toBe(true);
});
});
});
});
+11 -1
View File
@@ -527,10 +527,17 @@ export function isTrustedSystemPath(filePath: string): boolean {
// 1. Explicitly reject paths in current working directory to prevent RCE
// Exclude root directories to avoid inadvertently rejecting all system paths.
// Bypass this restriction in secure, hermetic environments (e.g., Bazel/Blaze).
const isHermeticEnv =
!!process.env['TEST_SRCDIR'] ||
!!process.env['TEST_WORKSPACE'] ||
!!process.env['BAZEL_TEST'] ||
!!process.env['RUNFILES_DIR'];
const normCwd = normalizePath(process.cwd());
const isRoot = normCwd === '/' || /^[a-zA-Z]:[\\/]?$/.test(normCwd);
if (!isRoot && isSubpath(normCwd, normPath)) {
return false;
return isHermeticEnv;
}
// 2. Allow standard system directories
@@ -555,6 +562,9 @@ export function isTrustedSystemPath(filePath: string): boolean {
'/usr/local/Cellar',
'/usr/sbin',
'/sbin',
// 1P internal hermetic execution paths
'/google/bin',
'/google/src/cloud',
].map((p) => normalizePath(p));
return trustedPrefixes.some(
@@ -1,497 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import { execSync } from 'node:child_process';
import * as os from 'node:os';
import { detect as chardetDetect } from 'chardet';
import { debugLogger } from './debugLogger.js';
// Mock dependencies
vi.mock('child_process');
vi.mock('os');
vi.mock('chardet');
// Import the functions we want to test after refactoring
import {
getCachedEncodingForBuffer,
getSystemEncoding,
windowsCodePageToEncoding,
detectEncodingFromBuffer,
resetEncodingCache,
} from './systemEncoding.js';
describe('Shell Command Processor - Encoding Functions', () => {
let consoleWarnSpy: ReturnType<typeof vi.spyOn>;
let mockedExecSync: ReturnType<typeof vi.mocked<typeof execSync>>;
let mockedOsPlatform: ReturnType<typeof vi.mocked<() => string>>;
let mockedChardetDetect: ReturnType<typeof vi.mocked<typeof chardetDetect>>;
beforeEach(() => {
consoleWarnSpy = vi.spyOn(debugLogger, 'warn').mockImplementation(() => {});
mockedExecSync = vi.mocked(execSync);
mockedOsPlatform = vi.mocked(os.platform);
mockedChardetDetect = vi.mocked(chardetDetect);
// Reset the encoding cache before each test
resetEncodingCache();
// Clear environment variables that might affect tests
delete process.env['LC_ALL'];
delete process.env['LC_CTYPE'];
delete process.env['LANG'];
});
afterEach(() => {
vi.restoreAllMocks();
resetEncodingCache();
});
describe('windowsCodePageToEncoding', () => {
it('should map common Windows code pages correctly', () => {
expect(windowsCodePageToEncoding(437)).toBe('cp437');
expect(windowsCodePageToEncoding(850)).toBe('cp850');
expect(windowsCodePageToEncoding(65001)).toBe('utf-8');
expect(windowsCodePageToEncoding(1252)).toBe('windows-1252');
expect(windowsCodePageToEncoding(932)).toBe('shift_jis');
expect(windowsCodePageToEncoding(936)).toBe('gb2312');
expect(windowsCodePageToEncoding(949)).toBe('euc-kr');
expect(windowsCodePageToEncoding(950)).toBe('big5');
expect(windowsCodePageToEncoding(1200)).toBe('utf-16le');
expect(windowsCodePageToEncoding(1201)).toBe('utf-16be');
});
it('should return null for unmapped code pages and warn', () => {
expect(windowsCodePageToEncoding(99999)).toBe(null);
expect(consoleWarnSpy).toHaveBeenCalledWith(
'Unable to determine encoding for windows code page 99999.',
);
});
it('should handle all Windows-specific code pages', () => {
expect(windowsCodePageToEncoding(874)).toBe('windows-874');
expect(windowsCodePageToEncoding(1250)).toBe('windows-1250');
expect(windowsCodePageToEncoding(1251)).toBe('windows-1251');
expect(windowsCodePageToEncoding(1253)).toBe('windows-1253');
expect(windowsCodePageToEncoding(1254)).toBe('windows-1254');
expect(windowsCodePageToEncoding(1255)).toBe('windows-1255');
expect(windowsCodePageToEncoding(1256)).toBe('windows-1256');
expect(windowsCodePageToEncoding(1257)).toBe('windows-1257');
expect(windowsCodePageToEncoding(1258)).toBe('windows-1258');
});
});
describe('detectEncodingFromBuffer', () => {
it('should detect encoding using chardet successfully', () => {
const buffer = Buffer.from('test content', 'utf8');
mockedChardetDetect.mockReturnValue('UTF-8');
const result = detectEncodingFromBuffer(buffer);
expect(result).toBe('utf-8');
expect(mockedChardetDetect).toHaveBeenCalledWith(buffer);
});
it('should handle chardet returning mixed case encoding', () => {
const buffer = Buffer.from('test content', 'utf8');
mockedChardetDetect.mockReturnValue('ISO-8859-1');
const result = detectEncodingFromBuffer(buffer);
expect(result).toBe('iso-8859-1');
});
it('should return null when chardet fails', () => {
const buffer = Buffer.from('test content', 'utf8');
mockedChardetDetect.mockImplementation(() => {
throw new Error('Detection failed');
});
const result = detectEncodingFromBuffer(buffer);
expect(result).toBe(null);
expect(consoleWarnSpy).toHaveBeenCalledWith(
'Failed to detect encoding with chardet:',
expect.any(Error),
);
});
it('should return null when chardet returns null', () => {
const buffer = Buffer.from('test content', 'utf8');
mockedChardetDetect.mockReturnValue(null);
const result = detectEncodingFromBuffer(buffer);
expect(result).toBe(null);
});
it('should return null when chardet returns non-string', () => {
const buffer = Buffer.from('test content', 'utf8');
mockedChardetDetect.mockReturnValue([
'utf-8',
'iso-8859-1',
] as unknown as string);
const result = detectEncodingFromBuffer(buffer);
expect(result).toBe(null);
});
});
describe('getSystemEncoding - Windows', () => {
beforeEach(() => {
mockedOsPlatform.mockReturnValue('win32');
});
it('should parse Windows chcp output correctly', () => {
mockedExecSync.mockReturnValue('Active code page: 65001');
const result = getSystemEncoding();
expect(result).toBe('utf-8');
expect(mockedExecSync).toHaveBeenCalledWith('chcp', { encoding: 'utf8' });
});
it('should handle different chcp output formats', () => {
mockedExecSync.mockReturnValue('Current code page: 1252');
const result = getSystemEncoding();
expect(result).toBe('windows-1252');
});
it('should handle chcp output with extra whitespace', () => {
mockedExecSync.mockReturnValue('Active code page: 437 ');
const result = getSystemEncoding();
expect(result).toBe('cp437');
});
it('should return null when chcp command fails', () => {
mockedExecSync.mockImplementation(() => {
throw new Error('Command failed');
});
const result = getSystemEncoding();
expect(result).toBe(null);
expect(consoleWarnSpy).toHaveBeenCalledWith(
expect.stringContaining(
"Failed to get Windows code page using 'chcp' command",
),
);
});
it('should return null when chcp output cannot be parsed', () => {
mockedExecSync.mockReturnValue('Unexpected output format');
const result = getSystemEncoding();
expect(result).toBe(null);
expect(consoleWarnSpy).toHaveBeenCalledWith(
expect.stringContaining(
"Failed to get Windows code page using 'chcp' command",
),
);
});
it('should return null when code page is not a number', () => {
mockedExecSync.mockReturnValue('Active code page: abc');
const result = getSystemEncoding();
expect(result).toBe(null);
expect(consoleWarnSpy).toHaveBeenCalledWith(
expect.stringContaining(
"Failed to get Windows code page using 'chcp' command",
),
);
});
it('should return null when code page maps to null', () => {
mockedExecSync.mockReturnValue('Active code page: 99999');
const result = getSystemEncoding();
expect(result).toBe(null);
// Should warn about unknown code page from windowsCodePageToEncoding
expect(consoleWarnSpy).toHaveBeenCalledWith(
'Unable to determine encoding for windows code page 99999.',
);
});
});
describe('getSystemEncoding - Unix-like', () => {
beforeEach(() => {
mockedOsPlatform.mockReturnValue('linux');
});
it('should parse locale from LC_ALL environment variable', () => {
process.env['LC_ALL'] = 'en_US.UTF-8';
const result = getSystemEncoding();
expect(result).toBe('utf-8');
});
it('should parse locale from LC_CTYPE when LC_ALL is not set', () => {
process.env['LC_CTYPE'] = 'fr_FR.ISO-8859-1';
const result = getSystemEncoding();
expect(result).toBe('iso-8859-1');
});
it('should parse locale from LANG when LC_ALL and LC_CTYPE are not set', () => {
process.env['LANG'] = 'de_DE.UTF-8';
const result = getSystemEncoding();
expect(result).toBe('utf-8');
});
it('should handle locale charmap command when environment variables are empty', () => {
mockedExecSync.mockReturnValue('UTF-8\n');
const result = getSystemEncoding();
expect(result).toBe('utf-8');
expect(mockedExecSync).toHaveBeenCalledWith('locale charmap', {
encoding: 'utf8',
});
});
it('should handle locale charmap with mixed case', () => {
mockedExecSync.mockReturnValue('ISO-8859-1\n');
const result = getSystemEncoding();
expect(result).toBe('iso-8859-1');
});
it('should return null when locale charmap fails', () => {
mockedExecSync.mockImplementation(() => {
throw new Error('Command failed');
});
const result = getSystemEncoding();
expect(result).toBe(null);
expect(consoleWarnSpy).toHaveBeenCalledWith(
'Failed to get locale charmap.',
);
});
it('should handle locale without encoding (no dot)', () => {
process.env['LANG'] = 'C';
const result = getSystemEncoding();
expect(result).toBe('c');
});
it('should handle empty locale environment variables', () => {
process.env['LC_ALL'] = '';
process.env['LC_CTYPE'] = '';
process.env['LANG'] = '';
mockedExecSync.mockReturnValue('UTF-8');
const result = getSystemEncoding();
expect(result).toBe('utf-8');
});
it('should return locale as-is when locale format has no dot', () => {
process.env['LANG'] = 'invalid_format';
const result = getSystemEncoding();
expect(result).toBe('invalid_format');
});
it('should prioritize LC_ALL over other environment variables', () => {
process.env['LC_ALL'] = 'en_US.UTF-8';
process.env['LC_CTYPE'] = 'fr_FR.ISO-8859-1';
process.env['LANG'] = 'de_DE.CP1252';
const result = getSystemEncoding();
expect(result).toBe('utf-8');
});
it('should prioritize LC_CTYPE over LANG', () => {
process.env['LC_CTYPE'] = 'fr_FR.ISO-8859-1';
process.env['LANG'] = 'de_DE.CP1252';
const result = getSystemEncoding();
expect(result).toBe('iso-8859-1');
});
});
describe('getEncodingForBuffer', () => {
beforeEach(() => {
mockedOsPlatform.mockReturnValue('linux');
});
it('should use cached system encoding on subsequent calls', () => {
process.env['LANG'] = 'en_US.UTF-8';
const buffer = Buffer.from('test');
// First call
const result1 = getCachedEncodingForBuffer(buffer);
expect(result1).toBe('utf-8');
// Change environment (should not affect cached result)
process.env['LANG'] = 'fr_FR.ISO-8859-1';
// Second call should use cached value
const result2 = getCachedEncodingForBuffer(buffer);
expect(result2).toBe('utf-8');
});
it('should fall back to buffer detection when system encoding fails', () => {
// No environment variables set
mockedExecSync.mockImplementation(() => {
throw new Error('locale command failed');
});
const buffer = Buffer.from('test');
mockedChardetDetect.mockReturnValue('ISO-8859-1');
const result = getCachedEncodingForBuffer(buffer);
expect(result).toBe('iso-8859-1');
expect(mockedChardetDetect).toHaveBeenCalledWith(buffer);
});
it('should fall back to utf-8 when both system and buffer detection fail', () => {
// System encoding fails
mockedExecSync.mockImplementation(() => {
throw new Error('locale command failed');
});
// Buffer detection fails
mockedChardetDetect.mockImplementation(() => {
throw new Error('chardet failed');
});
const buffer = Buffer.from('test');
const result = getCachedEncodingForBuffer(buffer);
expect(result).toBe('utf-8');
});
it('should not cache buffer detection results', () => {
// System encoding fails initially
mockedExecSync.mockImplementation(() => {
throw new Error('locale command failed');
});
const buffer1 = Buffer.from('test1');
const buffer2 = Buffer.from('test2');
mockedChardetDetect
.mockReturnValueOnce('ISO-8859-1')
.mockReturnValueOnce('UTF-16');
const result1 = getCachedEncodingForBuffer(buffer1);
const result2 = getCachedEncodingForBuffer(buffer2);
expect(result1).toBe('iso-8859-1');
expect(result2).toBe('utf-16');
expect(mockedChardetDetect).toHaveBeenCalledTimes(2);
});
it('should handle Windows system encoding', () => {
mockedOsPlatform.mockReturnValue('win32');
mockedExecSync.mockReturnValue('Active code page: 1252');
const buffer = Buffer.from('test');
const result = getCachedEncodingForBuffer(buffer);
expect(result).toBe('windows-1252');
});
it('should cache null system encoding result', () => {
// Reset the cache specifically for this test
resetEncodingCache();
// Ensure we're on Unix-like for this test
mockedOsPlatform.mockReturnValue('linux');
// System encoding detection returns null
mockedExecSync.mockImplementation(() => {
throw new Error('locale command failed');
});
const buffer1 = Buffer.from('test1');
const buffer2 = Buffer.from('test2');
mockedChardetDetect
.mockReturnValueOnce('ISO-8859-1')
.mockReturnValueOnce('UTF-16');
// Clear any previous calls from beforeEach setup or previous tests
mockedExecSync.mockClear();
const result1 = getCachedEncodingForBuffer(buffer1);
const result2 = getCachedEncodingForBuffer(buffer2);
// Should call execSync only once due to caching (null result is cached)
expect(mockedExecSync).toHaveBeenCalledTimes(1);
expect(result1).toBe('iso-8859-1');
expect(result2).toBe('utf-16');
// Call a third time to verify cache is still used
const buffer3 = Buffer.from('test3');
mockedChardetDetect.mockReturnValueOnce('UTF-32');
const result3 = getCachedEncodingForBuffer(buffer3);
// Still should be only one call to execSync
expect(mockedExecSync).toHaveBeenCalledTimes(1);
expect(result3).toBe('utf-32');
});
});
describe('Cross-platform behavior', () => {
it('should work correctly on macOS', () => {
mockedOsPlatform.mockReturnValue('darwin');
process.env['LANG'] = 'en_US.UTF-8';
const result = getSystemEncoding();
expect(result).toBe('utf-8');
});
it('should work correctly on other Unix-like systems', () => {
mockedOsPlatform.mockReturnValue('freebsd');
process.env['LANG'] = 'en_US.UTF-8';
const result = getSystemEncoding();
expect(result).toBe('utf-8');
});
it('should handle unknown platforms as Unix-like', () => {
mockedOsPlatform.mockReturnValue('unknown' as NodeJS.Platform);
process.env['LANG'] = 'en_US.UTF-8';
const result = getSystemEncoding();
expect(result).toBe('utf-8');
});
});
describe('Edge cases and error handling', () => {
it('should handle empty buffer gracefully', () => {
mockedOsPlatform.mockReturnValue('linux');
process.env['LANG'] = 'en_US.UTF-8';
const buffer = Buffer.alloc(0);
const result = getCachedEncodingForBuffer(buffer);
expect(result).toBe('utf-8');
});
it('should handle very large buffers', () => {
mockedOsPlatform.mockReturnValue('linux');
process.env['LANG'] = 'en_US.UTF-8';
const buffer = Buffer.alloc(1024 * 1024, 'a');
const result = getCachedEncodingForBuffer(buffer);
expect(result).toBe('utf-8');
});
it('should handle Unicode content', () => {
mockedOsPlatform.mockReturnValue('linux');
const unicodeText = '你好世界 🌍 ñoño';
// System encoding fails
mockedExecSync.mockImplementation(() => {
throw new Error('locale command failed');
});
mockedChardetDetect.mockReturnValue('UTF-8');
const buffer = Buffer.from(unicodeText, 'utf8');
const result = getCachedEncodingForBuffer(buffer);
expect(result).toBe('utf-8');
});
});
});
-167
View File
@@ -1,167 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { execSync } from 'node:child_process';
import os from 'node:os';
import { detect as chardetDetect } from 'chardet';
import { debugLogger } from './debugLogger.js';
// Cache for system encoding to avoid repeated detection
// Use undefined to indicate "not yet checked" vs null meaning "checked but failed"
let cachedSystemEncoding: string | null | undefined = undefined;
/**
* Reset the encoding cache - useful for testing
*/
export function resetEncodingCache(): void {
cachedSystemEncoding = undefined;
}
/**
* Returns the system encoding, caching the result to avoid repeated system calls.
* If system encoding detection fails, falls back to detecting from the provided buffer.
* Note: Only the system encoding is cached - buffer-based detection runs for each buffer
* since different buffers may have different encodings.
* @param buffer A buffer to use for detecting encoding if system detection fails.
*/
export function getCachedEncodingForBuffer(buffer: Buffer): string {
// Cache system encoding detection since it's system-wide
if (cachedSystemEncoding === undefined) {
cachedSystemEncoding = getSystemEncoding();
}
// If we have a cached system encoding, use it
if (cachedSystemEncoding) {
return cachedSystemEncoding;
}
// Otherwise, detect from this specific buffer (don't cache this result)
return detectEncodingFromBuffer(buffer) || 'utf-8';
}
/**
* Detects the system encoding based on the platform.
* For Windows, it uses the 'chcp' command to get the current code page.
* For Unix-like systems, it checks environment variables like LC_ALL, LC_CTYPE, and LANG.
* If those are not set, it tries to run 'locale charmap' to get the encoding.
* If detection fails, it returns null.
* @returns The system encoding as a string, or null if detection fails.
*/
export function getSystemEncoding(): string | null {
// Windows
if (os.platform() === 'win32') {
try {
const output = execSync('chcp', { encoding: 'utf8' });
const match = output.match(/:\s*(\d+)/);
if (match) {
const codePage = parseInt(match[1], 10);
if (!isNaN(codePage)) {
return windowsCodePageToEncoding(codePage);
}
}
// Only warn if we can't parse the output format, not if windowsCodePageToEncoding fails
throw new Error(
`Unable to parse Windows code page from 'chcp' output "${output.trim()}". `,
);
} catch (error) {
debugLogger.warn(
`Failed to get Windows code page using 'chcp' command: ${error instanceof Error ? error.message : String(error)}. ` +
`Will attempt to detect encoding from command output instead.`,
);
}
return null;
}
// Unix-like
// Use environment variables LC_ALL, LC_CTYPE, and LANG to determine the
// system encoding. However, these environment variables might not always
// be set or accurate. Handle cases where none of these variables are set.
const env = process.env;
let locale = env['LC_ALL'] || env['LC_CTYPE'] || env['LANG'] || '';
// Fallback to querying the system directly when environment variables are missing
if (!locale) {
try {
locale = execSync('locale charmap', { encoding: 'utf8' })
.toString()
.trim();
} catch {
debugLogger.warn('Failed to get locale charmap.');
return null;
}
}
const match = locale.match(/\.(.+)/); // e.g., "en_US.UTF-8"
if (match && match[1]) {
return match[1].toLowerCase();
}
// Handle cases where locale charmap returns just the encoding name (e.g., "UTF-8")
if (locale && !locale.includes('.')) {
return locale.toLowerCase();
}
return null;
}
/**
* Converts a Windows code page number to a corresponding encoding name.
* @param cp The Windows code page number (e.g., 437, 850, etc.)
* @returns The corresponding encoding name as a string, or null if no mapping exists.
*/
export function windowsCodePageToEncoding(cp: number): string | null {
// Most common mappings; extend as needed
const map: { [key: number]: string } = {
437: 'cp437',
850: 'cp850',
852: 'cp852',
866: 'cp866',
874: 'windows-874',
932: 'shift_jis',
936: 'gb2312',
949: 'euc-kr',
950: 'big5',
1200: 'utf-16le',
1201: 'utf-16be',
1250: 'windows-1250',
1251: 'windows-1251',
1252: 'windows-1252',
1253: 'windows-1253',
1254: 'windows-1254',
1255: 'windows-1255',
1256: 'windows-1256',
1257: 'windows-1257',
1258: 'windows-1258',
65001: 'utf-8',
};
if (map[cp]) {
return map[cp];
}
debugLogger.warn(`Unable to determine encoding for windows code page ${cp}.`);
return null; // Return null if no mapping found
}
/**
* Attempts to detect encoding from a buffer using chardet.
* This is useful when system encoding detection fails.
* Returns the detected encoding in lowercase, or null if detection fails.
* @param buffer The buffer to analyze for encoding.
* @return The detected encoding as a lowercase string, or null if detection fails.
*/
export function detectEncodingFromBuffer(buffer: Buffer): string | null {
try {
const detected = chardetDetect(buffer);
if (detected && typeof detected === 'string') {
return detected.toLowerCase();
}
} catch (error) {
debugLogger.warn('Failed to detect encoding with chardet:', error);
}
return null;
}
+28 -3
View File
@@ -51,9 +51,34 @@
"properties": {
"preferredEditor": {
"title": "Preferred Editor",
"description": "The preferred editor to open files in.",
"markdownDescription": "The preferred editor to open files in.\n\n- Category: `General`\n- Requires restart: `no`",
"type": "string"
"description": "The preferred editor to open files in. Must be one of the built-in supported identifiers. Use /editor in the CLI to pick interactively, or leave unset to use $VISUAL/$EDITOR.",
"markdownDescription": "The preferred editor to open files in. Must be one of the built-in supported identifiers. Use /editor in the CLI to pick interactively, or leave unset to use $VISUAL/$EDITOR.\n\n- Category: `General`\n- Requires restart: `no`",
"type": "string",
"enum": [
"vscode",
"vscodium",
"windsurf",
"cursor",
"zed",
"antigravity",
"sublimetext",
"lapce",
"nova",
"bbedit",
"vim",
"neovim",
"emacs",
"hx",
"emacsclient",
"micro"
]
},
"openEditorInNewWindow": {
"title": "Open Editor in New Window",
"description": "Open VS Code-family editors in a new window when editing files.",
"markdownDescription": "Open VS Code-family editors in a new window when editing files.\n\n- Category: `General`\n- Requires restart: `no`\n- Default: `false`",
"default": false,
"type": "boolean"
},
"vimMode": {
"title": "Vim Mode",
+14 -24
View File
@@ -54,6 +54,18 @@ for (const file of policyFiles) {
console.log(`Copied ${policyFiles.length} policy files to bundle/policies/`);
// Also copy policies to a2a-server dist directory for bundled execution
const a2aPolicyDir = join(root, 'packages/a2a-server/dist/policies');
if (!existsSync(a2aPolicyDir)) {
mkdirSync(a2aPolicyDir, { recursive: true });
}
for (const file of policyFiles) {
copyFileSync(join(root, file), join(a2aPolicyDir, basename(file)));
}
console.log(
`Copied ${policyFiles.length} policy files to packages/a2a-server/dist/policies/`,
);
// 3. Copy Documentation (docs/)
const docsSrc = join(root, 'docs');
const docsDest = join(bundleDir, 'docs');
@@ -73,29 +85,7 @@ if (existsSync(builtinSkillsSrc)) {
console.log('Copied built-in skills to bundle/builtin/');
}
// 5. Copy DevTools package so the external dynamic import resolves at runtime
const devtoolsSrc = join(root, 'packages/devtools');
const devtoolsDest = join(
bundleDir,
'node_modules',
'@google',
'gemini-cli-devtools',
);
const devtoolsDistSrc = join(devtoolsSrc, 'dist');
if (existsSync(devtoolsDistSrc)) {
mkdirSync(devtoolsDest, { recursive: true });
cpSync(devtoolsDistSrc, join(devtoolsDest, 'dist'), {
recursive: true,
dereference: true,
});
copyFileSync(
join(devtoolsSrc, 'package.json'),
join(devtoolsDest, 'package.json'),
);
console.log('Copied devtools package to bundle/node_modules/');
}
// 6. Copy bundled chrome-devtools-mcp
// 5. Copy bundled chrome-devtools-mcp
const bundleMcpSrc = join(root, 'packages/core/dist/bundled');
const bundleMcpDest = join(bundleDir, 'bundled');
if (!existsSync(bundleMcpSrc)) {
@@ -108,7 +98,7 @@ if (!existsSync(bundleMcpSrc)) {
cpSync(bundleMcpSrc, bundleMcpDest, { recursive: true, dereference: true });
console.log('Copied bundled chrome-devtools-mcp to bundle/bundled/');
// 7. Copy Extension Examples
// 6. Copy Extension Examples
const extensionExamplesSrc = join(
root,
'packages/cli/src/commands/extensions/examples',
+80
View File
@@ -0,0 +1,80 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect, it, vi } from 'vitest';
vi.unmock('fs');
vi.unmock('node:fs');
import * as esbuild from 'esbuild';
import path from 'node:path';
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { fileURLToPath, pathToFileURL } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const projectRoot = path.resolve(__dirname, '../../');
describe('proxy-agent bundle shape', () => {
it('preserves named constructors after ESM splitting', async () => {
const tmpDir = mkdtempSync(path.join(tmpdir(), 'gemini-proxy-test-'));
const entryFile = path.join(tmpDir, 'entry.ts');
// Create a minimal entry file that dynamically imports the proxy agents
writeFileSync(
entryFile,
`
export async function getAgents() {
const httpsMod = await import('https-proxy-agent');
const httpMod = await import('http-proxy-agent');
return {
https: httpsMod,
http: httpMod,
};
}
`,
);
// Bundle with the exact same splitting config and aliases as cliConfig
await esbuild.build({
entryPoints: { gemini: entryFile },
outdir: path.join(tmpDir, 'bundle'),
bundle: true,
splitting: true,
format: 'esm',
platform: 'node',
outExtension: { '.js': '.mjs' },
alias: {
'https-proxy-agent': path.resolve(
projectRoot,
'packages/cli/src/patches/https-proxy-agent.ts',
),
'http-proxy-agent': path.resolve(
projectRoot,
'packages/cli/src/patches/http-proxy-agent.ts',
),
},
});
// Import the bundled chunk
const bundledEntryUrl = pathToFileURL(
path.join(tmpDir, 'bundle/gemini.mjs'),
).href;
const { getAgents } = await import(bundledEntryUrl);
const { https, http } = await getAgents();
// Verify named exports exist
expect(typeof https.HttpsProxyAgent).toBe('function');
expect(typeof http.HttpProxyAgent).toBe('function');
// Verify they are constructable
expect(() => new https.HttpsProxyAgent('http://127.0.0.1:9')).not.toThrow();
expect(() => new http.HttpProxyAgent('http://127.0.0.1:9')).not.toThrow();
// Cleanup
rmSync(tmpDir, { recursive: true, force: true });
});
});