import { spawn, type ChildProcess } from "node:child_process"; import fs from "node:fs"; import { homedir } from "node:os"; import path from "node:path"; import * as readline from "node:readline"; import { Readable, Writable } from "node:stream"; import { fileURLToPath } from "node:url"; import { ClientSideConnection, PROTOCOL_VERSION, ndJsonStream, type RequestPermissionRequest, type RequestPermissionResponse, type SessionNotification, } from "@agentclientprotocol/sdk"; import { isKnownCoreToolId } from "../agents/tool-catalog.js"; import { ensureOpenClawCliOnPath } from "../infra/path-env.js"; import { materializeWindowsSpawnProgram, resolveWindowsSpawnProgram, } from "../plugin-sdk/windows-spawn.js"; import { listKnownProviderAuthEnvVarNames, omitEnvKeysCaseInsensitive, } from "../secrets/provider-env-vars.js"; import { DANGEROUS_ACP_TOOLS } from "../security/dangerous-tools.js"; const SAFE_AUTO_APPROVE_TOOL_IDS = new Set(["read", "search", "web_search", "memory_search"]); const TRUSTED_SAFE_TOOL_ALIASES = new Set(["search"]); const READ_TOOL_PATH_KEYS = ["path", "file_path", "filePath"]; const TOOL_NAME_MAX_LENGTH = 128; const TOOL_NAME_PATTERN = /^[a-z0-9._-]+$/; const TOOL_KIND_BY_ID = new Map([ ["read", "read"], ["search", "search"], ["web_search", "search"], ["memory_search", "search"], ]); type PermissionOption = RequestPermissionRequest["options"][number]; type PermissionResolverDeps = { prompt?: (toolName: string | undefined, toolTitle?: string) => Promise; log?: (line: string) => void; cwd?: string; }; function asRecord(value: unknown): Record | undefined { return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : undefined; } function readFirstStringValue( source: Record | undefined, keys: string[], ): string | undefined { if (!source) { return undefined; } for (const key of keys) { const value = source[key]; if (typeof value === "string" && value.trim()) { return value.trim(); } } return undefined; } function normalizeToolName(value: string): string | undefined { const normalized = value.trim().toLowerCase(); if (!normalized || normalized.length > TOOL_NAME_MAX_LENGTH) { return undefined; } if (!TOOL_NAME_PATTERN.test(normalized)) { return undefined; } return normalized; } function parseToolNameFromTitle(title: string | undefined | null): string | undefined { if (!title) { return undefined; } const head = title.split(":", 1)[0]?.trim(); if (!head) { return undefined; } return normalizeToolName(head); } function resolveToolKindForPermission(toolName: string | undefined): string | undefined { if (!toolName) { return undefined; } return TOOL_KIND_BY_ID.get(toolName) ?? "other"; } function resolveToolNameForPermission(params: RequestPermissionRequest): string | undefined { const toolCall = params.toolCall; const toolMeta = asRecord(toolCall?._meta); const rawInput = asRecord(toolCall?.rawInput); const fromMeta = readFirstStringValue(toolMeta, ["toolName", "tool_name", "name"]); const fromRawInput = readFirstStringValue(rawInput, ["tool", "toolName", "tool_name", "name"]); const fromTitle = parseToolNameFromTitle(toolCall?.title); const metaName = fromMeta ? normalizeToolName(fromMeta) : undefined; const rawInputName = fromRawInput ? normalizeToolName(fromRawInput) : undefined; const titleName = fromTitle; if ((fromMeta && !metaName) || (fromRawInput && !rawInputName)) { return undefined; } if (metaName && titleName && metaName !== titleName) { return undefined; } if (rawInputName && metaName && rawInputName !== metaName) { return undefined; } if (rawInputName && titleName && rawInputName !== titleName) { return undefined; } return metaName ?? titleName ?? rawInputName; } function extractPathFromToolTitle( toolTitle: string | undefined, toolName: string | undefined, ): string | undefined { if (!toolTitle) { return undefined; } const separator = toolTitle.indexOf(":"); if (separator < 0) { return undefined; } const tail = toolTitle.slice(separator + 1).trim(); if (!tail) { return undefined; } const keyedMatch = tail.match(/(?:^|,\s*)(?:path|file_path|filePath)\s*:\s*([^,]+)/); if (keyedMatch?.[1]) { return keyedMatch[1].trim(); } if (toolName === "read") { return tail; } return undefined; } function resolveToolPathCandidate( params: RequestPermissionRequest, toolName: string | undefined, toolTitle: string | undefined, ): string | undefined { const rawInput = asRecord(params.toolCall?.rawInput); const fromRawInput = readFirstStringValue(rawInput, READ_TOOL_PATH_KEYS); const fromTitle = extractPathFromToolTitle(toolTitle, toolName); return fromRawInput ?? fromTitle; } function resolveAbsoluteScopedPath(value: string, cwd: string): string | undefined { let candidate = value.trim(); if (!candidate) { return undefined; } if (candidate.startsWith("file://")) { try { const parsed = new URL(candidate); candidate = decodeURIComponent(parsed.pathname || ""); } catch { return undefined; } } if (candidate === "~") { candidate = homedir(); } else if (candidate.startsWith("~/")) { candidate = path.join(homedir(), candidate.slice(2)); } const absolute = path.isAbsolute(candidate) ? path.normalize(candidate) : path.resolve(cwd, candidate); return absolute; } function isPathWithinRoot(candidatePath: string, root: string): boolean { const relative = path.relative(root, candidatePath); return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); } function isReadToolCallScopedToCwd( params: RequestPermissionRequest, toolName: string | undefined, toolTitle: string | undefined, cwd: string, ): boolean { if (toolName !== "read") { return false; } const rawPath = resolveToolPathCandidate(params, toolName, toolTitle); if (!rawPath) { return false; } const absolutePath = resolveAbsoluteScopedPath(rawPath, cwd); if (!absolutePath) { return false; } return isPathWithinRoot(absolutePath, path.resolve(cwd)); } function shouldAutoApproveToolCall( params: RequestPermissionRequest, toolName: string | undefined, toolTitle: string | undefined, cwd: string, ): boolean { const isTrustedToolId = typeof toolName === "string" && (isKnownCoreToolId(toolName) || TRUSTED_SAFE_TOOL_ALIASES.has(toolName)); if (!toolName || !isTrustedToolId || !SAFE_AUTO_APPROVE_TOOL_IDS.has(toolName)) { return false; } if (toolName === "read") { return isReadToolCallScopedToCwd(params, toolName, toolTitle, cwd); } return true; } 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 { 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 = answer.trim().toLowerCase() === "y"; console.error(`[permission ${approved ? "approved" : "denied"}] ${toolName ?? "unknown"}`); finish(approved); }); }); } export async function resolvePermissionRequest( params: RequestPermissionRequest, deps: PermissionResolverDeps = {}, ): Promise { 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 = params.toolCall?.title ?? "tool"; const toolName = resolveToolNameForPermission(params); const toolKind = resolveToolKindForPermission(toolName); 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 autoApproveAllowed = shouldAutoApproveToolCall(params, toolName, toolTitle, cwd); const promptRequired = !toolName || !autoApproveAllowed || DANGEROUS_ACP_TOOLS.has(toolName); 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 type AcpClientOptions = { cwd?: string; serverCommand?: string; serverArgs?: string[]; serverVerbose?: boolean; verbose?: boolean; }; export type AcpClientHandle = { client: ClientSideConnection; agent: ChildProcess; sessionId: string; }; function toArgs(value: string[] | string | undefined): string[] { if (!value) { return []; } return Array.isArray(value) ? value : [value]; } function buildServerArgs(opts: AcpClientOptions): string[] { const args = ["acp", ...toArgs(opts.serverArgs)]; if (opts.serverVerbose && !args.includes("--verbose") && !args.includes("-v")) { args.push("--verbose"); } return args; } type AcpClientSpawnEnvOptions = { stripKeys?: Iterable; }; 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 = params.serverCommand?.trim(); if (!serverCommand) { return true; } const defaultServerCommand = params.defaultServerCommand?.trim(); 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; }): Set { const stripKeys = new Set(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", allowShellFallback: true, }); 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 { const here = fileURLToPath(import.meta.url); const candidate = path.resolve(path.dirname(here), "..", "entry.js"); if (fs.existsSync(candidate)) { return candidate; } } catch { // ignore } const argv1 = process.argv[1]?.trim(); if (argv1) { return path.isAbsolute(argv1) ? argv1 : path.resolve(process.cwd(), argv1); } return null; } function printSessionUpdate(notification: SessionNotification): void { const update = notification.update; if (!("sessionUpdate" in update)) { return; } switch (update.sessionUpdate) { case "agent_message_chunk": { if (update.content?.type === "text") { process.stdout.write(update.content.text); } return; } case "tool_call": { console.log(`\n[tool] ${update.title} (${update.status})`); return; } case "tool_call_update": { if (update.status) { console.log(`[tool update] ${update.toolCallId}: ${update.status}`); } return; } case "available_commands_update": { const names = update.availableCommands?.map((cmd) => `/${cmd.name}`).join(" "); if (names) { console.log(`\n[commands] ${names}`); } return; } default: return; } } export async function createAcpClient(opts: AcpClientOptions = {}): Promise { const cwd = opts.cwd ?? process.cwd(); const verbose = Boolean(opts.verbose); const log = verbose ? (msg: string) => console.error(`[acp-client] ${msg}`) : () => {}; ensureOpenClawCliOnPath(); const serverArgs = buildServerArgs(opts); const entryPath = resolveSelfEntryPath(); const defaultServerCommand = entryPath ? process.execPath : "openclaw"; const defaultServerArgs = entryPath ? [entryPath, ...serverArgs] : serverArgs; const serverCommand = opts.serverCommand ?? defaultServerCommand; const effectiveArgs = opts.serverCommand || !entryPath ? serverArgs : defaultServerArgs; const { getActiveSkillEnvKeys } = await import("../agents/skills/env-overrides.runtime.js"); const stripProviderAuthEnvVars = shouldStripProviderAuthEnvVarsForAcpServer({ serverCommand, serverArgs: effectiveArgs, defaultServerCommand, defaultServerArgs, }); const stripKeys = buildAcpClientStripKeys({ stripProviderAuthEnvVars, activeSkillEnvKeys: getActiveSkillEnvKeys(), }); const spawnEnv = resolveAcpClientSpawnEnv(process.env, { stripKeys }); const spawnInvocation = resolveAcpClientSpawnInvocation( { serverCommand, serverArgs: effectiveArgs }, { platform: process.platform, env: spawnEnv, execPath: process.execPath, }, ); log(`spawning: ${spawnInvocation.command} ${spawnInvocation.args.join(" ")}`); const agent = spawn(spawnInvocation.command, spawnInvocation.args, { stdio: ["pipe", "pipe", "inherit"], cwd, env: spawnEnv, shell: spawnInvocation.shell, windowsHide: spawnInvocation.windowsHide, }); if (!agent.stdin || !agent.stdout) { throw new Error("Failed to create ACP stdio pipes"); } const input = Writable.toWeb(agent.stdin); const output = Readable.toWeb(agent.stdout) as unknown as ReadableStream; const stream = ndJsonStream(input, output); const client = new ClientSideConnection( () => ({ sessionUpdate: async (params: SessionNotification) => { printSessionUpdate(params); }, requestPermission: async (params: RequestPermissionRequest) => { return resolvePermissionRequest(params, { cwd }); }, }), stream, ); log("initializing"); await client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: { fs: { readTextFile: true, writeTextFile: true }, terminal: true, }, clientInfo: { name: "openclaw-acp-client", version: "1.0.0" }, }); log("creating session"); const session = await client.newSession({ cwd, mcpServers: [], }); return { client, agent, sessionId: session.sessionId, }; } export async function runAcpClientInteractive(opts: AcpClientOptions = {}): Promise { const { client, agent, sessionId } = await createAcpClient(opts); const rl = readline.createInterface({ input: process.stdin, output: process.stdout, }); console.log("OpenClaw ACP client"); console.log(`Session: ${sessionId}`); console.log('Type a prompt, or "exit" to quit.\n'); const prompt = () => { rl.question("> ", async (input) => { const text = input.trim(); if (!text) { prompt(); return; } if (text === "exit" || text === "quit") { agent.kill(); rl.close(); process.exit(0); } try { const response = await client.prompt({ sessionId, prompt: [{ type: "text", text }], }); console.log(`\n[${response.stopReason}]\n`); } catch (err) { console.error(`\n[error] ${String(err)}\n`); } prompt(); }); }; prompt(); agent.on("exit", (code) => { console.log(`\nAgent exited with code ${code ?? 0}`); rl.close(); process.exit(code ?? 0); }); }