feat(node-host): supervise durable worker launches (#122829)

* feat(node-host): add worker launch supervision

* fix(node-host): harden worker lifecycle ownership

* fix(node-host): harden worker execution boundary

* fix(node-host): preserve worker trust settings

* chore(plugin-sdk): refresh worker lifecycle baselines

* docs(plan): track runner implementation slices

* test(node-host): await runtime shutdown owner
This commit is contained in:
Peter Steinberger
2026-08-12 15:24:05 -07:00
committed by GitHub
parent 93f5e0f1f6
commit 6b9bea84f0
38 changed files with 2896 additions and 88 deletions
@@ -1 +1 @@
{"contentHash":"e2a5ce75757e7a275312c91ce95bdb4f13091a3c613dd4c66fdda0b2e4d3bf83","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"}
{"contentHash":"57cb6a98df58f04f4c31d37b35aa857e5329eb29560f272381be592e39946d5a","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"0e4494129d83b508c82788383f8dfead578976ed474be2cfbec557aa6caf88e7","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"}
{"contentHash":"a3e367817d1aa078cbd028aec5b080e73f7b67a85f6b91e4cfdfe32a09e6b509","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"8643a3553553855a519e825fcaa483024ecd3538675c5a2d474af841fb5070d7","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"}
{"contentHash":"355f3a91dabce0393a540e1d9f476eba6926cff82153f41e98c5a01d4e011ea2","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"}
@@ -1 +1 @@
{"contentHash":"13ec7276e7c37ae4e7589e67302c2bf531674f2c439b70d4a8a44a4ee136f685","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"}
{"contentHash":"695b06456e6e2bb7c3ca5891619683bf14e5cb5939e4a44bd907b2c798910cae","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"}
@@ -1 +1 @@
{"contentHash":"ed0a5bf19bbe6e9641c8de12ea94105865850343c319a58953a2dce21e6d3f49","entrypoint":"channel-inbound","importSpecifier":"openclaw/plugin-sdk/channel-inbound"}
{"contentHash":"cf487c3dd656a5f9d8c4a927bf0cb8ff27b1fb29c09c466c8685482c647df12d","entrypoint":"channel-inbound","importSpecifier":"openclaw/plugin-sdk/channel-inbound"}
@@ -1 +1 @@
{"contentHash":"feb6187469b8f0e88c0445ba025cc557c94871db55f39075eb63044d8ba48cd7","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"}
{"contentHash":"f9a6c7bd0ded857948b59ec8a8e2e8cc36204888e76cd5cfb50133439be59b1a","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"}
@@ -1 +1 @@
{"contentHash":"97d39817d24eaf5b2808960770447188daa0b2959514efbd44db007797e40e3b","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"}
{"contentHash":"dfebc8eae36256aedf44b8e4ea9a137f3b35946cc624d09246ec8d614223d752","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"}
@@ -1 +1 @@
{"contentHash":"e824840c22ccade58891ad2952a6aea4aafa8c163adb4383e9a5aa8b47cce44b","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"}
{"contentHash":"524471a854edf593a8f9f3b80eaedf4492e9bc60cea40e314ad7efbbbbf46d32","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"08bba8059fb44659737936c0ef4c163cfff29872db3088b72d2fff82f4c4ac85","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"}
{"contentHash":"f0a95c43b07dffdfbd05ac63a1b1dfd4b4828fab8d3613ca51465a7bbcae8cdf","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"db6ac3119377920f375bdb1cd01082815022f2ad49de3dc5d7ac258a6a864ca6","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"}
{"contentHash":"8c243343f62835f8570cf85a7c58523d76d5fed4f80704a179417d993a3ffc5d","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"}
@@ -1 +1 @@
{"contentHash":"7bb8bb13f101a7018aea253debd9c3611635541b8ad2d847e827300b9f94991f","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"}
{"contentHash":"a78162d9df85c5e96ef95b985a036af55110afe5768174c761d6fa0a485aa621","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"}
@@ -1 +1 @@
{"contentHash":"e5c950c85caf1fe7edf4d672f95afc074e926e300981d96707ed904304e001b4","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"}
{"contentHash":"46ce609eeb7ac8b23cdfb90469301cb988d4c18fdecfe5aa9aa2e444a52cb345","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"f6867c932f2632409f933d8e158d01f303679950b4e6e3a01ba28b0ea8b4d7da","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"}
{"contentHash":"fd5a6f903c1d177ccb090d0507bdec51d9494f094c8219d56334553044fff7cf","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"53a44cf7b1b8866054e905dd7a34623e75dbf4be6da5b58e7d47fd638e2a92f5","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"}
{"contentHash":"bbd1fa1f3afa8c8699a69a17d33d4ec9e6db46c5b94636f19e0cc5fe31b0b125","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"}
@@ -1 +1 @@
{"contentHash":"03aeade34faf6a1c6bfee3e88f684ccb4abe657ea8f3475c96162698baeeb314","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"}
{"contentHash":"9bab151043bafb665d20beb0f3e2685bbb9ac8fd7a19316cc695dafa7f977a5b","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"83795fac5e17a90840255de8446aaf23b988a14d19bc76a327b0b74ef5f9ee8a","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"}
{"contentHash":"6e2ee162d479ea014a08c64d6b85276e60d099137baf348f5d193bc8ed1cf162","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"}
@@ -1 +1 @@
{"contentHash":"9511576a6c8d6631cbe81dc7aca200f670feaf7fc03d6b3308066b5caa3ab50c","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"}
{"contentHash":"267759cc867869904787ed33483da8c59eba7f290427500836d98247ed135d91","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"}
+16 -16
View File
@@ -13,22 +13,22 @@ Proposal, revision 2. Supersedes revision 1 in place (2026-08-11, operator
decision). Implementation in progress; update this table in every PR that
advances a milestone.
| # | Milestone | Status | PRs |
| --- | ---------------------------------------------------------- | ----------- | ------------------------- |
| 0 | This plan (revision 2) | landed | #122454 |
| 1a | Naming: session copy revert | landed | #120667 |
| 1b | Naming: devices consolidation | landed | #120689 |
| 1c | Cleanup: node-pairing → device-pairing merge | landed | #120726 |
| 2 | `openclaw resume` + web Continue in terminal | in progress | #120664 |
| 3 | `openclaw connect` one-paste onboarding + `/j/` join route | in progress | #120768, #122499 |
| 4 | Picker: grouping, placement, liveness, enrichment | in progress | #120804, #122531, #122635 |
| F | Real-wire session boundary harness | landed | #121212 |
| 5 | Public worker ingress path | landed | #122578, #122643 |
| 6 | Node worker provider (device runners) | in progress | #122683 |
| 7 | Bundle push consent + runner updates | not started | — |
| 8 | Stop-and-continue moves | not started | — |
| 9 | Deletions (ssh sandbox, openshell, exec-host clones, …) | not started | — |
| 10 | Cloud convergence (provisioners run `openclaw connect`) | not started | — |
| # | Milestone | Status | PRs |
| --- | ---------------------------------------------------------- | ----------- | ---------------------------------- |
| 0 | This plan (revision 2) | landed | #122454 |
| 1a | Naming: session copy revert | landed | #120667 |
| 1b | Naming: devices consolidation | landed | #120689 |
| 1c | Cleanup: node-pairing → device-pairing merge | landed | #120726 |
| 2 | `openclaw resume` + web Continue in terminal | in progress | #120664 |
| 3 | `openclaw connect` one-paste onboarding + `/j/` join route | in progress | #120768, #122499 |
| 4 | Picker: grouping, placement, liveness, enrichment | in progress | #120804, #122531, #122635, #122774 |
| F | Real-wire session boundary harness | landed | #121212 |
| 5 | Public worker ingress path | landed | #122578, #122643 |
| 6 | Node worker provider (device runners) | in progress | #122683, #122829 |
| 7 | Bundle push consent + runner updates | not started | — |
| 8 | Stop-and-continue moves | not started | — |
| 9 | Deletions (ssh sandbox, openshell, exec-host clones, …) | not started | — |
| 10 | Cloud convergence (provisioners run `openclaw connect`) | not started | — |
Revision history: revision 1 (2026-08-08) established the session/runner
vocabulary, the naming rulings, and the milestone skeleton after a
+95 -2
View File
@@ -1,14 +1,107 @@
import type { Command } from "commander";
import { Option, type Command } from "commander";
import { signalProcessTree } from "../process/kill-tree.js";
import type { WorkerCommandLifetime } from "../worker/worker-command.runtime.js";
const WORKER_START_MESSAGE_TYPE = "openclaw-worker-start-v1";
function isWorkerStartMessage(value: unknown): boolean {
return (
typeof value === "object" &&
value !== null &&
!Array.isArray(value) &&
Object.keys(value).length === 1 &&
(value as { type?: unknown }).type === WORKER_START_MESSAGE_TYPE
);
}
function createWorkerIpcLifetime(): WorkerCommandLifetime {
if (!process.connected || !process.channel || typeof process.send !== "function") {
throw new Error("internal worker IPC mode requires a connected Node IPC channel");
}
const abortController = new AbortController();
let disposed = false;
let started = false;
let settled = false;
let resolveStarted!: (started: boolean) => void;
let rejectStarted!: (error: Error) => void;
const startedPromise = new Promise<boolean>((resolve, reject) => {
resolveStarted = resolve;
rejectStarted = reject;
});
const rejectOrAbort = (error: Error) => {
if (!settled) {
settled = true;
rejectStarted(error);
return;
}
abortController.abort(error);
};
const onMessage = (message: unknown) => {
if (disposed) {
return;
}
if (!isWorkerStartMessage(message) || settled) {
rejectOrAbort(new Error("invalid internal worker IPC start message"));
return;
}
started = true;
settled = true;
resolveStarted(true);
};
const onDisconnect = () => {
if (disposed) {
return;
}
if (!settled) {
settled = true;
resolveStarted(false);
return;
}
if (started) {
abortController.abort(new Error("worker supervisor lifetime ended"));
}
};
process.on("message", onMessage);
process.once("disconnect", onDisconnect);
return {
started: startedPromise,
signal: abortController.signal,
terminateOwnedTree: () => {
signalProcessTree(process.pid, "SIGKILL", {
detached: process.platform !== "win32",
});
},
dispose: () => {
if (disposed) {
return;
}
disposed = true;
process.off("message", onMessage);
process.off("disconnect", onDisconnect);
if (process.connected) {
try {
process.disconnect?.();
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ERR_IPC_DISCONNECTED") {
throw error;
}
}
}
},
};
}
/** Register the restricted cloud worker runtime entry point. */
export function registerWorkerCli(program: Command): void {
program
.command("worker")
.description("Run the restricted cloud worker runtime")
.action(async () => {
.addOption(new Option("--internal-worker-ipc").hideHelp())
.action(async (options: { internalWorkerIpc?: boolean }) => {
const { runWorkerCommand } = await import("../worker/worker-command.runtime.js");
await runWorkerCommand({
input: process.stdin,
...(options.internalWorkerIpc ? { lifetime: createWorkerIpcLifetime() } : {}),
output: process.stdout,
});
});
+51
View File
@@ -0,0 +1,51 @@
const POSIX_WORKER_ENV_KEYS = new Set([
"PATH",
"HOME",
"TMPDIR",
"TMP",
"TEMP",
"LANG",
"LANGUAGE",
"TZ",
"NODE_EXTRA_CA_CERTS",
"NODE_USE_SYSTEM_CA",
"OPENCLAW_ALLOW_INSECURE_PRIVATE_WS",
]);
const WINDOWS_WORKER_ENV_KEYS = new Set([
...POSIX_WORKER_ENV_KEYS,
"USERPROFILE",
"HOMEDRIVE",
"HOMEPATH",
"SYSTEMROOT",
"WINDIR",
"COMSPEC",
"PATHEXT",
]);
/** Freeze the minimal non-secret environment inherited by node-host workers. */
export function snapshotNodeWorkerEnv(source: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
const windows = process.platform === "win32";
const snapshot: NodeJS.ProcessEnv = {};
const retainedWindowsKeys = new Map<string, string>();
for (const [key, value] of Object.entries(source)) {
if (value === undefined) {
continue;
}
const normalized = windows ? key.toUpperCase() : key;
const allowed =
(windows ? WINDOWS_WORKER_ENV_KEYS : POSIX_WORKER_ENV_KEYS).has(normalized) ||
normalized.startsWith("LC_");
if (!allowed) {
continue;
}
if (windows) {
const previousKey = retainedWindowsKeys.get(normalized);
if (previousKey) {
delete snapshot[previousKey];
}
retainedWindowsKeys.set(normalized, key);
}
snapshot[key] = value;
}
return snapshot;
}
+434
View File
@@ -0,0 +1,434 @@
import type { DatabaseSync } from "node:sqlite";
import type { Selectable } from "kysely";
import {
executeSqliteQuerySync,
executeSqliteQueryTakeFirstSync,
getNodeSqliteKysely,
} from "../infra/kysely-sync.js";
import type { DB as OpenClawStateDatabase } from "../state/openclaw-state-db.generated.js";
import {
runOpenClawStateWriteTransaction,
type OpenClawStateDatabaseOptions,
} from "../state/openclaw-state-db.js";
import { OPENCLAW_STATE_SCHEMA_SQL } from "../state/openclaw-state-schema.js";
import {
inspectNodeWorkerProcessIdentity,
type NodeWorkerProcessIdentity,
} from "./node-worker-process-identity.js";
type NodeWorkerLaunchState =
| "pending"
| "running"
| "completed"
| "failed"
| "interrupted"
| "cancelled";
export type NodeWorkerTerminalState = Exclude<NodeWorkerLaunchState, "pending" | "running">;
type NodeWorkerLaunchDatabase = Pick<OpenClawStateDatabase, "node_worker_launches">;
type NodeWorkerLaunchRow = Selectable<NodeWorkerLaunchDatabase["node_worker_launches"]>;
export type NodeWorkerLaunchReceipt = {
launchId: string;
planHash: string;
gatewayNamespace: string;
environmentId: string;
sessionId: string;
ownerEpoch: number;
placementGeneration: number;
runId: string;
state: NodeWorkerLaunchState;
supervisor: NodeWorkerProcessIdentity;
worker: NodeWorkerProcessIdentity | null;
resultJson: string | null;
errorText: string | null;
completedAtMs: number | null;
createdAtMs: number;
updatedAtMs: number;
};
type NodeWorkerLaunchClaim = Pick<
NodeWorkerLaunchReceipt,
| "environmentId"
| "gatewayNamespace"
| "launchId"
| "ownerEpoch"
| "placementGeneration"
| "planHash"
| "runId"
| "sessionId"
>;
type NodeWorkerLaunchClaimResult = {
action: "start" | "replay" | "recover";
receipt: NodeWorkerLaunchReceipt;
};
const NODE_WORKER_LAUNCH_SCHEMA_START = "CREATE TABLE IF NOT EXISTS node_worker_launches (";
const NODE_WORKER_LAUNCH_SCHEMA_END = "\n) STRICT;";
const initializedDatabases = new WeakSet<DatabaseSync>();
const TERMINAL_STATES: ReadonlySet<string> = new Set([
"completed",
"failed",
"interrupted",
"cancelled",
]);
function ensureNodeWorkerLaunchSchema(database: DatabaseSync): void {
const start = OPENCLAW_STATE_SCHEMA_SQL.indexOf(NODE_WORKER_LAUNCH_SCHEMA_START);
const end =
start >= 0 ? OPENCLAW_STATE_SCHEMA_SQL.indexOf(NODE_WORKER_LAUNCH_SCHEMA_END, start) : -1;
if (start < 0 || end < start) {
throw new Error("OpenClaw node worker launch schema marker is missing.");
}
database.exec(OPENCLAW_STATE_SCHEMA_SQL.slice(start, end + NODE_WORKER_LAUNCH_SCHEMA_END.length)); // sqlite-allow-raw -- Canonical feature-local additive DDL only.
}
function query(database: DatabaseSync) {
return getNodeSqliteKysely<NodeWorkerLaunchDatabase>(database);
}
function readRow(database: DatabaseSync, launchId: string): NodeWorkerLaunchRow | undefined {
return executeSqliteQueryTakeFirstSync(
database,
query(database)
.selectFrom("node_worker_launches")
.selectAll()
.where("launch_id", "=", launchId),
);
}
function processIdentity(pid: number, startTime: number): NodeWorkerProcessIdentity {
return { pid, startTime };
}
function receiptFromRow(row: NodeWorkerLaunchRow): NodeWorkerLaunchReceipt {
if (!isNodeWorkerLaunchState(row.state)) {
throw new Error(`invalid node worker launch state ${row.state}`);
}
return {
launchId: row.launch_id,
planHash: row.plan_hash,
gatewayNamespace: row.gateway_namespace,
environmentId: row.environment_id,
sessionId: row.session_id,
ownerEpoch: row.owner_epoch,
placementGeneration: row.placement_generation,
runId: row.run_id,
state: row.state,
supervisor: processIdentity(row.supervisor_pid, row.supervisor_start_time),
worker:
row.worker_pid === null || row.worker_start_time === null
? null
: processIdentity(row.worker_pid, row.worker_start_time),
resultJson: row.result_json,
errorText: row.error_text,
completedAtMs: row.completed_at_ms,
createdAtMs: row.created_at_ms,
updatedAtMs: row.updated_at_ms,
};
}
function isNodeWorkerLaunchState(value: string): value is NodeWorkerLaunchState {
return value === "pending" || value === "running" || TERMINAL_STATES.has(value);
}
function validateIdentifier(value: string, label: string): void {
if (!value || value.trim() !== value || value.length > 256 || value.includes("\0")) {
throw new Error(`${label} must be a bounded non-empty identifier`);
}
}
function validatePlanHash(value: string): void {
if (!/^[a-f0-9]{64}$/u.test(value)) {
throw new Error("node worker plan hash must be 64 lowercase hexadecimal characters");
}
}
function validateTimestamp(value: number): void {
if (!Number.isSafeInteger(value) || value < 0) {
throw new Error("node worker launch timestamp must be a non-negative safe integer");
}
}
function validateProcessIdentity(identity: NodeWorkerProcessIdentity): void {
if (
!Number.isSafeInteger(identity.pid) ||
identity.pid <= 0 ||
identity.pid > 2_147_483_647 ||
!Number.isSafeInteger(identity.startTime) ||
identity.startTime < 0
) {
throw new Error("node worker process identity must contain a bounded pid and start time");
}
}
function requireMatchingRow(
database: DatabaseSync,
launchId: string,
planHash: string,
): NodeWorkerLaunchRow {
const row = readRow(database, launchId);
if (!row) {
throw new Error(`node worker launch ${launchId} does not exist`);
}
if (row.plan_hash !== planHash) {
throw new Error(`node worker launch ${launchId} was replayed with a different plan`);
}
return row;
}
function rowHasSupervisor(row: NodeWorkerLaunchRow, identity: NodeWorkerProcessIdentity): boolean {
return row.supervisor_pid === identity.pid && row.supervisor_start_time === identity.startTime;
}
function rowHasWorker(
row: NodeWorkerLaunchRow,
identity: NodeWorkerProcessIdentity | null,
): boolean {
return identity === null
? row.worker_pid === null && row.worker_start_time === null
: row.worker_pid === identity.pid && row.worker_start_time === identity.startTime;
}
function sameObservedOwner(current: NodeWorkerLaunchRow, observed: NodeWorkerLaunchRow): boolean {
return (
current.state === observed.state &&
current.supervisor_pid === observed.supervisor_pid &&
current.supervisor_start_time === observed.supervisor_start_time &&
current.worker_pid === observed.worker_pid &&
current.worker_start_time === observed.worker_start_time
);
}
/** Synchronous shared-state owner for durable node worker launch supervision. */
export class NodeWorkerLaunchStore {
private readonly databaseOptions: OpenClawStateDatabaseOptions;
constructor(options: { env?: NodeJS.ProcessEnv } = {}) {
this.databaseOptions = options.env ? { env: options.env } : {};
}
private write<T>(operationLabel: string, operation: (database: DatabaseSync) => T): T {
let initializedDatabase: DatabaseSync | undefined;
const result = runOpenClawStateWriteTransaction(
({ db }) => {
if (!initializedDatabases.has(db)) {
ensureNodeWorkerLaunchSchema(db);
initializedDatabase = db;
}
return operation(db);
},
this.databaseOptions,
{ operationLabel },
);
if (initializedDatabase) {
initializedDatabases.add(initializedDatabase);
}
return result;
}
claim(
claim: NodeWorkerLaunchClaim,
supervisor: NodeWorkerProcessIdentity,
nowMs = Date.now(),
): NodeWorkerLaunchClaimResult {
validateIdentifier(claim.launchId, "node worker launch id");
validatePlanHash(claim.planHash);
validateTimestamp(nowMs);
validateProcessIdentity(supervisor);
// Process inspection is intentionally outside SQLite. The second transaction
// re-reads the exact owner tuple before an adoption or recovery decision.
const observed = this.write("node-worker-launch.claim-inspect", (database) =>
readRow(database, claim.launchId),
);
if (observed && observed.plan_hash !== claim.planHash) {
throw new Error(`node worker launch ${claim.launchId} was replayed with a different plan`);
}
const observedSupervisorState = observed
? inspectNodeWorkerProcessIdentity(
processIdentity(observed.supervisor_pid, observed.supervisor_start_time),
)
: undefined;
return this.write("node-worker-launch.claim", (database) => {
let current = readRow(database, claim.launchId);
if (!current) {
executeSqliteQuerySync(
database,
query(database).insertInto("node_worker_launches").values({
launch_id: claim.launchId,
plan_hash: claim.planHash,
gateway_namespace: claim.gatewayNamespace,
environment_id: claim.environmentId,
session_id: claim.sessionId,
owner_epoch: claim.ownerEpoch,
placement_generation: claim.placementGeneration,
run_id: claim.runId,
state: "pending",
supervisor_pid: supervisor.pid,
supervisor_start_time: supervisor.startTime,
worker_pid: null,
worker_start_time: null,
result_json: null,
error_text: null,
completed_at_ms: null,
created_at_ms: nowMs,
updated_at_ms: nowMs,
}),
);
return {
action: "start",
receipt: receiptFromRow(requireMatchingRow(database, claim.launchId, claim.planHash)),
};
}
if (current.plan_hash !== claim.planHash) {
throw new Error(`node worker launch ${claim.launchId} was replayed with a different plan`);
}
const previousOwnerDefinitelyStale =
observedSupervisorState === "dead" || observedSupervisorState === "reused";
if (
current.state === "pending" &&
observed &&
sameObservedOwner(current, observed) &&
previousOwnerDefinitelyStale
) {
const updatedAtMs = Math.max(nowMs, current.created_at_ms, current.updated_at_ms);
executeSqliteQuerySync(
database,
query(database)
.updateTable("node_worker_launches")
.set({
supervisor_pid: supervisor.pid,
supervisor_start_time: supervisor.startTime,
updated_at_ms: updatedAtMs,
})
.where("launch_id", "=", claim.launchId)
.where("plan_hash", "=", claim.planHash)
.where("state", "=", "pending")
.where("supervisor_pid", "=", observed.supervisor_pid)
.where("supervisor_start_time", "=", observed.supervisor_start_time)
.where("worker_pid", "is", null)
.where("worker_start_time", "is", null),
);
current = requireMatchingRow(database, claim.launchId, claim.planHash);
return {
action: rowHasSupervisor(current, supervisor) ? "start" : "replay",
receipt: receiptFromRow(current),
};
}
if (
current.state === "running" &&
observed &&
sameObservedOwner(current, observed) &&
previousOwnerDefinitelyStale
) {
return { action: "recover", receipt: receiptFromRow(current) };
}
return { action: "replay", receipt: receiptFromRow(current) };
});
}
get(launchId: string): NodeWorkerLaunchReceipt | undefined {
validateIdentifier(launchId, "node worker launch id");
return this.write("node-worker-launch.get", (database) => {
const row = readRow(database, launchId);
return row ? receiptFromRow(row) : undefined;
});
}
markRunning(params: {
launchId: string;
planHash: string;
supervisor: NodeWorkerProcessIdentity;
worker: NodeWorkerProcessIdentity;
nowMs?: number;
}): NodeWorkerLaunchReceipt {
const nowMs = params.nowMs ?? Date.now();
validateTimestamp(nowMs);
validateProcessIdentity(params.supervisor);
validateProcessIdentity(params.worker);
return this.write("node-worker-launch.mark-running", (database) => {
const current = requireMatchingRow(database, params.launchId, params.planHash);
if (TERMINAL_STATES.has(current.state)) {
return receiptFromRow(current);
}
if (current.state === "running") {
return receiptFromRow(current);
}
if (!rowHasSupervisor(current, params.supervisor) || !rowHasWorker(current, null)) {
return receiptFromRow(current);
}
const updatedAtMs = Math.max(nowMs, current.created_at_ms, current.updated_at_ms);
executeSqliteQuerySync(
database,
query(database)
.updateTable("node_worker_launches")
.set({
state: "running",
worker_pid: params.worker.pid,
worker_start_time: params.worker.startTime,
updated_at_ms: updatedAtMs,
})
.where("launch_id", "=", params.launchId)
.where("plan_hash", "=", params.planHash)
.where("state", "=", "pending")
.where("supervisor_pid", "=", params.supervisor.pid)
.where("supervisor_start_time", "=", params.supervisor.startTime)
.where("worker_pid", "is", null)
.where("worker_start_time", "is", null),
);
return receiptFromRow(requireMatchingRow(database, params.launchId, params.planHash));
});
}
finish(params: {
launchId: string;
planHash: string;
supervisor: NodeWorkerProcessIdentity;
worker: NodeWorkerProcessIdentity | null;
state: NodeWorkerTerminalState;
resultJson?: string;
errorText?: string;
nowMs?: number;
}): NodeWorkerLaunchReceipt {
const nowMs = params.nowMs ?? Date.now();
validateTimestamp(nowMs);
validateProcessIdentity(params.supervisor);
if (params.worker) {
validateProcessIdentity(params.worker);
}
return this.write("node-worker-launch.finish", (database) => {
const current = requireMatchingRow(database, params.launchId, params.planHash);
if (TERMINAL_STATES.has(current.state)) {
return receiptFromRow(current);
}
if (!rowHasSupervisor(current, params.supervisor) || !rowHasWorker(current, params.worker)) {
return receiptFromRow(current);
}
const completedAtMs = Math.max(nowMs, current.created_at_ms, current.updated_at_ms);
let update = query(database)
.updateTable("node_worker_launches")
.set({
state: params.state,
result_json: params.state === "completed" ? (params.resultJson ?? null) : null,
error_text: params.state === "completed" ? null : (params.errorText ?? null),
completed_at_ms: completedAtMs,
updated_at_ms: completedAtMs,
})
.where("launch_id", "=", params.launchId)
.where("plan_hash", "=", params.planHash)
.where("state", "in", ["pending", "running"])
.where("supervisor_pid", "=", params.supervisor.pid)
.where("supervisor_start_time", "=", params.supervisor.startTime);
update = params.worker
? update
.where("worker_pid", "=", params.worker.pid)
.where("worker_start_time", "=", params.worker.startTime)
: update.where("worker_pid", "is", null).where("worker_start_time", "is", null);
executeSqliteQuerySync(database, update);
return receiptFromRow(requireMatchingRow(database, params.launchId, params.planHash));
});
}
}
@@ -0,0 +1,36 @@
import { readWindowsProcessStartTimeSync } from "../infra/windows-port-pids.js";
import { getFileLockProcessStartTime, isPidDefinitelyDead } from "../shared/pid-alive.js";
export type NodeWorkerProcessIdentity = {
pid: number;
startTime: number;
};
type NodeWorkerProcessIdentityState = "live" | "dead" | "reused" | "unknown";
function readNodeWorkerProcessStartTime(pid: number): number | null {
return process.platform === "win32"
? readWindowsProcessStartTimeSync(pid)
: getFileLockProcessStartTime(pid);
}
export function requireNodeWorkerProcessIdentity(pid: number): NodeWorkerProcessIdentity {
const startTime = readNodeWorkerProcessStartTime(pid);
if (startTime === null) {
throw new Error(`cannot establish PID-reuse-safe identity for process ${pid}`);
}
return { pid, startTime };
}
export function inspectNodeWorkerProcessIdentity(
identity: NodeWorkerProcessIdentity,
): NodeWorkerProcessIdentityState {
const observedStartTime = readNodeWorkerProcessStartTime(identity.pid);
if (observedStartTime !== null) {
if (observedStartTime !== identity.startTime) {
return "reused";
}
return isPidDefinitelyDead(identity.pid) ? "dead" : "live";
}
return isPidDefinitelyDead(identity.pid) ? "dead" : "unknown";
}
@@ -0,0 +1,362 @@
import { spawn, type ChildProcess } from "node:child_process";
import { createHash } from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { stableStringify } from "@openclaw/normalization-core";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
import {
closeOpenClawStateDatabaseForTest,
openOpenClawStateDatabase,
} from "../state/openclaw-state-db.js";
import type { NodeWorkerLaunchReceipt } from "./node-worker-launch-store.js";
import {
inspectNodeWorkerProcessIdentity,
requireNodeWorkerProcessIdentity,
type NodeWorkerProcessIdentity,
} from "./node-worker-process-identity.js";
import { createNodeWorkerSupervisor } from "./node-worker-supervisor.js";
import {
testWorkerLaunchInput,
writeNodeWorkerFixture,
} from "./node-worker-supervisor.test-support.js";
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
const spawned = new Set<ChildProcess>();
const ownedProcessGroups: NodeWorkerProcessIdentity[] = [];
afterEach(async () => {
for (const child of spawned) {
if (child.exitCode === null && child.signalCode === null) {
child.kill("SIGKILL");
}
}
if (process.platform !== "win32") {
for (const identity of ownedProcessGroups) {
if (inspectNodeWorkerProcessIdentity(identity) === "reused") {
continue;
}
try {
process.kill(-identity.pid, "SIGKILL");
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ESRCH") {
throw error;
}
}
}
}
spawned.clear();
ownedProcessGroups.length = 0;
closeOpenClawStateDatabaseForTest();
});
function fixture(label: string) {
return writeNodeWorkerFixture(tempDirs.make(label));
}
function planHash(input: ReturnType<typeof testWorkerLaunchInput>): string {
return createHash("sha256")
.update(
stableStringify({
bundleHash: input.bundleHash,
descriptor: input.descriptor,
gatewayNamespace: input.gatewayNamespace,
placementGeneration: input.placementGeneration,
}),
)
.digest("hex");
}
function insertLaunch(params: {
env: NodeJS.ProcessEnv;
input: ReturnType<typeof testWorkerLaunchInput>;
state: "pending" | "running";
supervisor: NodeWorkerProcessIdentity;
worker?: NodeWorkerProcessIdentity;
}) {
const database = openOpenClawStateDatabase({ env: params.env }).db;
database
.prepare(
`INSERT INTO node_worker_launches (
launch_id, plan_hash, gateway_namespace, environment_id, session_id,
owner_epoch, placement_generation, run_id, state,
supervisor_pid, supervisor_start_time, worker_pid, worker_start_time,
result_json, error_text, completed_at_ms, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, NULL, 1, 1)`,
)
.run(
params.input.launchId,
planHash(params.input),
params.input.gatewayNamespace,
params.input.descriptor.admission.environmentId,
params.input.descriptor.admission.sessionId,
params.input.descriptor.admission.ownerEpoch,
params.input.placementGeneration,
params.input.descriptor.assignment.runId,
params.state,
params.supervisor.pid,
params.supervisor.startTime,
params.worker?.pid ?? null,
params.worker?.startTime ?? null,
);
}
function waitForChildLine(child: ChildProcess): Promise<string> {
return new Promise((resolve, reject) => {
let stdout = "";
let stderr = "";
const onData = (chunk: Buffer) => {
stdout += chunk.toString("utf8");
const newline = stdout.indexOf("\n");
if (newline >= 0) {
resolve(stdout.slice(0, newline));
}
};
child.stdout?.on("data", onData);
child.stderr?.on("data", (chunk: Buffer) => {
stderr += chunk.toString("utf8");
});
child.once("error", reject);
child.once("close", (code, signal) => {
reject(new Error(`owner exited before ready (${code ?? signal}): ${stderr}`));
});
});
}
function waitForChildExit(child: ChildProcess): Promise<void> {
if (child.exitCode !== null || child.signalCode !== null) {
return Promise.resolve();
}
return new Promise((resolve, reject) => {
child.once("error", reject);
child.once("close", () => resolve());
});
}
function writeSupervisorOwnerScript(root: string): string {
const supervisorUrl = pathToFileURL(path.resolve("src/node-host/node-worker-supervisor.ts")).href;
const scriptPath = path.join(root, "supervisor-owner.mts");
fs.writeFileSync(
scriptPath,
`
import fs from "node:fs";
import { createNodeWorkerSupervisor } from ${JSON.stringify(supervisorUrl)};
const [bundleRoot, stateDir, inputPath] = process.argv.slice(2);
const supervisor = createNodeWorkerSupervisor({
bundleRoot,
env: { ...process.env, OPENCLAW_STATE_DIR: stateDir },
});
const shutdown = async () => {
await supervisor.close();
process.exit(0);
};
process.once("SIGTERM", () => void shutdown());
const input = JSON.parse(fs.readFileSync(inputPath, "utf8"));
const receipt = await supervisor.launch(input);
process.stdout.write(JSON.stringify(receipt) + "\\n");
setInterval(() => {}, 1000);
`,
);
return scriptPath;
}
function spawnSupervisorOwner(params: {
bundleRoot: string;
env: NodeJS.ProcessEnv;
input: ReturnType<typeof testWorkerLaunchInput>;
root: string;
}): ChildProcess {
const inputPath = path.join(params.root, `${params.input.launchId}.json`);
fs.writeFileSync(inputPath, JSON.stringify(params.input));
const child = spawn(
process.execPath,
[
"--import",
"tsx",
writeSupervisorOwnerScript(params.root),
params.bundleRoot,
params.env.OPENCLAW_STATE_DIR!,
inputPath,
],
{ stdio: ["ignore", "pipe", "pipe"] },
);
spawned.add(child);
return child;
}
async function waitForIdentityDeath(identity: NodeWorkerProcessIdentity) {
await vi.waitFor(() => expect(inspectNodeWorkerProcessIdentity(identity)).not.toBe("live"), {
timeout: 5_000,
});
}
describe("node worker supervisor recovery", () => {
it("atomically adopts pending work only after the previous supervisor is stale", async () => {
const { bundleRoot, env, workspaceDir } = fixture("node-worker-stale-pending-");
const supervisor = createNodeWorkerSupervisor({ bundleRoot, env });
await supervisor.status("schema-probe");
const input = testWorkerLaunchInput(workspaceDir, "stale-pending-launch");
insertLaunch({
env,
input,
state: "pending",
supervisor: { pid: 2_147_483_647, startTime: 1 },
});
const running = await supervisor.launch(input);
expect(running).toMatchObject({
state: "running",
supervisor: requireNodeWorkerProcessIdentity(process.pid),
worker: { pid: expect.any(Number), startTime: expect.any(Number) },
});
await supervisor.close();
});
it.runIf(process.platform !== "win32")(
"kills the exact stale-owner worker group before marking it interrupted",
async () => {
const { bundleRoot, env, root, workspaceDir } = fixture("node-worker-stale-running-");
const marker = path.join(root, "recovery-grandchild.pid");
const workerSource = `
const { spawn } = require("node:child_process");
const fs = require("node:fs");
const child = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { stdio: "ignore" });
fs.writeFileSync(process.argv[1], String(child.pid));
setInterval(() => {}, 1000);
`;
const workerProcess = spawn(process.execPath, ["-e", workerSource, marker], {
detached: true,
stdio: "ignore",
});
spawned.add(workerProcess);
const worker = requireNodeWorkerProcessIdentity(workerProcess.pid!);
ownedProcessGroups.push(worker);
await vi.waitFor(() => expect(fs.existsSync(marker)).toBe(true));
const grandchild = requireNodeWorkerProcessIdentity(Number(fs.readFileSync(marker, "utf8")));
const input = testWorkerLaunchInput(workspaceDir, "stale-running-launch", "wait");
const supervisor = createNodeWorkerSupervisor({ bundleRoot, env });
await supervisor.status("schema-probe");
insertLaunch({
env,
input,
state: "running",
supervisor: { pid: 2_147_483_647, startTime: 1 },
worker,
});
const recovered = await supervisor.launch(input);
expect(recovered).toMatchObject({ state: "interrupted", worker });
await waitForIdentityDeath(worker);
await waitForIdentityDeath(grandchild);
expect((await supervisor.status(input.launchId))?.worker).toEqual(worker);
await supervisor.close();
},
);
it("returns a live foreign running receipt from a real second process without mutation", async () => {
const { bundleRoot, env, root, workspaceDir } = fixture("node-worker-live-replay-");
const input = testWorkerLaunchInput(workspaceDir, "live-running-launch", "wait");
const owner = spawnSupervisorOwner({ bundleRoot, env, input, root });
const owned = JSON.parse(await waitForChildLine(owner)) as NodeWorkerLaunchReceipt;
if (owned.worker) {
ownedProcessGroups.push(owned.worker);
}
const second = createNodeWorkerSupervisor({ bundleRoot, env });
const replay = await second.launch(input);
expect(replay).toEqual(owned);
expect(inspectNodeWorkerProcessIdentity(owned.supervisor)).toBe("live");
expect(inspectNodeWorkerProcessIdentity(owned.worker!)).toBe("live");
owner.kill("SIGTERM");
await waitForChildExit(owner);
await second.close();
});
it.runIf(process.platform !== "win32")(
"uses IPC disconnect after owner SIGKILL, then reconciles only after exact tree death",
async () => {
const { bundleRoot, env, root, workspaceDir } = fixture("node-worker-owner-kill-");
const input = testWorkerLaunchInput(workspaceDir, "owner-kill-launch", "tree");
const owner = spawnSupervisorOwner({ bundleRoot, env, input, root });
const owned = JSON.parse(await waitForChildLine(owner)) as NodeWorkerLaunchReceipt;
ownedProcessGroups.push(owned.worker!);
const grandchildPath = path.join(workspaceDir, "grandchild.pid");
await vi.waitFor(() => expect(fs.existsSync(grandchildPath)).toBe(true));
const grandchild = requireNodeWorkerProcessIdentity(
Number(fs.readFileSync(grandchildPath, "utf8")),
);
owner.kill("SIGKILL");
await waitForChildExit(owner);
await waitForIdentityDeath(owned.supervisor);
await waitForIdentityDeath(owned.worker!);
await waitForIdentityDeath(grandchild);
const restarted = createNodeWorkerSupervisor({ bundleRoot, env });
const reconciled = await restarted.launch(input);
expect(reconciled).toMatchObject({
state: "interrupted",
supervisor: owned.supervisor,
worker: owned.worker,
});
await restarted.close();
},
);
it("keeps a live foreign pending claim unchanged across real processes", async () => {
const { bundleRoot, env, root, workspaceDir } = fixture("node-worker-live-pending-");
const input = testWorkerLaunchInput(workspaceDir, "live-pending-launch", "wait");
const claim = {
launchId: input.launchId,
planHash: planHash(input),
gatewayNamespace: input.gatewayNamespace,
environmentId: input.descriptor.admission.environmentId,
sessionId: input.descriptor.admission.sessionId,
ownerEpoch: input.descriptor.admission.ownerEpoch,
placementGeneration: input.placementGeneration,
runId: input.descriptor.assignment.runId,
};
const storeUrl = pathToFileURL(path.resolve("src/node-host/node-worker-launch-store.ts")).href;
const identityUrl = pathToFileURL(
path.resolve("src/node-host/node-worker-process-identity.ts"),
).href;
const claimPath = path.join(root, "claim.json");
const scriptPath = path.join(root, "pending-owner.mts");
fs.writeFileSync(claimPath, JSON.stringify(claim));
fs.writeFileSync(
scriptPath,
`
import fs from "node:fs";
import { NodeWorkerLaunchStore } from ${JSON.stringify(storeUrl)};
import { requireNodeWorkerProcessIdentity } from ${JSON.stringify(identityUrl)};
const [stateDir, claimPath] = process.argv.slice(2);
const store = new NodeWorkerLaunchStore({ env: { ...process.env, OPENCLAW_STATE_DIR: stateDir } });
const result = store.claim(
JSON.parse(fs.readFileSync(claimPath, "utf8")),
requireNodeWorkerProcessIdentity(process.pid),
);
process.stdout.write(JSON.stringify(result.receipt) + "\\n");
setInterval(() => {}, 1000);
`,
);
const owner = spawn(
process.execPath,
["--import", "tsx", scriptPath, env.OPENCLAW_STATE_DIR!, claimPath],
{ stdio: ["ignore", "pipe", "pipe"] },
);
spawned.add(owner);
const owned = JSON.parse(await waitForChildLine(owner)) as NodeWorkerLaunchReceipt;
const second = createNodeWorkerSupervisor({ bundleRoot, env });
const replay = await second.launch(input);
expect(replay).toEqual(owned);
owner.kill("SIGKILL");
await waitForChildExit(owner);
await second.close();
});
});
@@ -0,0 +1,184 @@
import fs from "node:fs";
import path from "node:path";
import {
WORKER_PROTOCOL_FEATURES,
WORKER_RPC_SET_VERSION,
} from "../../packages/gateway-protocol/src/schema/worker-admission.js";
import type { WorkerLaunchDescriptor } from "../worker/launch-descriptor.js";
const TEST_BUNDLE_HASH = "a".repeat(64);
export const TEST_WORKER_CREDENTIAL = 'node worker/"credential\\secret?';
export const TEST_WORKER_SOURCE = String.raw`
import fs from "node:fs";
import path from "node:path";
import { spawn } from "node:child_process";
let input = "";
for await (const chunk of process.stdin) input += chunk;
const descriptor = JSON.parse(input);
if (descriptor.assignment.prompt === "exit-before-start") {
fs.writeFileSync(path.join(descriptor.assignment.workspaceDir, "prestart-exited"), "exited");
process.exit(23);
}
if (!process.connected || !process.channel || !process.argv.includes("--internal-worker-ipc")) {
process.exit(24);
}
let grandchild;
let disposed = false;
let started = false;
let resolveStart;
const start = new Promise((resolve) => { resolveStart = resolve; });
const hardTerminate = () => {
if (process.platform === "win32") {
spawn("taskkill", ["/F", "/T", "/PID", String(process.pid)], {
detached: true,
stdio: "ignore",
windowsHide: true,
});
return;
}
process.kill(-process.pid, "SIGKILL");
};
const onMessage = (message) => {
if (
started ||
typeof message !== "object" ||
message === null ||
Array.isArray(message) ||
Object.keys(message).length !== 1 ||
message.type !== "openclaw-worker-start-v1"
) {
hardTerminate();
return;
}
started = true;
resolveStart();
};
const onDisconnect = () => {
if (disposed) return;
if (!started) process.exit(0);
hardTerminate();
};
process.on("message", onMessage);
process.once("disconnect", onDisconnect);
await start;
const exitWorker = (code) => {
disposed = true;
process.off("message", onMessage);
process.off("disconnect", onDisconnect);
if (process.connected) process.disconnect();
process.exit(code);
};
const writeResultAndExit = (value) => {
fs.writeSync(1, value);
exitWorker(0);
};
const mode = descriptor.assignment.prompt;
if (mode === "wait") {
setInterval(() => {}, 1000);
} else if (mode === "tree") {
grandchild = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { stdio: "ignore" });
fs.writeFileSync(path.join(descriptor.assignment.workspaceDir, "grandchild.pid"), String(grandchild.pid));
setInterval(() => {}, 1000);
} else if (mode === "secret-fail") {
await new Promise((resolve) => setTimeout(resolve, 500));
const credential = descriptor.admission.credential;
const escaped = JSON.stringify(credential).slice(1, -1);
process.stderr.write(
"failure " + "x".repeat(5000) + " " + credential + " " + encodeURIComponent(credential) + " " + escaped,
);
exitWorker(7);
} else if (mode.startsWith("secret-cutoff-")) {
const credential = descriptor.admission.credential;
const representations = {
"secret-cutoff-raw": credential,
"secret-cutoff-url": encodeURIComponent(credential),
"secret-cutoff-json": JSON.stringify(credential).slice(1, -1),
};
const representation = representations[mode];
const suffixBytes = 4096 - Math.floor(Buffer.byteLength(representation, "utf8") / 2);
process.stderr.write("x".repeat(5000) + representation + "y".repeat(suffixBytes));
exitWorker(7);
} else if (mode === "secret-success") {
await new Promise((resolve) => setTimeout(resolve, 500));
const credential = descriptor.admission.credential;
writeResultAndExit(
JSON.stringify({ raw: credential, encoded: encodeURIComponent(credential), status: "completed" }) + "\n",
);
} else if (mode === "overflow") {
writeResultAndExit("x".repeat(70 * 1024));
} else if (mode === "fast-terminal") {
const marker = path.join(descriptor.assignment.workspaceDir, "fast-terminal-marker");
process.once("SIGTERM", () => {
fs.writeFileSync(marker, "signal");
process.exit(143);
});
await new Promise((resolve) => setTimeout(resolve, 100));
fs.writeFileSync(marker, "normal");
writeResultAndExit(JSON.stringify({ status: "completed" }) + "\n");
} else if (mode === "env") {
writeResultAndExit(JSON.stringify(process.env) + "\n");
} else {
await new Promise((resolve) => setTimeout(resolve, 25));
writeResultAndExit(JSON.stringify({ argv: process.argv.slice(2), status: "completed" }) + "\n");
}
`;
export function testWorkerDescriptor(
workspaceDir: string,
prompt = "success",
): WorkerLaunchDescriptor {
return {
version: 3,
connectionEndpoint: { kind: "unix", socketPath: "/tmp/openclaw-worker/gateway.sock" },
admission: {
environmentId: "environment-1",
credential: TEST_WORKER_CREDENTIAL,
sessionId: "session-1",
ownerEpoch: 3,
rpcSetVersion: WORKER_RPC_SET_VERSION,
handshake: {
bundleHash: TEST_BUNDLE_HASH,
openclawVersion: "2026.8.1",
protocolFeatures: [...WORKER_PROTOCOL_FEATURES],
},
},
assignment: {
agentId: "agent-1",
operationalRunInstance: { instanceId: "instance-1", runId: "run-1" },
agentRuntimeIdentityToken: "signed-runtime-token",
runId: "run-1",
turnId: "turn-1",
prompt,
suppressPromptTranscript: false,
workspaceDir,
modelRef: { provider: "provider-1", model: "model-1" },
inferenceOptions: {},
initialMessages: [],
transcript: { baseLeafId: null, nextSeq: 1 },
liveEvents: { ackedSeq: 0, nextSeq: 1 },
toolAuthority: { allowedToolNames: [] },
},
};
}
export function writeNodeWorkerFixture(root: string) {
const stateDir = path.join(root, "state-root");
const bundleRoot = path.join(root, "bundles-root");
const workspaceDir = path.join(root, "workspace");
const bundleDir = path.join(bundleRoot, "gateway-1", "bundles", TEST_BUNDLE_HASH);
fs.mkdirSync(bundleDir, { recursive: true });
fs.mkdirSync(workspaceDir, { recursive: true });
fs.writeFileSync(path.join(bundleDir, "openclaw.mjs"), TEST_WORKER_SOURCE);
return { bundleRoot, env: { OPENCLAW_STATE_DIR: stateDir }, root, stateDir, workspaceDir };
}
export function testWorkerLaunchInput(workspaceDir: string, launchId: string, prompt = "success") {
return {
launchId,
gatewayNamespace: "gateway-1",
bundleHash: TEST_BUNDLE_HASH,
placementGeneration: 4,
descriptor: testWorkerDescriptor(workspaceDir, prompt),
};
}
@@ -0,0 +1,513 @@
import childProcess from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
import { registerSecretValueForRedaction } from "../logging/secret-redaction-registry.js";
import { resetSecretRedactionRegistryForTest } from "../logging/secret-redaction-registry.test-support.js";
import {
closeOpenClawStateDatabaseForTest,
openOpenClawStateDatabase,
} from "../state/openclaw-state-db.js";
import { withEnvAsync } from "../test-utils/env.js";
import { NodeWorkerLaunchStore } from "./node-worker-launch-store.js";
import {
inspectNodeWorkerProcessIdentity,
requireNodeWorkerProcessIdentity,
} from "./node-worker-process-identity.js";
import { createNodeWorkerSupervisor } from "./node-worker-supervisor.js";
import {
TEST_WORKER_CREDENTIAL,
TEST_WORKER_SOURCE,
testWorkerDescriptor,
testWorkerLaunchInput,
writeNodeWorkerFixture,
} from "./node-worker-supervisor.test-support.js";
type NodeWorkerSupervisor = ReturnType<typeof createNodeWorkerSupervisor>;
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
afterEach(() => {
vi.restoreAllMocks();
resetSecretRedactionRegistryForTest();
closeOpenClawStateDatabaseForTest();
});
function fixture() {
const root = tempDirs.make("node-worker-supervisor-");
const { bundleRoot, env, stateDir, workspaceDir } = writeNodeWorkerFixture(root);
const supervisor = createNodeWorkerSupervisor({ bundleRoot, env });
return { bundleRoot, env, root, stateDir, supervisor, workspaceDir };
}
function launchInput(workspaceDir: string, launchId: string, prompt = "success") {
return testWorkerLaunchInput(workspaceDir, launchId, prompt);
}
async function waitForTerminal(supervisor: NodeWorkerSupervisor, launchId: string) {
await vi.waitFor(
async () => {
expect((await supervisor.status(launchId))?.state).not.toMatch(/^(?:pending|running)$/u);
},
{ timeout: 5_000 },
);
const receipt = await supervisor.status(launchId);
if (!receipt) {
throw new Error(`missing launch receipt ${launchId}`);
}
return receipt;
}
describe("node worker supervisor", () => {
it("keeps construction and close inert without resolving process identity", async () => {
const root = tempDirs.make("node-worker-inert-");
const { bundleRoot, env } = writeNodeWorkerFixture(root);
const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform");
const spawnSync = vi.spyOn(childProcess, "spawnSync");
const execFileSync = vi.spyOn(childProcess, "execFileSync");
Object.defineProperty(process, "platform", { configurable: true, value: "win32" });
try {
const supervisor = createNodeWorkerSupervisor({ bundleRoot, env });
await supervisor.close();
expect(spawnSync).not.toHaveBeenCalled();
expect(execFileSync).not.toHaveBeenCalled();
} finally {
if (originalPlatform) {
Object.defineProperty(process, "platform", originalPlatform);
}
}
});
it("keeps the additive table absent until the first stateful operation", async () => {
const { bundleRoot, env, supervisor } = fixture();
const database = openOpenClawStateDatabase({ env });
const findTable = () =>
database.db
.prepare("SELECT name FROM sqlite_schema WHERE type = 'table' AND name = ?")
.get("node_worker_launches");
expect(findTable()).toBeUndefined();
await supervisor.close();
expect(findTable()).toBeUndefined();
const active = createNodeWorkerSupervisor({ bundleRoot, env });
expect(await active.status("missing-launch")).toBeUndefined();
expect(
database.db
.prepare("SELECT strict FROM pragma_table_list WHERE name = ?")
.get("node_worker_launches"),
).toEqual({ strict: 1 });
await active.close();
});
it("keeps pending and running launches owned by a live supervisor unchanged", async () => {
const { bundleRoot, env, supervisor } = fixture();
await supervisor.status("schema-probe");
const database = openOpenClawStateDatabase({ env }).db;
const supervisorIdentity = requireNodeWorkerProcessIdentity(process.pid);
const insert = database.prepare(`
INSERT INTO node_worker_launches (
launch_id, plan_hash, gateway_namespace, environment_id, session_id,
owner_epoch, placement_generation, run_id, state,
supervisor_pid, supervisor_start_time, worker_pid, worker_start_time,
result_json, error_text, completed_at_ms, created_at_ms, updated_at_ms
) VALUES (?, ?, 'gateway-1', 'environment-1', 'session-1', 3, 4, 'run-1', ?, ?, ?, ?, ?, NULL, NULL, NULL, 1, 1)
`);
insert.run(
"pending-launch",
"b".repeat(64),
"pending",
supervisorIdentity.pid,
supervisorIdentity.startTime,
null,
null,
);
insert.run(
"running-launch",
"c".repeat(64),
"running",
supervisorIdentity.pid,
supervisorIdentity.startTime,
process.pid,
supervisorIdentity.startTime,
);
const sameHandle = createNodeWorkerSupervisor({ bundleRoot, env });
expect(await sameHandle.status("pending-launch")).toMatchObject({
state: "pending",
worker: null,
});
expect(await sameHandle.status("running-launch")).toMatchObject({
state: "running",
worker: supervisorIdentity,
});
await supervisor.close();
await sameHandle.close();
closeOpenClawStateDatabaseForTest();
openOpenClawStateDatabase({ env });
const recovered = createNodeWorkerSupervisor({ bundleRoot, env });
expect(await recovered.status("pending-launch")).toMatchObject({
state: "pending",
worker: null,
});
expect(await recovered.status("running-launch")).toMatchObject({
state: "running",
worker: supervisorIdentity,
});
await recovered.close();
});
it("launches idempotently and persists only bounded non-secret facts", async () => {
const { env, supervisor, workspaceDir } = fixture();
const input = launchInput(workspaceDir, "success-launch");
expect(await supervisor.launch(input)).toMatchObject({
launchId: "success-launch",
state: "running",
environmentId: "environment-1",
sessionId: "session-1",
ownerEpoch: 3,
placementGeneration: 4,
runId: "run-1",
});
const completed = await waitForTerminal(supervisor, input.launchId);
expect(completed).toMatchObject({ state: "completed", errorText: null });
expect(JSON.parse(completed.resultJson ?? "null")).toEqual({
argv: ["worker", "--internal-worker-ipc"],
status: "completed",
});
expect(await supervisor.launch(input)).toEqual(completed);
await expect(
supervisor.launch({
...input,
descriptor: testWorkerDescriptor(workspaceDir, "different-plan"),
}),
).rejects.toThrow("replayed with a different plan");
const row = openOpenClawStateDatabase({ env })
.db.prepare("SELECT * FROM node_worker_launches WHERE launch_id = ?")
.get(input.launchId);
expect(JSON.stringify(row)).not.toContain(TEST_WORKER_CREDENTIAL);
await supervisor.close();
});
it.each(["status", "launch", "cancel", "close"] as const)(
"retains an observed terminal outcome when %s reconciliation keeps failing",
async (operation) => {
const { env, supervisor, workspaceDir } = fixture();
const input = launchInput(workspaceDir, `finish-failure-${operation}`);
const store = (supervisor as unknown as { store: NodeWorkerLaunchStore }).store;
const originalFinish = store.finish.bind(store);
let persistenceUnavailable = true;
const finish = vi.spyOn(store, "finish").mockImplementation((params) => {
if (persistenceUnavailable) {
throw new Error("injected finish failure");
}
return originalFinish(params);
});
const invoke = async () => {
switch (operation) {
case "status":
return await supervisor.status(input.launchId);
case "launch":
return await supervisor.launch(input);
case "cancel":
return await supervisor.cancel(input.launchId);
case "close":
await supervisor.close();
return new NodeWorkerLaunchStore({ env }).get(input.launchId);
default:
throw new Error("unsupported reconciliation operation");
}
};
expect(await supervisor.launch(input)).toMatchObject({ state: "running" });
await vi.waitFor(() => expect(finish).toHaveBeenCalled(), { timeout: 5_000 });
expect(new NodeWorkerLaunchStore({ env }).get(input.launchId)?.state).toBe("running");
await expect(invoke()).rejects.toThrow("injected finish failure");
expect(new NodeWorkerLaunchStore({ env }).get(input.launchId)?.state).toBe("running");
persistenceUnavailable = false;
const completed = await invoke();
expect(completed).toMatchObject({
state: "completed",
resultJson: expect.stringContaining('"status":"completed"'),
});
expect(new NodeWorkerLaunchStore({ env }).get(input.launchId)?.state).toBe("completed");
await supervisor.close();
},
);
it("spawns workers with only supplied runtime essentials", async () => {
const root = tempDirs.make("node-worker-env-");
const { bundleRoot, env, workspaceDir } = writeNodeWorkerFixture(root);
const suppliedPathKey = process.platform === "win32" ? "Path" : "PATH";
const suppliedEnv: NodeJS.ProcessEnv = {
...env,
[suppliedPathKey]: process.env.PATH,
HOME: path.join(root, "worker-home"),
LANG: "en_US.UTF-8",
LC_TIME: "de_DE.UTF-8",
NODE_EXTRA_CA_CERTS: path.join(root, "private-ca.pem"),
NODE_USE_SYSTEM_CA: "1",
OPENCLAW_ALLOW_INSECURE_PRIVATE_WS: "1",
OPENCLAW_SUPPLIED_SECRET: "supplied-openclaw-secret",
NODE_OPTIONS: "--title=forbidden-worker-title",
BASH_ENV: path.join(root, "forbidden-shell-init"),
DYLD_INSERT_LIBRARIES: path.join(root, "forbidden-runtime-injection"),
HTTPS_PROXY: "http://supplied-proxy.invalid",
SUPPLIED_SECRET: "supplied-secret",
};
await withEnvAsync(
{
AMBIENT_SECRET: "ambient-secret",
OPENCLAW_AMBIENT_SECRET: "ambient-openclaw-secret",
HTTP_PROXY: "http://ambient-proxy.invalid",
NODE_OPTIONS: undefined,
},
async () => {
const expectedWorkerEnv: NodeJS.ProcessEnv = {
HOME: suppliedEnv.HOME,
LANG: suppliedEnv.LANG,
LC_TIME: suppliedEnv.LC_TIME,
NODE_EXTRA_CA_CERTS: suppliedEnv.NODE_EXTRA_CA_CERTS,
NODE_USE_SYSTEM_CA: suppliedEnv.NODE_USE_SYSTEM_CA,
OPENCLAW_ALLOW_INSECURE_PRIVATE_WS: suppliedEnv.OPENCLAW_ALLOW_INSECURE_PRIVATE_WS,
[suppliedPathKey]: suppliedEnv[suppliedPathKey],
};
const supervisor = createNodeWorkerSupervisor({ bundleRoot, env: suppliedEnv });
suppliedEnv.HOME = path.join(root, "mutated-home");
suppliedEnv.LANG = "mutated-locale";
const input = launchInput(workspaceDir, "env-launch", "env");
await supervisor.launch(input);
const completed = await waitForTerminal(supervisor, input.launchId);
const workerEnv = JSON.parse(completed.resultJson ?? "null") as Record<string, string>;
expect(workerEnv).toMatchObject(expectedWorkerEnv);
expect(workerEnv).not.toHaveProperty("AMBIENT_SECRET");
expect(workerEnv).not.toHaveProperty("OPENCLAW_AMBIENT_SECRET");
expect(workerEnv).not.toHaveProperty("OPENCLAW_STATE_DIR");
expect(workerEnv).not.toHaveProperty("OPENCLAW_SUPPLIED_SECRET");
expect(workerEnv).not.toHaveProperty("NODE_OPTIONS");
expect(workerEnv).not.toHaveProperty("BASH_ENV");
expect(workerEnv).not.toHaveProperty("DYLD_INSERT_LIBRARIES");
expect(workerEnv).not.toHaveProperty("HTTP_PROXY");
expect(workerEnv).not.toHaveProperty("HTTPS_PROXY");
expect(workerEnv).not.toHaveProperty("SUPPLIED_SECRET");
expect(JSON.stringify(workerEnv)).not.toContain(TEST_WORKER_CREDENTIAL);
const platformInjectedKeys =
process.platform === "darwin" ? ["__CF_USER_TEXT_ENCODING"] : [];
expect(Object.keys(workerEnv).toSorted()).toEqual(
[...Object.keys(expectedWorkerEnv), ...platformInjectedKeys]
.filter(
(key) => expectedWorkerEnv[key] !== undefined || platformInjectedKeys.includes(key),
)
.toSorted(),
);
await supervisor.close();
},
);
});
it("bounds output and scrubs launch credentials after registry eviction", async () => {
const { supervisor, workspaceDir } = fixture();
const successInput = launchInput(workspaceDir, "secret-success-launch", "secret-success");
const failureInput = launchInput(workspaceDir, "failure-launch", "secret-fail");
const overflowInput = launchInput(workspaceDir, "overflow-launch", "overflow");
await supervisor.launch(successInput);
await supervisor.launch(failureInput);
await supervisor.launch(overflowInput);
for (let index = 0; index < 600; index += 1) {
registerSecretValueForRedaction(`eviction-secret-${index}`);
}
const success = await waitForTerminal(supervisor, successInput.launchId);
const failure = await waitForTerminal(supervisor, failureInput.launchId);
const overflow = await waitForTerminal(supervisor, overflowInput.launchId);
const representations = [
TEST_WORKER_CREDENTIAL,
encodeURIComponent(TEST_WORKER_CREDENTIAL),
JSON.stringify(TEST_WORKER_CREDENTIAL).slice(1, -1),
];
expect(success.state).toBe("completed");
expect(JSON.parse(success.resultJson ?? "null")).toEqual({
raw: "[REDACTED]",
encoded: "[REDACTED]",
status: "completed",
});
expect(failure.state).toBe("failed");
expect(Buffer.byteLength(failure.errorText ?? "", "utf8")).toBeLessThanOrEqual(4 * 1024);
for (const representation of representations) {
expect(success.resultJson).not.toContain(representation);
expect(failure.errorText).not.toContain(representation);
}
expect(overflow).toMatchObject({
state: "failed",
errorText: expect.stringContaining("stdout exceeded 65536 bytes"),
});
await supervisor.close();
});
it.each([
["raw", "secret-cutoff-raw", TEST_WORKER_CREDENTIAL],
["URL", "secret-cutoff-url", encodeURIComponent(TEST_WORKER_CREDENTIAL)],
["JSON-escaped", "secret-cutoff-json", JSON.stringify(TEST_WORKER_CREDENTIAL).slice(1, -1)],
])(
"redacts a %s credential representation across the stderr cutoff",
async (_, prompt, representation) => {
const { supervisor, workspaceDir } = fixture();
const input = launchInput(workspaceDir, `cutoff-${prompt}`, prompt);
await supervisor.launch(input);
const failure = await waitForTerminal(supervisor, input.launchId);
expect(failure.state).toBe("failed");
expect(Buffer.byteLength(failure.errorText ?? "", "utf8")).toBeLessThanOrEqual(4 * 1024);
expect(failure.errorText).not.toContain(representation);
expect(failure.errorText).not.toContain(representation.slice(-8));
await supervisor.close();
},
);
it("does not open or signal a child after markRunning observes its terminal receipt", async () => {
const { supervisor, workspaceDir } = fixture();
const input = launchInput(workspaceDir, "fast-terminal-launch", "fast-terminal");
vi.spyOn(NodeWorkerLaunchStore.prototype, "markRunning").mockImplementation(
function (this: NodeWorkerLaunchStore, params) {
return this.finish({
launchId: params.launchId,
planHash: params.planHash,
supervisor: params.supervisor,
worker: null,
state: "completed",
resultJson: '{"status":"completed"}',
});
},
);
expect(await supervisor.launch(input)).toMatchObject({ state: "completed" });
const marker = path.join(workspaceDir, "fast-terminal-marker");
await new Promise((resolve) => {
setTimeout(resolve, 150);
});
expect(fs.existsSync(marker)).toBe(false);
await supervisor.close();
});
it("records a gated child that exits before journal readiness as terminal", async () => {
const { supervisor, workspaceDir } = fixture();
const input = launchInput(workspaceDir, "prestart-exit-launch", "exit-before-start");
const exitedPath = path.join(workspaceDir, "prestart-exited");
await supervisor.launch(input);
const terminal = await waitForTerminal(supervisor, input.launchId);
expect(fs.existsSync(exitedPath)).toBe(true);
expect(terminal.state).toBe("failed");
await supervisor.close();
});
it.each([
["cancel", "cancelled"],
["close", "interrupted"],
] as const)("records %s while awaiting the owned child", async (operation, state) => {
const { supervisor, workspaceDir } = fixture();
const input = launchInput(workspaceDir, `${operation}-launch`, "wait");
expect(await supervisor.launch(input)).toMatchObject({ state: "running" });
if (operation === "cancel") {
await supervisor.cancel(input.launchId);
} else {
await supervisor.close();
}
expect(await supervisor.status(input.launchId)).toMatchObject({
state,
worker: { pid: expect.any(Number), startTime: expect.any(Number) },
});
await supervisor.close();
});
it.each([
["cancel", "cancelled"],
["close", "interrupted"],
] as const)(
"%s during startup closes the gate before worker code runs",
async (operation, state) => {
const { supervisor, workspaceDir } = fixture();
const input = launchInput(workspaceDir, `${operation}-startup-launch`, "tree");
const originalMarkRunning = Object.getOwnPropertyDescriptor(
NodeWorkerLaunchStore.prototype,
"markRunning",
)?.value as NodeWorkerLaunchStore["markRunning"];
let stopping: Promise<unknown> | undefined;
vi.spyOn(NodeWorkerLaunchStore.prototype, "markRunning").mockImplementation(
function (this: NodeWorkerLaunchStore, params) {
const receipt = Reflect.apply(originalMarkRunning, this, [params]);
stopping =
operation === "cancel" ? supervisor.cancel(input.launchId) : supervisor.close();
return receipt;
},
);
await supervisor.launch(input);
await stopping;
expect((await supervisor.status(input.launchId))?.state).toBe(state);
expect(fs.existsSync(path.join(workspaceDir, "grandchild.pid"))).toBe(false);
await supervisor.close();
},
);
it.each([
["cancel", "cancelled"],
["close", "interrupted"],
] as const)("%s terminates the worker-owned grandchild", async (operation, state) => {
const { supervisor, workspaceDir } = fixture();
const input = launchInput(workspaceDir, `${operation}-tree-launch`, "tree");
const running = await supervisor.launch(input);
expect(running.state).toBe("running");
const grandchildPath = path.join(workspaceDir, "grandchild.pid");
await vi.waitFor(() => expect(fs.existsSync(grandchildPath)).toBe(true));
const grandchildPid = Number(fs.readFileSync(grandchildPath, "utf8"));
const grandchild = requireNodeWorkerProcessIdentity(grandchildPid);
expect(inspectNodeWorkerProcessIdentity(grandchild)).toBe("live");
if (operation === "cancel") {
await supervisor.cancel(input.launchId);
} else {
await supervisor.close();
}
const terminal = await supervisor.status(input.launchId);
expect(terminal).toMatchObject({ state, worker: running.worker });
await vi.waitFor(() => {
expect(inspectNodeWorkerProcessIdentity(running.worker!)).not.toBe("live");
expect(inspectNodeWorkerProcessIdentity(grandchild)).not.toBe("live");
});
await supervisor.close();
});
it("fails closed when the bundle entry resolves outside its namespaced bundle", async () => {
const { bundleRoot, root, supervisor, workspaceDir } = fixture();
const escapedHash = "b".repeat(64);
const escapedBundle = path.join(bundleRoot, "gateway-1", "bundles", escapedHash);
const outsideEntry = path.join(root, "outside.mjs");
fs.mkdirSync(escapedBundle, { recursive: true });
fs.writeFileSync(outsideEntry, TEST_WORKER_SOURCE);
fs.symlinkSync(outsideEntry, path.join(escapedBundle, "openclaw.mjs"));
const input = launchInput(workspaceDir, "escaped-entry");
input.bundleHash = escapedHash;
input.descriptor.admission.handshake.bundleHash = escapedHash;
expect(await supervisor.launch(input)).toMatchObject({
state: "failed",
errorText: expect.stringContaining("inside its bundle"),
});
await supervisor.close();
});
});
+692
View File
@@ -0,0 +1,692 @@
import { createHash } from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import { stableStringify } from "@openclaw/normalization-core";
import { resolveStateDir } from "../config/paths.js";
import { formatErrorMessage } from "../infra/errors.js";
import { isPathInside } from "../infra/path-guards.js";
import { redactToolPayloadText } from "../logging/redact.js";
import {
redactRegisteredSecretValues,
registerSecretValueForRedaction,
} from "../logging/secret-redaction-registry.js";
import {
appendCapturedOutput,
createCapturedOutputBuffers,
finalizeCapturedOutput,
} from "../process/exec-output.js";
import { signalProcessTree } from "../process/kill-tree.js";
import { createChildAdapter } from "../process/supervisor/adapters/child.js";
import { truncateUtf8Suffix } from "../utils/utf8-truncate.js";
import {
parseWorkerLaunchDescriptor,
type WorkerLaunchDescriptor,
} from "../worker/launch-descriptor.js";
import { snapshotNodeWorkerEnv } from "./node-worker-environment.js";
import {
NodeWorkerLaunchStore,
type NodeWorkerLaunchReceipt,
type NodeWorkerTerminalState,
} from "./node-worker-launch-store.js";
import {
inspectNodeWorkerProcessIdentity,
requireNodeWorkerProcessIdentity,
type NodeWorkerProcessIdentity,
} from "./node-worker-process-identity.js";
const STDOUT_MAX_BYTES = 64 * 1024;
const STDERR_MAX_BYTES = 4 * 1024;
const STOP_GRACE_MS = 1_000;
const FORCE_STOP_WAIT_MS = 4_000;
const RECOVERY_POLL_MS = 25;
const GATEWAY_NAMESPACE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u;
const BUNDLE_HASH_PATTERN = /^[a-f0-9]{64}$/u;
type NodeWorkerLaunchInput = {
launchId: string;
gatewayNamespace: string;
bundleHash: string;
placementGeneration: number;
descriptor: WorkerLaunchDescriptor;
};
type ChildAdapter = Awaited<ReturnType<typeof createChildAdapter>>;
type StopState = Extract<NodeWorkerTerminalState, "cancelled" | "interrupted">;
type OwnedTreeState = "live" | "dead" | "unknown";
type CredentialScrubber = {
maxRepresentationBytes: number;
scrub: (text: string) => string;
};
type ActiveBase = {
launchId: string;
planHash: string;
supervisor: NodeWorkerProcessIdentity;
worker: NodeWorkerProcessIdentity;
};
type RunningChild = ActiveBase & {
state: "running";
adapter: ChildAdapter;
done: Promise<void>;
journalReady: Promise<void>;
releaseJournal: () => void;
scrubber: CredentialScrubber;
stopState?: StopState;
};
type TerminalOutcome = Readonly<{
state: NodeWorkerTerminalState;
resultJson?: string;
errorText?: string;
}>;
type ObservedTerminal = ActiveBase & {
state: "observed";
outcome: TerminalOutcome;
persistenceError?: unknown;
};
type ActiveOwnership = RunningChild | ObservedTerminal;
function nodeWorkerPlanHash(params: {
bundleHash: string;
descriptor: WorkerLaunchDescriptor;
gatewayNamespace: string;
placementGeneration: number;
}): string {
return createHash("sha256").update(stableStringify(params)).digest("hex");
}
function resolveWorkerEntry(params: {
bundleRoot: string;
bundleHash: string;
gatewayNamespace: string;
}): string {
const root = fs.realpathSync.native(params.bundleRoot);
const bundle = fs.realpathSync.native(
path.join(root, params.gatewayNamespace, "bundles", params.bundleHash),
);
if (!isPathInside(root, bundle)) {
throw new Error("node worker bundle resolves outside its configured root");
}
const entry = fs.realpathSync.native(path.join(bundle, "openclaw.mjs"));
if (!isPathInside(bundle, entry) || !fs.statSync(entry).isFile()) {
throw new Error("node worker entry must be a regular file inside its bundle");
}
return entry;
}
function createCredentialScrubber(credential: string): CredentialScrubber {
const representations = new Set([
credential,
encodeURIComponent(credential),
JSON.stringify(credential).slice(1, -1),
]);
const ordered = [...representations].toSorted((left, right) => right.length - left.length);
return {
maxRepresentationBytes: Math.max(
...ordered.map((representation) => Buffer.byteLength(representation, "utf8")),
),
scrub: (text) => {
let scrubbed = text;
for (const representation of ordered) {
scrubbed = scrubbed.replaceAll(representation, "[REDACTED]");
}
return scrubbed;
},
};
}
function redactLaunchText(value: string, scrubCredential: (text: string) => string): string {
const launchRedacted = scrubCredential(value);
const exactRedacted = redactRegisteredSecretValues(launchRedacted, () => "[REDACTED]");
return redactToolPayloadText(exactRedacted);
}
function sanitizeDiagnostic(
value: string,
fallback: string,
scrubCredential: (text: string) => string,
): string {
const oneLine = redactLaunchText(value, scrubCredential).replace(/\s+/gu, " ").trim();
return truncateUtf8Suffix(oneLine || fallback, STDERR_MAX_BYTES);
}
function successfulResult(
stdout: ReturnType<typeof createCapturedOutputBuffers>,
scrubCredential: (text: string) => string,
): string {
if (stdout.truncatedBytes > 0) {
throw new Error(`worker stdout exceeded ${STDOUT_MAX_BYTES} bytes`);
}
const raw = finalizeCapturedOutput(stdout, "head", true).toString("utf8").trim();
const redacted = redactLaunchText(raw, scrubCredential);
let parsed: unknown;
try {
parsed = JSON.parse(redacted) as unknown;
} catch (error) {
throw new Error("worker returned invalid JSON output", { cause: error });
}
const result = JSON.stringify(parsed);
if (Buffer.byteLength(result, "utf8") > STDOUT_MAX_BYTES) {
throw new Error(`worker result exceeded ${STDOUT_MAX_BYTES} bytes`);
}
return result;
}
function inspectPosixProcessGroup(pid: number): OwnedTreeState {
try {
process.kill(-pid, 0);
return "live";
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
return code === "ESRCH" ? "dead" : "unknown";
}
}
function inspectOwnedWorkerTree(worker: NodeWorkerProcessIdentity): OwnedTreeState {
const root = inspectNodeWorkerProcessIdentity(worker);
if (root === "reused") {
return "dead";
}
if (root === "live") {
return "live";
}
if (root === "unknown") {
return "unknown";
}
return process.platform === "win32" ? "dead" : inspectPosixProcessGroup(worker.pid);
}
async function signalOwnedWorkerTree(
worker: NodeWorkerProcessIdentity,
signal: "SIGTERM" | "SIGKILL",
): Promise<void> {
const root = inspectNodeWorkerProcessIdentity(worker);
if (root === "reused" || root === "unknown") {
return;
}
await new Promise<void>((resolve) => {
signalProcessTree(worker.pid, signal, { detached: true, onComplete: resolve });
});
}
async function waitForOwnedWorkerTreeDeath(
worker: NodeWorkerProcessIdentity,
timeoutMs: number,
): Promise<OwnedTreeState> {
const deadline = Date.now() + timeoutMs;
let state = inspectOwnedWorkerTree(worker);
while (state === "live" && Date.now() < deadline) {
await delay(RECOVERY_POLL_MS);
state = inspectOwnedWorkerTree(worker);
}
return state;
}
/** Owns worker process groups, lifetime gates, and the durable node-host launch journal. */
class NodeWorkerSupervisor {
private readonly active = new Map<string, ActiveOwnership>();
private readonly starting = new Map<string, Promise<NodeWorkerLaunchReceipt>>();
private readonly bundleRoot: string;
private readonly store: NodeWorkerLaunchStore;
private readonly workerEnv: NodeJS.ProcessEnv;
private supervisorIdentity?: NodeWorkerProcessIdentity;
private closed = false;
private closePromise?: Promise<void>;
constructor(options: { bundleRoot?: string; env?: NodeJS.ProcessEnv } = {}) {
const env = options.env ?? process.env;
this.bundleRoot = path.resolve(
options.bundleRoot ?? path.join(resolveStateDir(env), "node-host"),
);
this.store = new NodeWorkerLaunchStore({ env });
this.workerEnv = snapshotNodeWorkerEnv(env);
}
private requireSupervisorIdentity(): NodeWorkerProcessIdentity {
return (this.supervisorIdentity ??= requireNodeWorkerProcessIdentity(process.pid));
}
async launch(input: NodeWorkerLaunchInput): Promise<NodeWorkerLaunchReceipt> {
if (!GATEWAY_NAMESPACE_PATTERN.test(input.gatewayNamespace)) {
throw new Error("gateway namespace must be a safe bounded path component");
}
if (!BUNDLE_HASH_PATTERN.test(input.bundleHash)) {
throw new Error("node worker bundle hash must be 64 lowercase hexadecimal characters");
}
if (!Number.isSafeInteger(input.placementGeneration) || input.placementGeneration < 0) {
throw new Error("node worker placement generation must be a non-negative safe integer");
}
const descriptor = parseWorkerLaunchDescriptor(structuredClone(input.descriptor));
if (descriptor.admission.handshake.bundleHash !== input.bundleHash) {
throw new Error("node worker descriptor bundle hash does not match the launch bundle");
}
const planHash = nodeWorkerPlanHash({
bundleHash: input.bundleHash,
descriptor,
gatewayNamespace: input.gatewayNamespace,
placementGeneration: input.placementGeneration,
});
const local = this.active.get(input.launchId);
if (local) {
if (local.planHash !== planHash) {
throw new Error(`node worker launch ${input.launchId} was replayed with a different plan`);
}
if (local.state === "observed") {
return this.reconcileActiveTerminal(local);
}
const receipt = this.store.get(input.launchId);
if (receipt) {
return receipt;
}
}
if (this.closed) {
throw new Error("node worker supervisor is closed");
}
const supervisor = this.requireSupervisorIdentity();
const claim = this.store.claim(
{
launchId: input.launchId,
planHash,
gatewayNamespace: input.gatewayNamespace,
environmentId: descriptor.admission.environmentId,
sessionId: descriptor.admission.sessionId,
ownerEpoch: descriptor.admission.ownerEpoch,
placementGeneration: input.placementGeneration,
runId: descriptor.assignment.runId,
},
supervisor,
);
if (claim.action === "recover") {
return await this.recoverRunning(claim.receipt);
}
if (claim.action === "replay") {
const replay = this.active.get(input.launchId);
if (replay?.planHash === planHash && replay.state === "observed") {
return this.reconcileActiveTerminal(replay);
}
const startup = this.starting.get(input.launchId);
return startup && claim.receipt.state === "pending" ? await startup : claim.receipt;
}
const startup = this.startClaimed({ input, descriptor, planHash, supervisor });
this.starting.set(input.launchId, startup);
try {
return await startup;
} finally {
if (this.starting.get(input.launchId) === startup) {
this.starting.delete(input.launchId);
}
}
}
async status(launchId: string): Promise<NodeWorkerLaunchReceipt | undefined> {
const active = this.active.get(launchId);
if (active?.state === "observed") {
return this.reconcileActiveTerminal(active);
}
return this.store.get(launchId);
}
async cancel(launchId: string): Promise<NodeWorkerLaunchReceipt | undefined> {
const active = this.active.get(launchId);
if (active) {
if (active.state === "running") {
await this.stopChild(active, "cancelled");
}
const observed = this.active.get(launchId);
if (observed?.state === "observed") {
return this.reconcileActiveTerminal(observed);
}
return this.store.get(launchId);
}
const startup = this.starting.get(launchId);
const receipt = this.store.get(launchId);
if (!receipt || receipt.state === "completed" || receipt.state === "failed") {
return receipt;
}
if (receipt.state === "interrupted" || receipt.state === "cancelled") {
return receipt;
}
if (!startup || receipt.state !== "pending" || receipt.supervisor.pid !== process.pid) {
return receipt;
}
const cancelled = this.store.finish({
launchId,
planHash: receipt.planHash,
supervisor: this.requireSupervisorIdentity(),
worker: null,
state: "cancelled",
errorText: "node worker launch cancelled",
});
await startup;
return this.store.get(launchId) ?? cancelled;
}
close(): Promise<void> {
if (this.closePromise) {
return this.closePromise;
}
this.closed = true;
const operation = (async () => {
await Promise.allSettled(this.starting.values());
await Promise.all(
[...this.active.values()]
.filter((active): active is RunningChild => active.state === "running")
.map(async (active) => await this.stopChild(active, "interrupted")),
);
const errors: unknown[] = [];
for (const active of this.active.values()) {
if (active.state !== "observed") {
continue;
}
try {
this.reconcileActiveTerminal(active);
} catch (error) {
errors.push(error);
}
}
if (errors.length === 1) {
throw errors[0];
}
if (errors.length > 1) {
throw new AggregateError(errors, "node worker terminal reconciliation failed");
}
})();
const closePromise = operation.finally(() => {
if (this.closePromise === closePromise) {
this.closePromise = undefined;
}
});
this.closePromise = closePromise;
return closePromise;
}
private reconcileActiveTerminal(active: ObservedTerminal): NodeWorkerLaunchReceipt {
try {
const receipt = this.store.finish({
launchId: active.launchId,
planHash: active.planHash,
supervisor: active.supervisor,
worker: active.worker,
...active.outcome,
});
if (receipt.state === "pending" || receipt.state === "running") {
throw new Error(`node worker launch ${active.launchId} terminal state was not persisted`);
}
if (this.active.get(active.launchId) === active) {
this.active.delete(active.launchId);
}
return receipt;
} catch (error) {
active.persistenceError = error;
throw error;
}
}
private async recoverRunning(receipt: NodeWorkerLaunchReceipt): Promise<NodeWorkerLaunchReceipt> {
if (receipt.state !== "running" || !receipt.worker) {
return receipt;
}
const previousSupervisor = inspectNodeWorkerProcessIdentity(receipt.supervisor);
if (previousSupervisor !== "dead" && previousSupervisor !== "reused") {
return this.store.get(receipt.launchId) ?? receipt;
}
let workerState = inspectOwnedWorkerTree(receipt.worker);
if (workerState === "unknown") {
return this.store.get(receipt.launchId) ?? receipt;
}
if (workerState === "live") {
await signalOwnedWorkerTree(receipt.worker, "SIGTERM");
workerState = await waitForOwnedWorkerTreeDeath(receipt.worker, STOP_GRACE_MS);
}
if (workerState === "live") {
await signalOwnedWorkerTree(receipt.worker, "SIGKILL");
workerState = await waitForOwnedWorkerTreeDeath(receipt.worker, FORCE_STOP_WAIT_MS);
}
if (workerState !== "dead") {
return this.store.get(receipt.launchId) ?? receipt;
}
return this.store.finish({
launchId: receipt.launchId,
planHash: receipt.planHash,
supervisor: receipt.supervisor,
worker: receipt.worker,
state: "interrupted",
errorText: "node host stopped before the worker launch completed",
});
}
private async startClaimed(params: {
input: NodeWorkerLaunchInput;
descriptor: WorkerLaunchDescriptor;
planHash: string;
supervisor: NodeWorkerProcessIdentity;
}): Promise<NodeWorkerLaunchReceipt> {
const credential = params.descriptor.admission.credential;
const scrubber = createCredentialScrubber(credential);
registerSecretValueForRedaction(credential);
let adapter: ChildAdapter;
try {
const entry = resolveWorkerEntry({
bundleRoot: this.bundleRoot,
bundleHash: params.input.bundleHash,
gatewayNamespace: params.input.gatewayNamespace,
});
adapter = await createChildAdapter({
argv: [process.execPath, entry, "worker", "--internal-worker-ipc"],
env: this.workerEnv,
exactEnv: true,
ownedWorker: true,
input: JSON.stringify(params.descriptor),
});
} catch (error) {
return this.store.finish({
launchId: params.input.launchId,
planHash: params.planHash,
supervisor: params.supervisor,
worker: null,
state: "failed",
errorText: sanitizeDiagnostic(
formatErrorMessage(error),
"node worker spawn failed",
scrubber.scrub,
),
});
}
if (!adapter.pid) {
adapter.kill("SIGKILL");
adapter.dispose();
return this.store.finish({
launchId: params.input.launchId,
planHash: params.planHash,
supervisor: params.supervisor,
worker: null,
state: "failed",
errorText: "node worker spawn did not return a process id",
});
}
let worker: NodeWorkerProcessIdentity;
try {
worker = requireNodeWorkerProcessIdentity(adapter.pid);
} catch (error) {
adapter.kill("SIGKILL");
await adapter.wait().catch(() => undefined);
adapter.dispose();
return this.store.finish({
launchId: params.input.launchId,
planHash: params.planHash,
supervisor: params.supervisor,
worker: null,
state: "failed",
errorText: sanitizeDiagnostic(
formatErrorMessage(error),
"node worker process identity unavailable",
scrubber.scrub,
),
});
}
let journalReleased = false;
let releaseJournalPromise!: () => void;
const journalReady = new Promise<void>((resolve) => {
releaseJournalPromise = resolve;
});
const releaseJournal = () => {
if (!journalReleased) {
journalReleased = true;
releaseJournalPromise();
}
};
const active = {
state: "running",
adapter,
journalReady,
launchId: params.input.launchId,
planHash: params.planHash,
releaseJournal,
scrubber,
supervisor: params.supervisor,
worker,
} as RunningChild;
active.done = this.observeChild(active);
this.active.set(active.launchId, active);
void active.done.catch(() => undefined);
let running: NodeWorkerLaunchReceipt;
try {
running = this.store.markRunning({
launchId: active.launchId,
planHash: active.planHash,
supervisor: params.supervisor,
worker,
});
} catch (error) {
active.releaseJournal();
await this.stopChild(active, "interrupted").catch(() => undefined);
throw error;
}
active.releaseJournal();
if (running.state === "cancelled" || running.state === "interrupted") {
await this.stopChild(active, running.state);
return this.store.get(active.launchId) ?? running;
}
if (running.state !== "running") {
adapter.closeStartGate?.();
return running;
}
if (this.closed) {
await this.stopChild(active, "interrupted");
return this.store.get(active.launchId) ?? running;
}
try {
await adapter.openStartGate?.();
} catch {
await this.stopChild(active, "interrupted");
return this.store.get(active.launchId) ?? running;
}
return running;
}
private async observeChild(active: RunningChild): Promise<void> {
const stdout = createCapturedOutputBuffers();
const stderr = createCapturedOutputBuffers();
active.adapter.onStdout((chunk) =>
appendCapturedOutput(stdout, chunk, STDOUT_MAX_BYTES, "head"),
);
active.adapter.onStderr((chunk) =>
appendCapturedOutput(
stderr,
chunk,
STDERR_MAX_BYTES + active.scrubber.maxRepresentationBytes,
"tail",
),
);
let outcome: TerminalOutcome;
try {
const exit = await active.adapter.wait();
await active.journalReady;
if (active.stopState) {
outcome = Object.freeze({
state: active.stopState,
errorText:
active.stopState === "cancelled"
? "node worker launch cancelled"
: "node worker launch interrupted during node-host shutdown",
});
} else if (exit.code === 0 && exit.signal === null) {
try {
outcome = Object.freeze({
state: "completed",
resultJson: successfulResult(stdout, active.scrubber.scrub),
});
} catch (error) {
outcome = Object.freeze({
state: "failed",
errorText: sanitizeDiagnostic(
formatErrorMessage(error),
"invalid worker result",
active.scrubber.scrub,
),
});
}
} else {
const detail = finalizeCapturedOutput(stderr, "tail", true).toString("utf8");
const exitLabel = exit.signal ? `signal ${exit.signal}` : `exit code ${String(exit.code)}`;
outcome = Object.freeze({
state: "failed",
errorText: sanitizeDiagnostic(
`node worker failed with ${exitLabel}${detail ? `: ${detail}` : ""}`,
"node worker failed",
active.scrubber.scrub,
),
});
}
} catch (error) {
await active.journalReady;
outcome = Object.freeze({
state: active.stopState ?? "failed",
errorText: sanitizeDiagnostic(
formatErrorMessage(error),
"node worker wait failed",
active.scrubber.scrub,
),
});
} finally {
active.adapter.dispose();
}
const observed: ObservedTerminal = {
state: "observed",
launchId: active.launchId,
planHash: active.planHash,
supervisor: active.supervisor,
worker: active.worker,
outcome,
};
if (this.active.get(active.launchId) !== active) {
return;
}
this.active.set(active.launchId, observed);
try {
this.reconcileActiveTerminal(observed);
} catch {
// The observed outcome stays owned in memory for the next supervisor operation.
}
}
private async stopChild(active: RunningChild, state: StopState): Promise<void> {
active.stopState ??= state;
active.adapter.kill("SIGTERM");
const forceKill = setTimeout(() => active.adapter.kill("SIGKILL"), STOP_GRACE_MS);
forceKill.unref?.();
try {
await active.done;
} finally {
clearTimeout(forceKill);
}
}
}
export function createNodeWorkerSupervisor(
options: {
bundleRoot?: string;
env?: NodeJS.ProcessEnv;
} = {},
): NodeWorkerSupervisor {
return new NodeWorkerSupervisor(options);
}
+2
View File
@@ -545,6 +545,8 @@ describe("runNodeHost", () => {
await vi.waitFor(() => expect(mocks.capturedGatewayClients[0]?.stop).toHaveBeenCalledOnce());
expect(clearIntervalSpy).not.toHaveBeenCalled();
await vi.waitFor(() => expect(mocks.closeMcpManager).toHaveBeenCalledOnce());
expect(resolveCloseMcp).toBeTypeOf("function");
resolveCloseMcp?.();
await running;
+6
View File
@@ -8,6 +8,7 @@ import { prepareNodeHostRuntime } from "./runtime.js";
const mocks = vi.hoisted(() => ({
closeMcp: vi.fn(async () => undefined),
closeWorkerSupervisor: vi.fn(async () => undefined),
handleInvoke: vi.fn(async () => undefined),
progressStartHeartbeats: vi.fn(),
progressWrite: vi.fn(async () => undefined),
@@ -39,6 +40,10 @@ vi.mock("./node-invoke-progress.js", () => ({
})),
}));
vi.mock("./node-worker-supervisor.js", () => ({
createNodeWorkerSupervisor: vi.fn(() => ({ close: mocks.closeWorkerSupervisor })),
}));
vi.mock("./plugin-node-host.js", () => ({
ensureNodeHostPluginRegistry: vi.fn(async () => undefined),
isRegisteredNodeHostCommandDuplex: vi.fn((command: string) => command === "test.duplex"),
@@ -178,6 +183,7 @@ describe("node-host invocation cancellation", () => {
await runtime.close();
expect(held.signal?.aborted).toBe(true);
expect(mocks.closeWorkerSupervisor).toHaveBeenCalledOnce();
held.release();
await invoking;
});
+3
View File
@@ -26,6 +26,7 @@ import { handleInvoke, type NodeInvokeRequestPayload, type SkillBinsProvider } f
import { startNodeHostMcpManager, type NodeHostMcpManager } from "./mcp.js";
import { buildNodeEventParams } from "./node-event-params.js";
import { createNodeInvokeProgressWriter } from "./node-invoke-progress.js";
import { createNodeWorkerSupervisor } from "./node-worker-supervisor.js";
import {
ensureNodeHostPluginRegistry,
isRegisteredNodeHostCommandDuplex,
@@ -305,6 +306,7 @@ export async function prepareNodeHostRuntime(params?: {
initialInventory,
start({ client, onInventoryChanged, onManifestChanged }) {
const mcpAbort = new AbortController();
const workerSupervisor = createNodeWorkerSupervisor({ env });
const skillBins = new SkillBinsCache(client, pathEnv);
const activeInvokes = new Map<string, ActiveNodeInvoke>();
const pluginCommandContext: OpenClawPluginNodeHostCommandContext = {
@@ -448,6 +450,7 @@ export async function prepareNodeHostRuntime(params?: {
async close() {
this.cancelAll();
stopAvailabilityWatch();
await workerSupervisor.close();
mcpAbort.abort();
const resolved = manager ?? (await startup.catch(() => undefined));
await resolved?.close();
+1 -1
View File
@@ -2,7 +2,7 @@ import type { ChildProcess } from "node:child_process";
import type { Writable } from "node:stream";
import type { SpawnSecretInput } from "./supervisor/types.js";
export type SpawnStdioEntry = "ignore" | "inherit" | "overlapped" | "pipe";
export type SpawnStdioEntry = "ignore" | "inherit" | "ipc" | "overlapped" | "pipe";
export function addSecretInputStdio(
stdio: SpawnStdioEntry[],
+62 -1
View File
@@ -57,8 +57,23 @@ function createStubChild(pid = 1234) {
Object.defineProperty(child, "killed", { value: false, configurable: true, writable: true });
Object.defineProperty(child, "exitCode", { value: null, configurable: true, writable: true });
Object.defineProperty(child, "signalCode", { value: null, configurable: true, writable: true });
Object.defineProperty(child, "channel", { value: {}, configurable: true });
Object.defineProperty(child, "connected", { value: true, configurable: true, writable: true });
const killMock = vi.fn(() => true);
const sendMock = vi.fn((_message: unknown, ...args: unknown[]) => {
const callback = args.findLast((value) => typeof value === "function") as
| ((error: Error | null) => void)
| undefined;
callback?.(null);
return true;
});
const disconnectMock = vi.fn(() => {
Object.defineProperty(child, "connected", { value: false, configurable: true, writable: true });
child.emit("disconnect");
});
child.kill = killMock as ChildProcess["kill"];
child.send = sendMock as ChildProcess["send"];
child.disconnect = disconnectMock as ChildProcess["disconnect"];
const emitClose = (code: number | null, signal: NodeJS.Signals | null = null) => {
child.emit("close", code, signal);
};
@@ -71,7 +86,7 @@ function createStubChild(pid = 1234) {
});
child.emit("exit", code, signal);
};
return { child, killMock, emitClose, emitExit };
return { child, disconnectMock, killMock, sendMock, emitClose, emitExit };
}
async function createAdapterHarness(params?: {
@@ -224,6 +239,30 @@ describe("createChildAdapter", () => {
expect(killMock).toHaveBeenCalledWith("SIGKILL");
});
it("creates owned worker trees in a dedicated POSIX process group without fallback", async () => {
process.env.OPENCLAW_SERVICE_MARKER = "service-managed";
const { child, disconnectMock, sendMock } = createStubChild();
spawnWithFallbackMock.mockResolvedValue({ child, usedFallback: false });
const adapter = await createChildAdapter({
argv: ["node", "worker"],
ownedWorker: true,
input: "{}",
});
expect(firstSpawnWithFallbackParams().options?.detached).toBe(process.platform !== "win32");
expect(firstSpawnWithFallbackParams().fallbacks).toEqual([]);
expect(firstSpawnWithFallbackParams().options?.stdio).toEqual(["pipe", "pipe", "pipe", "ipc"]);
await adapter.openStartGate?.();
expect(sendMock).toHaveBeenCalledWith(
{ type: "openclaw-worker-start-v1" },
expect.any(Function),
);
adapter.closeStartGate?.();
expect(disconnectMock).toHaveBeenCalledOnce();
});
it("writes secret input to an extra descriptor and zeroes the transient buffer", async () => {
const { child } = createStubChild();
const secretStream = new PassThrough();
@@ -788,6 +827,28 @@ describe("createChildAdapter", () => {
expect(spawnArgs.options.env.CDPATH).toBeUndefined();
});
it("keeps an exact Linux child environment out of the OOM shell wrapper", async () => {
setPlatform("linux");
const restoreLinuxShell = mockLinuxOomWrapperShell();
const { child } = createStubChild(3335);
spawnWithFallbackMock.mockResolvedValue({ child, usedFallback: false });
try {
const adapter = await createChildAdapter({
argv: ["/usr/bin/node", "-e", "process.exit(0)"],
env: { HOME: "/worker-home", PATH: "/usr/bin" },
exactEnv: true,
stdinMode: "pipe-open",
});
expect(adapter.oomScoreWrapperSelected).toBe(false);
} finally {
restoreLinuxShell();
}
const spawnArgs = firstSpawnWithFallbackParams();
expect(spawnArgs.argv).toEqual(["/usr/bin/node", "-e", "process.exit(0)"]);
expect(spawnArgs.options?.env).toEqual({ HOME: "/worker-home", PATH: "/usr/bin" });
});
it("passes explicit env overrides as strings", async () => {
await createAdapterHarness({
pid: 4444,
+81 -16
View File
@@ -71,6 +71,12 @@ function resolveChildInvocation(params: {
}
type ChildAdapter = SpawnProcessAdapter<NodeJS.Signals | null>;
type WorkerChildAdapter = ChildAdapter & {
closeStartGate?: () => void;
openStartGate?: () => Promise<void>;
};
const WORKER_START_MESSAGE = { type: "openclaw-worker-start-v1" } as const;
function isServiceManagedRuntime(): boolean {
return Boolean(process.env.OPENCLAW_SERVICE_MARKER?.trim());
@@ -78,32 +84,40 @@ function isServiceManagedRuntime(): boolean {
export async function createChildAdapter(params: {
argv: string[];
/** Own a separately signalable tree whose private IPC channel gates worker startup. */
ownedWorker?: true;
/** Preserve the supplied environment exactly by skipping environment-mutating spawn wrappers. */
exactEnv?: true;
cwd?: string;
env?: NodeJS.ProcessEnv;
windowsVerbatimArguments?: boolean;
input?: string;
stdinMode?: "inherit" | "pipe-open" | "pipe-closed";
secretInput?: SpawnSecretInput;
}): Promise<ChildAdapter> {
}): Promise<WorkerChildAdapter> {
const baseEnv = params.env ? toStringEnv(params.env) : undefined;
const invocation = resolveChildInvocation({
argv: params.argv,
env: baseEnv,
windowsVerbatimArguments: params.windowsVerbatimArguments,
});
const preparedSpawn = prepareOomScoreAdjustedSpawn(invocation.command, invocation.args, {
env: baseEnv,
});
const preparedSpawn = params.exactEnv
? { command: invocation.command, args: invocation.args, env: baseEnv, wrapped: false }
: prepareOomScoreAdjustedSpawn(invocation.command, invocation.args, { env: baseEnv });
const stdinMode = params.stdinMode ?? (params.input !== undefined ? "pipe-closed" : "inherit");
// In service-managed mode keep children attached so systemd/launchd can
// stop the full process tree reliably. Outside service mode preserve the
// existing POSIX detached behavior.
const useDetached = process.platform !== "win32" && !isServiceManagedRuntime();
// A detached POSIX child is still a descendant in the service cgroup/job, but
// owns a process group that can be killed without touching the node host.
const useDetached =
process.platform !== "win32" &&
(params.ownedWorker !== undefined || !isServiceManagedRuntime());
const stdio: SpawnStdioEntry[] = [stdinMode === "inherit" ? "inherit" : "pipe", "pipe", "pipe"];
addSecretInputStdio(stdio, params.secretInput);
if (params.ownedWorker !== undefined) {
stdio.push("ipc");
}
const options: SpawnOptions = {
cwd: params.cwd,
@@ -117,17 +131,34 @@ export async function createChildAdapter(params: {
const spawned = await spawnWithFallback({
argv: [preparedSpawn.command, ...preparedSpawn.args],
options,
fallbacks: useDetached
? [
{
label: "no-detach",
options: { detached: false },
},
]
: [],
fallbacks:
useDetached && params.ownedWorker === undefined
? [
{
label: "no-detach",
options: { detached: false },
},
]
: [],
});
const child = spawned.child as ChildProcessWithoutNullStreams;
if (params.ownedWorker !== undefined && (!child.connected || !child.channel)) {
spawned.child.kill("SIGKILL");
throw new Error("worker lifecycle IPC channel was not created");
}
const disconnectWorkerIpc = () => {
if (!child.connected) {
return;
}
try {
child.disconnect();
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ERR_IPC_DISCONNECTED") {
throw error;
}
}
};
// Pipe errors can arrive before output subscribers attach. Close remains
// responsible for decoder flush and Windows drain completion.
const ignoreOutputStreamError = () => {};
@@ -489,9 +520,41 @@ export async function createChildAdapter(params: {
const dispose = () => {
clearForceKillWaitFallback();
clearForcedWindowsCloseTimer();
if (params.ownedWorker !== undefined) {
disconnectWorkerIpc();
}
child.removeAllListeners();
};
const closeStartGate = params.ownedWorker ? disconnectWorkerIpc : undefined;
let startGateOpened = false;
const openStartGate = params.ownedWorker
? async () => {
if (startGateOpened) {
return;
}
startGateOpened = true;
await new Promise<void>((resolve, reject) => {
if (!child.connected) {
reject(new Error("worker lifecycle IPC channel closed before startup"));
return;
}
try {
child.send(WORKER_START_MESSAGE, (error) => {
if (error) {
reject(error);
return;
}
resolve();
});
} catch (error) {
reject(toErrorObject(error, "worker lifecycle IPC send failed"));
}
});
}
: undefined;
return {
pid: child.pid ?? undefined,
stdin,
@@ -501,5 +564,7 @@ export async function createChildAdapter(params: {
wait,
kill,
dispose,
closeStartGate,
openStartGate,
};
}
+32 -32
View File
@@ -311,39 +311,39 @@ describe("OpenClaw database maintenance schema validation", () => {
}
});
it("allows the lazy worker SSH fallback table to be absent but rejects drift", () => {
const database = createGlobalDatabase();
try {
const canonicalTable = database
.prepare("SELECT sql FROM sqlite_schema WHERE type = 'table' AND name = ?")
.get("worker_environment_ssh_fallback_ports") as { sql?: unknown } | undefined;
if (typeof canonicalTable?.sql !== "string") {
throw new Error("missing canonical worker SSH fallback port table");
it.each(["node_worker_launches", "worker_environment_ssh_fallback_ports"])(
"allows lazy table %s to be absent but rejects drift",
(tableName) => {
const database = createGlobalDatabase();
try {
const canonicalTable = database
.prepare("SELECT sql FROM sqlite_schema WHERE type = 'table' AND name = ?")
.get(tableName) as { sql?: unknown } | undefined;
if (typeof canonicalTable?.sql !== "string") {
throw new Error(`missing canonical ${tableName} table`);
}
database.exec(`DROP TABLE ${tableName};`);
expect(() =>
assertOpenClawStateDatabaseForMaintenance(database, {
pathname: "global.sqlite",
}),
).not.toThrow();
const driftedTableSql = canonicalTable.sql.replace("(\n", "(\n unexpected TEXT,\n");
expect(driftedTableSql).not.toBe(canonicalTable.sql);
database.exec(driftedTableSql);
expect(() =>
assertOpenClawStateDatabaseForMaintenance(database, {
pathname: "global.sqlite",
}),
).toThrow(`column definitions differ for ${tableName}`);
} finally {
database.close();
}
database.exec("DROP TABLE worker_environment_ssh_fallback_ports;");
expect(() =>
assertOpenClawStateDatabaseForMaintenance(database, {
pathname: "global.sqlite",
}),
).not.toThrow();
const driftedTableSql = canonicalTable.sql.replace(
" PRIMARY KEY (environment_id, position)",
" unexpected TEXT,\n PRIMARY KEY (environment_id, position)",
);
expect(driftedTableSql).not.toBe(canonicalTable.sql);
database.exec(driftedTableSql);
expect(() =>
assertOpenClawStateDatabaseForMaintenance(database, {
pathname: "global.sqlite",
}),
).toThrow("column definitions differ for worker_environment_ssh_fallback_ports");
} finally {
database.close();
}
});
},
);
it("rejects a current agent database with a missing canonical table", () => {
const database = createAgentDatabase();
+1
View File
@@ -12,6 +12,7 @@ export const FIRST_USE_STATE_TABLES = [
"cron_job_runtime_authorities",
"execution_identity_contexts",
"mcp_oauth_pending_authorizations",
"node_worker_launches",
"operator_approval_execution_identities",
"execution_decision_facts",
] as const;
+22
View File
@@ -941,6 +941,27 @@ export interface NodeHostConfig {
version: number;
}
export interface NodeWorkerLaunches {
completed_at_ms: number | null;
created_at_ms: number;
environment_id: string;
error_text: string | null;
gateway_namespace: string;
launch_id: string;
owner_epoch: number;
placement_generation: number;
plan_hash: string;
result_json: string | null;
run_id: string;
session_id: string;
state: string;
supervisor_pid: number;
supervisor_start_time: number;
updated_at_ms: number;
worker_pid: number | null;
worker_start_time: number | null;
}
export interface OfficialExternalPluginCatalogSnapshots {
body: string;
checksum: string;
@@ -1717,6 +1738,7 @@ export interface DB {
model_catalog_remote: ModelCatalogRemote;
native_hook_relay_bridges: NativeHookRelayBridges;
node_host_config: NodeHostConfig;
node_worker_launches: NodeWorkerLaunches;
official_external_plugin_catalog_snapshots: OfficialExternalPluginCatalogSnapshots;
onboarding_recommendations: OnboardingRecommendations;
operator_approval_execution_identities: OperatorApprovalExecutionIdentities;
+75
View File
@@ -854,6 +854,81 @@ CREATE TABLE IF NOT EXISTS node_host_config (
updated_at_ms INTEGER NOT NULL
) STRICT;
-- Node-host-owned launch journal. The descriptor and its credential remain
-- process memory only; this table records bounded supervision facts.
CREATE TABLE IF NOT EXISTS node_worker_launches (
launch_id TEXT NOT NULL PRIMARY KEY
CHECK (length(launch_id) BETWEEN 1 AND 256 AND instr(launch_id, char(0)) = 0),
plan_hash TEXT NOT NULL
CHECK (length(plan_hash) = 64 AND plan_hash NOT GLOB '*[^0-9a-f]*'),
gateway_namespace TEXT NOT NULL
CHECK (
length(gateway_namespace) BETWEEN 1 AND 128
AND gateway_namespace NOT GLOB '*[^A-Za-z0-9._-]*'
AND gateway_namespace GLOB '[A-Za-z0-9]*'
),
environment_id TEXT NOT NULL
CHECK (length(environment_id) BETWEEN 1 AND 256 AND instr(environment_id, char(0)) = 0),
session_id TEXT NOT NULL
CHECK (length(session_id) BETWEEN 1 AND 256 AND instr(session_id, char(0)) = 0),
owner_epoch INTEGER NOT NULL CHECK (owner_epoch BETWEEN 1 AND 9007199254740991),
placement_generation INTEGER NOT NULL
CHECK (placement_generation BETWEEN 0 AND 9007199254740991),
run_id TEXT NOT NULL
CHECK (length(run_id) BETWEEN 1 AND 256 AND instr(run_id, char(0)) = 0),
state TEXT NOT NULL
CHECK (state IN ('pending', 'running', 'completed', 'failed', 'interrupted', 'cancelled')),
supervisor_pid INTEGER NOT NULL CHECK (supervisor_pid BETWEEN 1 AND 2147483647),
supervisor_start_time INTEGER NOT NULL
CHECK (supervisor_start_time BETWEEN 0 AND 9007199254740991),
worker_pid INTEGER CHECK (worker_pid IS NULL OR worker_pid BETWEEN 1 AND 2147483647),
worker_start_time INTEGER CHECK (
worker_start_time IS NULL OR worker_start_time BETWEEN 0 AND 9007199254740991
),
result_json TEXT CHECK (
result_json IS NULL
OR (
length(CAST(result_json AS BLOB)) BETWEEN 1 AND 65536
AND instr(result_json, char(0)) = 0
AND json_valid(result_json)
)
),
error_text TEXT CHECK (
error_text IS NULL
OR (
length(CAST(error_text AS BLOB)) BETWEEN 1 AND 4096
AND instr(error_text, char(0)) = 0
AND instr(error_text, char(10)) = 0
AND instr(error_text, char(13)) = 0
)
),
completed_at_ms INTEGER CHECK (
completed_at_ms IS NULL OR completed_at_ms BETWEEN 0 AND 9007199254740991
),
created_at_ms INTEGER NOT NULL CHECK (created_at_ms BETWEEN 0 AND 9007199254740991),
updated_at_ms INTEGER NOT NULL CHECK (
updated_at_ms BETWEEN created_at_ms AND 9007199254740991
),
CHECK ((worker_pid IS NULL) = (worker_start_time IS NULL)),
CHECK (
(state = 'pending'
AND worker_pid IS NULL AND result_json IS NULL AND error_text IS NULL
AND completed_at_ms IS NULL)
OR
(state = 'running'
AND worker_pid IS NOT NULL AND result_json IS NULL AND error_text IS NULL
AND completed_at_ms IS NULL)
OR
(state = 'completed'
AND result_json IS NOT NULL AND error_text IS NULL
AND completed_at_ms BETWEEN created_at_ms AND updated_at_ms)
OR
(state IN ('failed', 'interrupted', 'cancelled')
AND result_json IS NULL AND error_text IS NOT NULL
AND completed_at_ms BETWEEN created_at_ms AND updated_at_ms)
)
) STRICT;
CREATE TABLE IF NOT EXISTS voicewake_triggers (
config_key TEXT NOT NULL,
position INTEGER NOT NULL,
+177
View File
@@ -0,0 +1,177 @@
import { PassThrough } from "node:stream";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
WORKER_PROTOCOL_FEATURES,
WORKER_RPC_SET_VERSION,
} from "../../packages/gateway-protocol/src/schema/worker-admission.js";
import type { WorkerLaunchDescriptor } from "./launch-descriptor.js";
import { runWorkerCommand } from "./worker-command.runtime.js";
import { runWorkerDescriptor } from "./worker.runtime.js";
vi.mock("./worker.runtime.js", () => ({
runWorkerDescriptor: vi.fn(),
}));
const descriptor = {
version: 3,
connectionEndpoint: { kind: "unix", socketPath: "/tmp/openclaw-worker/gateway.sock" },
admission: {
environmentId: "environment-1",
credential: ["worker", "fixture", "value"].join("-"),
sessionId: "session-1",
ownerEpoch: 1,
rpcSetVersion: WORKER_RPC_SET_VERSION,
handshake: {
bundleHash: "a".repeat(64),
openclawVersion: "2026.7.12",
protocolFeatures: [...WORKER_PROTOCOL_FEATURES],
},
},
assignment: {
agentId: "agent-1",
operationalRunInstance: { instanceId: "instance-run-1", runId: "run-1" },
agentRuntimeIdentityToken: "signed-runtime-token",
runId: "run-1",
turnId: "turn-1",
prompt: "Inspect the workspace.",
suppressPromptTranscript: false,
workspaceDir: "/tmp/openclaw-worker/workspace",
modelRef: { provider: "provider-1", model: "model-1" },
inferenceOptions: { reasoning: "medium", maxTokens: 512 },
initialMessages: [
{
role: "user",
content: [{ type: "text", text: "Earlier context." }],
timestamp: 1,
},
],
transcript: { baseLeafId: "leaf-7", nextSeq: 8 },
liveEvents: { ackedSeq: 12, nextSeq: 13 },
toolAuthority: { allowedToolNames: ["read", "exec"] },
},
} satisfies WorkerLaunchDescriptor;
function commandInput() {
const input = new PassThrough();
input.end(JSON.stringify(descriptor));
return input;
}
function lifetimeHarness() {
const controller = new AbortController();
let resolveStarted!: (started: boolean) => void;
const started = new Promise<boolean>((resolve) => {
resolveStarted = resolve;
});
const dispose = vi.fn();
const terminateOwnedTree = vi.fn();
return {
contract: { dispose, signal: controller.signal, started, terminateOwnedTree },
disconnectAfterStart: () => controller.abort(new Error("worker supervisor lifetime ended")),
disconnectBeforeStart: () => resolveStarted(false),
dispose,
open: () => resolveStarted(true),
terminateOwnedTree,
};
}
describe("worker command lifetime gate", () => {
beforeEach(() => {
vi.mocked(runWorkerDescriptor).mockReset();
vi.mocked(runWorkerDescriptor).mockResolvedValue({
status: "completed",
transcriptLeafId: null,
transcriptNextSeq: 1,
});
});
it("keeps the ordinary worker command path ungated", async () => {
const output = new PassThrough();
const chunks: Buffer[] = [];
output.on("data", (chunk: Buffer) => chunks.push(Buffer.from(chunk)));
await runWorkerCommand({ input: commandInput(), output });
expect(runWorkerDescriptor).toHaveBeenCalledOnce();
expect(JSON.parse(Buffer.concat(chunks).toString("utf8"))).toMatchObject({
status: "completed",
});
});
it("does not enter the worker runtime before the explicit start message", async () => {
const output = new PassThrough();
const lifetime = lifetimeHarness();
const running = runWorkerCommand({
input: commandInput(),
output,
lifetime: lifetime.contract,
});
await new Promise((resolve) => {
setImmediate(resolve);
});
expect(runWorkerDescriptor).not.toHaveBeenCalled();
lifetime.open();
await running;
expect(runWorkerDescriptor).toHaveBeenCalledOnce();
expect(lifetime.terminateOwnedTree).not.toHaveBeenCalled();
expect(lifetime.dispose).toHaveBeenCalledOnce();
});
it("exits without starting when IPC disconnects before the start message", async () => {
const output = new PassThrough();
const lifetime = lifetimeHarness();
const running = runWorkerCommand({
input: commandInput(),
output,
lifetime: lifetime.contract,
});
lifetime.disconnectBeforeStart();
await running;
expect(runWorkerDescriptor).not.toHaveBeenCalled();
expect(lifetime.terminateOwnedTree).not.toHaveBeenCalled();
expect(lifetime.dispose).toHaveBeenCalledOnce();
});
it("aborts the real worker path and terminates its owned tree on IPC disconnect", async () => {
const output = new PassThrough();
let runtimeSignal: AbortSignal | undefined;
vi.mocked(runWorkerDescriptor).mockImplementation(async (_descriptor, options) => {
const signal = options?.signal;
if (!signal) {
throw new Error("expected worker lifetime abort signal");
}
runtimeSignal = signal;
return await new Promise<never>((_, reject) => {
signal.addEventListener(
"abort",
() => {
const reason = signal.reason;
reject(reason instanceof Error ? reason : new Error("worker interrupted"));
},
{ once: true },
);
});
});
const lifetime = lifetimeHarness();
lifetime.terminateOwnedTree.mockImplementation(() => {
expect(runtimeSignal?.aborted).toBe(true);
});
const running = runWorkerCommand({
input: commandInput(),
output,
lifetime: lifetime.contract,
});
lifetime.open();
await vi.waitFor(() => expect(runWorkerDescriptor).toHaveBeenCalledOnce());
lifetime.disconnectAfterStart();
await expect(running).rejects.toThrow("worker supervisor lifetime ended");
expect(lifetime.terminateOwnedTree).toHaveBeenCalledOnce();
expect(lifetime.dispose).toHaveBeenCalledOnce();
});
});
+34 -3
View File
@@ -5,9 +5,17 @@ import { runWorkerDescriptor } from "./worker.runtime.js";
type RunWorkerCommandOptions = {
input: Readable;
lifetime?: WorkerCommandLifetime;
output: Writable;
};
export type WorkerCommandLifetime = {
dispose: () => void;
signal: AbortSignal;
started: Promise<boolean>;
terminateOwnedTree: () => void;
};
async function readLaunchDescriptor(input: Readable): Promise<WorkerLaunchDescriptor> {
const chunks: Buffer[] = [];
let byteLength = 0;
@@ -41,16 +49,39 @@ async function readLaunchDescriptor(input: Readable): Promise<WorkerLaunchDescri
/** Process shell for `openclaw worker`: stdin descriptor in, JSON result out, signals abort the run. */
export async function runWorkerCommand(options: RunWorkerCommandOptions): Promise<void> {
const descriptor = await readLaunchDescriptor(options.input);
const abortController = new AbortController();
const stop = () => abortController.abort(new Error("worker interrupted"));
process.once("SIGINT", stop);
process.once("SIGTERM", stop);
let lifetimeEnded = false;
const stopForLifetime = () => {
if (lifetimeEnded || !options.lifetime) {
return;
}
lifetimeEnded = true;
abortController.abort(
options.lifetime.signal.reason ?? new Error("worker supervisor lifetime ended"),
);
options.lifetime.terminateOwnedTree();
};
try {
const [descriptor, started] = await Promise.all([
readLaunchDescriptor(options.input),
options.lifetime?.started ?? Promise.resolve(true),
]);
if (!started) {
return;
}
options.lifetime?.signal.addEventListener("abort", stopForLifetime, { once: true });
if (options.lifetime?.signal.aborted) {
stopForLifetime();
}
process.once("SIGINT", stop);
process.once("SIGTERM", stop);
const result = await runWorkerDescriptor(descriptor, { signal: abortController.signal });
const encoded = `${JSON.stringify(result)}\n`;
options.output.write(encoded);
} finally {
options.lifetime?.signal.removeEventListener("abort", stopForLifetime);
options.lifetime?.dispose();
process.off("SIGINT", stop);
process.off("SIGTERM", stop);
}