mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-19 03:41:35 +00:00
refactor(mac): make app node a CLI capability superset (#105642)
* refactor(mac): reuse CLI node-host runtime * fix(mac): prefer checkout CLI in debug builds * chore: leave release notes to release automation * chore(mac): sync native string inventory * chore(mac): refresh native locale artifacts * fix(node): satisfy native and deadcode gates
This commit is contained in:
committed by
GitHub
parent
f15a5e566b
commit
d287c9b414
10
src/node-host/client.ts
Normal file
10
src/node-host/client.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/** Minimal Gateway request surface consumed by the reusable node-host runtime. */
|
||||
import type { GatewayClientRequestOptions } from "../gateway/client.js";
|
||||
|
||||
export type NodeHostClient = {
|
||||
request<T = Record<string, unknown>>(
|
||||
method: string,
|
||||
params?: unknown,
|
||||
opts?: GatewayClientRequestOptions,
|
||||
): Promise<T>;
|
||||
};
|
||||
@@ -2,7 +2,6 @@
|
||||
import crypto from "node:crypto";
|
||||
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import type { GatewayClient } from "../gateway/client.js";
|
||||
import {
|
||||
describeInterpreterInlineEval,
|
||||
type InterpreterInlineEvalHit,
|
||||
@@ -49,6 +48,7 @@ import { normalizeSystemRunApprovalPlan } from "../infra/system-run-approval-bin
|
||||
import { formatExecCommand, resolveSystemRunCommandRequest } from "../infra/system-run-command.js";
|
||||
import { logWarn } from "../logger.js";
|
||||
import { normalizeAgentId } from "../routing/session-key.js";
|
||||
import type { NodeHostClient } from "./client.js";
|
||||
import { evaluateSystemRunPolicy, resolveExecApprovalDecision } from "./exec-policy.js";
|
||||
import {
|
||||
applyOutputTruncation,
|
||||
@@ -263,7 +263,7 @@ async function resolveSystemRunAutoReviewer(params: {
|
||||
}
|
||||
|
||||
export type HandleSystemRunInvokeOptions = {
|
||||
client: GatewayClient;
|
||||
client: NodeHostClient;
|
||||
params: SystemRunParams;
|
||||
skillBins: SkillBinsProvider;
|
||||
execHostEnforced: boolean;
|
||||
@@ -282,7 +282,7 @@ export type HandleSystemRunInvokeOptions = {
|
||||
approvals: ExecApprovalsResolved;
|
||||
request: ExecHostRequest;
|
||||
}) => Promise<ExecHostResponse | null>;
|
||||
sendNodeEvent: (client: GatewayClient, event: string, payload: unknown) => Promise<void>;
|
||||
sendNodeEvent: (client: NodeHostClient, event: string, payload: unknown) => Promise<void>;
|
||||
buildExecEventPayload: (payload: ExecEventPayload) => ExecEventPayload;
|
||||
sendInvokeResult: (result: SystemRunInvokeResult) => Promise<void>;
|
||||
sendExecFinishedEvent: (params: ExecFinishedEventParams) => Promise<void>;
|
||||
|
||||
@@ -8,7 +8,6 @@ import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/st
|
||||
import { normalizeStringEntries } from "@openclaw/normalization-core/string-normalization";
|
||||
import { sliceUtf16Safe, truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
|
||||
import { mcpContentBlockToAgentContent } from "../agents/mcp-content.js";
|
||||
import { GatewayClient } from "../gateway/client.js";
|
||||
import {
|
||||
analyzeArgvCommand,
|
||||
createExecApprovalPolicySnapshot,
|
||||
@@ -46,6 +45,7 @@ import {
|
||||
} from "../infra/windows-encoding.js";
|
||||
import { logWarn } from "../logger.js";
|
||||
import { truncateUtf8Prefix } from "../utils/utf8-truncate.js";
|
||||
import type { NodeHostClient } from "./client.js";
|
||||
import {
|
||||
buildSystemRunApprovalPlan,
|
||||
handleSystemRunInvoke,
|
||||
@@ -226,7 +226,7 @@ type ExecApprovalsSnapshot = {
|
||||
file: ExecApprovalsFile;
|
||||
};
|
||||
|
||||
type NodeInvokeRequestPayload = {
|
||||
export type NodeInvokeRequestPayload = {
|
||||
id: string;
|
||||
nodeId: string;
|
||||
command: string;
|
||||
@@ -540,7 +540,7 @@ function buildExecEventPayload(payload: ExecEventPayload): ExecEventPayload {
|
||||
|
||||
async function sendExecFinishedEvent(
|
||||
params: ExecFinishedEventParams & {
|
||||
client: GatewayClient;
|
||||
client: NodeHostClient;
|
||||
},
|
||||
) {
|
||||
const combined = [params.result.stdout, params.result.stderr, params.result.error]
|
||||
@@ -576,7 +576,7 @@ async function runViaMacAppExecHost(params: {
|
||||
}
|
||||
|
||||
async function sendJsonPayloadResult(
|
||||
client: GatewayClient,
|
||||
client: NodeHostClient,
|
||||
frame: NodeInvokeRequestPayload,
|
||||
payload: unknown,
|
||||
) {
|
||||
@@ -587,7 +587,7 @@ async function sendJsonPayloadResult(
|
||||
}
|
||||
|
||||
async function sendMcpPayloadResult(
|
||||
client: GatewayClient,
|
||||
client: NodeHostClient,
|
||||
frame: NodeInvokeRequestPayload,
|
||||
payload: unknown,
|
||||
) {
|
||||
@@ -595,7 +595,7 @@ async function sendMcpPayloadResult(
|
||||
}
|
||||
|
||||
async function sendRawPayloadResult(
|
||||
client: GatewayClient,
|
||||
client: NodeHostClient,
|
||||
frame: NodeInvokeRequestPayload,
|
||||
payloadJSON: string,
|
||||
) {
|
||||
@@ -606,7 +606,7 @@ async function sendRawPayloadResult(
|
||||
}
|
||||
|
||||
async function sendErrorResult(
|
||||
client: GatewayClient,
|
||||
client: NodeHostClient,
|
||||
frame: NodeInvokeRequestPayload,
|
||||
code: string,
|
||||
message: string,
|
||||
@@ -618,7 +618,7 @@ async function sendErrorResult(
|
||||
}
|
||||
|
||||
async function sendInvalidRequestResult(
|
||||
client: GatewayClient,
|
||||
client: NodeHostClient,
|
||||
frame: NodeInvokeRequestPayload,
|
||||
err: unknown,
|
||||
) {
|
||||
@@ -632,7 +632,7 @@ function classifyExecApprovalsStorageError(err: unknown): "TIMEOUT" | "UNAVAILAB
|
||||
}
|
||||
|
||||
async function sendExecApprovalsStorageErrorResult(
|
||||
client: GatewayClient,
|
||||
client: NodeHostClient,
|
||||
frame: NodeInvokeRequestPayload,
|
||||
err: unknown,
|
||||
) {
|
||||
@@ -642,7 +642,7 @@ async function sendExecApprovalsStorageErrorResult(
|
||||
/** Handles one node-host command invocation payload and returns serialized results. */
|
||||
export async function handleInvoke(
|
||||
frame: NodeInvokeRequestPayload,
|
||||
client: GatewayClient,
|
||||
client: NodeHostClient,
|
||||
skillBins: SkillBinsProvider,
|
||||
mcpManager?: NodeHostMcpManager,
|
||||
) {
|
||||
@@ -668,7 +668,7 @@ export async function handleInvoke(
|
||||
|
||||
async function dispatchInvoke(
|
||||
frame: NodeInvokeRequestPayload,
|
||||
client: GatewayClient,
|
||||
client: NodeHostClient,
|
||||
skillBins: SkillBinsProvider,
|
||||
mcpManager?: NodeHostMcpManager,
|
||||
) {
|
||||
@@ -1046,7 +1046,7 @@ function mcpToolErrorMessage(result: { content: readonly unknown[] }): string {
|
||||
|
||||
async function handleMcpToolsCall(
|
||||
frame: NodeInvokeRequestPayload,
|
||||
client: GatewayClient,
|
||||
client: NodeHostClient,
|
||||
mcpManager: NodeHostMcpManager | undefined,
|
||||
): Promise<void> {
|
||||
if (!mcpManager) {
|
||||
@@ -1126,7 +1126,7 @@ export function coerceNodeInvokePayload(payload: unknown): NodeInvokeRequestPayl
|
||||
}
|
||||
|
||||
async function sendInvokeResult(
|
||||
client: GatewayClient,
|
||||
client: NodeHostClient,
|
||||
frame: NodeInvokeRequestPayload,
|
||||
result: {
|
||||
ok: boolean;
|
||||
@@ -1193,7 +1193,7 @@ export function buildNodeEventParams(
|
||||
};
|
||||
}
|
||||
|
||||
async function sendNodeEvent(client: GatewayClient, event: string, payload: unknown) {
|
||||
async function sendNodeEvent(client: NodeHostClient, event: string, payload: unknown) {
|
||||
try {
|
||||
await client.request("node.event", buildNodeEventParams(event, payload));
|
||||
} catch {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
/** CLI runner for node-host stdin/stdout command dispatch. */
|
||||
import fs from "node:fs";
|
||||
import {
|
||||
GATEWAY_CLIENT_MODES,
|
||||
GATEWAY_CLIENT_NAMES,
|
||||
@@ -14,30 +13,11 @@ import {
|
||||
} from "../gateway/client.js";
|
||||
import { resolveGatewayConnectionAuth } from "../gateway/connection-auth.js";
|
||||
import { loadOrCreateDeviceIdentity } from "../infra/device-identity.js";
|
||||
import type { SkillBinTrustEntry } from "../infra/exec-approvals.js";
|
||||
import { resolveExecutableFromPathEnv } from "../infra/executable-path.js";
|
||||
import { getMachineDisplayName } from "../infra/machine-name.js";
|
||||
import {
|
||||
NODE_EXEC_APPROVALS_COMMANDS,
|
||||
NODE_FS_LIST_DIR_COMMAND,
|
||||
NODE_MCP_TOOLS_CALL_COMMAND,
|
||||
NODE_SYSTEM_RUN_COMMANDS,
|
||||
} from "../infra/node-commands.js";
|
||||
import { ensureOpenClawCliOnPath } from "../infra/path-env.js";
|
||||
import { VERSION } from "../version.js";
|
||||
import { ensureNodeHostConfig, saveNodeHostConfig, type NodeHostGatewayConfig } from "./config.js";
|
||||
import {
|
||||
coerceNodeInvokePayload,
|
||||
type SkillBinsProvider,
|
||||
buildNodeInvokeResultParams,
|
||||
handleInvoke,
|
||||
} from "./invoke.js";
|
||||
import { startNodeHostMcpManager, type NodeHostMcpManager } from "./mcp.js";
|
||||
import {
|
||||
ensureNodeHostPluginRegistry,
|
||||
listRegisteredNodeHostCapsAndCommands,
|
||||
} from "./plugin-node-host.js";
|
||||
import { scanNodeHostedSkills } from "./skills.js";
|
||||
import { coerceNodeInvokePayload, buildNodeInvokeResultParams } from "./invoke.js";
|
||||
import { prepareNodeHostRuntime, type NodeHostInventory } from "./runtime.js";
|
||||
|
||||
export { buildNodeInvokeResultParams };
|
||||
export { buildNodeEventParams } from "./invoke.js";
|
||||
@@ -53,8 +33,6 @@ type NodeHostRunOptions = {
|
||||
displayName?: string;
|
||||
};
|
||||
|
||||
const DEFAULT_NODE_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
|
||||
|
||||
export function resolveNodeHostGatewayPlatform(platform: NodeJS.Platform): string {
|
||||
switch (platform) {
|
||||
case "darwin":
|
||||
@@ -168,92 +146,6 @@ async function publishNodeSkills(client: GatewayClient, skills: unknown[]): Prom
|
||||
}
|
||||
}
|
||||
|
||||
function resolveExecutablePathFromEnv(bin: string, pathEnv: string): string | null {
|
||||
if (bin.includes("/") || bin.includes("\\")) {
|
||||
return null;
|
||||
}
|
||||
return resolveExecutableFromPathEnv(bin, pathEnv) ?? null;
|
||||
}
|
||||
|
||||
function resolveExecutableTrustPathFromEnv(bin: string, pathEnv: string): string | null {
|
||||
const resolvedPath = resolveExecutablePathFromEnv(bin, pathEnv);
|
||||
if (!resolvedPath) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return fs.realpathSync(resolvedPath);
|
||||
} catch {
|
||||
return resolvedPath;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveSkillBinTrustEntries(bins: string[], pathEnv: string): SkillBinTrustEntry[] {
|
||||
const trustEntries: SkillBinTrustEntry[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const bin of bins) {
|
||||
const name = bin.trim();
|
||||
if (!name) {
|
||||
continue;
|
||||
}
|
||||
const resolvedPath = resolveExecutableTrustPathFromEnv(name, pathEnv);
|
||||
if (!resolvedPath) {
|
||||
continue;
|
||||
}
|
||||
const key = `${name}\u0000${resolvedPath}`;
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
trustEntries.push({ name, resolvedPath });
|
||||
}
|
||||
return trustEntries.toSorted(
|
||||
(left, right) =>
|
||||
left.name.localeCompare(right.name) || left.resolvedPath.localeCompare(right.resolvedPath),
|
||||
);
|
||||
}
|
||||
|
||||
class SkillBinsCache implements SkillBinsProvider {
|
||||
private bins: SkillBinTrustEntry[] = [];
|
||||
private lastRefresh = 0;
|
||||
private readonly ttlMs = 90_000;
|
||||
private readonly fetch: () => Promise<string[]>;
|
||||
private readonly pathEnv: string;
|
||||
|
||||
constructor(fetch: () => Promise<string[]>, pathEnv: string) {
|
||||
this.fetch = fetch;
|
||||
this.pathEnv = pathEnv;
|
||||
}
|
||||
|
||||
async current(force = false): Promise<SkillBinTrustEntry[]> {
|
||||
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 = resolveSkillBinTrustEntries(bins, this.pathEnv);
|
||||
this.lastRefresh = Date.now();
|
||||
} catch {
|
||||
if (!this.lastRefresh) {
|
||||
this.bins = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function ensureNodePathEnv(): string {
|
||||
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;
|
||||
}
|
||||
|
||||
export async function resolveNodeHostGatewayCredentials(params: {
|
||||
config: OpenClawConfig;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
@@ -306,8 +198,7 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise<void> {
|
||||
await saveNodeHostConfig(config);
|
||||
|
||||
const cfg = getRuntimeConfig();
|
||||
await ensureNodeHostPluginRegistry({ config: cfg, env: process.env });
|
||||
const pluginNodeHost = listRegisteredNodeHostCapsAndCommands({ config: cfg, env: process.env });
|
||||
const preparedRuntime = await prepareNodeHostRuntime({ config: cfg, env: process.env });
|
||||
const { token, password } = await resolveNodeHostGatewayCredentials({
|
||||
config: cfg,
|
||||
env: process.env,
|
||||
@@ -322,33 +213,17 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise<void> {
|
||||
: `/${gateway.contextPath}`
|
||||
: "";
|
||||
const url = `${scheme}://${host}:${port}${contextPath}`;
|
||||
const pathEnv = ensureNodePathEnv();
|
||||
const mcpServers = cfg.nodeHost?.mcp?.servers;
|
||||
const nodeSkills = cfg.nodeHost?.skills?.enabled === false ? null : scanNodeHostedSkills();
|
||||
const mcpStartupAbort = new AbortController();
|
||||
const mcpRuntime: {
|
||||
manager?: NodeHostMcpManager;
|
||||
startup?: Promise<NodeHostMcpManager>;
|
||||
} = {};
|
||||
let inventory: NodeHostInventory = preparedRuntime.initialInventory;
|
||||
let gatewayHelloReceived = false;
|
||||
|
||||
const publishNodeToolsWhenReady = () => {
|
||||
if (!gatewayHelloReceived || !mcpRuntime.manager) {
|
||||
const publishInventory = () => {
|
||||
if (!gatewayHelloReceived) {
|
||||
return;
|
||||
}
|
||||
const nodePluginTools = [
|
||||
...pluginNodeHost.nodePluginTools,
|
||||
...mcpRuntime.manager.descriptors,
|
||||
].toSorted(
|
||||
(left, right) =>
|
||||
left.pluginId.localeCompare(right.pluginId) || left.name.localeCompare(right.name),
|
||||
);
|
||||
void publishNodePluginTools(client, nodePluginTools);
|
||||
};
|
||||
const closeMcpRuntime = async () => {
|
||||
mcpStartupAbort.abort();
|
||||
const manager = mcpRuntime.manager ?? (await mcpRuntime.startup?.catch(() => undefined));
|
||||
await manager?.close();
|
||||
if (inventory.skills) {
|
||||
void publishNodeSkills(client, inventory.skills);
|
||||
}
|
||||
void publishNodePluginTools(client, inventory.pluginTools);
|
||||
};
|
||||
|
||||
const client = new GatewayClient({
|
||||
@@ -367,15 +242,9 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise<void> {
|
||||
scopes: [],
|
||||
// Pair the built-in MCP command family up front. Server inventory is
|
||||
// restart-scoped availability, not a capability upgrade requiring re-pairing.
|
||||
caps: ["system", "mcp", ...pluginNodeHost.caps],
|
||||
commands: [
|
||||
...NODE_SYSTEM_RUN_COMMANDS,
|
||||
...NODE_EXEC_APPROVALS_COMMANDS,
|
||||
NODE_FS_LIST_DIR_COMMAND,
|
||||
NODE_MCP_TOOLS_CALL_COMMAND,
|
||||
...pluginNodeHost.commands,
|
||||
],
|
||||
pathEnv,
|
||||
caps: preparedRuntime.manifest.caps,
|
||||
commands: preparedRuntime.manifest.commands,
|
||||
pathEnv: preparedRuntime.manifest.pathEnv,
|
||||
permissions: undefined,
|
||||
deviceIdentity: loadOrCreateDeviceIdentity(),
|
||||
tlsFingerprint: gateway.tlsFingerprint,
|
||||
@@ -387,20 +256,12 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise<void> {
|
||||
if (!payload) {
|
||||
return;
|
||||
}
|
||||
void handleInvoke(payload, client, skillBins, mcpRuntime.manager);
|
||||
void activeRuntime.invoke(payload);
|
||||
},
|
||||
onHelloOk: () => {
|
||||
writeStderrLine(`node host gateway connected: ${url}`);
|
||||
gatewayHelloReceived = true;
|
||||
if (nodeSkills) {
|
||||
void publishNodeSkills(client, nodeSkills);
|
||||
}
|
||||
if (mcpRuntime.manager) {
|
||||
publishNodeToolsWhenReady();
|
||||
} else {
|
||||
// Do not make existing plugin tools wait for optional MCP discovery.
|
||||
void publishNodePluginTools(client, pluginNodeHost.nodePluginTools);
|
||||
}
|
||||
publishInventory();
|
||||
},
|
||||
onConnectError: (err) => {
|
||||
// keep retrying (handled by GatewayClient)
|
||||
@@ -412,7 +273,7 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise<void> {
|
||||
client.stop();
|
||||
// Terminal auth/version pauses restart under a supervisor; close MCP
|
||||
// subprocesses first so restart loops cannot orphan server processes.
|
||||
void closeMcpRuntime().finally(() => process.exit(code));
|
||||
void activeRuntime.close().finally(() => process.exit(code));
|
||||
},
|
||||
});
|
||||
},
|
||||
@@ -420,12 +281,13 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise<void> {
|
||||
writeStderrLine(`node host gateway closed (${code}): ${reason}`);
|
||||
},
|
||||
});
|
||||
|
||||
const skillBins = new SkillBinsCache(async () => {
|
||||
const res = await client.request<{ bins: Array<unknown> }>("skills.bins", {});
|
||||
const bins = Array.isArray(res?.bins) ? res.bins.map((bin) => String(bin)) : [];
|
||||
return bins;
|
||||
}, pathEnv);
|
||||
const activeRuntime = preparedRuntime.start({
|
||||
client,
|
||||
onInventoryChanged: (nextInventory) => {
|
||||
inventory = nextInventory;
|
||||
publishInventory();
|
||||
},
|
||||
});
|
||||
|
||||
let stopping = false;
|
||||
let resolveStopped: (() => void) | undefined;
|
||||
@@ -442,7 +304,7 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise<void> {
|
||||
const stopClientAndMcp = async () => {
|
||||
client.stop();
|
||||
try {
|
||||
await closeMcpRuntime();
|
||||
await activeRuntime.close();
|
||||
} finally {
|
||||
clearInterval(lifetimeInterval);
|
||||
}
|
||||
@@ -468,14 +330,6 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise<void> {
|
||||
const readinessPromise = startGatewayClientWhenEventLoopReady(client, {
|
||||
clientOptions: { preauthHandshakeTimeoutMs: cfg.gateway?.handshakeTimeoutMs },
|
||||
});
|
||||
// Gateway startup begins first; optional MCP discovery must not delay core node availability.
|
||||
mcpRuntime.startup = startNodeHostMcpManager(mcpServers, { signal: mcpStartupAbort.signal }).then(
|
||||
(manager) => {
|
||||
mcpRuntime.manager = manager;
|
||||
publishNodeToolsWhenReady();
|
||||
return manager;
|
||||
},
|
||||
);
|
||||
let readiness;
|
||||
try {
|
||||
readiness = await readinessPromise;
|
||||
|
||||
213
src/node-host/runtime.ts
Normal file
213
src/node-host/runtime.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
/** Transport-independent CLI node-host runtime shared by Gateway and app workers. */
|
||||
import fs from "node:fs";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { getRuntimeConfig } from "../config/config.js";
|
||||
import type { SkillBinTrustEntry } from "../infra/exec-approvals.js";
|
||||
import { resolveExecutableFromPathEnv } from "../infra/executable-path.js";
|
||||
import {
|
||||
NODE_EXEC_APPROVALS_COMMANDS,
|
||||
NODE_FS_LIST_DIR_COMMAND,
|
||||
NODE_MCP_TOOLS_CALL_COMMAND,
|
||||
NODE_SYSTEM_RUN_COMMANDS,
|
||||
} from "../infra/node-commands.js";
|
||||
import { ensureOpenClawCliOnPath } from "../infra/path-env.js";
|
||||
import type { NodeHostClient } from "./client.js";
|
||||
import { handleInvoke, type NodeInvokeRequestPayload, type SkillBinsProvider } from "./invoke.js";
|
||||
import { startNodeHostMcpManager, type NodeHostMcpManager } from "./mcp.js";
|
||||
import {
|
||||
ensureNodeHostPluginRegistry,
|
||||
listRegisteredNodeHostCapsAndCommands,
|
||||
} from "./plugin-node-host.js";
|
||||
import { scanNodeHostedSkills } from "./skills.js";
|
||||
|
||||
const DEFAULT_NODE_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
|
||||
|
||||
type NodeHostManifest = {
|
||||
caps: string[];
|
||||
commands: string[];
|
||||
pathEnv: string;
|
||||
};
|
||||
|
||||
export type NodeHostInventory = {
|
||||
skills: unknown[] | null;
|
||||
pluginTools: unknown[];
|
||||
};
|
||||
|
||||
type PreparedNodeHostRuntime = {
|
||||
manifest: NodeHostManifest;
|
||||
initialInventory: NodeHostInventory;
|
||||
start(params: {
|
||||
client: NodeHostClient;
|
||||
onInventoryChanged?: (inventory: NodeHostInventory) => void;
|
||||
}): ActiveNodeHostRuntime;
|
||||
};
|
||||
|
||||
type ActiveNodeHostRuntime = {
|
||||
invoke(frame: NodeInvokeRequestPayload): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
};
|
||||
|
||||
function resolveExecutablePathFromEnv(bin: string, pathEnv: string): string | null {
|
||||
if (bin.includes("/") || bin.includes("\\")) {
|
||||
return null;
|
||||
}
|
||||
return resolveExecutableFromPathEnv(bin, pathEnv) ?? null;
|
||||
}
|
||||
|
||||
function resolveExecutableTrustPathFromEnv(bin: string, pathEnv: string): string | null {
|
||||
const resolvedPath = resolveExecutablePathFromEnv(bin, pathEnv);
|
||||
if (!resolvedPath) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return fs.realpathSync(resolvedPath);
|
||||
} catch {
|
||||
return resolvedPath;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveSkillBinTrustEntries(bins: string[], pathEnv: string): SkillBinTrustEntry[] {
|
||||
const trustEntries: SkillBinTrustEntry[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const raw of bins) {
|
||||
const name = raw.trim();
|
||||
if (!name) {
|
||||
continue;
|
||||
}
|
||||
const resolvedPath = resolveExecutableTrustPathFromEnv(name, pathEnv);
|
||||
if (!resolvedPath) {
|
||||
continue;
|
||||
}
|
||||
const key = `${name}\u0000${resolvedPath}`;
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
trustEntries.push({ name, resolvedPath });
|
||||
}
|
||||
return trustEntries.toSorted(
|
||||
(left, right) =>
|
||||
left.name.localeCompare(right.name) || left.resolvedPath.localeCompare(right.resolvedPath),
|
||||
);
|
||||
}
|
||||
|
||||
class SkillBinsCache implements SkillBinsProvider {
|
||||
private bins: SkillBinTrustEntry[] = [];
|
||||
private lastRefresh = 0;
|
||||
private readonly ttlMs = 90_000;
|
||||
|
||||
constructor(
|
||||
private readonly client: NodeHostClient,
|
||||
private readonly pathEnv: string,
|
||||
) {}
|
||||
|
||||
async current(force = false): Promise<SkillBinTrustEntry[]> {
|
||||
if (force || Date.now() - this.lastRefresh > this.ttlMs) {
|
||||
await this.refresh();
|
||||
}
|
||||
return this.bins;
|
||||
}
|
||||
|
||||
private async refresh() {
|
||||
try {
|
||||
const res = await this.client.request<{ bins: Array<unknown> }>("skills.bins", {});
|
||||
const bins = Array.isArray(res?.bins) ? res.bins.map((bin) => String(bin)) : [];
|
||||
this.bins = resolveSkillBinTrustEntries(bins, this.pathEnv);
|
||||
this.lastRefresh = Date.now();
|
||||
} catch {
|
||||
if (!this.lastRefresh) {
|
||||
this.bins = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function ensureNodePathEnv(): string {
|
||||
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 createInventory(params: {
|
||||
skills: unknown[] | null;
|
||||
pluginTools: unknown[];
|
||||
mcpManager?: NodeHostMcpManager;
|
||||
}): NodeHostInventory {
|
||||
const pluginTools = [...params.pluginTools, ...(params.mcpManager?.descriptors ?? [])].toSorted(
|
||||
(left, right) => {
|
||||
const a = left as { pluginId?: string; name?: string };
|
||||
const b = right as { pluginId?: string; name?: string };
|
||||
return (
|
||||
(a.pluginId ?? "").localeCompare(b.pluginId ?? "") ||
|
||||
(a.name ?? "").localeCompare(b.name ?? "")
|
||||
);
|
||||
},
|
||||
);
|
||||
return { skills: params.skills, pluginTools };
|
||||
}
|
||||
|
||||
export async function prepareNodeHostRuntime(params?: {
|
||||
config?: OpenClawConfig;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): Promise<PreparedNodeHostRuntime> {
|
||||
const config = params?.config ?? getRuntimeConfig();
|
||||
const env = params?.env ?? process.env;
|
||||
await ensureNodeHostPluginRegistry({ config, env });
|
||||
const pluginNodeHost = listRegisteredNodeHostCapsAndCommands({ config, env });
|
||||
const pathEnv = ensureNodePathEnv();
|
||||
const skills = config.nodeHost?.skills?.enabled === false ? null : scanNodeHostedSkills();
|
||||
const manifest: NodeHostManifest = {
|
||||
caps: [...new Set(["system", "mcp", ...pluginNodeHost.caps])].toSorted(),
|
||||
commands: [
|
||||
...new Set([
|
||||
...NODE_SYSTEM_RUN_COMMANDS,
|
||||
...NODE_EXEC_APPROVALS_COMMANDS,
|
||||
NODE_FS_LIST_DIR_COMMAND,
|
||||
NODE_MCP_TOOLS_CALL_COMMAND,
|
||||
...pluginNodeHost.commands,
|
||||
]),
|
||||
].toSorted(),
|
||||
pathEnv,
|
||||
};
|
||||
const initialInventory = createInventory({
|
||||
skills,
|
||||
pluginTools: pluginNodeHost.nodePluginTools,
|
||||
});
|
||||
|
||||
return {
|
||||
manifest,
|
||||
initialInventory,
|
||||
start({ client, onInventoryChanged }) {
|
||||
const mcpAbort = new AbortController();
|
||||
const skillBins = new SkillBinsCache(client, pathEnv);
|
||||
let manager: NodeHostMcpManager | undefined;
|
||||
const startup = startNodeHostMcpManager(config.nodeHost?.mcp?.servers, {
|
||||
signal: mcpAbort.signal,
|
||||
}).then((resolved) => {
|
||||
manager = resolved;
|
||||
onInventoryChanged?.(
|
||||
createInventory({
|
||||
skills,
|
||||
pluginTools: pluginNodeHost.nodePluginTools,
|
||||
mcpManager: manager,
|
||||
}),
|
||||
);
|
||||
return resolved;
|
||||
});
|
||||
return {
|
||||
async invoke(frame) {
|
||||
await handleInvoke(frame, client, skillBins, manager);
|
||||
},
|
||||
async close() {
|
||||
mcpAbort.abort();
|
||||
const resolved = manager ?? (await startup.catch(() => undefined));
|
||||
await resolved?.close();
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
79
src/node-host/worker-support.ts
Normal file
79
src/node-host/worker-support.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import type { GatewayClientRequestOptions } from "../gateway/client.js";
|
||||
import type { NodeHostClient } from "./client.js";
|
||||
|
||||
export type NodeHostWorkerGatewayResponse =
|
||||
| { type: "gateway-response"; id: string; ok: true; result: unknown }
|
||||
| { type: "gateway-response"; id: string; ok: false; error: string };
|
||||
|
||||
type PendingGatewayRequest = {
|
||||
resolve: (value: unknown) => void;
|
||||
reject: (error: Error) => void;
|
||||
timer: NodeJS.Timeout;
|
||||
};
|
||||
|
||||
export class NodeHostWorkerBridgeClient implements NodeHostClient {
|
||||
private nextRequestId = 1;
|
||||
private readonly pending = new Map<string, PendingGatewayRequest>();
|
||||
|
||||
constructor(private readonly writeMessage: (message: unknown) => void) {}
|
||||
|
||||
async request<T = Record<string, unknown>>(
|
||||
method: string,
|
||||
params?: unknown,
|
||||
opts?: GatewayClientRequestOptions,
|
||||
): Promise<T> {
|
||||
if (method === "node.invoke.result") {
|
||||
this.writeMessage({ type: "invoke-result", result: params ?? {} });
|
||||
return {} as T;
|
||||
}
|
||||
if (method === "node.event") {
|
||||
this.writeMessage({ type: "node-event", event: params ?? {} });
|
||||
return {} as T;
|
||||
}
|
||||
|
||||
const id = `gateway-${this.nextRequestId++}`;
|
||||
const timeoutMs = Math.max(1, opts?.timeoutMs ?? 15_000);
|
||||
const response = new Promise<unknown>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.pending.delete(id);
|
||||
reject(new Error(`Gateway request timed out: ${method}`));
|
||||
}, timeoutMs);
|
||||
this.pending.set(id, { resolve, reject, timer });
|
||||
});
|
||||
this.writeMessage({ type: "gateway-request", id, method, params: params ?? {}, timeoutMs });
|
||||
return (await response) as T;
|
||||
}
|
||||
|
||||
handleResponse(message: NodeHostWorkerGatewayResponse): boolean {
|
||||
const pending = this.pending.get(message.id);
|
||||
if (!pending) {
|
||||
return false;
|
||||
}
|
||||
this.pending.delete(message.id);
|
||||
clearTimeout(pending.timer);
|
||||
if (message.ok) {
|
||||
pending.resolve(message.result);
|
||||
} else {
|
||||
pending.reject(new Error(message.error));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
close(): void {
|
||||
for (const pending of this.pending.values()) {
|
||||
clearTimeout(pending.timer);
|
||||
pending.reject(new Error("node-host worker stopped"));
|
||||
}
|
||||
this.pending.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export async function stopNodeHostWorkerFromSignal(
|
||||
input: { close(): void },
|
||||
stop: (exitCode: number) => Promise<void>,
|
||||
exitCode: number,
|
||||
): Promise<void> {
|
||||
const stopped = stop(exitCode);
|
||||
input.close();
|
||||
await stopped;
|
||||
}
|
||||
80
src/node-host/worker.test.ts
Normal file
80
src/node-host/worker.test.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { NodeHostWorkerBridgeClient, stopNodeHostWorkerFromSignal } from "./worker-support.js";
|
||||
|
||||
describe("NodeHostWorkerBridgeClient", () => {
|
||||
it("forwards invoke results and events without creating gateway request waits", async () => {
|
||||
const messages: unknown[] = [];
|
||||
const client = new NodeHostWorkerBridgeClient((message) => messages.push(message));
|
||||
|
||||
await client.request("node.invoke.result", { id: "invoke-1", ok: true });
|
||||
await client.request("node.event", { event: "exec.started", payloadJSON: "{}" });
|
||||
|
||||
expect(messages).toEqual([
|
||||
{ type: "invoke-result", result: { id: "invoke-1", ok: true } },
|
||||
{ type: "node-event", event: { event: "exec.started", payloadJSON: "{}" } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("tunnels runtime gateway requests and resolves their matching response", async () => {
|
||||
const messages: Array<Record<string, unknown>> = [];
|
||||
const client = new NodeHostWorkerBridgeClient((message) => {
|
||||
messages.push(message as Record<string, unknown>);
|
||||
});
|
||||
|
||||
const response = client.request<{ bins: string[] }>("skills.bins", {}, { timeoutMs: 1_000 });
|
||||
expect(messages).toEqual([
|
||||
{
|
||||
type: "gateway-request",
|
||||
id: "gateway-1",
|
||||
method: "skills.bins",
|
||||
params: {},
|
||||
timeoutMs: 1_000,
|
||||
},
|
||||
]);
|
||||
expect(
|
||||
client.handleResponse({
|
||||
type: "gateway-response",
|
||||
id: "gateway-1",
|
||||
ok: true,
|
||||
result: { bins: ["rg"] },
|
||||
}),
|
||||
).toBe(true);
|
||||
await expect(response).resolves.toEqual({ bins: ["rg"] });
|
||||
});
|
||||
|
||||
it("fails pending gateway requests when the app worker stops", async () => {
|
||||
const client = new NodeHostWorkerBridgeClient(() => {});
|
||||
const response = client.request("skills.bins", {}, { timeoutMs: 1_000 });
|
||||
|
||||
client.close();
|
||||
|
||||
await expect(response).rejects.toThrow("node-host worker stopped");
|
||||
});
|
||||
});
|
||||
|
||||
describe("stopNodeHostWorkerFromSignal", () => {
|
||||
it("preserves the signal exit code when closing stdin emits EOF", async () => {
|
||||
const calls: string[] = [];
|
||||
let stopping = false;
|
||||
const stop = async (exitCode: number) => {
|
||||
if (stopping) {
|
||||
return;
|
||||
}
|
||||
stopping = true;
|
||||
calls.push(`stop:${exitCode}`);
|
||||
};
|
||||
|
||||
await stopNodeHostWorkerFromSignal(
|
||||
{
|
||||
close: () => {
|
||||
calls.push("close");
|
||||
void stop(0);
|
||||
},
|
||||
},
|
||||
stop,
|
||||
143,
|
||||
);
|
||||
|
||||
expect(calls).toEqual(["stop:143", "close"]);
|
||||
});
|
||||
});
|
||||
120
src/node-host/worker.ts
Normal file
120
src/node-host/worker.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
/** Private JSONL worker exposing the CLI node-host runtime to the macOS app. */
|
||||
import { createInterface } from "node:readline";
|
||||
import { VERSION } from "../version.js";
|
||||
import type { NodeInvokeRequestPayload } from "./invoke.js";
|
||||
import { prepareNodeHostRuntime, type NodeHostInventory } from "./runtime.js";
|
||||
import {
|
||||
NodeHostWorkerBridgeClient,
|
||||
type NodeHostWorkerGatewayResponse,
|
||||
stopNodeHostWorkerFromSignal,
|
||||
} from "./worker-support.js";
|
||||
|
||||
type WorkerInput =
|
||||
| { type: "invoke"; request: NodeInvokeRequestPayload }
|
||||
| NodeHostWorkerGatewayResponse
|
||||
| { type: "stop" };
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
function writeMessage(message: unknown): void {
|
||||
process.stdout.write(`${JSON.stringify(message)}\n`);
|
||||
}
|
||||
|
||||
function parseInput(line: string): WorkerInput | null {
|
||||
try {
|
||||
const parsed = asRecord(JSON.parse(line));
|
||||
const type = typeof parsed?.type === "string" ? parsed.type : "";
|
||||
if (type === "invoke") {
|
||||
const request = asRecord(parsed?.request);
|
||||
if (
|
||||
request &&
|
||||
typeof request.id === "string" &&
|
||||
typeof request.nodeId === "string" &&
|
||||
typeof request.command === "string"
|
||||
) {
|
||||
return { type, request: request as NodeInvokeRequestPayload };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (type === "gateway-response") {
|
||||
const id = typeof parsed?.id === "string" ? parsed.id : "";
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
return parsed?.ok === true
|
||||
? { type, id, ok: true, result: parsed.result }
|
||||
: {
|
||||
type,
|
||||
id,
|
||||
ok: false,
|
||||
error: typeof parsed?.error === "string" ? parsed.error : "Gateway request failed",
|
||||
};
|
||||
}
|
||||
return type === "stop" ? { type } : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function emitInventory(inventory: NodeHostInventory): void {
|
||||
writeMessage({ type: "inventory", inventory });
|
||||
}
|
||||
|
||||
export async function runNodeHostWorker(): Promise<void> {
|
||||
const prepared = await prepareNodeHostRuntime();
|
||||
const client = new NodeHostWorkerBridgeClient(writeMessage);
|
||||
let stopping = false;
|
||||
let resolveStopped: (() => void) | undefined;
|
||||
const stopped = new Promise<void>((resolve) => {
|
||||
resolveStopped = resolve;
|
||||
});
|
||||
|
||||
const stop = async (exitCode: number) => {
|
||||
if (stopping) {
|
||||
return;
|
||||
}
|
||||
stopping = true;
|
||||
try {
|
||||
client.close();
|
||||
await runtime.close();
|
||||
process.exitCode = exitCode;
|
||||
} finally {
|
||||
resolveStopped?.();
|
||||
}
|
||||
};
|
||||
|
||||
const runtime = prepared.start({ client, onInventoryChanged: emitInventory });
|
||||
writeMessage({
|
||||
type: "ready",
|
||||
version: VERSION,
|
||||
manifest: prepared.manifest,
|
||||
inventory: prepared.initialInventory,
|
||||
});
|
||||
|
||||
const input = createInterface({ input: process.stdin, crlfDelay: Infinity });
|
||||
input.on("line", (line) => {
|
||||
const message = parseInput(line);
|
||||
if (!message) {
|
||||
writeMessage({ type: "protocol-error", error: "invalid worker request" });
|
||||
return;
|
||||
}
|
||||
if (message.type === "gateway-response") {
|
||||
client.handleResponse(message);
|
||||
return;
|
||||
}
|
||||
if (message.type === "stop") {
|
||||
input.close();
|
||||
void stop(0);
|
||||
return;
|
||||
}
|
||||
void runtime.invoke(message.request);
|
||||
});
|
||||
input.on("close", () => void stop(0));
|
||||
process.once("SIGINT", () => void stopNodeHostWorkerFromSignal(input, stop, 130));
|
||||
process.once("SIGTERM", () => void stopNodeHostWorkerFromSignal(input, stop, 143));
|
||||
await stopped;
|
||||
}
|
||||
Reference in New Issue
Block a user