Files
openclaw/src/node-host/invoke.ts
2026-02-22 22:24:28 +01:00

568 lines
15 KiB
TypeScript

import { spawn } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { GatewayClient } from "../gateway/client.js";
import {
ensureExecApprovals,
mergeExecApprovalsSocketDefaults,
normalizeExecApprovals,
readExecApprovalsSnapshot,
saveExecApprovals,
type ExecAsk,
type ExecApprovalsFile,
type ExecApprovalsResolved,
type ExecSecurity,
} from "../infra/exec-approvals.js";
import {
requestExecHostViaSocket,
type ExecHostRequest,
type ExecHostResponse,
} from "../infra/exec-host.js";
import { sanitizeHostExecEnv } from "../infra/host-env-security.js";
import { runBrowserProxyCommand } from "./invoke-browser.js";
import { handleSystemRunInvoke } from "./invoke-system-run.js";
import type {
ExecEventPayload,
RunResult,
SkillBinsProvider,
SystemRunParams,
} from "./invoke-types.js";
const OUTPUT_CAP = 200_000;
const OUTPUT_EVENT_TAIL = 20_000;
const DEFAULT_NODE_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
const execHostEnforced = process.env.OPENCLAW_NODE_EXEC_HOST?.trim().toLowerCase() === "app";
const execHostFallbackAllowed =
process.env.OPENCLAW_NODE_EXEC_FALLBACK?.trim().toLowerCase() !== "0";
const preferMacAppExecHost = process.platform === "darwin" && execHostEnforced;
type SystemWhichParams = {
bins: string[];
};
type SystemExecApprovalsSetParams = {
file: ExecApprovalsFile;
baseHash?: string | null;
};
type ExecApprovalsSnapshot = {
path: string;
exists: boolean;
hash: string;
file: ExecApprovalsFile;
};
export type NodeInvokeRequestPayload = {
id: string;
nodeId: string;
command: string;
paramsJSON?: string | null;
timeoutMs?: number | null;
idempotencyKey?: string | null;
};
export type { SkillBinsProvider } from "./invoke-types.js";
function resolveExecSecurity(value?: string): ExecSecurity {
return value === "deny" || value === "allowlist" || value === "full" ? value : "allowlist";
}
function isCmdExeInvocation(argv: string[]): boolean {
const token = argv[0]?.trim();
if (!token) {
return false;
}
const base = path.win32.basename(token).toLowerCase();
return base === "cmd.exe" || base === "cmd";
}
function resolveExecAsk(value?: string): ExecAsk {
return value === "off" || value === "on-miss" || value === "always" ? value : "on-miss";
}
export function sanitizeEnv(overrides?: Record<string, string> | null): Record<string, string> {
return sanitizeHostExecEnv({ overrides, blockPathOverrides: true });
}
function truncateOutput(raw: string, maxChars: number): { text: string; truncated: boolean } {
if (raw.length <= maxChars) {
return { text: raw, truncated: false };
}
return { text: `... (truncated) ${raw.slice(raw.length - maxChars)}`, truncated: true };
}
function redactExecApprovals(file: ExecApprovalsFile): ExecApprovalsFile {
const socketPath = file.socket?.path?.trim();
return {
...file,
socket: socketPath ? { path: socketPath } : undefined,
};
}
function requireExecApprovalsBaseHash(
params: SystemExecApprovalsSetParams,
snapshot: ExecApprovalsSnapshot,
) {
if (!snapshot.exists) {
return;
}
if (!snapshot.hash) {
throw new Error("INVALID_REQUEST: exec approvals base hash unavailable; reload and retry");
}
const baseHash = typeof params.baseHash === "string" ? params.baseHash.trim() : "";
if (!baseHash) {
throw new Error("INVALID_REQUEST: exec approvals base hash required; reload and retry");
}
if (baseHash !== snapshot.hash) {
throw new Error("INVALID_REQUEST: exec approvals changed; reload and retry");
}
}
async function runCommand(
argv: string[],
cwd: string | undefined,
env: Record<string, string> | undefined,
timeoutMs: number | undefined,
): Promise<RunResult> {
return await new Promise((resolve) => {
let stdout = "";
let stderr = "";
let outputLen = 0;
let truncated = false;
let timedOut = false;
let settled = false;
const child = spawn(argv[0], argv.slice(1), {
cwd,
env,
stdio: ["ignore", "pipe", "pipe"],
windowsHide: true,
});
const onChunk = (chunk: Buffer, target: "stdout" | "stderr") => {
if (outputLen >= OUTPUT_CAP) {
truncated = true;
return;
}
const remaining = OUTPUT_CAP - outputLen;
const slice = chunk.length > remaining ? chunk.subarray(0, remaining) : chunk;
const str = slice.toString("utf8");
outputLen += slice.length;
if (target === "stdout") {
stdout += str;
} else {
stderr += str;
}
if (chunk.length > remaining) {
truncated = true;
}
};
child.stdout?.on("data", (chunk) => onChunk(chunk as Buffer, "stdout"));
child.stderr?.on("data", (chunk) => onChunk(chunk as Buffer, "stderr"));
let timer: NodeJS.Timeout | undefined;
if (timeoutMs && timeoutMs > 0) {
timer = setTimeout(() => {
timedOut = true;
try {
child.kill("SIGKILL");
} catch {
// ignore
}
}, timeoutMs);
}
const finalize = (exitCode?: number, error?: string | null) => {
if (settled) {
return;
}
settled = true;
if (timer) {
clearTimeout(timer);
}
resolve({
exitCode,
timedOut,
success: exitCode === 0 && !timedOut && !error,
stdout,
stderr,
error: error ?? null,
truncated,
});
};
child.on("error", (err) => {
finalize(undefined, err.message);
});
child.on("exit", (code) => {
finalize(code === null ? undefined : code, null);
});
});
}
function resolveEnvPath(env?: Record<string, string>): string[] {
const raw =
env?.PATH ??
(env as Record<string, string>)?.Path ??
process.env.PATH ??
process.env.Path ??
DEFAULT_NODE_PATH;
return raw.split(path.delimiter).filter(Boolean);
}
function resolveExecutable(bin: string, env?: Record<string, string>) {
if (bin.includes("/") || bin.includes("\\")) {
return null;
}
const extensions =
process.platform === "win32"
? (process.env.PATHEXT ?? process.env.PathExt ?? ".EXE;.CMD;.BAT;.COM")
.split(";")
.map((ext) => ext.toLowerCase())
: [""];
for (const dir of resolveEnvPath(env)) {
for (const ext of extensions) {
const candidate = path.join(dir, bin + ext);
if (fs.existsSync(candidate)) {
return candidate;
}
}
}
return null;
}
async function handleSystemWhich(params: SystemWhichParams, env?: Record<string, string>) {
const bins = params.bins.map((bin) => bin.trim()).filter(Boolean);
const found: Record<string, string> = {};
for (const bin of bins) {
const path = resolveExecutable(bin, env);
if (path) {
found[bin] = path;
}
}
return { bins: found };
}
function buildExecEventPayload(payload: ExecEventPayload): ExecEventPayload {
if (!payload.output) {
return payload;
}
const trimmed = payload.output.trim();
if (!trimmed) {
return payload;
}
const { text } = truncateOutput(trimmed, OUTPUT_EVENT_TAIL);
return { ...payload, output: text };
}
async function sendExecFinishedEvent(params: {
client: GatewayClient;
sessionKey: string;
runId: string;
cmdText: string;
result: {
stdout?: string;
stderr?: string;
error?: string | null;
exitCode?: number | null;
timedOut?: boolean;
success?: boolean;
};
}) {
const combined = [params.result.stdout, params.result.stderr, params.result.error]
.filter(Boolean)
.join("\n");
await sendNodeEvent(
params.client,
"exec.finished",
buildExecEventPayload({
sessionKey: params.sessionKey,
runId: params.runId,
host: "node",
command: params.cmdText,
exitCode: params.result.exitCode ?? undefined,
timedOut: params.result.timedOut,
success: params.result.success,
output: combined,
}),
);
}
async function runViaMacAppExecHost(params: {
approvals: ExecApprovalsResolved;
request: ExecHostRequest;
}): Promise<ExecHostResponse | null> {
const { approvals, request } = params;
return await requestExecHostViaSocket({
socketPath: approvals.socketPath,
token: approvals.token,
request,
});
}
async function sendJsonPayloadResult(
client: GatewayClient,
frame: NodeInvokeRequestPayload,
payload: unknown,
) {
await sendInvokeResult(client, frame, {
ok: true,
payloadJSON: JSON.stringify(payload),
});
}
async function sendRawPayloadResult(
client: GatewayClient,
frame: NodeInvokeRequestPayload,
payloadJSON: string,
) {
await sendInvokeResult(client, frame, {
ok: true,
payloadJSON,
});
}
async function sendErrorResult(
client: GatewayClient,
frame: NodeInvokeRequestPayload,
code: string,
message: string,
) {
await sendInvokeResult(client, frame, {
ok: false,
error: { code, message },
});
}
async function sendInvalidRequestResult(
client: GatewayClient,
frame: NodeInvokeRequestPayload,
err: unknown,
) {
await sendErrorResult(client, frame, "INVALID_REQUEST", String(err));
}
export async function handleInvoke(
frame: NodeInvokeRequestPayload,
client: GatewayClient,
skillBins: SkillBinsProvider,
) {
const command = String(frame.command ?? "");
if (command === "system.execApprovals.get") {
try {
ensureExecApprovals();
const snapshot = readExecApprovalsSnapshot();
const payload: ExecApprovalsSnapshot = {
path: snapshot.path,
exists: snapshot.exists,
hash: snapshot.hash,
file: redactExecApprovals(snapshot.file),
};
await sendJsonPayloadResult(client, frame, payload);
} catch (err) {
const message = String(err);
const code = message.toLowerCase().includes("timed out") ? "TIMEOUT" : "INVALID_REQUEST";
await sendErrorResult(client, frame, code, message);
}
return;
}
if (command === "system.execApprovals.set") {
try {
const params = decodeParams<SystemExecApprovalsSetParams>(frame.paramsJSON);
if (!params.file || typeof params.file !== "object") {
throw new Error("INVALID_REQUEST: exec approvals file required");
}
ensureExecApprovals();
const snapshot = readExecApprovalsSnapshot();
requireExecApprovalsBaseHash(params, snapshot);
const normalized = normalizeExecApprovals(params.file);
const next = mergeExecApprovalsSocketDefaults({ normalized, current: snapshot.file });
saveExecApprovals(next);
const nextSnapshot = readExecApprovalsSnapshot();
const payload: ExecApprovalsSnapshot = {
path: nextSnapshot.path,
exists: nextSnapshot.exists,
hash: nextSnapshot.hash,
file: redactExecApprovals(nextSnapshot.file),
};
await sendJsonPayloadResult(client, frame, payload);
} catch (err) {
await sendInvalidRequestResult(client, frame, err);
}
return;
}
if (command === "system.which") {
try {
const params = decodeParams<SystemWhichParams>(frame.paramsJSON);
if (!Array.isArray(params.bins)) {
throw new Error("INVALID_REQUEST: bins required");
}
const env = sanitizeEnv(undefined);
const payload = await handleSystemWhich(params, env);
await sendJsonPayloadResult(client, frame, payload);
} catch (err) {
await sendInvalidRequestResult(client, frame, err);
}
return;
}
if (command === "browser.proxy") {
try {
const payload = await runBrowserProxyCommand(frame.paramsJSON);
await sendRawPayloadResult(client, frame, payload);
} catch (err) {
await sendInvalidRequestResult(client, frame, err);
}
return;
}
if (command !== "system.run") {
await sendErrorResult(client, frame, "UNAVAILABLE", "command not supported");
return;
}
let params: SystemRunParams;
try {
params = decodeParams<SystemRunParams>(frame.paramsJSON);
} catch (err) {
await sendInvalidRequestResult(client, frame, err);
return;
}
if (!Array.isArray(params.command) || params.command.length === 0) {
await sendErrorResult(client, frame, "INVALID_REQUEST", "command required");
return;
}
await handleSystemRunInvoke({
client,
params,
skillBins,
execHostEnforced,
execHostFallbackAllowed,
resolveExecSecurity,
resolveExecAsk,
isCmdExeInvocation,
sanitizeEnv,
runCommand,
runViaMacAppExecHost,
sendNodeEvent,
buildExecEventPayload,
sendInvokeResult: async (result) => {
await sendInvokeResult(client, frame, result);
},
sendExecFinishedEvent: async ({ sessionKey, runId, cmdText, result }) => {
await sendExecFinishedEvent({ client, sessionKey, runId, cmdText, result });
},
preferMacAppExecHost,
});
}
function decodeParams<T>(raw?: string | null): T {
if (!raw) {
throw new Error("INVALID_REQUEST: paramsJSON required");
}
return JSON.parse(raw) as T;
}
export function coerceNodeInvokePayload(payload: unknown): NodeInvokeRequestPayload | null {
if (!payload || typeof payload !== "object") {
return null;
}
const obj = payload as Record<string, unknown>;
const id = typeof obj.id === "string" ? obj.id.trim() : "";
const nodeId = typeof obj.nodeId === "string" ? obj.nodeId.trim() : "";
const command = typeof obj.command === "string" ? obj.command.trim() : "";
if (!id || !nodeId || !command) {
return null;
}
const paramsJSON =
typeof obj.paramsJSON === "string"
? obj.paramsJSON
: obj.params !== undefined
? JSON.stringify(obj.params)
: null;
const timeoutMs = typeof obj.timeoutMs === "number" ? obj.timeoutMs : null;
const idempotencyKey = typeof obj.idempotencyKey === "string" ? obj.idempotencyKey : null;
return {
id,
nodeId,
command,
paramsJSON,
timeoutMs,
idempotencyKey,
};
}
async function sendInvokeResult(
client: GatewayClient,
frame: NodeInvokeRequestPayload,
result: {
ok: boolean;
payload?: unknown;
payloadJSON?: string | null;
error?: { code?: string; message?: string } | null;
},
) {
try {
await client.request("node.invoke.result", buildNodeInvokeResultParams(frame, result));
} catch {
// ignore: node invoke responses are best-effort
}
}
export function buildNodeInvokeResultParams(
frame: NodeInvokeRequestPayload,
result: {
ok: boolean;
payload?: unknown;
payloadJSON?: string | null;
error?: { code?: string; message?: string } | null;
},
): {
id: string;
nodeId: string;
ok: boolean;
payload?: unknown;
payloadJSON?: string;
error?: { code?: string; message?: string };
} {
const params: {
id: string;
nodeId: string;
ok: boolean;
payload?: unknown;
payloadJSON?: string;
error?: { code?: string; message?: string };
} = {
id: frame.id,
nodeId: frame.nodeId,
ok: result.ok,
};
if (result.payload !== undefined) {
params.payload = result.payload;
}
if (typeof result.payloadJSON === "string") {
params.payloadJSON = result.payloadJSON;
}
if (result.error) {
params.error = result.error;
}
return params;
}
async function sendNodeEvent(client: GatewayClient, event: string, payload: unknown) {
try {
await client.request("node.event", {
event,
payloadJSON: payload ? JSON.stringify(payload) : null,
});
} catch {
// ignore: node events are best-effort
}
}