diff --git a/docs/custom-providers.md b/docs/custom-providers.md index 05e039c5b..2d11ab171 100644 --- a/docs/custom-providers.md +++ b/docs/custom-providers.md @@ -457,6 +457,38 @@ Paseo tools such as subagent creation come from the shared internal tool catalog } ``` +ACP agents normally receive access to Paseo's filesystem and terminal through +the client capabilities advertised during initialization. For a compliant agent +running in a container or remote environment, disable those capabilities so it +keeps file and command execution in the agent environment: + +```json +{ + "agents": { + "providers": { + "container-agent": { + "extends": "acp", + "label": "Container Agent", + "command": ["container-agent", "acp"], + "params": { + "clientCapabilities": { + "fs": { + "readTextFile": false, + "writeTextFile": false + }, + "terminal": false + } + } + } + } + } +} +``` + +The defaults remain enabled for compatibility with local ACP agents. Configure +both environments with equivalent absolute workspace paths before enabling +client filesystem or terminal delegation across a process boundary. + ### Generic ACP diagnostics Paseo diagnostics for `extends: "acp"` providers report the configured command, resolved launcher binary, version output, ACP `initialize`, ACP `session/new`, model count, modes, and final status. diff --git a/packages/server/src/server/agent/providers/acp-agent.test.ts b/packages/server/src/server/agent/providers/acp-agent.test.ts index 124101dea..21152ac4b 100644 --- a/packages/server/src/server/agent/providers/acp-agent.test.ts +++ b/packages/server/src/server/agent/providers/acp-agent.test.ts @@ -20,6 +20,7 @@ import { ACPAgentSession, type SpawnedACPProcess, type SessionStateResponse, + buildACPClientCapabilities, createLoggedNdJsonStream, deriveModelDefinitionsFromACP, deriveModesFromACP, @@ -50,6 +51,39 @@ import { buildStringCommandShellInvocation } from "../../../utils/string-command import { asInternals } from "../../test-utils/class-mocks.js"; import * as spawnUtils from "../../../utils/spawn.js"; +describe("buildACPClientCapabilities", () => { + test("preserves the default client filesystem and terminal capabilities", () => { + expect(buildACPClientCapabilities()).toEqual({ + fs: { + readTextFile: true, + writeTextFile: true, + }, + terminal: true, + }); + }); + + test("applies provider capability overrides without dropping metadata", () => { + expect( + buildACPClientCapabilities( + { source: "provider" }, + { + fs: { + readTextFile: false, + }, + terminal: false, + }, + ), + ).toEqual({ + fs: { + readTextFile: false, + writeTextFile: true, + }, + terminal: false, + _meta: { source: "provider" }, + }); + }); +}); + interface ACPSessionInternals { sessionId: string | null; connection: { prompt: (...args: unknown[]) => Promise }; diff --git a/packages/server/src/server/agent/providers/acp-agent.ts b/packages/server/src/server/agent/providers/acp-agent.ts index 5e7e3c99e..23ce328b1 100644 --- a/packages/server/src/server/agent/providers/acp-agent.ts +++ b/packages/server/src/server/agent/providers/acp-agent.ts @@ -239,14 +239,19 @@ const BASE_ACP_CLIENT_CAPABILITIES: ACPClientCapabilities = { export type ACPClientCapabilityMeta = Record; -export function buildACPClientCapabilities(meta?: ACPClientCapabilityMeta): ACPClientCapabilities { - if (!meta || Object.keys(meta).length === 0) { - return BASE_ACP_CLIENT_CAPABILITIES; - } - return { +export function buildACPClientCapabilities( + meta?: ACPClientCapabilityMeta, + override?: ACPClientCapabilities, +): ACPClientCapabilities { + const capabilities: ACPClientCapabilities = { ...BASE_ACP_CLIENT_CAPABILITIES, - _meta: meta, + ...override, + fs: { + ...BASE_ACP_CLIENT_CAPABILITIES.fs, + ...override?.fs, + }, }; + return meta && Object.keys(meta).length > 0 ? { ...capabilities, _meta: meta } : capabilities; } // Suppress interactive auth side-effects (e.g. Gemini CLI opening a Google @@ -371,6 +376,7 @@ interface ACPAgentClientOptions { sessionResponseTransformer?: (response: SessionStateResponse) => SessionStateResponse; configOptionsTransformer?: (configOptions: SessionConfigOption[]) => SessionConfigOption[]; configFeatureOptions?: ACPConfigFeatureOption[]; + clientCapabilities?: ACPClientCapabilities; clientCapabilityMeta?: ACPClientCapabilityMeta; modeIdTransformer?: (modeId: string) => string | null; toolSnapshotTransformer?: (snapshot: ACPToolSnapshot) => ACPToolSnapshot; @@ -400,6 +406,7 @@ interface ACPAgentSessionOptions { sessionResponseTransformer?: (response: SessionStateResponse) => SessionStateResponse; configOptionsTransformer?: (configOptions: SessionConfigOption[]) => SessionConfigOption[]; configFeatureOptions?: ACPConfigFeatureOption[]; + clientCapabilities?: ACPClientCapabilities; clientCapabilityMeta?: ACPClientCapabilityMeta; modeIdTransformer?: (modeId: string) => string | null; toolSnapshotTransformer?: (snapshot: ACPToolSnapshot) => ACPToolSnapshot; @@ -708,6 +715,7 @@ export class ACPAgentClient implements AgentClient { configOptions: SessionConfigOption[], ) => SessionConfigOption[]; private readonly configFeatureOptions: ACPConfigFeatureOption[]; + private readonly clientCapabilities?: ACPClientCapabilities; private readonly clientCapabilityMeta?: ACPClientCapabilityMeta; private readonly modeIdTransformer?: (modeId: string) => string | null; private readonly toolSnapshotTransformer?: (snapshot: ACPToolSnapshot) => ACPToolSnapshot; @@ -742,6 +750,7 @@ export class ACPAgentClient implements AgentClient { this.sessionResponseTransformer = options.sessionResponseTransformer; this.configOptionsTransformer = options.configOptionsTransformer; this.configFeatureOptions = options.configFeatureOptions ?? []; + this.clientCapabilities = options.clientCapabilities; this.clientCapabilityMeta = options.clientCapabilityMeta; this.modeIdTransformer = options.modeIdTransformer; this.toolSnapshotTransformer = options.toolSnapshotTransformer; @@ -770,6 +779,7 @@ export class ACPAgentClient implements AgentClient { sessionResponseTransformer: this.sessionResponseTransformer, configOptionsTransformer: this.configOptionsTransformer, configFeatureOptions: this.configFeatureOptions, + clientCapabilities: this.clientCapabilities, clientCapabilityMeta: this.clientCapabilityMeta, modeIdTransformer: this.modeIdTransformer, toolSnapshotTransformer: this.toolSnapshotTransformer, @@ -819,6 +829,7 @@ export class ACPAgentClient implements AgentClient { sessionResponseTransformer: this.sessionResponseTransformer, configOptionsTransformer: this.configOptionsTransformer, configFeatureOptions: this.configFeatureOptions, + clientCapabilities: this.clientCapabilities, clientCapabilityMeta: this.clientCapabilityMeta, modeIdTransformer: this.modeIdTransformer, toolSnapshotTransformer: this.toolSnapshotTransformer, @@ -1057,7 +1068,10 @@ export class ACPAgentClient implements AgentClient { Promise.race([ transport.connection.initialize({ protocolVersion: PROTOCOL_VERSION, - clientCapabilities: buildACPClientCapabilities(this.clientCapabilityMeta), + clientCapabilities: buildACPClientCapabilities( + this.clientCapabilityMeta, + this.clientCapabilities, + ), clientInfo: { name: "Paseo", version: "dev" }, }), transport.spawnError, @@ -1269,6 +1283,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { configOptions: SessionConfigOption[], ) => SessionConfigOption[]; private readonly configFeatureOptions: ACPConfigFeatureOption[]; + private readonly clientCapabilities?: ACPClientCapabilities; private readonly clientCapabilityMeta?: ACPClientCapabilityMeta; private readonly modeIdTransformer?: (modeId: string) => string | null; private readonly toolSnapshotTransformer?: (snapshot: ACPToolSnapshot) => ACPToolSnapshot; @@ -1334,6 +1349,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { this.sessionResponseTransformer = options.sessionResponseTransformer; this.configOptionsTransformer = options.configOptionsTransformer; this.configFeatureOptions = options.configFeatureOptions ?? []; + this.clientCapabilities = options.clientCapabilities; this.clientCapabilityMeta = options.clientCapabilityMeta; this.modeIdTransformer = options.modeIdTransformer; this.toolSnapshotTransformer = options.toolSnapshotTransformer; @@ -2320,7 +2336,10 @@ export class ACPAgentSession implements AgentSession, ACPClient { const initialize = await this.runACPRequest(() => connection.initialize({ protocolVersion: PROTOCOL_VERSION, - clientCapabilities: buildACPClientCapabilities(this.clientCapabilityMeta), + clientCapabilities: buildACPClientCapabilities( + this.clientCapabilityMeta, + this.clientCapabilities, + ), clientInfo: { name: "Paseo", version: "dev" }, }), ); diff --git a/packages/server/src/server/agent/providers/generic-acp-agent.diagnostic.test.ts b/packages/server/src/server/agent/providers/generic-acp-agent.diagnostic.test.ts index f29b84431..4b9749030 100644 --- a/packages/server/src/server/agent/providers/generic-acp-agent.diagnostic.test.ts +++ b/packages/server/src/server/agent/providers/generic-acp-agent.diagnostic.test.ts @@ -9,6 +9,13 @@ import { buildVersionProbeCommand, GenericACPAgentClient } from "./generic-acp-a const TEST_ACP_TIMEOUT_MS = 1_000; +function parseInitializeTrace(content: string): Array<{ clientCapabilities: unknown }> { + return content + .trim() + .split("\n") + .map((line) => JSON.parse(line) as { clientCapabilities: unknown }); +} + describe("GenericACPAgentClient diagnostics", () => { test("probes npx-backed agent packages instead of npx itself", () => { expect(buildVersionProbeCommand(["npx", "-y", "@google/gemini-cli@0.41.1", "--acp"])).toEqual({ @@ -92,6 +99,53 @@ describe("GenericACPAgentClient diagnostics", () => { }); }); + test("sends configured client capabilities in catalog and live session initialization", async () => { + await withFakeACPAgent("success", async (scriptPath, mode, testDir) => { + const initializeTracePath = path.join(testDir, "initialize.jsonl"); + const client = new GenericACPAgentClient({ + logger: createTestLogger(), + command: [process.execPath, scriptPath, mode, "", initializeTracePath], + providerParams: { + clientCapabilities: { + fs: { + readTextFile: false, + writeTextFile: false, + }, + terminal: false, + }, + }, + }); + + await client.fetchCatalog({ cwd: testDir, force: true, timeoutMs: TEST_ACP_TIMEOUT_MS }); + const session = await client.createSession({ provider: "acp", cwd: testDir }); + await session.close(); + + const initializeRequests = parseInitializeTrace(await readFile(initializeTracePath, "utf8")); + + expect(initializeRequests).toHaveLength(2); + expect(initializeRequests).toEqual([ + { + clientCapabilities: { + fs: { + readTextFile: false, + writeTextFile: false, + }, + terminal: false, + }, + }, + { + clientCapabilities: { + fs: { + readTextFile: false, + writeTextFile: false, + }, + terminal: false, + }, + }, + ]); + }); + }); + test("reports a missing launcher without dropping the rest of the diagnostic", async () => { await withTempDir("paseo-missing-acp-agent-", async (testDir) => { const missingCommand = path.join(testDir, "missing-acp-agent"); @@ -167,6 +221,7 @@ const readline = require("node:readline"); const mode = process.argv[2]; const pidPath = process.argv[3]; +const initializeTracePath = process.argv[4]; if (pidPath) { fs.writeFileSync(pidPath, String(process.pid)); } @@ -179,6 +234,12 @@ function send(id, result) { rl.on("line", (line) => { const message = JSON.parse(line); if (message.method === "initialize") { + if (initializeTracePath) { + fs.appendFileSync( + initializeTracePath, + JSON.stringify({ clientCapabilities: message.params?.clientCapabilities }) + "\\n", + ); + } send(message.id, { protocolVersion: message.params?.protocolVersion ?? 1, agentCapabilities: {}, diff --git a/packages/server/src/server/agent/providers/generic-acp-agent.ts b/packages/server/src/server/agent/providers/generic-acp-agent.ts index c1a0419b2..72f29faa1 100644 --- a/packages/server/src/server/agent/providers/generic-acp-agent.ts +++ b/packages/server/src/server/agent/providers/generic-acp-agent.ts @@ -20,6 +20,17 @@ import { export const GenericACPProviderParamsSchema = z .object({ supportsMcpServers: z.boolean().optional(), + clientCapabilities: z + .object({ + fs: z + .object({ + readTextFile: z.boolean().optional(), + writeTextFile: z.boolean().optional(), + }) + .optional(), + terminal: z.boolean().optional(), + }) + .optional(), }) .passthrough(); @@ -47,6 +58,7 @@ export class GenericACPAgentClient extends ACPAgentClient { private readonly diagnosticPhaseTimeoutMs?: number; constructor(options: GenericACPAgentClientOptions) { + const providerParams = parseGenericACPProviderParams(options.providerParams); super({ provider: "acp", logger: options.logger, @@ -54,9 +66,10 @@ export class GenericACPAgentClient extends ACPAgentClient { env: options.env, }, defaultCommand: options.command, - capabilities: buildGenericACPCapabilities(options), + capabilities: buildGenericACPCapabilities(providerParams), waitForInitialCommands: options.waitForInitialCommands, initialCommandsWaitTimeoutMs: options.initialCommandsWaitTimeoutMs, + clientCapabilities: providerParams.clientCapabilities, clientCapabilityMeta: options.clientCapabilityMeta, configFeatureOptions: options.configFeatureOptions, extensionCommandsParser: options.extensionCommandsParser, @@ -145,8 +158,7 @@ export class GenericACPAgentClient extends ACPAgentClient { } } -function buildGenericACPCapabilities(options: GenericACPAgentClientOptions): AgentCapabilityFlags { - const params = parseGenericACPProviderParams(options.providerParams); +function buildGenericACPCapabilities(params: GenericACPProviderParams): AgentCapabilityFlags { return { ...DEFAULT_ACP_CAPABILITIES, supportsMcpServers: params.supportsMcpServers ?? DEFAULT_ACP_CAPABILITIES.supportsMcpServers,