mirror of
https://github.com/openclaw/openclaw.git
synced 2026-09-03 06:38:40 +00:00
* fix(codex): reap orphaned app-servers before reconnect * style(codex): avoid returning the containment timer handle * fix(codex): inspect Linux children without requiring procps * fix(codex): tolerate processes exiting during procfs reads * test(codex): normalize socket fixture binary frames * fix(codex): preserve startup errors during process registration
296 lines
12 KiB
TypeScript
296 lines
12 KiB
TypeScript
/** Owns approved, connection-bound Codex exec-server processes on paired nodes. */
|
|
import type { ChildProcessWithoutNullStreams } from "node:child_process";
|
|
import { once } from "node:events";
|
|
import { mkdir } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import type { OpenClawPluginNodeHostCommandIo } from "openclaw/plugin-sdk/node-host";
|
|
import { killProcessTree } from "openclaw/plugin-sdk/process-runtime";
|
|
import { sanitizeEnvVars } from "openclaw/plugin-sdk/sandbox";
|
|
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
|
|
import { resolvePreferredOpenClawTmpDir, withTempWorkspace } from "openclaw/plugin-sdk/temp-path";
|
|
import {
|
|
isManagedCodexDesktopCommand,
|
|
resolveManagedCodexAppServerStartOptions,
|
|
resolveManagedCodexNativeCommand,
|
|
} from "./app-server/managed-binary.js";
|
|
import { createStdioTransport } from "./app-server/transport-stdio.js";
|
|
import { closeCodexAppServerTransportAndWait } from "./app-server/transport.js";
|
|
|
|
const MAX_CODEX_EXEC_SERVER_MESSAGE_BYTES = 64 * 1024 * 1024;
|
|
const MAX_CODEX_EXEC_SERVER_STDERR_BYTES = 4 * 1024;
|
|
const CODEX_EXEC_SERVER_TERMINATION_GRACE_MS = 1_000;
|
|
const CODEX_EXEC_SERVER_REAP_TIMEOUT_MS = 5_000;
|
|
const NODE_EXEC_SERVER_PLATFORM_ENVIRONMENT =
|
|
/^(?:SYSTEMROOT|WINDIR|COMSPEC|PATHEXT|TEMP|TMP|TMPDIR)$/iu;
|
|
|
|
type CodexNodeExecProcessOwner = {
|
|
terminate: () => Promise<void>;
|
|
};
|
|
|
|
function validateNodeExecServerMessage(message: Uint8Array): Buffer {
|
|
if (message.byteLength === 0 || message.byteLength > MAX_CODEX_EXEC_SERVER_MESSAGE_BYTES) {
|
|
throw new Error("Codex exec-server JSON-RPC message exceeds its 64 MiB limit.");
|
|
}
|
|
const encoded = Buffer.from(message.buffer, message.byteOffset, message.byteLength);
|
|
if (encoded.includes(0x0a) || encoded.includes(0x0d)) {
|
|
throw new Error("Codex exec-server JSON-RPC frames must contain exactly one message.");
|
|
}
|
|
let decoded: unknown;
|
|
try {
|
|
const text = new TextDecoder("utf-8", { fatal: true }).decode(encoded);
|
|
decoded = JSON.parse(text) as unknown;
|
|
} catch {
|
|
throw new Error("Codex exec-server received malformed UTF-8 or JSON-RPC.");
|
|
}
|
|
if (
|
|
!isRecord(decoded) ||
|
|
(decoded.jsonrpc !== undefined && decoded.jsonrpc !== "2.0") ||
|
|
(typeof decoded.method !== "string" &&
|
|
!("id" in decoded && ("result" in decoded || "error" in decoded)))
|
|
) {
|
|
throw new Error("Codex exec-server received an invalid JSON-RPC message.");
|
|
}
|
|
return encoded;
|
|
}
|
|
|
|
function nodeExecServerAbortError(signal: AbortSignal): Error {
|
|
return signal.reason instanceof Error
|
|
? signal.reason
|
|
: new Error("Codex node exec-server connection closed.");
|
|
}
|
|
|
|
function writeNodeExecServerMessage(
|
|
child: ChildProcessWithoutNullStreams,
|
|
message: Buffer,
|
|
signal: AbortSignal,
|
|
): Promise<void> | void {
|
|
if (signal.aborted) {
|
|
throw nodeExecServerAbortError(signal);
|
|
}
|
|
const payload = Buffer.concat([message, Buffer.from("\n")]);
|
|
if (!child.stdin.write(payload)) {
|
|
return once(child.stdin, "drain", { signal }).then(() => undefined);
|
|
}
|
|
}
|
|
|
|
async function relayNodeExecServerOutput(
|
|
child: ChildProcessWithoutNullStreams,
|
|
send: (message: Uint8Array) => Promise<void>,
|
|
): Promise<void> {
|
|
let fragments: Buffer[] = [];
|
|
let pendingBytes = 0;
|
|
for await (const rawChunk of child.stdout) {
|
|
const chunk = Buffer.isBuffer(rawChunk) ? rawChunk : Buffer.from(rawChunk);
|
|
let offset = 0;
|
|
while (offset < chunk.byteLength) {
|
|
const newline = chunk.indexOf(0x0a, offset);
|
|
const fragment = chunk.subarray(offset, newline === -1 ? chunk.byteLength : newline);
|
|
const nextLength = pendingBytes + fragment.byteLength;
|
|
if (nextLength > MAX_CODEX_EXEC_SERVER_MESSAGE_BYTES + 1) {
|
|
throw new Error("Codex exec-server stdout message exceeds its 64 MiB limit.");
|
|
}
|
|
if (fragment.byteLength > 0) {
|
|
fragments.push(fragment);
|
|
}
|
|
pendingBytes = nextLength;
|
|
if (newline === -1) {
|
|
if (
|
|
pendingBytes > MAX_CODEX_EXEC_SERVER_MESSAGE_BYTES &&
|
|
fragment[fragment.byteLength - 1] !== 0x0d
|
|
) {
|
|
throw new Error("Codex exec-server stdout message exceeds its 64 MiB limit.");
|
|
}
|
|
break;
|
|
}
|
|
const trailing = fragments.at(-1);
|
|
if (trailing?.[trailing.byteLength - 1] === 0x0d) {
|
|
pendingBytes -= 1;
|
|
if (trailing.byteLength === 1) {
|
|
fragments.pop();
|
|
} else {
|
|
fragments[fragments.length - 1] = trailing.subarray(0, trailing.byteLength - 1);
|
|
}
|
|
}
|
|
const pending =
|
|
fragments.length === 1 ? fragments[0]! : Buffer.concat(fragments, pendingBytes);
|
|
const message = validateNodeExecServerMessage(pending);
|
|
fragments = [];
|
|
pendingBytes = 0;
|
|
await send(message);
|
|
offset = newline + 1;
|
|
}
|
|
}
|
|
if (pendingBytes > 0) {
|
|
throw new Error("Codex exec-server stdout ended with an unterminated JSON-RPC message.");
|
|
}
|
|
}
|
|
|
|
function createNodeExecServerProcessOwner(
|
|
child: ChildProcessWithoutNullStreams,
|
|
closed: Promise<{ code: number | null; signal: NodeJS.Signals | null }>,
|
|
): CodexNodeExecProcessOwner {
|
|
let termination: Promise<void> | undefined;
|
|
return {
|
|
terminate: () =>
|
|
(termination ??= (async () => {
|
|
// The shared transport closes only the root on Windows; taskkill /T
|
|
// owns its descendants before that root can disappear.
|
|
if (process.platform === "win32" && child.pid) {
|
|
killProcessTree(child.pid, { graceMs: CODEX_EXEC_SERVER_TERMINATION_GRACE_MS });
|
|
}
|
|
const exited = await closeCodexAppServerTransportAndWait(child, {
|
|
forceKillDelayMs: CODEX_EXEC_SERVER_TERMINATION_GRACE_MS,
|
|
exitTimeoutMs: CODEX_EXEC_SERVER_REAP_TIMEOUT_MS,
|
|
});
|
|
if (!exited) {
|
|
throw new Error("Codex node exec-server process tree did not terminate.");
|
|
}
|
|
await closed;
|
|
})()),
|
|
};
|
|
}
|
|
|
|
/** Runs the one-connection paired-node exec-server after lightweight command admission. */
|
|
export async function runCodexNodeExecServer(params: {
|
|
assertExecAuthorized: () => void;
|
|
workspaceDir: string;
|
|
io: OpenClawPluginNodeHostCommandIo;
|
|
activeProcesses: Set<() => Promise<void>>;
|
|
onFrameReceiver: (receiver: (message: Uint8Array) => Promise<void> | void) => void;
|
|
}): Promise<string> {
|
|
const { io } = params;
|
|
const frames = io.frames;
|
|
if (!frames) {
|
|
throw new Error("Codex node exec-server requires duplex frames.");
|
|
}
|
|
const cwd = params.workspaceDir;
|
|
let writes: Promise<void> | undefined;
|
|
let rejectDisconnected!: (error: Error) => void;
|
|
const disconnected = new Promise<never>((_resolve, reject) => {
|
|
rejectDisconnected = reject;
|
|
});
|
|
void disconnected.catch(() => {});
|
|
const onAbort = () => {
|
|
const error = nodeExecServerAbortError(io.signal);
|
|
rejectDisconnected(error);
|
|
};
|
|
io.signal.addEventListener("abort", onAbort, { once: true });
|
|
|
|
try {
|
|
if (io.signal.aborted) {
|
|
throw nodeExecServerAbortError(io.signal);
|
|
}
|
|
return await withTempWorkspace(
|
|
{ rootDir: resolvePreferredOpenClawTmpDir(), prefix: "codex-node-exec-server-" },
|
|
async ({ dir }) => {
|
|
const codexHome = path.join(dir, ".codex");
|
|
// Codex canonicalizes CODEX_HOME during startup and rejects missing directories.
|
|
await mkdir(codexHome, { recursive: true, mode: 0o700 });
|
|
const resolved = await resolveManagedCodexAppServerStartOptions({
|
|
transport: "stdio",
|
|
command: "codex",
|
|
commandSource: "managed",
|
|
managedCommandOrder: "package-first",
|
|
args: ["exec-server", "--listen", "stdio"],
|
|
headers: {},
|
|
});
|
|
const native = resolveManagedCodexNativeCommand(resolved.command);
|
|
if (!native || isManagedCodexDesktopCommand(resolved.command)) {
|
|
throw new Error("Codex node exec-server requires the pinned managed package binary.");
|
|
}
|
|
// The exec-server needs platform/locale basics, never provider, forge,
|
|
// cloud, SSH-agent, XDG, or runtime-injection state from its node host.
|
|
const baseEnv = sanitizeEnvVars(process.env, {
|
|
strictMode: true,
|
|
customAllowedPatterns: [NODE_EXEC_SERVER_PLATFORM_ENVIRONMENT],
|
|
}).allowed;
|
|
if (io.signal.aborted) {
|
|
throw nodeExecServerAbortError(io.signal);
|
|
}
|
|
// Awaited setup is complete; policy and invocation closure win at spawn.
|
|
params.assertExecAuthorized();
|
|
const child = await createStdioTransport(
|
|
{
|
|
transport: "stdio",
|
|
command: native,
|
|
commandSource: "resolved-managed",
|
|
args: resolved.args,
|
|
headers: {},
|
|
cwd,
|
|
env: {
|
|
HOME: dir,
|
|
CODEX_HOME: codexHome,
|
|
...(process.platform === "win32" ? { USERPROFILE: dir } : {}),
|
|
},
|
|
clearEnv: ["NODE_OPTIONS"],
|
|
},
|
|
baseEnv,
|
|
() => {
|
|
if (io.signal.aborted) {
|
|
throw nodeExecServerAbortError(io.signal);
|
|
}
|
|
params.assertExecAuthorized();
|
|
},
|
|
);
|
|
child.stdin.on("error", (error) => {
|
|
rejectDisconnected(error);
|
|
});
|
|
let stderr = Buffer.alloc(0);
|
|
child.stderr.on("data", (chunk: Buffer | string) => {
|
|
const next = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
const bounded = next.subarray(-MAX_CODEX_EXEC_SERVER_STDERR_BYTES);
|
|
stderr = Buffer.concat([stderr, bounded]).subarray(-MAX_CODEX_EXEC_SERVER_STDERR_BYTES);
|
|
});
|
|
const closed = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>(
|
|
(resolve) => {
|
|
child.once("close", (code, signal) => resolve({ code, signal }));
|
|
},
|
|
);
|
|
child.once("error", (error) => {
|
|
rejectDisconnected(error);
|
|
});
|
|
const owner = createNodeExecServerProcessOwner(child, closed);
|
|
params.activeProcesses.add(owner.terminate);
|
|
const output = relayNodeExecServerOutput(child, frames.send.bind(frames));
|
|
void output.catch((error: unknown) => {
|
|
rejectDisconnected(error instanceof Error ? error : new Error(String(error)));
|
|
});
|
|
try {
|
|
if (io.signal.aborted) {
|
|
throw nodeExecServerAbortError(io.signal);
|
|
}
|
|
// Registration publishes framed readiness; avoid promising it before
|
|
// the child and every cleanup/error owner can consume incoming frames.
|
|
params.onFrameReceiver((message) => {
|
|
const encoded = validateNodeExecServerMessage(message);
|
|
const operation = writes
|
|
? writes.then(() => writeNodeExecServerMessage(child, encoded, io.signal))
|
|
: writeNodeExecServerMessage(child, encoded, io.signal);
|
|
if (!operation) {
|
|
return undefined;
|
|
}
|
|
const observed = operation.catch(() => {});
|
|
writes = observed;
|
|
void observed.then(() => {
|
|
if (writes === observed) {
|
|
writes = undefined;
|
|
}
|
|
});
|
|
return operation;
|
|
});
|
|
const outcome = await Promise.race([closed, disconnected]);
|
|
const diagnostic = stderr.toString("utf8").trim();
|
|
throw new Error(
|
|
`Codex node exec-server exited (code ${outcome.code ?? "none"}, signal ${outcome.signal ?? "none"})${diagnostic ? `: ${diagnostic}` : "."}`,
|
|
);
|
|
} finally {
|
|
await owner.terminate();
|
|
params.activeProcesses.delete(owner.terminate);
|
|
await Promise.allSettled([output, ...(writes ? [writes] : [])]);
|
|
}
|
|
},
|
|
);
|
|
} finally {
|
|
io.signal.removeEventListener("abort", onAbort);
|
|
}
|
|
}
|