1180 lines
35 KiB
TypeScript
1180 lines
35 KiB
TypeScript
import crypto from "node:crypto";
|
|
import { spawn } from "node:child_process";
|
|
import fs from "node:fs";
|
|
import fsPromises from "node:fs/promises";
|
|
import path from "node:path";
|
|
|
|
import {
|
|
addAllowlistEntry,
|
|
analyzeArgvCommand,
|
|
evaluateExecAllowlist,
|
|
evaluateShellAllowlist,
|
|
requiresExecApproval,
|
|
normalizeExecApprovals,
|
|
recordAllowlistUse,
|
|
resolveExecApprovals,
|
|
resolveSafeBins,
|
|
ensureExecApprovals,
|
|
readExecApprovalsSnapshot,
|
|
resolveExecApprovalsSocketPath,
|
|
saveExecApprovals,
|
|
type ExecApprovalsFile,
|
|
type ExecAllowlistEntry,
|
|
type ExecCommandSegment,
|
|
} from "../infra/exec-approvals.js";
|
|
import {
|
|
requestExecHostViaSocket,
|
|
type ExecHostRequest,
|
|
type ExecHostResponse,
|
|
type ExecHostRunResult,
|
|
} from "../infra/exec-host.js";
|
|
import { getMachineDisplayName } from "../infra/machine-name.js";
|
|
import { loadOrCreateDeviceIdentity } from "../infra/device-identity.js";
|
|
import { loadConfig } from "../config/config.js";
|
|
import { resolveBrowserConfig, shouldStartLocalBrowserServer } from "../browser/config.js";
|
|
import { detectMime } from "../media/mime.js";
|
|
import { resolveAgentConfig } from "../agents/agent-scope.js";
|
|
import { ensureClawdbotCliOnPath } from "../infra/path-env.js";
|
|
import { VERSION } from "../version.js";
|
|
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
|
|
|
|
import { ensureNodeHostConfig, saveNodeHostConfig, type NodeHostGatewayConfig } from "./config.js";
|
|
import { GatewayClient } from "../gateway/client.js";
|
|
|
|
type NodeHostRunOptions = {
|
|
gatewayHost: string;
|
|
gatewayPort: number;
|
|
gatewayTls?: boolean;
|
|
gatewayTlsFingerprint?: string;
|
|
nodeId?: string;
|
|
displayName?: string;
|
|
};
|
|
|
|
type SystemRunParams = {
|
|
command: string[];
|
|
rawCommand?: string | null;
|
|
cwd?: string | null;
|
|
env?: Record<string, string>;
|
|
timeoutMs?: number | null;
|
|
needsScreenRecording?: boolean | null;
|
|
agentId?: string | null;
|
|
sessionKey?: string | null;
|
|
approved?: boolean | null;
|
|
approvalDecision?: string | null;
|
|
runId?: string | null;
|
|
};
|
|
|
|
type SystemWhichParams = {
|
|
bins: string[];
|
|
};
|
|
|
|
type BrowserProxyParams = {
|
|
method?: string;
|
|
path?: string;
|
|
query?: Record<string, string | number | boolean | null | undefined>;
|
|
body?: unknown;
|
|
timeoutMs?: number;
|
|
profile?: string;
|
|
};
|
|
|
|
type BrowserProxyFile = {
|
|
path: string;
|
|
base64: string;
|
|
mimeType?: string;
|
|
};
|
|
|
|
type BrowserProxyResult = {
|
|
result: unknown;
|
|
files?: BrowserProxyFile[];
|
|
};
|
|
|
|
type SystemExecApprovalsSetParams = {
|
|
file: ExecApprovalsFile;
|
|
baseHash?: string | null;
|
|
};
|
|
|
|
type ExecApprovalsSnapshot = {
|
|
path: string;
|
|
exists: boolean;
|
|
hash: string;
|
|
file: ExecApprovalsFile;
|
|
};
|
|
|
|
type RunResult = {
|
|
exitCode?: number;
|
|
timedOut: boolean;
|
|
success: boolean;
|
|
stdout: string;
|
|
stderr: string;
|
|
error?: string | null;
|
|
truncated: boolean;
|
|
};
|
|
|
|
type ExecEventPayload = {
|
|
sessionKey: string;
|
|
runId: string;
|
|
host: string;
|
|
command?: string;
|
|
exitCode?: number;
|
|
timedOut?: boolean;
|
|
success?: boolean;
|
|
output?: string;
|
|
reason?: string;
|
|
};
|
|
|
|
type NodeInvokeRequestPayload = {
|
|
id: string;
|
|
nodeId: string;
|
|
command: string;
|
|
paramsJSON?: string | null;
|
|
timeoutMs?: number | null;
|
|
idempotencyKey?: string | null;
|
|
};
|
|
|
|
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 BROWSER_PROXY_MAX_FILE_BYTES = 10 * 1024 * 1024;
|
|
|
|
const execHostEnforced = process.env.CLAWDBOT_NODE_EXEC_HOST?.trim().toLowerCase() === "app";
|
|
const execHostFallbackAllowed =
|
|
process.env.CLAWDBOT_NODE_EXEC_FALLBACK?.trim().toLowerCase() !== "0";
|
|
|
|
const blockedEnvKeys = new Set([
|
|
"NODE_OPTIONS",
|
|
"PYTHONHOME",
|
|
"PYTHONPATH",
|
|
"PERL5LIB",
|
|
"PERL5OPT",
|
|
"RUBYOPT",
|
|
]);
|
|
|
|
const blockedEnvPrefixes = ["DYLD_", "LD_"];
|
|
|
|
class SkillBinsCache {
|
|
private bins = new Set<string>();
|
|
private lastRefresh = 0;
|
|
private readonly ttlMs = 90_000;
|
|
private readonly fetch: () => Promise<string[]>;
|
|
|
|
constructor(fetch: () => Promise<string[]>) {
|
|
this.fetch = fetch;
|
|
}
|
|
|
|
async current(force = false): Promise<Set<string>> {
|
|
if (force || Date.now() - this.lastRefresh > this.ttlMs) {
|
|
await this.refresh();
|
|
}
|
|
return this.bins;
|
|
}
|
|
|
|
private async refresh() {
|
|
try {
|
|
const bins = await this.fetch();
|
|
this.bins = new Set(bins);
|
|
this.lastRefresh = Date.now();
|
|
} catch {
|
|
if (!this.lastRefresh) {
|
|
this.bins = new Set();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function sanitizeEnv(
|
|
overrides?: Record<string, string> | null,
|
|
): Record<string, string> | undefined {
|
|
if (!overrides) return undefined;
|
|
const merged = { ...process.env } as Record<string, string>;
|
|
const basePath = process.env.PATH ?? DEFAULT_NODE_PATH;
|
|
for (const [rawKey, value] of Object.entries(overrides)) {
|
|
const key = rawKey.trim();
|
|
if (!key) continue;
|
|
const upper = key.toUpperCase();
|
|
if (upper === "PATH") {
|
|
const trimmed = value.trim();
|
|
if (!trimmed) continue;
|
|
if (!basePath || trimmed === basePath) {
|
|
merged[key] = trimmed;
|
|
continue;
|
|
}
|
|
const suffix = `${path.delimiter}${basePath}`;
|
|
if (trimmed.endsWith(suffix)) {
|
|
merged[key] = trimmed;
|
|
}
|
|
continue;
|
|
}
|
|
if (blockedEnvKeys.has(upper)) continue;
|
|
if (blockedEnvPrefixes.some((prefix) => upper.startsWith(prefix))) continue;
|
|
merged[key] = value;
|
|
}
|
|
return merged;
|
|
}
|
|
|
|
function normalizeProfileAllowlist(raw?: string[]): string[] {
|
|
return Array.isArray(raw) ? raw.map((entry) => entry.trim()).filter(Boolean) : [];
|
|
}
|
|
|
|
function resolveBrowserProxyConfig() {
|
|
const cfg = loadConfig();
|
|
const proxy = cfg.nodeHost?.browserProxy;
|
|
const allowProfiles = normalizeProfileAllowlist(proxy?.allowProfiles);
|
|
const enabled = proxy?.enabled !== false;
|
|
return { enabled, allowProfiles };
|
|
}
|
|
|
|
let browserControlReady: Promise<void> | null = null;
|
|
|
|
async function ensureBrowserControlServer(): Promise<void> {
|
|
if (browserControlReady) return browserControlReady;
|
|
browserControlReady = (async () => {
|
|
const cfg = loadConfig();
|
|
const resolved = resolveBrowserConfig(cfg.browser);
|
|
if (!resolved.enabled) {
|
|
throw new Error("browser control disabled");
|
|
}
|
|
if (!shouldStartLocalBrowserServer(resolved)) {
|
|
throw new Error("browser control URL is non-loopback");
|
|
}
|
|
const mod = await import("../browser/server.js");
|
|
await mod.startBrowserControlServerFromConfig();
|
|
})();
|
|
return browserControlReady;
|
|
}
|
|
|
|
function isProfileAllowed(params: { allowProfiles: string[]; profile?: string | null }) {
|
|
const { allowProfiles, profile } = params;
|
|
if (!allowProfiles.length) return true;
|
|
if (!profile) return false;
|
|
return allowProfiles.includes(profile.trim());
|
|
}
|
|
|
|
function collectBrowserProxyPaths(payload: unknown): string[] {
|
|
const paths = new Set<string>();
|
|
const obj =
|
|
typeof payload === "object" && payload !== null ? (payload as Record<string, unknown>) : null;
|
|
if (!obj) return [];
|
|
if (typeof obj.path === "string" && obj.path.trim()) paths.add(obj.path.trim());
|
|
if (typeof obj.imagePath === "string" && obj.imagePath.trim()) paths.add(obj.imagePath.trim());
|
|
const download = obj.download;
|
|
if (download && typeof download === "object") {
|
|
const dlPath = (download as Record<string, unknown>).path;
|
|
if (typeof dlPath === "string" && dlPath.trim()) paths.add(dlPath.trim());
|
|
}
|
|
return [...paths];
|
|
}
|
|
|
|
async function readBrowserProxyFile(filePath: string): Promise<BrowserProxyFile | null> {
|
|
const stat = await fsPromises.stat(filePath).catch(() => null);
|
|
if (!stat || !stat.isFile()) return null;
|
|
if (stat.size > BROWSER_PROXY_MAX_FILE_BYTES) {
|
|
throw new Error(
|
|
`browser proxy file exceeds ${Math.round(BROWSER_PROXY_MAX_FILE_BYTES / (1024 * 1024))}MB`,
|
|
);
|
|
}
|
|
const buffer = await fsPromises.readFile(filePath);
|
|
const mimeType = await detectMime({ buffer, filePath });
|
|
return { path: filePath, base64: buffer.toString("base64"), mimeType };
|
|
}
|
|
|
|
function formatCommand(argv: string[]): string {
|
|
return argv
|
|
.map((arg) => {
|
|
const trimmed = arg.trim();
|
|
if (!trimmed) return '""';
|
|
const needsQuotes = /\s|"/.test(trimmed);
|
|
if (!needsQuotes) return trimmed;
|
|
return `"${trimmed.replace(/"/g, '\\"')}"`;
|
|
})
|
|
.join(" ");
|
|
}
|
|
|
|
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 ensureNodePathEnv(): string {
|
|
ensureClawdbotCliOnPath({ pathEnv: process.env.PATH ?? "" });
|
|
const current = process.env.PATH ?? "";
|
|
if (current.trim()) return current;
|
|
process.env.PATH = DEFAULT_NODE_PATH;
|
|
return DEFAULT_NODE_PATH;
|
|
}
|
|
|
|
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 runViaMacAppExecHost(params: {
|
|
approvals: ReturnType<typeof resolveExecApprovals>;
|
|
request: ExecHostRequest;
|
|
}): Promise<ExecHostResponse | null> {
|
|
const { approvals, request } = params;
|
|
return await requestExecHostViaSocket({
|
|
socketPath: approvals.socketPath,
|
|
token: approvals.token,
|
|
request,
|
|
});
|
|
}
|
|
|
|
export async function runNodeHost(opts: NodeHostRunOptions): Promise<void> {
|
|
const config = await ensureNodeHostConfig();
|
|
const nodeId = opts.nodeId?.trim() || config.nodeId;
|
|
if (nodeId !== config.nodeId) {
|
|
config.nodeId = nodeId;
|
|
}
|
|
const displayName =
|
|
opts.displayName?.trim() || config.displayName || (await getMachineDisplayName());
|
|
config.displayName = displayName;
|
|
const gateway: NodeHostGatewayConfig = {
|
|
host: opts.gatewayHost,
|
|
port: opts.gatewayPort,
|
|
tls: opts.gatewayTls ?? loadConfig().gateway?.tls?.enabled ?? false,
|
|
tlsFingerprint: opts.gatewayTlsFingerprint,
|
|
};
|
|
config.gateway = gateway;
|
|
await saveNodeHostConfig(config);
|
|
|
|
const cfg = loadConfig();
|
|
const browserProxy = resolveBrowserProxyConfig();
|
|
const resolvedBrowser = resolveBrowserConfig(cfg.browser);
|
|
const browserProxyEnabled =
|
|
browserProxy.enabled &&
|
|
resolvedBrowser.enabled &&
|
|
shouldStartLocalBrowserServer(resolvedBrowser);
|
|
const isRemoteMode = cfg.gateway?.mode === "remote";
|
|
const token =
|
|
process.env.CLAWDBOT_GATEWAY_TOKEN?.trim() ||
|
|
(isRemoteMode ? cfg.gateway?.remote?.token : cfg.gateway?.auth?.token);
|
|
const password =
|
|
process.env.CLAWDBOT_GATEWAY_PASSWORD?.trim() ||
|
|
(isRemoteMode ? cfg.gateway?.remote?.password : cfg.gateway?.auth?.password);
|
|
|
|
const host = gateway.host ?? "127.0.0.1";
|
|
const port = gateway.port ?? 18789;
|
|
const scheme = gateway.tls ? "wss" : "ws";
|
|
const url = `${scheme}://${host}:${port}`;
|
|
const pathEnv = ensureNodePathEnv();
|
|
// eslint-disable-next-line no-console
|
|
console.log(`node host PATH: ${pathEnv}`);
|
|
|
|
const client = new GatewayClient({
|
|
url,
|
|
token: token?.trim() || undefined,
|
|
password: password?.trim() || undefined,
|
|
instanceId: nodeId,
|
|
clientName: GATEWAY_CLIENT_NAMES.NODE_HOST,
|
|
clientDisplayName: displayName,
|
|
clientVersion: VERSION,
|
|
platform: process.platform,
|
|
mode: GATEWAY_CLIENT_MODES.NODE,
|
|
role: "node",
|
|
scopes: [],
|
|
caps: ["system", ...(browserProxyEnabled ? ["browser"] : [])],
|
|
commands: [
|
|
"system.run",
|
|
"system.which",
|
|
"system.execApprovals.get",
|
|
"system.execApprovals.set",
|
|
...(browserProxyEnabled ? ["browser.proxy"] : []),
|
|
],
|
|
pathEnv,
|
|
permissions: undefined,
|
|
deviceIdentity: loadOrCreateDeviceIdentity(),
|
|
tlsFingerprint: gateway.tlsFingerprint,
|
|
onEvent: (evt) => {
|
|
if (evt.event !== "node.invoke.request") return;
|
|
const payload = coerceNodeInvokePayload(evt.payload);
|
|
if (!payload) return;
|
|
void handleInvoke(payload, client, skillBins);
|
|
},
|
|
onConnectError: (err) => {
|
|
// keep retrying (handled by GatewayClient)
|
|
// eslint-disable-next-line no-console
|
|
console.error(`node host gateway connect failed: ${err.message}`);
|
|
},
|
|
onClose: (code, reason) => {
|
|
// eslint-disable-next-line no-console
|
|
console.error(`node host gateway closed (${code}): ${reason}`);
|
|
},
|
|
});
|
|
|
|
const skillBins = new SkillBinsCache(async () => {
|
|
const res = (await client.request("skills.bins", {})) as
|
|
| { bins?: unknown[] }
|
|
| null
|
|
| undefined;
|
|
const bins = Array.isArray(res?.bins) ? res.bins.map((bin) => String(bin)) : [];
|
|
return bins;
|
|
});
|
|
|
|
client.start();
|
|
await new Promise(() => {});
|
|
}
|
|
|
|
async function handleInvoke(
|
|
frame: NodeInvokeRequestPayload,
|
|
client: GatewayClient,
|
|
skillBins: SkillBinsCache,
|
|
) {
|
|
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 sendInvokeResult(client, frame, {
|
|
ok: true,
|
|
payloadJSON: JSON.stringify(payload),
|
|
});
|
|
} catch (err) {
|
|
await sendInvokeResult(client, frame, {
|
|
ok: false,
|
|
error: { code: "INVALID_REQUEST", message: String(err) },
|
|
});
|
|
}
|
|
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 currentSocketPath = snapshot.file.socket?.path?.trim();
|
|
const currentToken = snapshot.file.socket?.token?.trim();
|
|
const socketPath =
|
|
normalized.socket?.path?.trim() ?? currentSocketPath ?? resolveExecApprovalsSocketPath();
|
|
const token = normalized.socket?.token?.trim() ?? currentToken ?? "";
|
|
const next: ExecApprovalsFile = {
|
|
...normalized,
|
|
socket: {
|
|
path: socketPath,
|
|
token,
|
|
},
|
|
};
|
|
saveExecApprovals(next);
|
|
const nextSnapshot = readExecApprovalsSnapshot();
|
|
const payload: ExecApprovalsSnapshot = {
|
|
path: nextSnapshot.path,
|
|
exists: nextSnapshot.exists,
|
|
hash: nextSnapshot.hash,
|
|
file: redactExecApprovals(nextSnapshot.file),
|
|
};
|
|
await sendInvokeResult(client, frame, {
|
|
ok: true,
|
|
payloadJSON: JSON.stringify(payload),
|
|
});
|
|
} catch (err) {
|
|
await sendInvokeResult(client, frame, {
|
|
ok: false,
|
|
error: { code: "INVALID_REQUEST", message: String(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 sendInvokeResult(client, frame, {
|
|
ok: true,
|
|
payloadJSON: JSON.stringify(payload),
|
|
});
|
|
} catch (err) {
|
|
await sendInvokeResult(client, frame, {
|
|
ok: false,
|
|
error: { code: "INVALID_REQUEST", message: String(err) },
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (command === "browser.proxy") {
|
|
try {
|
|
const params = decodeParams<BrowserProxyParams>(frame.paramsJSON);
|
|
const pathValue = typeof params.path === "string" ? params.path.trim() : "";
|
|
if (!pathValue) {
|
|
throw new Error("INVALID_REQUEST: path required");
|
|
}
|
|
const proxyConfig = resolveBrowserProxyConfig();
|
|
if (!proxyConfig.enabled) {
|
|
throw new Error("UNAVAILABLE: node browser proxy disabled");
|
|
}
|
|
await ensureBrowserControlServer();
|
|
const resolved = resolveBrowserConfig(loadConfig().browser);
|
|
const requestedProfile = typeof params.profile === "string" ? params.profile.trim() : "";
|
|
const allowedProfiles = proxyConfig.allowProfiles;
|
|
if (allowedProfiles.length > 0) {
|
|
if (pathValue !== "/profiles") {
|
|
const profileToCheck = requestedProfile || resolved.defaultProfile;
|
|
if (!isProfileAllowed({ allowProfiles: allowedProfiles, profile: profileToCheck })) {
|
|
throw new Error("INVALID_REQUEST: browser profile not allowed");
|
|
}
|
|
} else if (requestedProfile) {
|
|
if (!isProfileAllowed({ allowProfiles: allowedProfiles, profile: requestedProfile })) {
|
|
throw new Error("INVALID_REQUEST: browser profile not allowed");
|
|
}
|
|
}
|
|
}
|
|
|
|
const url = new URL(
|
|
pathValue.startsWith("/") ? pathValue : `/${pathValue}`,
|
|
resolved.controlUrl,
|
|
);
|
|
if (requestedProfile) {
|
|
url.searchParams.set("profile", requestedProfile);
|
|
}
|
|
const query = params.query ?? {};
|
|
for (const [key, value] of Object.entries(query)) {
|
|
if (value === undefined || value === null) continue;
|
|
url.searchParams.set(key, String(value));
|
|
}
|
|
const method = typeof params.method === "string" ? params.method.toUpperCase() : "GET";
|
|
const body = params.body;
|
|
const ctrl = new AbortController();
|
|
const timeoutMs =
|
|
typeof params.timeoutMs === "number" && Number.isFinite(params.timeoutMs)
|
|
? Math.max(1, Math.floor(params.timeoutMs))
|
|
: 20_000;
|
|
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
const headers = new Headers();
|
|
let bodyJson: string | undefined;
|
|
if (body !== undefined) {
|
|
headers.set("Content-Type", "application/json");
|
|
bodyJson = JSON.stringify(body);
|
|
}
|
|
const token =
|
|
process.env.CLAWDBOT_BROWSER_CONTROL_TOKEN?.trim() || resolved.controlToken?.trim();
|
|
if (token) {
|
|
headers.set("Authorization", `Bearer ${token}`);
|
|
}
|
|
let res: Response;
|
|
try {
|
|
res = await fetch(url.toString(), {
|
|
method,
|
|
headers,
|
|
body: bodyJson,
|
|
signal: ctrl.signal,
|
|
});
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
if (!res.ok) {
|
|
const text = await res.text().catch(() => "");
|
|
throw new Error(text ? `${res.status}: ${text}` : `HTTP ${res.status}`);
|
|
}
|
|
const result = (await res.json()) as unknown;
|
|
if (allowedProfiles.length > 0 && url.pathname === "/profiles") {
|
|
const obj =
|
|
typeof result === "object" && result !== null ? (result as Record<string, unknown>) : {};
|
|
const profiles = Array.isArray(obj.profiles) ? obj.profiles : [];
|
|
obj.profiles = profiles.filter((entry) => {
|
|
if (!entry || typeof entry !== "object") return false;
|
|
const name = (entry as Record<string, unknown>).name;
|
|
return typeof name === "string" && allowedProfiles.includes(name);
|
|
});
|
|
}
|
|
let files: BrowserProxyFile[] | undefined;
|
|
const paths = collectBrowserProxyPaths(result);
|
|
if (paths.length > 0) {
|
|
const loaded = await Promise.all(
|
|
paths.map(async (p) => {
|
|
try {
|
|
const file = await readBrowserProxyFile(p);
|
|
if (!file) {
|
|
throw new Error("file not found");
|
|
}
|
|
return file;
|
|
} catch (err) {
|
|
throw new Error(`browser proxy file read failed for ${p}: ${String(err)}`);
|
|
}
|
|
}),
|
|
);
|
|
if (loaded.length > 0) files = loaded;
|
|
}
|
|
const payload: BrowserProxyResult = files ? { result, files } : { result };
|
|
await sendInvokeResult(client, frame, {
|
|
ok: true,
|
|
payloadJSON: JSON.stringify(payload),
|
|
});
|
|
} catch (err) {
|
|
await sendInvokeResult(client, frame, {
|
|
ok: false,
|
|
error: { code: "INVALID_REQUEST", message: String(err) },
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (command !== "system.run") {
|
|
await sendInvokeResult(client, frame, {
|
|
ok: false,
|
|
error: { code: "UNAVAILABLE", message: "command not supported" },
|
|
});
|
|
return;
|
|
}
|
|
|
|
let params: SystemRunParams;
|
|
try {
|
|
params = decodeParams<SystemRunParams>(frame.paramsJSON);
|
|
} catch (err) {
|
|
await sendInvokeResult(client, frame, {
|
|
ok: false,
|
|
error: { code: "INVALID_REQUEST", message: String(err) },
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (!Array.isArray(params.command) || params.command.length === 0) {
|
|
await sendInvokeResult(client, frame, {
|
|
ok: false,
|
|
error: { code: "INVALID_REQUEST", message: "command required" },
|
|
});
|
|
return;
|
|
}
|
|
|
|
const argv = params.command.map((item) => String(item));
|
|
const rawCommand = typeof params.rawCommand === "string" ? params.rawCommand.trim() : "";
|
|
const cmdText = rawCommand || formatCommand(argv);
|
|
const agentId = params.agentId?.trim() || undefined;
|
|
const approvals = resolveExecApprovals(agentId, { security: "allowlist" });
|
|
const security = approvals.agent.security;
|
|
const ask = approvals.agent.ask;
|
|
const autoAllowSkills = approvals.agent.autoAllowSkills;
|
|
const sessionKey = params.sessionKey?.trim() || "node";
|
|
const runId = params.runId?.trim() || crypto.randomUUID();
|
|
const env = sanitizeEnv(params.env ?? undefined);
|
|
const cfg = loadConfig();
|
|
const agentExec = agentId ? resolveAgentConfig(cfg, agentId)?.tools?.exec : undefined;
|
|
const safeBins = resolveSafeBins(agentExec?.safeBins ?? cfg.tools?.exec?.safeBins);
|
|
const bins = autoAllowSkills ? await skillBins.current() : new Set<string>();
|
|
let analysisOk = false;
|
|
let allowlistMatches: ExecAllowlistEntry[] = [];
|
|
let allowlistSatisfied = false;
|
|
let segments: ExecCommandSegment[] = [];
|
|
if (rawCommand) {
|
|
const allowlistEval = evaluateShellAllowlist({
|
|
command: rawCommand,
|
|
allowlist: approvals.allowlist,
|
|
safeBins,
|
|
cwd: params.cwd ?? undefined,
|
|
env,
|
|
skillBins: bins,
|
|
autoAllowSkills,
|
|
});
|
|
analysisOk = allowlistEval.analysisOk;
|
|
allowlistMatches = allowlistEval.allowlistMatches;
|
|
allowlistSatisfied =
|
|
security === "allowlist" && analysisOk ? allowlistEval.allowlistSatisfied : false;
|
|
segments = allowlistEval.segments;
|
|
} else {
|
|
const analysis = analyzeArgvCommand({ argv, cwd: params.cwd ?? undefined, env });
|
|
const allowlistEval = evaluateExecAllowlist({
|
|
analysis,
|
|
allowlist: approvals.allowlist,
|
|
safeBins,
|
|
cwd: params.cwd ?? undefined,
|
|
skillBins: bins,
|
|
autoAllowSkills,
|
|
});
|
|
analysisOk = analysis.ok;
|
|
allowlistMatches = allowlistEval.allowlistMatches;
|
|
allowlistSatisfied =
|
|
security === "allowlist" && analysisOk ? allowlistEval.allowlistSatisfied : false;
|
|
segments = analysis.segments;
|
|
}
|
|
|
|
const useMacAppExec = process.platform === "darwin";
|
|
if (useMacAppExec) {
|
|
const approvalDecision =
|
|
params.approvalDecision === "allow-once" || params.approvalDecision === "allow-always"
|
|
? params.approvalDecision
|
|
: null;
|
|
const execRequest: ExecHostRequest = {
|
|
command: argv,
|
|
rawCommand: rawCommand || null,
|
|
cwd: params.cwd ?? null,
|
|
env: params.env ?? null,
|
|
timeoutMs: params.timeoutMs ?? null,
|
|
needsScreenRecording: params.needsScreenRecording ?? null,
|
|
agentId: agentId ?? null,
|
|
sessionKey: sessionKey ?? null,
|
|
approvalDecision,
|
|
};
|
|
const response = await runViaMacAppExecHost({ approvals, request: execRequest });
|
|
if (!response) {
|
|
if (execHostEnforced || !execHostFallbackAllowed) {
|
|
await sendNodeEvent(
|
|
client,
|
|
"exec.denied",
|
|
buildExecEventPayload({
|
|
sessionKey,
|
|
runId,
|
|
host: "node",
|
|
command: cmdText,
|
|
reason: "companion-unavailable",
|
|
}),
|
|
);
|
|
await sendInvokeResult(client, frame, {
|
|
ok: false,
|
|
error: {
|
|
code: "UNAVAILABLE",
|
|
message: "COMPANION_APP_UNAVAILABLE: macOS app exec host unreachable",
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
} else if (!response.ok) {
|
|
const reason = response.error.reason ?? "approval-required";
|
|
await sendNodeEvent(
|
|
client,
|
|
"exec.denied",
|
|
buildExecEventPayload({
|
|
sessionKey,
|
|
runId,
|
|
host: "node",
|
|
command: cmdText,
|
|
reason,
|
|
}),
|
|
);
|
|
await sendInvokeResult(client, frame, {
|
|
ok: false,
|
|
error: { code: "UNAVAILABLE", message: response.error.message },
|
|
});
|
|
return;
|
|
} else {
|
|
const result: ExecHostRunResult = response.payload;
|
|
const combined = [result.stdout, result.stderr, result.error].filter(Boolean).join("\n");
|
|
await sendNodeEvent(
|
|
client,
|
|
"exec.finished",
|
|
buildExecEventPayload({
|
|
sessionKey,
|
|
runId,
|
|
host: "node",
|
|
command: cmdText,
|
|
exitCode: result.exitCode,
|
|
timedOut: result.timedOut,
|
|
success: result.success,
|
|
output: combined,
|
|
}),
|
|
);
|
|
await sendInvokeResult(client, frame, {
|
|
ok: true,
|
|
payloadJSON: JSON.stringify(result),
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (security === "deny") {
|
|
await sendNodeEvent(
|
|
client,
|
|
"exec.denied",
|
|
buildExecEventPayload({
|
|
sessionKey,
|
|
runId,
|
|
host: "node",
|
|
command: cmdText,
|
|
reason: "security=deny",
|
|
}),
|
|
);
|
|
await sendInvokeResult(client, frame, {
|
|
ok: false,
|
|
error: { code: "UNAVAILABLE", message: "SYSTEM_RUN_DISABLED: security=deny" },
|
|
});
|
|
return;
|
|
}
|
|
|
|
const requiresAsk = requiresExecApproval({
|
|
ask,
|
|
security,
|
|
analysisOk,
|
|
allowlistSatisfied,
|
|
});
|
|
|
|
const approvalDecision =
|
|
params.approvalDecision === "allow-once" || params.approvalDecision === "allow-always"
|
|
? params.approvalDecision
|
|
: null;
|
|
const approvedByAsk = approvalDecision !== null || params.approved === true;
|
|
if (requiresAsk && !approvedByAsk) {
|
|
await sendNodeEvent(
|
|
client,
|
|
"exec.denied",
|
|
buildExecEventPayload({
|
|
sessionKey,
|
|
runId,
|
|
host: "node",
|
|
command: cmdText,
|
|
reason: "approval-required",
|
|
}),
|
|
);
|
|
await sendInvokeResult(client, frame, {
|
|
ok: false,
|
|
error: { code: "UNAVAILABLE", message: "SYSTEM_RUN_DENIED: approval required" },
|
|
});
|
|
return;
|
|
}
|
|
if (approvalDecision === "allow-always" && security === "allowlist") {
|
|
if (analysisOk) {
|
|
for (const segment of segments) {
|
|
const pattern = segment.resolution?.resolvedPath ?? "";
|
|
if (pattern) addAllowlistEntry(approvals.file, agentId, pattern);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (security === "allowlist" && (!analysisOk || !allowlistSatisfied) && !approvedByAsk) {
|
|
await sendNodeEvent(
|
|
client,
|
|
"exec.denied",
|
|
buildExecEventPayload({
|
|
sessionKey,
|
|
runId,
|
|
host: "node",
|
|
command: cmdText,
|
|
reason: "allowlist-miss",
|
|
}),
|
|
);
|
|
await sendInvokeResult(client, frame, {
|
|
ok: false,
|
|
error: { code: "UNAVAILABLE", message: "SYSTEM_RUN_DENIED: allowlist miss" },
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (allowlistMatches.length > 0) {
|
|
const seen = new Set<string>();
|
|
for (const match of allowlistMatches) {
|
|
if (!match?.pattern || seen.has(match.pattern)) continue;
|
|
seen.add(match.pattern);
|
|
recordAllowlistUse(
|
|
approvals.file,
|
|
agentId,
|
|
match,
|
|
cmdText,
|
|
segments[0]?.resolution?.resolvedPath,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (params.needsScreenRecording === true) {
|
|
await sendNodeEvent(
|
|
client,
|
|
"exec.denied",
|
|
buildExecEventPayload({
|
|
sessionKey,
|
|
runId,
|
|
host: "node",
|
|
command: cmdText,
|
|
reason: "permission:screenRecording",
|
|
}),
|
|
);
|
|
await sendInvokeResult(client, frame, {
|
|
ok: false,
|
|
error: { code: "UNAVAILABLE", message: "PERMISSION_MISSING: screenRecording" },
|
|
});
|
|
return;
|
|
}
|
|
|
|
const result = await runCommand(
|
|
argv,
|
|
params.cwd?.trim() || undefined,
|
|
env,
|
|
params.timeoutMs ?? undefined,
|
|
);
|
|
if (result.truncated) {
|
|
const suffix = "... (truncated)";
|
|
if (result.stderr.trim().length > 0) {
|
|
result.stderr = `${result.stderr}\n${suffix}`;
|
|
} else {
|
|
result.stdout = `${result.stdout}\n${suffix}`;
|
|
}
|
|
}
|
|
const combined = [result.stdout, result.stderr, result.error].filter(Boolean).join("\n");
|
|
await sendNodeEvent(
|
|
client,
|
|
"exec.finished",
|
|
buildExecEventPayload({
|
|
sessionKey,
|
|
runId,
|
|
host: "node",
|
|
command: cmdText,
|
|
exitCode: result.exitCode,
|
|
timedOut: result.timedOut,
|
|
success: result.success,
|
|
output: combined,
|
|
}),
|
|
);
|
|
|
|
await sendInvokeResult(client, frame, {
|
|
ok: true,
|
|
payloadJSON: JSON.stringify({
|
|
exitCode: result.exitCode,
|
|
timedOut: result.timedOut,
|
|
success: result.success,
|
|
stdout: result.stdout,
|
|
stderr: result.stderr,
|
|
error: result.error ?? null,
|
|
}),
|
|
});
|
|
}
|
|
|
|
function decodeParams<T>(raw?: string | null): T {
|
|
if (!raw) {
|
|
throw new Error("INVALID_REQUEST: paramsJSON required");
|
|
}
|
|
return JSON.parse(raw) as T;
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|