1459 lines
48 KiB
JavaScript
1459 lines
48 KiB
JavaScript
import { d as resolveIsNixMode, g as resolveStateDir } from "./paths-BDd7_JUB.js";
|
|
import { n as resolveAgentConfig } from "./agent-scope-CrgUOY3f.js";
|
|
import { I as colorize, L as isRich, R as theme, c as defaultRuntime } from "./subsystem-46MXi6Ip.js";
|
|
import "./utils-Dg0Xbl6w.js";
|
|
import "./exec-CTo4hK94.js";
|
|
import "./model-selection-Cp1maz7B.js";
|
|
import "./github-copilot-token-VZsS4013.js";
|
|
import { t as formatCliCommand } from "./command-format-BQK1OIvH.js";
|
|
import "./boolean-CE7i9tBR.js";
|
|
import "./env-DOcCob95.js";
|
|
import { M as VERSION, r as loadConfig } from "./config-BAN8QfW5.js";
|
|
import "./manifest-registry-BFpLJJDB.js";
|
|
import "./chrome-D2LUApAY.js";
|
|
import { l as detectMime } from "./routes-CB4ih7ZH.js";
|
|
import { i as resolveBrowserConfig } from "./server-context-BjC2Yruc.js";
|
|
import { h as GATEWAY_CLIENT_NAMES, m as GATEWAY_CLIENT_MODES } from "./message-channel-BD3kuDks.js";
|
|
import "./logging-BdnOSVPD.js";
|
|
import { i as getMachineDisplayName, n as isWSL, t as createBrowserRouteDispatcher } from "./dispatcher-DLhjSne5.js";
|
|
import { At as loadOrCreateDeviceIdentity, t as GatewayClient } from "./client-i9GLCujO.js";
|
|
import { t as formatDocsLink } from "./links-C9fyAH-V.js";
|
|
import { a as evaluateShellAllowlist, c as normalizeExecApprovals, d as requiresExecApproval, f as resolveExecApprovals, g as saveExecApprovals, h as resolveSafeBins, i as evaluateExecAllowlist, l as readExecApprovalsSnapshot, m as resolveExecApprovalsSocketPath, n as analyzeArgvCommand, r as ensureExecApprovals, t as addAllowlistEntry, u as recordAllowlistUse } from "./exec-approvals-CK-Umdr3.js";
|
|
import { r as startBrowserControlServiceFromConfig, t as createBrowserControlContext } from "./control-service-yGq0G92l.js";
|
|
import { c as formatNodeServiceDescription, g as resolveNodeWindowsTaskName, h as resolveNodeSystemdServiceName, m as resolveNodeLaunchAgentLabel } from "./constants-3er_81qc.js";
|
|
import { t as ensureOpenClawCliOnPath } from "./path-env-CuGC6r1F.js";
|
|
import { o as resolveGatewayLogPaths } from "./service-CAxAjHNB.js";
|
|
import { r as isSystemdUserServiceAvailable } from "./systemd-CNYEkRek.js";
|
|
import { t as resolveNodeService } from "./node-service-CjtBRyjp.js";
|
|
import { d as renderSystemNodeWarning, f as resolvePreferredNodePath, h as resolveNodeProgramArguments, o as resolveGatewayDevMode, p as resolveSystemNodeInfo, r as isGatewayDaemonRuntime, s as buildNodeServiceEnvironment, t as DEFAULT_GATEWAY_DAEMON_RUNTIME } from "./daemon-runtime-B9eMPdZi.js";
|
|
import { n as renderSystemdUnavailableHints } from "./systemd-hints-BTRJHXEE.js";
|
|
import { d as createNullWriter, f as emitDaemonActionJson, i as parsePort, n as formatRuntimeStatus, u as buildDaemonServiceSnapshot } from "./shared-ChNOqAzp.js";
|
|
import path from "node:path";
|
|
import fs from "node:fs";
|
|
import fs$1 from "node:fs/promises";
|
|
import { spawn } from "node:child_process";
|
|
import crypto from "node:crypto";
|
|
import net from "node:net";
|
|
|
|
//#region src/node-host/config.ts
|
|
const NODE_HOST_FILE = "node.json";
|
|
function resolveNodeHostConfigPath() {
|
|
return path.join(resolveStateDir(), NODE_HOST_FILE);
|
|
}
|
|
function normalizeConfig(config) {
|
|
const base = {
|
|
version: 1,
|
|
nodeId: "",
|
|
token: config?.token,
|
|
displayName: config?.displayName,
|
|
gateway: config?.gateway
|
|
};
|
|
if (config?.version === 1 && typeof config.nodeId === "string") base.nodeId = config.nodeId.trim();
|
|
if (!base.nodeId) base.nodeId = crypto.randomUUID();
|
|
return base;
|
|
}
|
|
async function loadNodeHostConfig() {
|
|
const filePath = resolveNodeHostConfigPath();
|
|
try {
|
|
const raw = await fs$1.readFile(filePath, "utf8");
|
|
return normalizeConfig(JSON.parse(raw));
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
async function saveNodeHostConfig(config) {
|
|
const filePath = resolveNodeHostConfigPath();
|
|
await fs$1.mkdir(path.dirname(filePath), { recursive: true });
|
|
const payload = JSON.stringify(config, null, 2);
|
|
await fs$1.writeFile(filePath, `${payload}\n`, { mode: 384 });
|
|
try {
|
|
await fs$1.chmod(filePath, 384);
|
|
} catch {}
|
|
}
|
|
async function ensureNodeHostConfig() {
|
|
const normalized = normalizeConfig(await loadNodeHostConfig());
|
|
await saveNodeHostConfig(normalized);
|
|
return normalized;
|
|
}
|
|
|
|
//#endregion
|
|
//#region src/infra/exec-host.ts
|
|
async function requestExecHostViaSocket(params) {
|
|
const { socketPath, token, request } = params;
|
|
if (!socketPath || !token) return null;
|
|
const timeoutMs = params.timeoutMs ?? 2e4;
|
|
return await new Promise((resolve) => {
|
|
const client = new net.Socket();
|
|
let settled = false;
|
|
let buffer = "";
|
|
const finish = (value) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
try {
|
|
client.destroy();
|
|
} catch {}
|
|
resolve(value);
|
|
};
|
|
const requestJson = JSON.stringify(request);
|
|
const nonce = crypto.randomBytes(16).toString("hex");
|
|
const ts = Date.now();
|
|
const hmac = crypto.createHmac("sha256", token).update(`${nonce}:${ts}:${requestJson}`).digest("hex");
|
|
const payload = JSON.stringify({
|
|
type: "exec",
|
|
id: crypto.randomUUID(),
|
|
nonce,
|
|
ts,
|
|
hmac,
|
|
requestJson
|
|
});
|
|
const timer = setTimeout(() => finish(null), timeoutMs);
|
|
client.on("error", () => finish(null));
|
|
client.connect(socketPath, () => {
|
|
client.write(`${payload}\n`);
|
|
});
|
|
client.on("data", (data) => {
|
|
buffer += data.toString("utf8");
|
|
let idx = buffer.indexOf("\n");
|
|
while (idx !== -1) {
|
|
const line = buffer.slice(0, idx).trim();
|
|
buffer = buffer.slice(idx + 1);
|
|
idx = buffer.indexOf("\n");
|
|
if (!line) continue;
|
|
try {
|
|
const msg = JSON.parse(line);
|
|
if (msg?.type === "exec-res") {
|
|
clearTimeout(timer);
|
|
if (msg.ok === true && msg.payload) {
|
|
finish({
|
|
ok: true,
|
|
payload: msg.payload
|
|
});
|
|
return;
|
|
}
|
|
if (msg.ok === false && msg.error) {
|
|
finish({
|
|
ok: false,
|
|
error: msg.error
|
|
});
|
|
return;
|
|
}
|
|
finish(null);
|
|
return;
|
|
}
|
|
} catch {}
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
//#endregion
|
|
//#region src/node-host/runner.ts
|
|
function resolveExecSecurity(value) {
|
|
return value === "deny" || value === "allowlist" || value === "full" ? value : "allowlist";
|
|
}
|
|
function isCmdExeInvocation(argv) {
|
|
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) {
|
|
return value === "off" || value === "on-miss" || value === "always" ? value : "on-miss";
|
|
}
|
|
const OUTPUT_CAP = 2e5;
|
|
const OUTPUT_EVENT_TAIL = 2e4;
|
|
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.OPENCLAW_NODE_EXEC_HOST?.trim().toLowerCase() === "app";
|
|
const execHostFallbackAllowed = process.env.OPENCLAW_NODE_EXEC_FALLBACK?.trim().toLowerCase() !== "0";
|
|
const blockedEnvKeys = new Set([
|
|
"NODE_OPTIONS",
|
|
"PYTHONHOME",
|
|
"PYTHONPATH",
|
|
"PERL5LIB",
|
|
"PERL5OPT",
|
|
"RUBYOPT"
|
|
]);
|
|
const blockedEnvPrefixes = ["DYLD_", "LD_"];
|
|
var SkillBinsCache = class {
|
|
constructor(fetch) {
|
|
this.bins = /* @__PURE__ */ new Set();
|
|
this.lastRefresh = 0;
|
|
this.ttlMs = 9e4;
|
|
this.fetch = fetch;
|
|
}
|
|
async current(force = false) {
|
|
if (force || Date.now() - this.lastRefresh > this.ttlMs) await this.refresh();
|
|
return this.bins;
|
|
}
|
|
async refresh() {
|
|
try {
|
|
const bins = await this.fetch();
|
|
this.bins = new Set(bins);
|
|
this.lastRefresh = Date.now();
|
|
} catch {
|
|
if (!this.lastRefresh) this.bins = /* @__PURE__ */ new Set();
|
|
}
|
|
}
|
|
};
|
|
function sanitizeEnv(overrides) {
|
|
if (!overrides) return;
|
|
const merged = { ...process.env };
|
|
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) {
|
|
return Array.isArray(raw) ? raw.map((entry) => entry.trim()).filter(Boolean) : [];
|
|
}
|
|
function resolveBrowserProxyConfig() {
|
|
const proxy = loadConfig().nodeHost?.browserProxy;
|
|
const allowProfiles = normalizeProfileAllowlist(proxy?.allowProfiles);
|
|
return {
|
|
enabled: proxy?.enabled !== false,
|
|
allowProfiles
|
|
};
|
|
}
|
|
let browserControlReady = null;
|
|
async function ensureBrowserControlService() {
|
|
if (browserControlReady) return browserControlReady;
|
|
browserControlReady = (async () => {
|
|
const cfg = loadConfig();
|
|
if (!resolveBrowserConfig(cfg.browser, cfg).enabled) throw new Error("browser control disabled");
|
|
if (!await startBrowserControlServiceFromConfig()) throw new Error("browser control disabled");
|
|
})();
|
|
return browserControlReady;
|
|
}
|
|
async function withTimeout(promise, timeoutMs, label) {
|
|
const resolved = typeof timeoutMs === "number" && Number.isFinite(timeoutMs) ? Math.max(1, Math.floor(timeoutMs)) : void 0;
|
|
if (!resolved) return await promise;
|
|
let timer;
|
|
const timeoutPromise = new Promise((_, reject) => {
|
|
timer = setTimeout(() => {
|
|
reject(/* @__PURE__ */ new Error(`${label ?? "request"} timed out`));
|
|
}, resolved);
|
|
});
|
|
try {
|
|
return await Promise.race([promise, timeoutPromise]);
|
|
} finally {
|
|
if (timer) clearTimeout(timer);
|
|
}
|
|
}
|
|
function isProfileAllowed(params) {
|
|
const { allowProfiles, profile } = params;
|
|
if (!allowProfiles.length) return true;
|
|
if (!profile) return false;
|
|
return allowProfiles.includes(profile.trim());
|
|
}
|
|
function collectBrowserProxyPaths(payload) {
|
|
const paths = /* @__PURE__ */ new Set();
|
|
const obj = typeof payload === "object" && payload !== null ? payload : 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.path;
|
|
if (typeof dlPath === "string" && dlPath.trim()) paths.add(dlPath.trim());
|
|
}
|
|
return [...paths];
|
|
}
|
|
async function readBrowserProxyFile(filePath) {
|
|
const stat = await fs$1.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 fs$1.readFile(filePath);
|
|
const mimeType = await detectMime({
|
|
buffer,
|
|
filePath
|
|
});
|
|
return {
|
|
path: filePath,
|
|
base64: buffer.toString("base64"),
|
|
mimeType
|
|
};
|
|
}
|
|
function formatCommand(argv) {
|
|
return argv.map((arg) => {
|
|
const trimmed = arg.trim();
|
|
if (!trimmed) return "\"\"";
|
|
if (!/\s|"/.test(trimmed)) return trimmed;
|
|
return `"${trimmed.replace(/"/g, "\\\"")}"`;
|
|
}).join(" ");
|
|
}
|
|
function truncateOutput(raw, maxChars) {
|
|
if (raw.length <= maxChars) return {
|
|
text: raw,
|
|
truncated: false
|
|
};
|
|
return {
|
|
text: `... (truncated) ${raw.slice(raw.length - maxChars)}`,
|
|
truncated: true
|
|
};
|
|
}
|
|
function redactExecApprovals(file) {
|
|
const socketPath = file.socket?.path?.trim();
|
|
return {
|
|
...file,
|
|
socket: socketPath ? { path: socketPath } : void 0
|
|
};
|
|
}
|
|
function requireExecApprovalsBaseHash(params, snapshot) {
|
|
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, cwd, env, timeoutMs) {
|
|
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, target) => {
|
|
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, "stdout"));
|
|
child.stderr?.on("data", (chunk) => onChunk(chunk, "stderr"));
|
|
let timer;
|
|
if (timeoutMs && timeoutMs > 0) timer = setTimeout(() => {
|
|
timedOut = true;
|
|
try {
|
|
child.kill("SIGKILL");
|
|
} catch {}
|
|
}, timeoutMs);
|
|
const finalize = (exitCode, error) => {
|
|
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(void 0, err.message);
|
|
});
|
|
child.on("exit", (code) => {
|
|
finalize(code === null ? void 0 : code, null);
|
|
});
|
|
});
|
|
}
|
|
function resolveEnvPath(env) {
|
|
return (env?.PATH ?? env?.Path ?? process.env.PATH ?? process.env.Path ?? DEFAULT_NODE_PATH).split(path.delimiter).filter(Boolean);
|
|
}
|
|
function ensureNodePathEnv() {
|
|
ensureOpenClawCliOnPath({ 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, env) {
|
|
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, env) {
|
|
const bins = params.bins.map((bin) => bin.trim()).filter(Boolean);
|
|
const found = {};
|
|
for (const bin of bins) {
|
|
const path = resolveExecutable(bin, env);
|
|
if (path) found[bin] = path;
|
|
}
|
|
return { bins: found };
|
|
}
|
|
function buildExecEventPayload(payload) {
|
|
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) {
|
|
const { approvals, request } = params;
|
|
return await requestExecHostViaSocket({
|
|
socketPath: approvals.socketPath,
|
|
token: approvals.token,
|
|
request
|
|
});
|
|
}
|
|
async function runNodeHost(opts) {
|
|
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 = {
|
|
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, cfg);
|
|
const browserProxyEnabled = browserProxy.enabled && resolvedBrowser.enabled;
|
|
const isRemoteMode = cfg.gateway?.mode === "remote";
|
|
const token = process.env.OPENCLAW_GATEWAY_TOKEN?.trim() || (isRemoteMode ? cfg.gateway?.remote?.token : cfg.gateway?.auth?.token);
|
|
const password = process.env.OPENCLAW_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 url = `${gateway.tls ? "wss" : "ws"}://${host}:${port}`;
|
|
const pathEnv = ensureNodePathEnv();
|
|
console.log(`node host PATH: ${pathEnv}`);
|
|
const client = new GatewayClient({
|
|
url,
|
|
token: token?.trim() || void 0,
|
|
password: password?.trim() || void 0,
|
|
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: void 0,
|
|
deviceIdentity: loadOrCreateDeviceIdentity(),
|
|
tlsFingerprint: gateway.tlsFingerprint,
|
|
onEvent: (evt) => {
|
|
if (evt.event !== "node.invoke.request") return;
|
|
const payload = coerceNodeInvokePayload(evt.payload);
|
|
if (!payload) return;
|
|
handleInvoke(payload, client, skillBins);
|
|
},
|
|
onConnectError: (err) => {
|
|
console.error(`node host gateway connect failed: ${err.message}`);
|
|
},
|
|
onClose: (code, reason) => {
|
|
console.error(`node host gateway closed (${code}): ${reason}`);
|
|
}
|
|
});
|
|
const skillBins = new SkillBinsCache(async () => {
|
|
const res = await client.request("skills.bins", {});
|
|
return Array.isArray(res?.bins) ? res.bins.map((bin) => String(bin)) : [];
|
|
});
|
|
client.start();
|
|
await new Promise(() => {});
|
|
}
|
|
async function handleInvoke(frame, client, skillBins) {
|
|
const command = String(frame.command ?? "");
|
|
if (command === "system.execApprovals.get") {
|
|
try {
|
|
ensureExecApprovals();
|
|
const snapshot = readExecApprovalsSnapshot();
|
|
const payload = {
|
|
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) {
|
|
const message = String(err);
|
|
await sendInvokeResult(client, frame, {
|
|
ok: false,
|
|
error: {
|
|
code: message.toLowerCase().includes("timed out") ? "TIMEOUT" : "INVALID_REQUEST",
|
|
message
|
|
}
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
if (command === "system.execApprovals.set") {
|
|
try {
|
|
const params = decodeParams(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 ?? "";
|
|
saveExecApprovals({
|
|
...normalized,
|
|
socket: {
|
|
path: socketPath,
|
|
token
|
|
}
|
|
});
|
|
const nextSnapshot = readExecApprovalsSnapshot();
|
|
const payload = {
|
|
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(frame.paramsJSON);
|
|
if (!Array.isArray(params.bins)) throw new Error("INVALID_REQUEST: bins required");
|
|
const payload = await handleSystemWhich(params, sanitizeEnv(void 0));
|
|
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(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 ensureBrowserControlService();
|
|
const cfg = loadConfig();
|
|
const resolved = resolveBrowserConfig(cfg.browser, cfg);
|
|
const requestedProfile = typeof params.profile === "string" ? params.profile.trim() : "";
|
|
const allowedProfiles = proxyConfig.allowProfiles;
|
|
if (allowedProfiles.length > 0) {
|
|
if (pathValue !== "/profiles") {
|
|
if (!isProfileAllowed({
|
|
allowProfiles: allowedProfiles,
|
|
profile: requestedProfile || resolved.defaultProfile
|
|
})) 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 method = typeof params.method === "string" ? params.method.toUpperCase() : "GET";
|
|
const path = pathValue.startsWith("/") ? pathValue : `/${pathValue}`;
|
|
const body = params.body;
|
|
const query = {};
|
|
if (requestedProfile) query.profile = requestedProfile;
|
|
const rawQuery = params.query ?? {};
|
|
for (const [key, value] of Object.entries(rawQuery)) {
|
|
if (value === void 0 || value === null) continue;
|
|
query[key] = typeof value === "string" ? value : String(value);
|
|
}
|
|
const response = await withTimeout(createBrowserRouteDispatcher(createBrowserControlContext()).dispatch({
|
|
method: method === "DELETE" ? "DELETE" : method === "POST" ? "POST" : "GET",
|
|
path,
|
|
query,
|
|
body
|
|
}), params.timeoutMs, "browser proxy request");
|
|
if (response.status >= 400) {
|
|
const message = response.body && typeof response.body === "object" && "error" in response.body ? String(response.body.error) : `HTTP ${response.status}`;
|
|
throw new Error(message);
|
|
}
|
|
const result = response.body;
|
|
if (allowedProfiles.length > 0 && path === "/profiles") {
|
|
const obj = typeof result === "object" && result !== null ? result : {};
|
|
obj.profiles = (Array.isArray(obj.profiles) ? obj.profiles : []).filter((entry) => {
|
|
if (!entry || typeof entry !== "object") return false;
|
|
const name = entry.name;
|
|
return typeof name === "string" && allowedProfiles.includes(name);
|
|
});
|
|
}
|
|
let files;
|
|
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)}`, { cause: err });
|
|
}
|
|
}));
|
|
if (loaded.length > 0) files = loaded;
|
|
}
|
|
const payload = 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;
|
|
try {
|
|
params = decodeParams(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() || void 0;
|
|
const cfg = loadConfig();
|
|
const agentExec = agentId ? resolveAgentConfig(cfg, agentId)?.tools?.exec : void 0;
|
|
const approvals = resolveExecApprovals(agentId, {
|
|
security: resolveExecSecurity(agentExec?.security ?? cfg.tools?.exec?.security),
|
|
ask: resolveExecAsk(agentExec?.ask ?? cfg.tools?.exec?.ask)
|
|
});
|
|
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 ?? void 0);
|
|
const safeBins = resolveSafeBins(agentExec?.safeBins ?? cfg.tools?.exec?.safeBins);
|
|
const bins = autoAllowSkills ? await skillBins.current() : /* @__PURE__ */ new Set();
|
|
let analysisOk = false;
|
|
let allowlistMatches = [];
|
|
let allowlistSatisfied = false;
|
|
let segments = [];
|
|
if (rawCommand) {
|
|
const allowlistEval = evaluateShellAllowlist({
|
|
command: rawCommand,
|
|
allowlist: approvals.allowlist,
|
|
safeBins,
|
|
cwd: params.cwd ?? void 0,
|
|
env,
|
|
skillBins: bins,
|
|
autoAllowSkills,
|
|
platform: process.platform
|
|
});
|
|
analysisOk = allowlistEval.analysisOk;
|
|
allowlistMatches = allowlistEval.allowlistMatches;
|
|
allowlistSatisfied = security === "allowlist" && analysisOk ? allowlistEval.allowlistSatisfied : false;
|
|
segments = allowlistEval.segments;
|
|
} else {
|
|
const analysis = analyzeArgvCommand({
|
|
argv,
|
|
cwd: params.cwd ?? void 0,
|
|
env
|
|
});
|
|
const allowlistEval = evaluateExecAllowlist({
|
|
analysis,
|
|
allowlist: approvals.allowlist,
|
|
safeBins,
|
|
cwd: params.cwd ?? void 0,
|
|
skillBins: bins,
|
|
autoAllowSkills
|
|
});
|
|
analysisOk = analysis.ok;
|
|
allowlistMatches = allowlistEval.allowlistMatches;
|
|
allowlistSatisfied = security === "allowlist" && analysisOk ? allowlistEval.allowlistSatisfied : false;
|
|
segments = analysis.segments;
|
|
}
|
|
const isWindows = process.platform === "win32";
|
|
const cmdInvocation = rawCommand ? isCmdExeInvocation(segments[0]?.argv ?? []) : isCmdExeInvocation(argv);
|
|
if (security === "allowlist" && isWindows && cmdInvocation) {
|
|
analysisOk = false;
|
|
allowlistSatisfied = false;
|
|
}
|
|
if (process.platform === "darwin") {
|
|
const approvalDecision = params.approvalDecision === "allow-once" || params.approvalDecision === "allow-always" ? params.approvalDecision : null;
|
|
const response = await runViaMacAppExecHost({
|
|
approvals,
|
|
request: {
|
|
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
|
|
}
|
|
});
|
|
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) {
|
|
await sendNodeEvent(client, "exec.denied", buildExecEventPayload({
|
|
sessionKey,
|
|
runId,
|
|
host: "node",
|
|
command: cmdText,
|
|
reason: response.error.reason ?? "approval-required"
|
|
}));
|
|
await sendInvokeResult(client, frame, {
|
|
ok: false,
|
|
error: {
|
|
code: "UNAVAILABLE",
|
|
message: response.error.message
|
|
}
|
|
});
|
|
return;
|
|
} else {
|
|
const result = 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 = /* @__PURE__ */ new Set();
|
|
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;
|
|
}
|
|
let execArgv = argv;
|
|
if (security === "allowlist" && isWindows && !approvedByAsk && rawCommand && analysisOk && allowlistSatisfied && segments.length === 1 && segments[0]?.argv.length > 0) execArgv = segments[0].argv;
|
|
const result = await runCommand(execArgv, params.cwd?.trim() || void 0, env, params.timeoutMs ?? void 0);
|
|
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(raw) {
|
|
if (!raw) throw new Error("INVALID_REQUEST: paramsJSON required");
|
|
return JSON.parse(raw);
|
|
}
|
|
function coerceNodeInvokePayload(payload) {
|
|
if (!payload || typeof payload !== "object") return null;
|
|
const obj = payload;
|
|
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;
|
|
return {
|
|
id,
|
|
nodeId,
|
|
command,
|
|
paramsJSON: typeof obj.paramsJSON === "string" ? obj.paramsJSON : obj.params !== void 0 ? JSON.stringify(obj.params) : null,
|
|
timeoutMs: typeof obj.timeoutMs === "number" ? obj.timeoutMs : null,
|
|
idempotencyKey: typeof obj.idempotencyKey === "string" ? obj.idempotencyKey : null
|
|
};
|
|
}
|
|
async function sendInvokeResult(client, frame, result) {
|
|
try {
|
|
await client.request("node.invoke.result", buildNodeInvokeResultParams(frame, result));
|
|
} catch {}
|
|
}
|
|
function buildNodeInvokeResultParams(frame, result) {
|
|
const params = {
|
|
id: frame.id,
|
|
nodeId: frame.nodeId,
|
|
ok: result.ok
|
|
};
|
|
if (result.payload !== void 0) 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, event, payload) {
|
|
try {
|
|
await client.request("node.event", {
|
|
event,
|
|
payloadJSON: payload ? JSON.stringify(payload) : null
|
|
});
|
|
} catch {}
|
|
}
|
|
|
|
//#endregion
|
|
//#region src/commands/node-daemon-install-helpers.ts
|
|
async function buildNodeInstallPlan(params) {
|
|
const devMode = params.devMode ?? resolveGatewayDevMode();
|
|
const nodePath = params.nodePath ?? await resolvePreferredNodePath({
|
|
env: params.env,
|
|
runtime: params.runtime
|
|
});
|
|
const { programArguments, workingDirectory } = await resolveNodeProgramArguments({
|
|
host: params.host,
|
|
port: params.port,
|
|
tls: params.tls,
|
|
tlsFingerprint: params.tlsFingerprint,
|
|
nodeId: params.nodeId,
|
|
displayName: params.displayName,
|
|
dev: devMode,
|
|
runtime: params.runtime,
|
|
nodePath
|
|
});
|
|
if (params.runtime === "node") {
|
|
const warning = renderSystemNodeWarning(await resolveSystemNodeInfo({ env: params.env }), programArguments[0]);
|
|
if (warning) params.warn?.(warning, "Node daemon runtime");
|
|
}
|
|
const environment = buildNodeServiceEnvironment({ env: params.env });
|
|
return {
|
|
programArguments,
|
|
workingDirectory,
|
|
environment,
|
|
description: formatNodeServiceDescription({ version: environment.OPENCLAW_SERVICE_VERSION })
|
|
};
|
|
}
|
|
|
|
//#endregion
|
|
//#region src/commands/node-daemon-runtime.ts
|
|
const DEFAULT_NODE_DAEMON_RUNTIME = DEFAULT_GATEWAY_DAEMON_RUNTIME;
|
|
function isNodeDaemonRuntime(value) {
|
|
return isGatewayDaemonRuntime(value);
|
|
}
|
|
|
|
//#endregion
|
|
//#region src/cli/node-cli/daemon.ts
|
|
function renderNodeServiceStartHints() {
|
|
const base = [formatCliCommand("openclaw node install"), formatCliCommand("openclaw node start")];
|
|
switch (process.platform) {
|
|
case "darwin": return [...base, `launchctl bootstrap gui/$UID ~/Library/LaunchAgents/${resolveNodeLaunchAgentLabel()}.plist`];
|
|
case "linux": return [...base, `systemctl --user start ${resolveNodeSystemdServiceName()}.service`];
|
|
case "win32": return [...base, `schtasks /Run /TN "${resolveNodeWindowsTaskName()}"`];
|
|
default: return base;
|
|
}
|
|
}
|
|
function buildNodeRuntimeHints(env = process.env) {
|
|
if (process.platform === "darwin") {
|
|
const logs = resolveGatewayLogPaths(env);
|
|
return [`Launchd stdout (if installed): ${logs.stdoutPath}`, `Launchd stderr (if installed): ${logs.stderrPath}`];
|
|
}
|
|
if (process.platform === "linux") return [`Logs: journalctl --user -u ${resolveNodeSystemdServiceName()}.service -n 200 --no-pager`];
|
|
if (process.platform === "win32") return [`Logs: schtasks /Query /TN "${resolveNodeWindowsTaskName()}" /V /FO LIST`];
|
|
return [];
|
|
}
|
|
function resolveNodeDefaults(opts, config) {
|
|
const host = opts.host?.trim() || config?.gateway?.host || "127.0.0.1";
|
|
const portOverride = parsePort(opts.port);
|
|
if (opts.port !== void 0 && portOverride === null) return {
|
|
host,
|
|
port: null
|
|
};
|
|
return {
|
|
host,
|
|
port: portOverride ?? config?.gateway?.port ?? 18789
|
|
};
|
|
}
|
|
async function runNodeDaemonInstall(opts) {
|
|
const json = Boolean(opts.json);
|
|
const warnings = [];
|
|
const stdout = json ? createNullWriter() : process.stdout;
|
|
const emit = (payload) => {
|
|
if (!json) return;
|
|
emitDaemonActionJson({
|
|
action: "install",
|
|
...payload
|
|
});
|
|
};
|
|
const fail = (message, hints) => {
|
|
if (json) emit({
|
|
ok: false,
|
|
error: message,
|
|
hints,
|
|
warnings: warnings.length ? warnings : void 0
|
|
});
|
|
else {
|
|
defaultRuntime.error(message);
|
|
if (hints?.length) for (const hint of hints) defaultRuntime.log(`Tip: ${hint}`);
|
|
}
|
|
defaultRuntime.exit(1);
|
|
};
|
|
if (resolveIsNixMode(process.env)) {
|
|
fail("Nix mode detected; service install is disabled.");
|
|
return;
|
|
}
|
|
const config = await loadNodeHostConfig();
|
|
const { host, port } = resolveNodeDefaults(opts, config);
|
|
if (!Number.isFinite(port ?? NaN) || (port ?? 0) <= 0) {
|
|
fail("Invalid port");
|
|
return;
|
|
}
|
|
const runtimeRaw = opts.runtime ? String(opts.runtime) : DEFAULT_NODE_DAEMON_RUNTIME;
|
|
if (!isNodeDaemonRuntime(runtimeRaw)) {
|
|
fail("Invalid --runtime (use \"node\" or \"bun\")");
|
|
return;
|
|
}
|
|
const service = resolveNodeService();
|
|
let loaded = false;
|
|
try {
|
|
loaded = await service.isLoaded({ env: process.env });
|
|
} catch (err) {
|
|
fail(`Node service check failed: ${String(err)}`);
|
|
return;
|
|
}
|
|
if (loaded && !opts.force) {
|
|
emit({
|
|
ok: true,
|
|
result: "already-installed",
|
|
message: `Node service already ${service.loadedText}.`,
|
|
service: buildDaemonServiceSnapshot(service, loaded),
|
|
warnings: warnings.length ? warnings : void 0
|
|
});
|
|
if (!json) {
|
|
defaultRuntime.log(`Node service already ${service.loadedText}.`);
|
|
defaultRuntime.log(`Reinstall with: ${formatCliCommand("openclaw node install --force")}`);
|
|
}
|
|
return;
|
|
}
|
|
const tlsFingerprint = opts.tlsFingerprint?.trim() || config?.gateway?.tlsFingerprint;
|
|
const tls = Boolean(opts.tls) || Boolean(tlsFingerprint) || Boolean(config?.gateway?.tls);
|
|
const { programArguments, workingDirectory, environment, description } = await buildNodeInstallPlan({
|
|
env: process.env,
|
|
host,
|
|
port: port ?? 18789,
|
|
tls,
|
|
tlsFingerprint: tlsFingerprint || void 0,
|
|
nodeId: opts.nodeId,
|
|
displayName: opts.displayName,
|
|
runtime: runtimeRaw,
|
|
warn: (message) => {
|
|
if (json) warnings.push(message);
|
|
else defaultRuntime.log(message);
|
|
}
|
|
});
|
|
try {
|
|
await service.install({
|
|
env: process.env,
|
|
stdout,
|
|
programArguments,
|
|
workingDirectory,
|
|
environment,
|
|
description
|
|
});
|
|
} catch (err) {
|
|
fail(`Node install failed: ${String(err)}`);
|
|
return;
|
|
}
|
|
let installed = true;
|
|
try {
|
|
installed = await service.isLoaded({ env: process.env });
|
|
} catch {
|
|
installed = true;
|
|
}
|
|
emit({
|
|
ok: true,
|
|
result: "installed",
|
|
service: buildDaemonServiceSnapshot(service, installed),
|
|
warnings: warnings.length ? warnings : void 0
|
|
});
|
|
}
|
|
async function runNodeDaemonUninstall(opts = {}) {
|
|
const json = Boolean(opts.json);
|
|
const stdout = json ? createNullWriter() : process.stdout;
|
|
const emit = (payload) => {
|
|
if (!json) return;
|
|
emitDaemonActionJson({
|
|
action: "uninstall",
|
|
...payload
|
|
});
|
|
};
|
|
const fail = (message) => {
|
|
if (json) emit({
|
|
ok: false,
|
|
error: message
|
|
});
|
|
else defaultRuntime.error(message);
|
|
defaultRuntime.exit(1);
|
|
};
|
|
if (resolveIsNixMode(process.env)) {
|
|
fail("Nix mode detected; service uninstall is disabled.");
|
|
return;
|
|
}
|
|
const service = resolveNodeService();
|
|
try {
|
|
await service.uninstall({
|
|
env: process.env,
|
|
stdout
|
|
});
|
|
} catch (err) {
|
|
fail(`Node uninstall failed: ${String(err)}`);
|
|
return;
|
|
}
|
|
let loaded = false;
|
|
try {
|
|
loaded = await service.isLoaded({ env: process.env });
|
|
} catch {
|
|
loaded = false;
|
|
}
|
|
emit({
|
|
ok: true,
|
|
result: "uninstalled",
|
|
service: buildDaemonServiceSnapshot(service, loaded)
|
|
});
|
|
}
|
|
async function runNodeDaemonRestart(opts = {}) {
|
|
const json = Boolean(opts.json);
|
|
const stdout = json ? createNullWriter() : process.stdout;
|
|
const emit = (payload) => {
|
|
if (!json) return;
|
|
emitDaemonActionJson({
|
|
action: "restart",
|
|
...payload
|
|
});
|
|
};
|
|
const fail = (message, hints) => {
|
|
if (json) emit({
|
|
ok: false,
|
|
error: message,
|
|
hints
|
|
});
|
|
else defaultRuntime.error(message);
|
|
defaultRuntime.exit(1);
|
|
};
|
|
const service = resolveNodeService();
|
|
let loaded = false;
|
|
try {
|
|
loaded = await service.isLoaded({ env: process.env });
|
|
} catch (err) {
|
|
fail(`Node service check failed: ${String(err)}`);
|
|
return;
|
|
}
|
|
if (!loaded) {
|
|
let hints = renderNodeServiceStartHints();
|
|
if (process.platform === "linux") {
|
|
if (!await isSystemdUserServiceAvailable().catch(() => false)) hints = [...hints, ...renderSystemdUnavailableHints({ wsl: await isWSL() })];
|
|
}
|
|
emit({
|
|
ok: true,
|
|
result: "not-loaded",
|
|
message: `Node service ${service.notLoadedText}.`,
|
|
hints,
|
|
service: buildDaemonServiceSnapshot(service, loaded)
|
|
});
|
|
if (!json) {
|
|
defaultRuntime.log(`Node service ${service.notLoadedText}.`);
|
|
for (const hint of hints) defaultRuntime.log(`Start with: ${hint}`);
|
|
}
|
|
return;
|
|
}
|
|
try {
|
|
await service.restart({
|
|
env: process.env,
|
|
stdout
|
|
});
|
|
} catch (err) {
|
|
const hints = renderNodeServiceStartHints();
|
|
fail(`Node restart failed: ${String(err)}`, hints);
|
|
return;
|
|
}
|
|
let restarted = true;
|
|
try {
|
|
restarted = await service.isLoaded({ env: process.env });
|
|
} catch {
|
|
restarted = true;
|
|
}
|
|
emit({
|
|
ok: true,
|
|
result: "restarted",
|
|
service: buildDaemonServiceSnapshot(service, restarted)
|
|
});
|
|
}
|
|
async function runNodeDaemonStop(opts = {}) {
|
|
const json = Boolean(opts.json);
|
|
const stdout = json ? createNullWriter() : process.stdout;
|
|
const emit = (payload) => {
|
|
if (!json) return;
|
|
emitDaemonActionJson({
|
|
action: "stop",
|
|
...payload
|
|
});
|
|
};
|
|
const fail = (message) => {
|
|
if (json) emit({
|
|
ok: false,
|
|
error: message
|
|
});
|
|
else defaultRuntime.error(message);
|
|
defaultRuntime.exit(1);
|
|
};
|
|
const service = resolveNodeService();
|
|
let loaded = false;
|
|
try {
|
|
loaded = await service.isLoaded({ env: process.env });
|
|
} catch (err) {
|
|
fail(`Node service check failed: ${String(err)}`);
|
|
return;
|
|
}
|
|
if (!loaded) {
|
|
emit({
|
|
ok: true,
|
|
result: "not-loaded",
|
|
message: `Node service ${service.notLoadedText}.`,
|
|
service: buildDaemonServiceSnapshot(service, loaded)
|
|
});
|
|
if (!json) defaultRuntime.log(`Node service ${service.notLoadedText}.`);
|
|
return;
|
|
}
|
|
try {
|
|
await service.stop({
|
|
env: process.env,
|
|
stdout
|
|
});
|
|
} catch (err) {
|
|
fail(`Node stop failed: ${String(err)}`);
|
|
return;
|
|
}
|
|
let stopped = false;
|
|
try {
|
|
stopped = await service.isLoaded({ env: process.env });
|
|
} catch {
|
|
stopped = false;
|
|
}
|
|
emit({
|
|
ok: true,
|
|
result: "stopped",
|
|
service: buildDaemonServiceSnapshot(service, stopped)
|
|
});
|
|
}
|
|
async function runNodeDaemonStatus(opts = {}) {
|
|
const json = Boolean(opts.json);
|
|
const service = resolveNodeService();
|
|
const [loaded, command, runtime] = await Promise.all([
|
|
service.isLoaded({ env: process.env }).catch(() => false),
|
|
service.readCommand(process.env).catch(() => null),
|
|
service.readRuntime(process.env).catch((err) => ({
|
|
status: "unknown",
|
|
detail: String(err)
|
|
}))
|
|
]);
|
|
const payload = { service: {
|
|
...buildDaemonServiceSnapshot(service, loaded),
|
|
command,
|
|
runtime
|
|
} };
|
|
if (json) {
|
|
defaultRuntime.log(JSON.stringify(payload, null, 2));
|
|
return;
|
|
}
|
|
const rich = isRich();
|
|
const label = (value) => colorize(rich, theme.muted, value);
|
|
const accent = (value) => colorize(rich, theme.accent, value);
|
|
const infoText = (value) => colorize(rich, theme.info, value);
|
|
const okText = (value) => colorize(rich, theme.success, value);
|
|
const warnText = (value) => colorize(rich, theme.warn, value);
|
|
const errorText = (value) => colorize(rich, theme.error, value);
|
|
const serviceStatus = loaded ? okText(service.loadedText) : warnText(service.notLoadedText);
|
|
defaultRuntime.log(`${label("Service:")} ${accent(service.label)} (${serviceStatus})`);
|
|
if (command?.programArguments?.length) defaultRuntime.log(`${label("Command:")} ${infoText(command.programArguments.join(" "))}`);
|
|
if (command?.sourcePath) defaultRuntime.log(`${label("Service file:")} ${infoText(command.sourcePath)}`);
|
|
if (command?.workingDirectory) defaultRuntime.log(`${label("Working dir:")} ${infoText(command.workingDirectory)}`);
|
|
const runtimeLine = formatRuntimeStatus(runtime);
|
|
if (runtimeLine) {
|
|
const runtimeStatus = runtime?.status ?? "unknown";
|
|
const runtimeColor = runtimeStatus === "running" ? theme.success : runtimeStatus === "stopped" ? theme.error : runtimeStatus === "unknown" ? theme.muted : theme.warn;
|
|
defaultRuntime.log(`${label("Runtime:")} ${colorize(rich, runtimeColor, runtimeLine)}`);
|
|
}
|
|
if (!loaded) {
|
|
defaultRuntime.log("");
|
|
for (const hint of renderNodeServiceStartHints()) defaultRuntime.log(`${warnText("Start with:")} ${infoText(hint)}`);
|
|
return;
|
|
}
|
|
const baseEnv = {
|
|
...process.env,
|
|
...command?.environment ?? void 0
|
|
};
|
|
const hintEnv = {
|
|
...baseEnv,
|
|
OPENCLAW_LOG_PREFIX: baseEnv.OPENCLAW_LOG_PREFIX ?? "node"
|
|
};
|
|
if (runtime?.missingUnit) {
|
|
defaultRuntime.error(errorText("Service unit not found."));
|
|
for (const hint of buildNodeRuntimeHints(hintEnv)) defaultRuntime.error(errorText(hint));
|
|
return;
|
|
}
|
|
if (runtime?.status === "stopped") {
|
|
defaultRuntime.error(errorText("Service is loaded but not running."));
|
|
for (const hint of buildNodeRuntimeHints(hintEnv)) defaultRuntime.error(errorText(hint));
|
|
}
|
|
}
|
|
|
|
//#endregion
|
|
//#region src/cli/node-cli/register.ts
|
|
function parsePortWithFallback(value, fallback) {
|
|
return parsePort(value) ?? fallback;
|
|
}
|
|
function registerNodeCli(program) {
|
|
const node = program.command("node").description("Run a headless node host (system.run/system.which)").addHelpText("after", () => `\n${theme.muted("Docs:")} ${formatDocsLink("/cli/node", "docs.openclaw.ai/cli/node")}\n`);
|
|
node.command("run").description("Run the headless node host (foreground)").option("--host <host>", "Gateway host").option("--port <port>", "Gateway port").option("--tls", "Use TLS for the gateway connection", false).option("--tls-fingerprint <sha256>", "Expected TLS certificate fingerprint (sha256)").option("--node-id <id>", "Override node id (clears pairing token)").option("--display-name <name>", "Override node display name").action(async (opts) => {
|
|
const existing = await loadNodeHostConfig();
|
|
await runNodeHost({
|
|
gatewayHost: opts.host?.trim() || existing?.gateway?.host || "127.0.0.1",
|
|
gatewayPort: parsePortWithFallback(opts.port, existing?.gateway?.port ?? 18789),
|
|
gatewayTls: Boolean(opts.tls) || Boolean(opts.tlsFingerprint),
|
|
gatewayTlsFingerprint: opts.tlsFingerprint,
|
|
nodeId: opts.nodeId,
|
|
displayName: opts.displayName
|
|
});
|
|
});
|
|
node.command("status").description("Show node host status").option("--json", "Output JSON", false).action(async (opts) => {
|
|
await runNodeDaemonStatus(opts);
|
|
});
|
|
node.command("install").description("Install the node host service (launchd/systemd/schtasks)").option("--host <host>", "Gateway host").option("--port <port>", "Gateway port").option("--tls", "Use TLS for the gateway connection", false).option("--tls-fingerprint <sha256>", "Expected TLS certificate fingerprint (sha256)").option("--node-id <id>", "Override node id (clears pairing token)").option("--display-name <name>", "Override node display name").option("--runtime <runtime>", "Service runtime (node|bun). Default: node").option("--force", "Reinstall/overwrite if already installed", false).option("--json", "Output JSON", false).action(async (opts) => {
|
|
await runNodeDaemonInstall(opts);
|
|
});
|
|
node.command("uninstall").description("Uninstall the node host service (launchd/systemd/schtasks)").option("--json", "Output JSON", false).action(async (opts) => {
|
|
await runNodeDaemonUninstall(opts);
|
|
});
|
|
node.command("stop").description("Stop the node host service (launchd/systemd/schtasks)").option("--json", "Output JSON", false).action(async (opts) => {
|
|
await runNodeDaemonStop(opts);
|
|
});
|
|
node.command("restart").description("Restart the node host service (launchd/systemd/schtasks)").option("--json", "Output JSON", false).action(async (opts) => {
|
|
await runNodeDaemonRestart(opts);
|
|
});
|
|
}
|
|
|
|
//#endregion
|
|
export { registerNodeCli }; |