openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
525 lines (524 loc) • 18.7 kB
JavaScript
import { a as asOptionalRecord } from "./record-coerce-DItp3I4t.js";
import { l as normalizeOptionalString, o as normalizeLowercaseStringOrEmpty } from "./string-coerce-CIXf7egm.js";
import { n as signalProcessTree, t as killProcessTree } from "./kill-tree-CR2oLt9D.js";
import { r as isPathInside } from "./path-guards-Cp-mGr3-.js";
import { n as readTrimmedStringAlias } from "./string-readers-e58-jh1A.js";
import { n as sanitizeTerminalText } from "./safe-text-BGBqp1a4.js";
import { r as isKnownCoreToolId } from "./tool-catalog-79RBtNnN.js";
import { i as omitEnvKeysCaseInsensitive, n as listKnownProviderAuthEnvVarNames } from "./provider-env-vars-B8XgJCSL.js";
import { o as trySafeFileURLToPath } from "./local-file-access-B1HKoQAc.js";
import { a as resolveWindowsSpawnProgram, r as materializeWindowsSpawnProgram } from "./windows-spawn-oyE-2b7Z.js";
import { n as isMutatingToolCall } from "./tool-mutation-7Z6dWuA8.js";
import { t as ensureOpenClawCliOnPath } from "./path-env-6L3Z8lTZ.js";
import fs from "node:fs";
import { fileURLToPath } from "node:url";
import path from "node:path";
import { spawn } from "node:child_process";
import { homedir } from "node:os";
import { Readable, Writable } from "node:stream";
import { setTimeout as setTimeout$1 } from "node:timers/promises";
import * as readline$1 from "node:readline";
import { ClientSideConnection, PROTOCOL_VERSION, ndJsonStream } from "@agentclientprotocol/sdk";
//#region src/acp/approval-classifier.ts
/** Classifies ACP tool permission requests into auto-approved and prompt-required risk buckets. */
const SAFE_SEARCH_TOOL_IDS = /* @__PURE__ */ new Set([
"search",
"web_search",
"memory_search"
]);
const TRUSTED_SAFE_TOOL_ALIASES = /* @__PURE__ */ new Set(["search"]);
const EXEC_CAPABLE_TOOL_IDS = /* @__PURE__ */ new Set([
"exec",
"spawn",
"shell",
"bash",
"process",
"code_execution",
"nodes"
]);
const CONTROL_PLANE_TOOL_IDS = /* @__PURE__ */ new Set([
"cron",
"gateway",
"sessions_spawn",
"sessions_send",
"session_status"
]);
function readFirstStringValue(source, keys) {
if (!source) return;
return readTrimmedStringAlias(source, keys);
}
function normalizeToolPolicyName(value) {
const normalized = normalizeLowercaseStringOrEmpty(value);
if (!normalized || normalized.length > 128) return;
return /^[a-z0-9._-]+$/.test(normalized) ? normalized : void 0;
}
function parseToolNameFromTitle(title) {
if (!title) return;
const head = normalizeOptionalString(title.split(":", 1)[0]);
return head ? normalizeToolPolicyName(head) : void 0;
}
function resolveToolNameForPermission(params) {
const toolCall = params.toolCall;
const toolMeta = asOptionalRecord(toolCall?.["_meta"]);
const rawInput = asOptionalRecord(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 ? normalizeToolPolicyName(fromMeta) : void 0;
const rawInputName = fromRawInput ? normalizeToolPolicyName(fromRawInput) : void 0;
const titleName = fromTitle;
if (fromMeta && !metaName || fromRawInput && !rawInputName) return;
if (metaName && titleName && metaName !== titleName) return;
if (rawInputName && metaName && rawInputName !== metaName) return;
if (rawInputName && titleName && rawInputName !== titleName) return;
return metaName ?? titleName ?? rawInputName;
}
function extractPathFromToolTitle(toolTitle, toolName) {
if (!toolTitle) return;
const separator = toolTitle.indexOf(":");
if (separator < 0) return;
const tail = toolTitle.slice(separator + 1).trim();
if (!tail) return;
const keyedMatch = toolName === "read" ? tail.match(/(?:^|,\s*)(?:path|file_path|filePath)\s*:\s*([^,]+)/) : tail.match(/^(?:path|file_path|filePath)\s*:\s*([^,]+)/);
if (keyedMatch?.[1]) return keyedMatch[1].trim();
return toolName === "read" ? tail : void 0;
}
function readLocationPaths(locations) {
if (!Array.isArray(locations)) return [];
const paths = [];
for (const location of locations) {
const pathValue = readFirstStringValue(asOptionalRecord(location), [
"path",
"file_path",
"filePath"
]);
if (pathValue) paths.push(pathValue);
}
return paths;
}
function resolveToolPathCandidates(params) {
return [
readFirstStringValue(asOptionalRecord(params.toolCall?.rawInput), [
"path",
"file_path",
"filePath"
]),
extractPathFromToolTitle(params.toolTitle, params.toolName),
...params.includeLocations ? readLocationPaths(params.toolCall?.locations) : []
].filter((value) => value !== void 0);
}
function resolveAbsoluteScopedPath(value, cwd) {
let candidate = value.trim();
if (!candidate) return;
if (/^file:/i.test(candidate)) {
candidate = trySafeFileURLToPath(candidate) ?? "";
if (!candidate) return;
}
if (candidate === "~") candidate = homedir();
else if (candidate.startsWith("~/")) candidate = path.join(homedir(), candidate.slice(2));
return path.isAbsolute(candidate) ? path.normalize(candidate) : path.resolve(cwd, candidate);
}
function isToolPathScopedToCwd(rawPath, cwd) {
const absolutePath = resolveAbsoluteScopedPath(rawPath, cwd);
if (!absolutePath) return false;
return isPathInside(path.resolve(cwd), absolutePath);
}
/** Resolves the ACP approval class for one tool call, failing closed on spoofed tool identity. */
function classifyAcpToolApproval(params) {
const toolName = resolveToolNameForPermission(params);
if (!toolName) return {
toolName: void 0,
approvalClass: "unknown",
autoApprove: false
};
const isTrustedToolId = isKnownCoreToolId(toolName) || TRUSTED_SAFE_TOOL_ALIASES.has(toolName);
if (toolName === "read" && isTrustedToolId) {
const rawPaths = resolveToolPathCandidates({
includeLocations: false,
toolCall: params.toolCall,
toolName,
toolTitle: params.toolCall?.title ?? void 0
});
const autoApprove = rawPaths.length > 0 && rawPaths.every((rawPath) => isToolPathScopedToCwd(rawPath, params.cwd));
return {
toolName,
approvalClass: autoApprove ? "readonly_scoped" : "other",
autoApprove
};
}
if (SAFE_SEARCH_TOOL_IDS.has(toolName) && isTrustedToolId) {
if (resolveToolPathCandidates({
includeLocations: true,
toolCall: params.toolCall,
toolName,
toolTitle: params.toolCall?.title ?? void 0
}).some((rawPath) => !isToolPathScopedToCwd(rawPath, params.cwd))) return {
toolName,
approvalClass: "other",
autoApprove: false
};
return {
toolName,
approvalClass: "readonly_search",
autoApprove: true
};
}
if (EXEC_CAPABLE_TOOL_IDS.has(toolName)) return {
toolName,
approvalClass: "exec_capable",
autoApprove: false
};
if (CONTROL_PLANE_TOOL_IDS.has(toolName)) return {
toolName,
approvalClass: "control_plane",
autoApprove: false
};
if (isMutatingToolCall(toolName, params.toolCall?.rawInput)) return {
toolName,
approvalClass: "mutating",
autoApprove: false
};
return {
toolName,
approvalClass: "other",
autoApprove: false
};
}
//#endregion
//#region src/acp/client-helpers.ts
/** Permission, environment, and spawn helpers for the standalone ACP client. */
function resolveToolKindForPermission(toolName, approvalClass) {
if (!toolName && approvalClass === "unknown") return;
if (approvalClass === "readonly_scoped") return "readonly_scoped";
if (approvalClass === "readonly_search") return "readonly_search";
return approvalClass;
}
function pickOption(options, kinds) {
for (const kind of kinds) {
const match = options.find((option) => option.kind === kind);
if (match) return match;
}
}
function selectedPermission(optionId) {
return { outcome: {
outcome: "selected",
optionId
} };
}
function cancelledPermission() {
return { outcome: { outcome: "cancelled" } };
}
function promptUserPermission(toolName, toolTitle) {
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$1.createInterface({
input: process.stdin,
output: process.stderr
});
const finish = (approved) => {
if (settled) return;
settled = true;
clearTimeout(timeout);
rl.close();
resolve(approved);
};
const timeout = setTimeout(() => {
console.error(`\n[permission timeout] denied: ${toolName ?? "unknown"}`);
finish(false);
}, 3e4);
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);
});
});
}
/** Converts an ACP permission request into a selected allow/reject option or cancellation. */
async function resolvePermissionRequest(params, deps = {}) {
const log = deps.log ?? ((line) => 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"]);
if (!!classification.autoApprove) {
if (!allowOption) {
log(`[permission cancelled] ${toolName ?? "unknown"}: missing allow option`);
return cancelledPermission();
}
log(`[permission auto-approved] ${toolName} (${toolKind ?? "unknown"})`);
return selectedPermission(allowOption.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();
}
/** Builds the sanitized environment used when spawning an ACP client process. */
function resolveAcpClientSpawnEnv(baseEnv = process.env, options = {}) {
const env = omitEnvKeysCaseInsensitive(baseEnv, options.stripKeys ?? []);
env.OPENCLAW_SHELL = "acp-client";
return env;
}
/** Returns true when the client should hide provider credentials from the spawned server. */
function shouldStripProviderAuthEnvVarsForAcpServer(params = {}) {
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]);
}
/** Builds the exact environment variable denylist used for ACP client subprocesses. */
function buildAcpClientStripKeys(params) {
const stripKeys = new Set(params.activeSkillEnvKeys ?? []);
if (params.stripProviderAuthEnvVars) for (const key of listKnownProviderAuthEnvVarNames()) stripKeys.add(key);
return stripKeys;
}
const DEFAULT_ACP_SPAWN_RUNTIME = {
platform: process.platform,
env: process.env,
execPath: process.execPath
};
/** Resolves the executable/args used to spawn an ACP server, including Windows shims. */
function resolveAcpClientSpawnInvocation(params, runtime = DEFAULT_ACP_SPAWN_RUNTIME) {
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
};
}
//#endregion
//#region src/acp/client.ts
/** Interactive stdio ACP client used to connect a terminal session to an OpenClaw ACP server. */
const ACP_SERVER_KILL_GRACE_MS = 1e3;
const ACP_SERVER_FORCE_KILL_TIMEOUT_MS = 1e3;
const ACP_SERVER_EXIT_POLL_MS = 25;
function hasChildExited(child) {
return child.exitCode !== null || child.signalCode !== null;
}
async function waitForChildExit(child, timeoutMs) {
const deadline = Date.now() + timeoutMs;
while (!hasChildExited(child) && Date.now() < deadline) await setTimeout$1(ACP_SERVER_EXIT_POLL_MS);
return hasChildExited(child);
}
async function terminateAcpServer(child) {
if (hasChildExited(child)) return;
if (child.pid) killProcessTree(child.pid, {
detached: false,
graceMs: ACP_SERVER_KILL_GRACE_MS
});
else child.kill("SIGTERM");
if (await waitForChildExit(child, 2e3)) return;
if (child.pid) signalProcessTree(child.pid, "SIGKILL", { detached: false });
else child.kill("SIGKILL");
await waitForChildExit(child, ACP_SERVER_FORCE_KILL_TIMEOUT_MS);
}
function toArgs(value) {
if (!value) return [];
return Array.isArray(value) ? value : [value];
}
function buildServerArgs(opts) {
const args = ["acp", ...toArgs(opts.serverArgs)];
if (opts.serverVerbose && !args.includes("--verbose") && !args.includes("-v")) args.push("--verbose");
return args;
}
function resolveSelfEntryPath() {
try {
const here = fileURLToPath(import.meta.url);
const candidate = path.resolve(path.dirname(here), "..", "entry.js");
if (fs.existsSync(candidate)) return candidate;
} catch {}
const argv1 = normalizeOptionalString(process.argv[1]);
if (argv1) return path.isAbsolute(argv1) ? argv1 : path.resolve(process.cwd(), argv1);
return null;
}
function printSessionUpdate(notification) {
const update = notification.update;
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}`);
}
}
}
async function createAcpClient(opts = {}) {
const cwd = opts.cwd ?? process.cwd();
const log = Boolean(opts.verbose) ? (msg) => 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("./env-overrides.runtime.js");
const stripKeys = buildAcpClientStripKeys({
stripProviderAuthEnvVars: shouldStripProviderAuthEnvVarsForAcpServer({
serverCommand,
serverArgs: effectiveArgs,
defaultServerCommand,
defaultServerArgs
}),
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
});
agent.on("error", (err) => {
log(`agent error: ${String(err)}`);
});
try {
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);
const stream = ndJsonStream(input, output);
const client = new ClientSideConnection(() => ({
sessionUpdate: async (params) => {
printSessionUpdate(params);
},
requestPermission: async (params) => {
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");
return {
client,
agent,
sessionId: (await client.newSession({
cwd,
mcpServers: []
})).sessionId
};
} catch (error) {
await terminateAcpServer(agent);
throw error;
}
}
/** Starts the terminal prompt loop for a local ACP client session. */
async function runAcpClientInteractive(opts = {}) {
const { client, agent, sessionId } = await createAcpClient(opts);
const rl = readline$1.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("> ", (input) => {
(async () => {
const text = input.trim();
if (!text) {
prompt();
return;
}
if (text === "exit" || text === "quit") {
await terminateAcpServer(agent);
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);
});
}
//#endregion
export { runAcpClientInteractive };