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
@@ -198,6 +198,27 @@ vi.mock('../utils/promptIdContext.js', async (importOriginal) => {
};
});
vi.mock('../config/scoped-config.js', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../config/scoped-config.js')>();
return {
...actual,
runWithScopedWorkspaceContext: vi.fn(actual.runWithScopedWorkspaceContext),
createScopedWorkspaceContext: vi.fn(actual.createScopedWorkspaceContext),
};
});
import {
runWithScopedWorkspaceContext,
createScopedWorkspaceContext,
} from '../config/scoped-config.js';
const mockedRunWithScopedWorkspaceContext = vi.mocked(
runWithScopedWorkspaceContext,
);
const mockedCreateScopedWorkspaceContext = vi.mocked(
createScopedWorkspaceContext,
);
const MockedGeminiChat = vi.mocked(GeminiChat);
const mockedGetDirectoryContextString = vi.mocked(getDirectoryContextString);
const mockedPromptIdContext = vi.mocked(promptIdContext);
@@ -396,6 +417,8 @@ describe('LocalAgentExecutor', () => {
);
mockedLogAgentStart.mockReset();
mockedLogAgentFinish.mockReset();
mockedRunWithScopedWorkspaceContext.mockClear();
mockedCreateScopedWorkspaceContext.mockClear();
mockedPromptIdContext.getStore.mockReset();
mockedPromptIdContext.run.mockImplementation((_id, fn) => fn());
@@ -885,6 +908,55 @@ describe('LocalAgentExecutor', () => {
});
});
describe('run (Workspace Scoping)', () => {
it('should use runWithScopedWorkspaceContext when workspaceDirectories is set', async () => {
const definition = createTestDefinition();
definition.workspaceDirectories = ['/tmp/extra-dir'];
const executor = await LocalAgentExecutor.create(
definition,
mockConfig,
onActivity,
);
// Mock a simple complete_task response so run() terminates
mockModelResponse([
{
name: COMPLETE_TASK_TOOL_NAME,
args: { finalResult: 'done' },
id: 'c1',
},
]);
await executor.run({ goal: 'test' }, signal);
expect(mockedCreateScopedWorkspaceContext).toHaveBeenCalledOnce();
expect(mockedRunWithScopedWorkspaceContext).toHaveBeenCalledOnce();
});
it('should not use runWithScopedWorkspaceContext when workspaceDirectories is not set', async () => {
const definition = createTestDefinition();
const executor = await LocalAgentExecutor.create(
definition,
mockConfig,
onActivity,
);
// Mock a simple complete_task response so run() terminates
mockModelResponse([
{
name: COMPLETE_TASK_TOOL_NAME,
args: { finalResult: 'done' },
id: 'c1',
},
]);
await executor.run({ goal: 'test' }, signal);
expect(mockedCreateScopedWorkspaceContext).not.toHaveBeenCalled();
expect(mockedRunWithScopedWorkspaceContext).not.toHaveBeenCalled();
});
});
describe('run (Execution Loop and Logic)', () => {
it('should log AgentFinish with error if run throws', async () => {
const definition = createTestDefinition();
@@ -73,6 +73,10 @@ import {
formatBackgroundCompletionForModel,
} from '../utils/fastAckHelper.js';
import type { InjectionSource } from '../config/injectionService.js';
import {
createScopedWorkspaceContext,
runWithScopedWorkspaceContext,
} from '../config/scoped-config.js';
import { CompleteTaskTool } from '../tools/complete-task.js';
import { COMPLETE_TASK_TOOL_NAME } from '../tools/definitions/base-declarations.js';
@@ -521,6 +525,27 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
* @returns A promise that resolves to the agent's final output.
*/
async run(inputs: AgentInputs, signal: AbortSignal): Promise<OutputObject> {
// If the agent definition declares additional workspace directories,
// wrap execution in a scoped workspace context. All calls to
// Config.getWorkspaceContext() within this scope will see the extended
// directories, without mutating the shared Config.
const dirs = this.definition.workspaceDirectories;
if (dirs && dirs.length > 0) {
const scopedCtx = createScopedWorkspaceContext(
this.context.config.getWorkspaceContext(),
dirs,
);
return runWithScopedWorkspaceContext(scopedCtx, () =>
this.runInternal(inputs, signal),
);
}
return this.runInternal(inputs, signal);
}
private async runInternal(
inputs: AgentInputs,
signal: AbortSignal,
): Promise<OutputObject> {
const startTime = Date.now();
let turnCounter = 0;
let terminateReason: AgentTerminateMode = AgentTerminateMode.ERROR;
@@ -150,4 +150,11 @@ describe('MemoryManagerAgent', () => {
const agent = MemoryManagerAgent(createMockConfig());
expect(agent.modelConfig.model).toBe('flash');
});
it('should declare workspaceDirectories containing the global .gemini directory', () => {
const agent = MemoryManagerAgent(createMockConfig());
const globalGeminiDir = Storage.getGlobalGeminiDir();
expect(agent.workspaceDirectories).toBeDefined();
expect(agent.workspaceDirectories).toContain(globalGeminiDir);
});
});
@@ -131,6 +131,7 @@ reply with what you need, and exit. Do not search the codebase for the missing c
modelConfig: {
model: GEMINI_MODEL_ALIAS_FLASH,
},
workspaceDirectories: [globalGeminiDir],
toolConfig: {
tools: [
READ_FILE_TOOL_NAME,
+3 -13
View File
@@ -281,21 +281,11 @@ export class AgentRegistry {
}
// Register the memory manager agent as a replacement for the save_memory tool.
// The agent declares its own workspaceDirectories (e.g. ~/.gemini) which are
// scoped to its execution via runWithScopedWorkspaceContext in LocalAgentExecutor,
// keeping the main agent's workspace context clean.
if (this.config.isMemoryManagerEnabled()) {
this.registerLocalAgent(MemoryManagerAgent(this.config));
// Ensure the global .gemini directory is accessible to tools.
// This allows the save_memory agent to read and write to it.
// Access control is enforced by the Policy Engine (memory-manager.toml).
try {
const globalDir = Storage.getGlobalGeminiDir();
this.config.getWorkspaceContext().addDirectory(globalDir);
} catch (e) {
debugLogger.warn(
`[AgentRegistry] Could not add global .gemini directory to workspace:`,
e,
);
}
}
}
+12
View File
@@ -215,6 +215,18 @@ export interface LocalAgentDefinition<
// Optional configs
toolConfig?: ToolConfig;
/**
* Optional additional workspace directories scoped to this agent.
* When provided, the agent receives a workspace context that extends
* the parent's with these directories. Other agents and the main
* session are unaffected. If omitted, the parent workspace context
* is inherited unchanged.
*
* Note: Filesystem root paths (e.g. `/` or `C:\`) are rejected at
* runtime to prevent accidentally granting access to the entire filesystem.
*/
workspaceDirectories?: string[];
/**
* Optional inline MCP servers for this agent.
*/