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:
Peter Steinberger
2026-07-12 13:16:07 -07:00
committed by GitHub
parent f15a5e566b
commit d287c9b414
62 changed files with 2195 additions and 3130 deletions

10
src/node-host/client.ts Normal file
View 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>;
};

View File

@@ -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>;

View File

@@ -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 {

View File

@@ -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
View 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();
},
};
},
};
}

View 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;
}

View 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
View 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;
}