perf: split acp client test helpers

This commit is contained in:
Peter Steinberger
2026-04-23 19:25:43 +01:00
parent 5b3e73e099
commit e03b9647ad
3 changed files with 248 additions and 231 deletions

233
src/acp/client-helpers.ts Normal file
View File

@@ -0,0 +1,233 @@
import * as readline from "node:readline";
import type { RequestPermissionRequest, RequestPermissionResponse } from "@agentclientprotocol/sdk";
import {
materializeWindowsSpawnProgram,
resolveWindowsSpawnProgram,
} from "../plugin-sdk/windows-spawn.js";
import {
listKnownProviderAuthEnvVarNames,
omitEnvKeysCaseInsensitive,
} from "../secrets/provider-env-vars.js";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
} from "../shared/string-coerce.js";
import { sanitizeTerminalText } from "../terminal/safe-text.js";
import { classifyAcpToolApproval, type AcpApprovalClass } from "./approval-classifier.js";
type PermissionOption = RequestPermissionRequest["options"][number];
type PermissionResolverDeps = {
prompt?: (toolName: string | undefined, toolTitle?: string) => Promise<boolean>;
log?: (line: string) => void;
cwd?: string;
};
function resolveToolKindForPermission(
toolName: string | undefined,
approvalClass: AcpApprovalClass,
): string | undefined {
if (!toolName && approvalClass === "unknown") {
return undefined;
}
if (approvalClass === "readonly_scoped") {
return "readonly_scoped";
}
if (approvalClass === "readonly_search") {
return "readonly_search";
}
return approvalClass;
}
function pickOption(
options: PermissionOption[],
kinds: PermissionOption["kind"][],
): PermissionOption | undefined {
for (const kind of kinds) {
const match = options.find((option) => option.kind === kind);
if (match) {
return match;
}
}
return undefined;
}
function selectedPermission(optionId: string): RequestPermissionResponse {
return { outcome: { outcome: "selected", optionId } };
}
function cancelledPermission(): RequestPermissionResponse {
return { outcome: { outcome: "cancelled" } };
}
function promptUserPermission(toolName: string | undefined, toolTitle?: string): Promise<boolean> {
if (!process.stdin.isTTY || !process.stderr.isTTY) {
console.error(`[permission denied] ${toolName ?? "unknown"}: non-interactive terminal`);
return Promise.resolve(false);
}
return new Promise((resolve) => {
let settled = false;
const rl = readline.createInterface({
input: process.stdin,
output: process.stderr,
});
const finish = (approved: boolean) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timeout);
rl.close();
resolve(approved);
};
const timeout = setTimeout(() => {
console.error(`\n[permission timeout] denied: ${toolName ?? "unknown"}`);
finish(false);
}, 30_000);
const label = toolTitle
? toolName
? `${toolTitle} (${toolName})`
: toolTitle
: (toolName ?? "unknown tool");
rl.question(`\n[permission] Allow "${label}"? (y/N) `, (answer) => {
const approved = normalizeLowercaseStringOrEmpty(answer) === "y";
console.error(`[permission ${approved ? "approved" : "denied"}] ${toolName ?? "unknown"}`);
finish(approved);
});
});
}
export async function resolvePermissionRequest(
params: RequestPermissionRequest,
deps: PermissionResolverDeps = {},
): Promise<RequestPermissionResponse> {
const log = deps.log ?? ((line: string) => console.error(line));
const prompt = deps.prompt ?? promptUserPermission;
const cwd = deps.cwd ?? process.cwd();
const options = params.options ?? [];
const toolTitle = sanitizeTerminalText(params.toolCall?.title ?? "tool");
const classification = classifyAcpToolApproval({ toolCall: params.toolCall, cwd });
const toolName = classification.toolName;
const toolKind = resolveToolKindForPermission(toolName, classification.approvalClass);
if (options.length === 0) {
log(`[permission cancelled] ${toolName ?? "unknown"}: no options available`);
return cancelledPermission();
}
const allowOption = pickOption(options, ["allow_once", "allow_always"]);
const rejectOption = pickOption(options, ["reject_once", "reject_always"]);
const promptRequired = !classification.autoApprove;
if (!promptRequired) {
const option = allowOption ?? options[0];
if (!option) {
log(`[permission cancelled] ${toolName}: no selectable options`);
return cancelledPermission();
}
log(`[permission auto-approved] ${toolName} (${toolKind ?? "unknown"})`);
return selectedPermission(option.optionId);
}
log(
`\n[permission requested] ${toolTitle}${toolName ? ` (${toolName})` : ""}${toolKind ? ` [${toolKind}]` : ""}`,
);
const approved = await prompt(toolName, toolTitle);
if (approved && allowOption) {
return selectedPermission(allowOption.optionId);
}
if (!approved && rejectOption) {
return selectedPermission(rejectOption.optionId);
}
log(
`[permission cancelled] ${toolName ?? "unknown"}: missing ${approved ? "allow" : "reject"} option`,
);
return cancelledPermission();
}
type AcpClientSpawnEnvOptions = {
stripKeys?: Iterable<string>;
};
export function resolveAcpClientSpawnEnv(
baseEnv: NodeJS.ProcessEnv = process.env,
options: AcpClientSpawnEnvOptions = {},
): NodeJS.ProcessEnv {
const env = omitEnvKeysCaseInsensitive(baseEnv, options.stripKeys ?? []);
env.OPENCLAW_SHELL = "acp-client";
return env;
}
export function shouldStripProviderAuthEnvVarsForAcpServer(
params: {
serverCommand?: string;
serverArgs?: string[];
defaultServerCommand?: string;
defaultServerArgs?: string[];
} = {},
): boolean {
const serverCommand = normalizeOptionalString(params.serverCommand);
if (!serverCommand) {
return true;
}
const defaultServerCommand = normalizeOptionalString(params.defaultServerCommand);
if (!defaultServerCommand || serverCommand !== defaultServerCommand) {
return false;
}
const serverArgs = params.serverArgs ?? [];
const defaultServerArgs = params.defaultServerArgs ?? [];
return (
serverArgs.length === defaultServerArgs.length &&
serverArgs.every((arg, index) => arg === defaultServerArgs[index])
);
}
export function buildAcpClientStripKeys(params: {
stripProviderAuthEnvVars?: boolean;
activeSkillEnvKeys?: Iterable<string>;
}): Set<string> {
const stripKeys = new Set<string>(params.activeSkillEnvKeys ?? []);
if (params.stripProviderAuthEnvVars) {
for (const key of listKnownProviderAuthEnvVarNames()) {
stripKeys.add(key);
}
}
return stripKeys;
}
type AcpSpawnRuntime = {
platform: NodeJS.Platform;
env: NodeJS.ProcessEnv;
execPath: string;
};
const DEFAULT_ACP_SPAWN_RUNTIME: AcpSpawnRuntime = {
platform: process.platform,
env: process.env,
execPath: process.execPath,
};
export function resolveAcpClientSpawnInvocation(
params: { serverCommand: string; serverArgs: string[] },
runtime: AcpSpawnRuntime = DEFAULT_ACP_SPAWN_RUNTIME,
): { command: string; args: string[]; shell?: boolean; windowsHide?: boolean } {
const program = resolveWindowsSpawnProgram({
command: params.serverCommand,
platform: runtime.platform,
env: runtime.env,
execPath: runtime.execPath,
packageName: "openclaw",
});
const resolved = materializeWindowsSpawnProgram(program, params.serverArgs);
return {
command: resolved.command,
args: resolved.argv,
shell: resolved.shell,
windowsHide: resolved.windowsHide,
};
}

