feat(acp): configure generic client capabilities

Closes #2012
This commit is contained in:
Victor Araújo
2026-07-12 15:10:35 -03:00
committed by GitHub
parent cf6c014b6b
commit a9ba0392b7
5 changed files with 169 additions and 11 deletions

View File

@@ -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.

View File

@@ -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<PromptResponse> };

View File

@@ -239,14 +239,19 @@ const BASE_ACP_CLIENT_CAPABILITIES: ACPClientCapabilities = {
export type ACPClientCapabilityMeta = Record<string, unknown>;
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" },
}),
);

View File

@@ -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: {},

View File

@@ -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,