feat(core): scope subagent workspace directories via AsyncLocalStorage (#24445)

This commit is contained in:
Sandy Tao
2026-04-02 09:33:08 -07:00
committed by GitHub
parent e0044f2868
commit 63cc363606
10 changed files with 425 additions and 15 deletions
+2 -1
View File
@@ -132,6 +132,7 @@ import type { GenerateContentParameters } from '@google/genai';
export type { MCPOAuthConfig, AnyToolInvocation, AnyDeclarativeTool };
import type { AnyToolInvocation, AnyDeclarativeTool } from '../tools/tools.js';
import { WorkspaceContext } from '../utils/workspaceContext.js';
import { getWorkspaceContextOverride } from './scoped-config.js';
import { Storage } from './storage.js';
import type { ShellExecutionConfig } from '../services/shellExecutionService.js';
import { FileExclusions } from '../utils/ignorePatterns.js';
@@ -2001,7 +2002,7 @@ export class Config implements McpContext, AgentLoopContext {
}
getWorkspaceContext(): WorkspaceContext {
return this.workspaceContext;
return getWorkspaceContextOverride() ?? this.workspaceContext;
}
getAgentRegistry(): AgentRegistry {
@@ -0,0 +1,206 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import * as os from 'node:os';
import * as fs from 'node:fs';
import * as path from 'node:path';
import {
createScopedWorkspaceContext,
runWithScopedWorkspaceContext,
getWorkspaceContextOverride,
} from './scoped-config.js';
import { Config } from './config.js';
vi.mock('../utils/paths.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../utils/paths.js')>();
return {
...actual,
resolveToRealPath: vi.fn((p) => p),
isSubpath: (parent: string, child: string) => child.startsWith(parent),
};
});
describe('createScopedWorkspaceContext', () => {
let tempDir: string;
let extraDir: string;
let config: Config;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'scoped-config-'));
extraDir = fs.mkdtempSync(path.join(os.tmpdir(), 'scoped-extra-'));
config = new Config({
targetDir: tempDir,
sessionId: 'test-session',
debugMode: false,
cwd: tempDir,
model: 'test-model',
});
});
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
fs.rmSync(extraDir, { recursive: true, force: true });
});
it('should include parent workspace directories', () => {
const scoped = createScopedWorkspaceContext(config.getWorkspaceContext(), [
extraDir,
]);
const dirs = scoped.getDirectories();
expect(dirs).toContain(fs.realpathSync(tempDir));
});
it('should include additional directories', () => {
const scoped = createScopedWorkspaceContext(config.getWorkspaceContext(), [
extraDir,
]);
const dirs = scoped.getDirectories();
expect(dirs).toContain(fs.realpathSync(extraDir));
});
it('should not modify the parent workspace context', () => {
const parentDirsBefore = [...config.getWorkspaceContext().getDirectories()];
createScopedWorkspaceContext(config.getWorkspaceContext(), [extraDir]);
const parentDirsAfter = [...config.getWorkspaceContext().getDirectories()];
expect(parentDirsAfter).toEqual(parentDirsBefore);
expect(parentDirsAfter).not.toContain(fs.realpathSync(extraDir));
});
it('should throw when parent context has no directories', () => {
const emptyCtx = { getDirectories: () => [] } as unknown as ReturnType<
typeof config.getWorkspaceContext
>;
expect(() => createScopedWorkspaceContext(emptyCtx, [extraDir])).toThrow(
'parent has no directories',
);
});
it('should return parent context unchanged when additionalDirectories is empty', () => {
const parentCtx = config.getWorkspaceContext();
const scoped = createScopedWorkspaceContext(parentCtx, []);
expect(scoped).toBe(parentCtx);
});
it('should throw when adding a filesystem root directory', () => {
expect(() =>
createScopedWorkspaceContext(config.getWorkspaceContext(), ['/']),
).toThrow('Cannot add filesystem root');
});
});
describe('runWithScopedWorkspaceContext', () => {
let tempDir: string;
let extraDir: string;
let config: Config;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'scoped-run-'));
extraDir = fs.mkdtempSync(path.join(os.tmpdir(), 'scoped-run-extra-'));
config = new Config({
targetDir: tempDir,
sessionId: 'test-session',
debugMode: false,
cwd: tempDir,
model: 'test-model',
});
});
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
fs.rmSync(extraDir, { recursive: true, force: true });
});
it('should override Config.getWorkspaceContext() within scope', () => {
const scoped = createScopedWorkspaceContext(config.getWorkspaceContext(), [
extraDir,
]);
runWithScopedWorkspaceContext(scoped, () => {
const ctx = config.getWorkspaceContext();
expect(ctx).toBe(scoped);
expect(ctx.getDirectories()).toContain(fs.realpathSync(extraDir));
});
});
it('should not affect Config.getWorkspaceContext() outside scope', () => {
const scoped = createScopedWorkspaceContext(config.getWorkspaceContext(), [
extraDir,
]);
runWithScopedWorkspaceContext(scoped, () => {
// Inside scope — overridden
expect(config.getWorkspaceContext()).toBe(scoped);
});
// Outside scope — original
const ctx = config.getWorkspaceContext();
expect(ctx.getDirectories()).not.toContain(fs.realpathSync(extraDir));
});
it('should allow paths within scoped directories via Config.isPathAllowed()', () => {
const scoped = createScopedWorkspaceContext(config.getWorkspaceContext(), [
extraDir,
]);
// Use realpathSync because WorkspaceContext resolves symlinks internally
const filePath = path.join(fs.realpathSync(extraDir), 'test.md');
// Outside scope — not allowed
expect(config.isPathAllowed(filePath)).toBe(false);
// Inside scope — allowed
runWithScopedWorkspaceContext(scoped, () => {
expect(config.isPathAllowed(filePath)).toBe(true);
});
// After scope — not allowed again
expect(config.isPathAllowed(filePath)).toBe(false);
});
it('should still allow parent workspace paths within scope', () => {
const scoped = createScopedWorkspaceContext(config.getWorkspaceContext(), [
extraDir,
]);
const filePath = path.join(fs.realpathSync(tempDir), 'src/index.ts');
runWithScopedWorkspaceContext(scoped, () => {
expect(config.isPathAllowed(filePath)).toBe(true);
});
});
it('should work with async functions', async () => {
const scoped = createScopedWorkspaceContext(config.getWorkspaceContext(), [
extraDir,
]);
await runWithScopedWorkspaceContext(scoped, async () => {
// Simulate async work
await new Promise((resolve) => setTimeout(resolve, 1));
const ctx = config.getWorkspaceContext();
expect(ctx).toBe(scoped);
});
});
it('should return undefined from getWorkspaceContextOverride outside scope', () => {
expect(getWorkspaceContextOverride()).toBeUndefined();
});
it('should return scoped context from getWorkspaceContextOverride inside scope', () => {
const scoped = createScopedWorkspaceContext(config.getWorkspaceContext(), [
extraDir,
]);
runWithScopedWorkspaceContext(scoped, () => {
expect(getWorkspaceContextOverride()).toBe(scoped);
});
});
});
+86
View File
@@ -0,0 +1,86 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { AsyncLocalStorage } from 'node:async_hooks';
import * as path from 'node:path';
import { WorkspaceContext } from '../utils/workspaceContext.js';
/**
* AsyncLocalStorage for scoped workspace context overrides.
*
* When a subagent declares additional workspace directories, its execution
* runs inside this store. `Config.getWorkspaceContext()` checks this store
* first, allowing per-agent workspace scoping without mutating the shared
* Config instance.
*
* This follows the same pattern as `toolCallContext` and `promptIdContext`.
*/
const workspaceContextOverride = new AsyncLocalStorage<WorkspaceContext>();
/**
* Returns the current workspace context override, if any.
* Called by `Config.getWorkspaceContext()` to check for per-agent scoping.
*/
export function getWorkspaceContextOverride(): WorkspaceContext | undefined {
return workspaceContextOverride.getStore();
}
/**
* Runs a function with a scoped workspace context override.
* Any calls to `Config.getWorkspaceContext()` within `fn` will return
* the scoped context instead of the default.
*
* @param scopedContext The workspace context to use within the scope.
* @param fn The function to run.
* @returns The result of the function.
*/
export function runWithScopedWorkspaceContext<T>(
scopedContext: WorkspaceContext,
fn: () => T,
): T {
return workspaceContextOverride.run(scopedContext, fn);
}
/**
* Creates a {@link WorkspaceContext} that extends a parent's directories
* with additional ones.
*
* @param parentContext The parent workspace context.
* @param additionalDirectories Extra directories to include.
* @returns A new WorkspaceContext with the combined directories.
*/
export function createScopedWorkspaceContext(
parentContext: WorkspaceContext,
additionalDirectories: string[],
): WorkspaceContext {
if (additionalDirectories.length === 0) {
return parentContext;
}
const parentDirs = [...parentContext.getDirectories()];
if (parentDirs.length === 0) {
throw new Error(
'Cannot create scoped workspace context: parent has no directories',
);
}
// Reject overly broad directories (filesystem roots) to prevent
// accidentally granting access to the entire filesystem.
for (const dir of additionalDirectories) {
if (path.resolve(dir) === path.parse(path.resolve(dir)).root) {
throw new Error(
`Cannot add filesystem root "${dir}" as a workspace directory`,
);
}
}
// WorkspaceContext's first constructor argument is the primary targetDir.
// getDirectories() returns targetDir first, so parentDirs[0] is always it.
return new WorkspaceContext(parentDirs[0], [
...parentDirs.slice(1),
...additionalDirectories,
]);
}