View File

@@ -27,7 +27,7 @@ import {
resolveAcpClientSpawnInvocation,
resolvePermissionRequest,
shouldStripProviderAuthEnvVarsForAcpServer,
} from "./client.js";
} from "./client-helpers.js";
import {
extractAttachmentsFromPrompt,
extractTextFromPrompt,

View File

@@ -9,159 +9,25 @@ import {
PROTOCOL_VERSION,
ndJsonStream,
type RequestPermissionRequest,
type RequestPermissionResponse,
type SessionNotification,
} from "@agentclientprotocol/sdk";
import { ensureOpenClawCliOnPath } from "../infra/path-env.js";
import { normalizeOptionalString } from "../shared/string-coerce.js";
import {
materializeWindowsSpawnProgram,
resolveWindowsSpawnProgram,
} from "../plugin-sdk/windows-spawn.js";
import {
listKnownProviderAuthEnvVarNames,
omitEnvKeysCaseInsensitive,
} from "../secrets/provider-env-vars.js";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
} from "../shared/string-coerce.js";
import { sanitizeTerminalText } from "../terminal/safe-text.js";
import { classifyAcpToolApproval, type AcpApprovalClass } from "./approval-classifier.js";
buildAcpClientStripKeys,
resolveAcpClientSpawnEnv,
resolveAcpClientSpawnInvocation,
resolvePermissionRequest,
shouldStripProviderAuthEnvVarsForAcpServer,
} from "./client-helpers.js";
type PermissionOption = RequestPermissionRequest["options"][number];
type PermissionResolverDeps = {
prompt?: (toolName: string | undefined, toolTitle?: string) => Promise<boolean>;
log?: (line: string) => void;
cwd?: string;
};
function resolveToolKindForPermission(
toolName: string | undefined,
approvalClass: AcpApprovalClass,
): string | undefined {
if (!toolName && approvalClass === "unknown") {
return undefined;
}
if (approvalClass === "readonly_scoped") {
return "readonly_scoped";
}
if (approvalClass === "readonly_search") {
return "readonly_search";
}
return approvalClass;
}
function pickOption(
options: PermissionOption[],
kinds: PermissionOption["kind"][],
): PermissionOption | undefined {
for (const kind of kinds) {
const match = options.find((option) => option.kind === kind);
if (match) {
return match;
}
}
return undefined;
}
function selectedPermission(optionId: string): RequestPermissionResponse {
return { outcome: { outcome: "selected", optionId } };
}
function cancelledPermission(): RequestPermissionResponse {
return { outcome: { outcome: "cancelled" } };
}
function promptUserPermission(toolName: string | undefined, toolTitle?: string): Promise<boolean> {
if (!process.stdin.isTTY || !process.stderr.isTTY) {
console.error(`[permission denied] ${toolName ?? "unknown"}: non-interactive terminal`);
return Promise.resolve(false);
}
return new Promise((resolve) => {
let settled = false;
const rl = readline.createInterface({
input: process.stdin,
output: process.stderr,
});
const finish = (approved: boolean) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timeout);
rl.close();
resolve(approved);
};
const timeout = setTimeout(() => {
console.error(`\n[permission timeout] denied: ${toolName ?? "unknown"}`);
finish(false);
}, 30_000);
const label = toolTitle
? toolName
? `${toolTitle} (${toolName})`
: toolTitle
: (toolName ?? "unknown tool");
rl.question(`\n[permission] Allow "${label}"? (y/N) `, (answer) => {
const approved = normalizeLowercaseStringOrEmpty(answer) === "y";
console.error(`[permission ${approved ? "approved" : "denied"}] ${toolName ?? "unknown"}`);
finish(approved);
});
});
}
export async function resolvePermissionRequest(
params: RequestPermissionRequest,
deps: PermissionResolverDeps = {},
): Promise<RequestPermissionResponse> {
const log = deps.log ?? ((line: string) => console.error(line));
const prompt = deps.prompt ?? promptUserPermission;
const cwd = deps.cwd ?? process.cwd();
const options = params.options ?? [];
const toolTitle = sanitizeTerminalText(params.toolCall?.title ?? "tool");
const classification = classifyAcpToolApproval({ toolCall: params.toolCall, cwd });
const toolName = classification.toolName;
const toolKind = resolveToolKindForPermission(toolName, classification.approvalClass);
if (options.length === 0) {
log(`[permission cancelled] ${toolName ?? "unknown"}: no options available`);
return cancelledPermission();
}
const allowOption = pickOption(options, ["allow_once", "allow_always"]);
const rejectOption = pickOption(options, ["reject_once", "reject_always"]);
const promptRequired = !classification.autoApprove;
if (!promptRequired) {
const option = allowOption ?? options[0];
if (!option) {
log(`[permission cancelled] ${toolName}: no selectable options`);
return cancelledPermission();
}
log(`[permission auto-approved] ${toolName} (${toolKind ?? "unknown"})`);
return selectedPermission(option.optionId);
}
log(
`\n[permission requested] ${toolTitle}${toolName ? ` (${toolName})` : ""}${toolKind ? ` [${toolKind}]` : ""}`,
);
const approved = await prompt(toolName, toolTitle);
if (approved && allowOption) {
return selectedPermission(allowOption.optionId);
}
if (!approved && rejectOption) {
return selectedPermission(rejectOption.optionId);
}
log(
`[permission cancelled] ${toolName ?? "unknown"}: missing ${approved ? "allow" : "reject"} option`,
);
return cancelledPermission();
}
export {
buildAcpClientStripKeys,
resolveAcpClientSpawnEnv,
resolveAcpClientSpawnInvocation,
resolvePermissionRequest,
shouldStripProviderAuthEnvVarsForAcpServer,
} from "./client-helpers.js";
export type AcpClientOptions = {
cwd?: string;
@@ -192,88 +58,6 @@ function buildServerArgs(opts: AcpClientOptions): string[] {
return args;
}
type AcpClientSpawnEnvOptions = {
stripKeys?: Iterable<string>;
};
export function resolveAcpClientSpawnEnv(
baseEnv: NodeJS.ProcessEnv = process.env,
options: AcpClientSpawnEnvOptions = {},
): NodeJS.ProcessEnv {
const env = omitEnvKeysCaseInsensitive(baseEnv, options.stripKeys ?? []);
env.OPENCLAW_SHELL = "acp-client";
return env;
}
export function shouldStripProviderAuthEnvVarsForAcpServer(
params: {
serverCommand?: string;
serverArgs?: string[];
defaultServerCommand?: string;
defaultServerArgs?: string[];
} = {},
): boolean {
const serverCommand = normalizeOptionalString(params.serverCommand);
if (!serverCommand) {
return true;
}
const defaultServerCommand = normalizeOptionalString(params.defaultServerCommand);
if (!defaultServerCommand || serverCommand !== defaultServerCommand) {
return false;
}
const serverArgs = params.serverArgs ?? [];
const defaultServerArgs = params.defaultServerArgs ?? [];
return (
serverArgs.length === defaultServerArgs.length &&
serverArgs.every((arg, index) => arg === defaultServerArgs[index])
);
}
export function buildAcpClientStripKeys(params: {
stripProviderAuthEnvVars?: boolean;
activeSkillEnvKeys?: Iterable<string>;
}): Set<string> {
const stripKeys = new Set<string>(params.activeSkillEnvKeys ?? []);
if (params.stripProviderAuthEnvVars) {
for (const key of listKnownProviderAuthEnvVarNames()) {
stripKeys.add(key);
}
}
return stripKeys;
}
type AcpSpawnRuntime = {
platform: NodeJS.Platform;
env: NodeJS.ProcessEnv;
execPath: string;
};
const DEFAULT_ACP_SPAWN_RUNTIME: AcpSpawnRuntime = {
platform: process.platform,
env: process.env,
execPath: process.execPath,
};
export function resolveAcpClientSpawnInvocation(
params: { serverCommand: string; serverArgs: string[] },
runtime: AcpSpawnRuntime = DEFAULT_ACP_SPAWN_RUNTIME,
): { command: string; args: string[]; shell?: boolean; windowsHide?: boolean } {
const program = resolveWindowsSpawnProgram({
command: params.serverCommand,
platform: runtime.platform,
env: runtime.env,
execPath: runtime.execPath,
packageName: "openclaw",
});
const resolved = materializeWindowsSpawnProgram(program, params.serverArgs);
return {
command: resolved.command,
args: resolved.argv,
shell: resolved.shell,
windowsHide: resolved.windowsHide,
};
}
function resolveSelfEntryPath(): string | null {
// Prefer a path relative to the built module location (dist/acp/client.js -> dist/entry.js).
try {