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