openclaw/extensions/codex/src/app-server/sandbox-exec-server.test.ts
Peter Steinberger 268c8d2500
fix(codex): settle sandbox processes and readiness probes (#151764)
* fix(codex): settle sandbox processes and readiness probes

* fix(codex): revoke sandbox termination signals at exit

* fix(codex): preserve sandbox command process identity

* fix(ci): recognize cancellation-aware sandbox upload checks

* fix(ci): register sandbox upload scanner fixtures

* fix(gateway): settle cancelled chat waiters

Publish cancellation completion from the common dispatch finalizer for both resolved and rejected runtimes. Preserve the abort marker captured before rejection cleanup, session binding, and completed-delivery barrier.

Release-note context: agent.wait already watching a chat.send now finishes after chat.abort, including CLI runtimes that resolve with partial output. The real Gateway regression fails on the original code and passes after this owner repair; 59 focused tests, selected changed checks, final targeted checks, and independent P2 review passed.

* test(pdf): cancel worker extraction deterministically

Abort synchronously after submitting the real extraction so a worker result cannot race the former zero-delay timer. Preserve exact cancellation reason, pre-aborted rejection, and successful subsequent extraction assertions.
2026-09-19 02:44:43 -07:00

1061 lines
36 KiB
TypeScript

// Codex tests cover sandbox exec server plugin behavior.
import { useIsolatedStateGuard, withEnvAsync } from "openclaw/plugin-sdk/test-env";
import { afterEach, describe, expect, it, vi } from "vitest";
import { sandboxExecServerRegistry } from "./sandbox-exec-server-registry.js";
import {
ensureCodexSandboxExecServerEnvironment,
releaseCodexSandboxExecServerEnvironment,
} from "./sandbox-exec-server.js";
import { CODEX_APP_SERVER_VERSION } from "./version.js";
const CODEX_SANDBOX_EXEC_SERVER_MAX_INBOUND_MESSAGE_BYTES = 100 * 1024 * 1024;
import {
collectNotifications,
codexFsSandboxContext,
createClient,
createSandboxContext,
execServerUrlFromClient,
openSocket,
readUntilClosed,
rpc,
waitForSocketClose,
} from "./sandbox-exec-server.test-helpers.js";
useIsolatedStateGuard();
afterEach(async () => {
await sandboxExecServerRegistry.closeAll();
});
function testExecEnv(): NodeJS.ProcessEnv {
return {
PATH: process.env.PATH,
};
}
function echoFirstInputLineScript(prefix: string): string {
return [
"let data = '';",
"process.stdin.setEncoding('utf8');",
"process.stdin.on('data', (chunk) => {",
"data += chunk;",
"if (data.includes('\\n')) {",
`process.stdout.write(${JSON.stringify(prefix)} + data);`,
"process.exit(0);",
"}",
"});",
].join(" ");
}
async function readStartedPid(
socket: Awaited<ReturnType<typeof openSocket>>,
processId: string,
): Promise<number> {
for (let attempt = 0; attempt < 20; attempt += 1) {
const read = (await rpc(socket, "process/read", {
processId,
afterSeq: 0,
waitMs: 100,
})) as { chunks?: Array<{ chunk: string }> };
const output = (read.chunks ?? [])
.map((chunk) => Buffer.from(chunk.chunk, "base64").toString("utf8"))
.join("");
const pid = /PID=(\d+)/u.exec(output)?.[1];
if (pid) {
return Number(pid);
}
}
throw new Error(`process ${processId} did not report its PID`);
}
describe("OpenClaw Codex sandbox exec-server", () => {
it("rejects an incomplete sandbox environment before publishing an exec-server", async () => {
const sandbox = createSandboxContext({});
sandbox.fsBridge = undefined;
const client = createClient();
await expect(
ensureCodexSandboxExecServerEnvironment({ client: client as never, sandbox }),
).rejects.toThrow("Sandbox filesystem bridge is unavailable.");
expect(client.request).not.toHaveBeenCalled();
expect(sandboxExecServerRegistry.servers.has(sandbox.runtimeId)).toBe(false);
});
it.each([
{ containerWorkdir: "/workspace", cwd: "file:///workspace" },
{
containerWorkdir: "/workspace/space #project",
cwd: "file:///workspace/space%20%23project",
},
])(
"reports target shell, encoded workdir, and readiness for $containerWorkdir",
async ({ containerWorkdir, cwd }) => {
const sandbox = createSandboxContext({});
sandbox.containerWorkdir = containerWorkdir;
const client = createClient();
await ensureCodexSandboxExecServerEnvironment({
client: client as never,
sandbox,
});
const socket = await openSocket(execServerUrlFromClient(client));
await rpc(socket, "initialize", { clientName: "test" });
socket.send(JSON.stringify({ method: "initialized" }));
await expect(rpc(socket, "environment/info", {})).resolves.toEqual({
shell: { name: "sh", path: "/bin/sh" },
cwd,
capabilities: { networkProxyLaunch: false },
});
await expect(rpc(socket, "environment/status", {})).resolves.toEqual({
status: "ready",
});
socket.close();
},
);
it("does not advertise a local exec-server URL to remote app-servers", async () => {
const sandbox = createSandboxContext({});
const client = createClient();
await expect(
ensureCodexSandboxExecServerEnvironment({
client: client as never,
sandbox,
appServerStartOptions: {
transport: "websocket",
command: "codex",
commandSource: "config",
args: [],
url: "wss://codex.example.test/app-server",
headers: {},
},
}),
).rejects.toThrow("cannot be registered with a remote Codex app-server");
expect(client.request).not.toHaveBeenCalled();
});
it("does not treat 127-prefixed DNS names as local app-server hosts", async () => {
const sandbox = createSandboxContext({});
const client = createClient();
await expect(
ensureCodexSandboxExecServerEnvironment({
client: client as never,
sandbox,
appServerStartOptions: {
transport: "websocket",
command: "codex",
commandSource: "config",
args: [],
url: "wss://127.example.test/app-server",
headers: {},
},
}),
).rejects.toThrow("cannot be registered with a remote Codex app-server");
expect(client.request).not.toHaveBeenCalled();
});
it("registers a sandbox-backed Codex environment and routes process execution through it", async () => {
const buildExecSpec = vi.fn(async () => ({
argv: [process.execPath, "-e", "process.stdout.write('sandbox-process-ok\\n')"],
env: testExecEnv(),
stdinMode: "pipe-closed" as const,
}));
const sandbox = createSandboxContext({ buildExecSpec });
const requests: Array<{ method: string; params: unknown }> = [];
const client = {
getServerVersion: vi.fn(() => CODEX_APP_SERVER_VERSION),
request: vi.fn(async (method: string, params: unknown) => {
requests.push({ method, params });
return {};
}),
};
const environment = await ensureCodexSandboxExecServerEnvironment({
client: client as never,
sandbox,
});
const addRequest = requests[0];
expect(addRequest?.method).toBe("environment/add");
expect(environment).toEqual({
environmentId: expect.stringMatching(/^openclaw-sandbox-/),
cwd: "/workspace",
});
const execServerUrl =
typeof addRequest?.params === "object" &&
addRequest.params &&
"execServerUrl" in addRequest.params
? String(addRequest.params.execServerUrl)
: "";
expect(execServerUrl).toMatch(/^ws:\/\/127\.0\.0\.1:/);
const socket = await openSocket(execServerUrl);
const notifications = collectNotifications(socket);
await rpc(socket, "initialize", { clientName: "test" });
socket.send(JSON.stringify({ method: "initialized" }));
const start = (await rpc(socket, "process/start", {
processId: "proc-1",
argv: ["/bin/sh", "-lc", "printf ok"],
cwd: "file:///workspace",
env: {
POLICY_SET: "env-wins",
TEST_FLAG: "1",
CODEX_API_KEY: "must-not-cross",
OPENAI_API_KEY: "must-not-cross",
},
envPolicy: {
inherit: "none",
ignoreDefaultExcludes: true,
exclude: [],
set: {
POLICY_SET: "policy",
POLICY_ONLY: "1",
CODEX_ACCESS_TOKEN: "must-not-cross",
},
includeOnly: [],
},
tty: false,
pipeStdin: false,
arg0: null,
})) as { processId?: string; nextSeq?: number };
expect(start).toEqual({ processId: "proc-1", sandboxType: "none" });
const read = await readUntilClosed(socket, "proc-1");
expect(read.exited).toBe(true);
expect(read.exitCode).toBe(0);
expect(read.closed).toBe(true);
expect(Buffer.from(read.chunks?.[0]?.chunk ?? "", "base64").toString("utf8")).toBe(
"sandbox-process-ok\n",
);
expect(buildExecSpec).toHaveBeenCalledWith(
expect.objectContaining({
env: expect.objectContaining({
CODEX_SANDBOX_EXEC_ID: expect.any(String),
POLICY_ONLY: "1",
POLICY_SET: "env-wins",
TEST_FLAG: "1",
}),
usePty: false,
workdir: "/workspace",
}),
);
expect(notifications.map((notification) => notification.method)).toEqual(
expect.arrayContaining(["process/output", "process/exited", "process/closed"]),
);
socket.close();
});
it("decodes a Codex file URI cwd before sandbox execution", async () => {
const buildExecSpec = vi.fn(async () => ({
argv: [process.execPath, "-e", ""],
env: testExecEnv(),
stdinMode: "pipe-closed" as const,
}));
const sandbox = createSandboxContext({ buildExecSpec });
const client = createClient();
await ensureCodexSandboxExecServerEnvironment({
client: client as never,
sandbox,
});
const socket = await openSocket(execServerUrlFromClient(client));
await rpc(socket, "initialize", { clientName: "test" });
socket.send(JSON.stringify({ method: "initialized" }));
await rpc(socket, "process/start", {
processId: "proc-uri-cwd",
argv: ["/usr/bin/pwd"],
cwd: "file:///projects/example%20repo",
env: {},
tty: false,
pipeStdin: false,
arg0: null,
});
expect(buildExecSpec).toHaveBeenCalledWith(
expect.objectContaining({ workdir: "/projects/example repo" }),
);
socket.close();
});
it.each([
["a native absolute path", "/workspace"],
["a relative path", "workspace"],
["a non-file scheme", "https://example.test/workspace"],
["a remote file authority", "file://remote.example.test/workspace"],
["a query", "file:///workspace?revision=1"],
["a fragment", "file:///workspace#section"],
["a Windows drive path", "file:///C:/workspace"],
["an encoded Windows drive path", "file:///%43:/workspace"],
["an encoded null byte", "file:///workspace%00"],
])("rejects a process cwd with %s", async (_label, cwd) => {
const buildExecSpec = vi.fn(async () => ({
argv: [process.execPath, "-e", ""],
env: testExecEnv(),
stdinMode: "pipe-closed" as const,
}));
const sandbox = createSandboxContext({ buildExecSpec });
const client = createClient();
await ensureCodexSandboxExecServerEnvironment({
client: client as never,
sandbox,
});
const socket = await openSocket(execServerUrlFromClient(client));
await rpc(socket, "initialize", { clientName: "test" });
socket.send(JSON.stringify({ method: "initialized" }));
await expect(
rpc(socket, "process/start", {
processId: "proc-invalid-cwd",
argv: ["/usr/bin/pwd"],
cwd,
env: {},
tty: false,
pipeStdin: false,
arg0: null,
}),
).rejects.toThrow(/process cwd/u);
expect(buildExecSpec).not.toHaveBeenCalled();
socket.close();
});
it("closes oversized sandbox exec-server frames before JSON-RPC parsing", async () => {
const sandbox = createSandboxContext({});
const client = createClient();
await ensureCodexSandboxExecServerEnvironment({
client: client as never,
sandbox,
});
const socket = await openSocket(execServerUrlFromClient(client));
const closed = waitForSocketClose(socket);
socket.send(Buffer.alloc(CODEX_SANDBOX_EXEC_SERVER_MAX_INBOUND_MESSAGE_BYTES + 1));
await expect(closed).resolves.toEqual({ code: 1009 });
});
it("rejects unsupported arg0 overrides instead of dropping them", async () => {
const buildExecSpec = vi.fn(async () => ({
argv: [process.execPath, "-e", ""],
env: testExecEnv(),
stdinMode: "pipe-closed" as const,
}));
const sandbox = createSandboxContext({ buildExecSpec });
const client = createClient();
await ensureCodexSandboxExecServerEnvironment({
client: client as never,
sandbox,
});
const socket = await openSocket(execServerUrlFromClient(client));
await rpc(socket, "initialize", { clientName: "test" });
socket.send(JSON.stringify({ method: "initialized" }));
await expect(
rpc(socket, "process/start", {
processId: "proc-arg0",
argv: ["/bin/sh", "-lc", "true"],
cwd: "file:///workspace",
env: {},
tty: false,
pipeStdin: false,
arg0: "codex-linux-sandbox",
}),
).rejects.toThrow("does not support arg0 overrides");
expect(buildExecSpec).not.toHaveBeenCalled();
socket.close();
});
it.each([
{
description: "managed per-process filesystem restrictions",
restrictions: {
sandbox: codexFsSandboxContext({
entries: [
{
path: { type: "path", path: "file:///workspace/allowed" },
access: "read",
},
],
}),
},
error: /filesystem sandbox.*cannot be enforced/iu,
},
{
description: "required managed networking without enforcement details",
restrictions: { enforceManagedNetwork: true },
error: /managed network.*cannot be enforced/iu,
},
{
description: "required managed networking with an explicit network context",
restrictions: {
enforceManagedNetwork: true,
managedNetwork: { loopbackPorts: [43123], allowLocalBinding: false },
},
error: /managed network.*cannot be enforced/iu,
},
{
description: "an executor-local managed network proxy",
restrictions: { networkProxy: { policyDecisionTimeoutMs: 1_000 } },
error: /network proxy.*not supported/iu,
},
{
description: "restricted network access in a network-enabled container",
restrictions: {
sandbox: { permissions: { type: "external", network: "restricted" } },
},
backendNetwork: "bridge",
error: /network restrictions.*cannot be enforced/iu,
},
{
description: "restricted managed network access in an SSH sandbox",
restrictions: {
sandbox: {
permissions: {
type: "managed",
file_system: { type: "unrestricted" },
network: "restricted",
},
},
},
backendId: "ssh",
error: /network restrictions.*cannot be enforced/iu,
},
{
description: "network isolation claimed after a network-enabled container started",
restrictions: {
sandbox: { permissions: { type: "external", network: "restricted" } },
},
backendNetwork: "bridge",
networkAfterStartup: "none",
error: /network restrictions.*cannot be enforced/iu,
},
])(
"rejects $description before launching a process",
async ({ restrictions, error, backendNetwork, backendId, networkAfterStartup }) => {
const buildExecSpec = vi.fn(async () => ({
argv: [process.execPath, "-e", ""],
env: testExecEnv(),
stdinMode: "pipe-closed" as const,
}));
const sandbox = createSandboxContext({ buildExecSpec });
if (backendNetwork) {
sandbox.docker.network = backendNetwork;
}
if (backendId) {
sandbox.backendId = backendId;
if (sandbox.backend) {
sandbox.backend.id = backendId;
}
}
const client = createClient();
await ensureCodexSandboxExecServerEnvironment({ client: client as never, sandbox });
if (networkAfterStartup) {
sandbox.docker.network = networkAfterStartup;
}
const socket = await openSocket(execServerUrlFromClient(client));
await rpc(socket, "initialize", { clientName: "test" });
await expect(
rpc(socket, "process/start", {
processId: "proc-unsupported-sandbox",
argv: ["/bin/sh", "-lc", "true"],
cwd: "file:///workspace",
env: {},
tty: false,
pipeStdin: false,
arg0: null,
...restrictions,
}),
).rejects.toThrow(error);
expect(buildExecSpec).not.toHaveBeenCalled();
socket.close();
},
);
it.each([
{ description: "ordinary externally sandboxed execution", sandbox: undefined },
{
description: "explicit external filesystem ownership",
sandbox: { permissions: { type: "external", network: "restricted" } },
},
{ description: "disabled nested sandboxing", sandbox: { permissions: { type: "disabled" } } },
{
description: "unrestricted managed filesystem access",
sandbox: {
permissions: {
type: "managed",
file_system: { type: "unrestricted" },
network: "enabled",
},
},
},
{
description: "network restrictions already enforced by the container",
sandbox: {
permissions: {
type: "managed",
file_system: { type: "unrestricted" },
network: "restricted",
},
},
},
{
description: "network restrictions already enforced by a Podman container",
sandbox: { permissions: { type: "external", network: "restricted" } },
backendId: "podman",
},
{
description: "externally enabled container networking",
sandbox: { permissions: { type: "external", network: "enabled" } },
backendNetwork: "bridge",
},
{
description: "provisioned network isolation after subsequent config mutation",
sandbox: { permissions: { type: "external", network: "restricted" } },
networkAfterStartup: "bridge",
},
])(
"preserves $description and reports its actual sandbox type",
async ({ sandbox: policy, backendId, backendNetwork, networkAfterStartup }) => {
const buildExecSpec = vi.fn(async () => ({
argv: [process.execPath, "-e", ""],
env: testExecEnv(),
stdinMode: "pipe-closed" as const,
}));
const sandbox = createSandboxContext({ buildExecSpec });
if (backendNetwork) {
sandbox.docker.network = backendNetwork;
}
if (backendId) {
sandbox.backendId = backendId;
if (sandbox.backend) {
sandbox.backend.id = backendId;
}
}
const client = createClient();
await ensureCodexSandboxExecServerEnvironment({ client: client as never, sandbox });
if (networkAfterStartup) {
sandbox.docker.network = networkAfterStartup;
}
const socket = await openSocket(execServerUrlFromClient(client));
await rpc(socket, "initialize", { clientName: "test" });
await expect(
rpc(socket, "process/start", {
processId: "proc-external-sandbox",
argv: ["/bin/sh", "-lc", "true"],
cwd: "file:///workspace",
env: {},
tty: false,
pipeStdin: false,
arg0: null,
...(policy ? { sandbox: policy } : {}),
}),
).resolves.toEqual({ processId: "proc-external-sandbox", sandboxType: "none" });
expect(buildExecSpec).toHaveBeenCalledOnce();
socket.close();
},
);
it("accepts stdin writes for pipe-backed processes", async () => {
const sandbox = createSandboxContext({
buildExecSpec: async () => ({
argv: [process.execPath, "-e", echoFirstInputLineScript("echo:")],
env: testExecEnv(),
stdinMode: "pipe-open",
}),
});
const client = createClient();
await ensureCodexSandboxExecServerEnvironment({
client: client as never,
sandbox,
});
const socket = await openSocket(execServerUrlFromClient(client));
await rpc(socket, "initialize", { clientName: "test" });
socket.send(JSON.stringify({ method: "initialized" }));
await rpc(socket, "process/start", {
processId: "proc-stdin",
argv: ["/bin/sh", "-lc", "cat"],
cwd: "file:///workspace",
env: {},
tty: false,
pipeStdin: true,
arg0: null,
});
await expect(
rpc(socket, "process/write", {
processId: "proc-stdin",
chunk: Buffer.from("hello\n").toString("base64"),
}),
).resolves.toEqual({ status: "accepted" });
const read = await readUntilClosed(socket, "proc-stdin");
expect(Buffer.from(read.chunks?.[0]?.chunk ?? "", "base64").toString("utf8")).toBe(
"echo:hello\n",
);
socket.close();
});
it.runIf(process.platform !== "win32")(
"provides a real terminal and Ctrl-C for tty processes",
async () => {
const buildExecSpec = vi.fn(async () => ({
argv: [
process.execPath,
"-e",
[
"process.on('SIGINT', () => { console.log('INTERRUPTED'); process.exit(42); });",
"console.log('PID=' + process.pid + ' TTY=' + Boolean(process.stdin.isTTY && process.stdout.isTTY));",
"setInterval(() => {}, 1000);",
].join(" "),
],
env: testExecEnv(),
stdinMode: "pipe-open" as const,
}));
const sandbox = createSandboxContext({ buildExecSpec });
const client = createClient();
await ensureCodexSandboxExecServerEnvironment({
client: client as never,
sandbox,
});
const socket = await openSocket(execServerUrlFromClient(client));
await rpc(socket, "initialize", { clientName: "test" });
socket.send(JSON.stringify({ method: "initialized" }));
await rpc(socket, "process/start", {
processId: "proc-tty",
argv: ["/bin/sh", "-lc", "cat"],
cwd: "file:///workspace",
env: {},
tty: true,
pipeStdin: false,
arg0: null,
});
await readStartedPid(socket, "proc-tty");
const initial = (await rpc(socket, "process/read", {
processId: "proc-tty",
afterSeq: 0,
})) as {
chunks: Array<{ chunk: string }>;
};
expect(
initial.chunks.map(({ chunk }) => Buffer.from(chunk, "base64").toString()).join(""),
).toContain("TTY=true");
await expect(
rpc(socket, "process/write", {
processId: "proc-tty",
chunk: Buffer.from("\u0003").toString("base64"),
}),
).resolves.toEqual({ status: "accepted" });
const read = await readUntilClosed(socket, "proc-tty");
expect(buildExecSpec).toHaveBeenCalledWith(expect.objectContaining({ usePty: true }));
expect(read.chunks?.[0]?.stream).toBe("pty");
expect(
read.chunks?.map(({ chunk }) => Buffer.from(chunk, "base64").toString()).join(""),
).toContain("INTERRUPTED");
expect(read.exitCode).toBe(42);
socket.close();
},
);
it.runIf(process.platform !== "win32")(
"interrupts a non-tty process through the sandbox backend",
async () => {
let pid = 0;
let marker = "";
const sandbox = createSandboxContext({
buildExecSpec: async ({ env }) => {
marker = `CODEX_SANDBOX_EXEC_ID=${env.CODEX_SANDBOX_EXEC_ID}`;
return {
argv: [
process.execPath,
"-e",
[
"process.on('SIGINT', () => { console.log('INTERRUPTED'); process.exit(42); });",
"console.log('PID=' + process.pid); setInterval(() => {}, 1000);",
].join(" "),
],
env: testExecEnv(),
stdinMode: "pipe-closed",
};
},
runShellCommand: async ({ args, script }) => {
if (script.includes("kill -INT")) {
expect(args).toEqual([marker]);
process.kill(pid, "SIGINT");
}
return { stdout: Buffer.alloc(0), stderr: Buffer.alloc(0), code: 0 };
},
});
const client = createClient();
await ensureCodexSandboxExecServerEnvironment({ client: client as never, sandbox });
const socket = await openSocket(execServerUrlFromClient(client));
const notifications = collectNotifications(socket);
await rpc(socket, "initialize", {});
await rpc(socket, "process/start", {
processId: "proc-interrupt",
argv: ["ignored"],
cwd: "file:///workspace",
tty: false,
});
pid = await readStartedPid(socket, "proc-interrupt");
await expect(
rpc(socket, "process/signal", { processId: "proc-interrupt", signal: "interrupt" }),
).resolves.toEqual({});
const read = await readUntilClosed(socket, "proc-interrupt");
expect(read.exitCode).toBe(42);
expect(
read.chunks?.map(({ chunk }) => Buffer.from(chunk, "base64").toString()).join(""),
).toContain("INTERRUPTED");
expect(notifications).toContainEqual({
method: "process/exited",
params: expect.objectContaining({ exitCode: 42, sandboxDenied: false }),
});
await expect(
rpc(socket, "process/signal", { processId: "missing", signal: "interrupt" }),
).resolves.toEqual({});
socket.close();
},
);
it.runIf(process.platform !== "win32")(
"reports a signal-killed PTY as a failed process",
async () => {
const finalizeExec = vi.fn(async () => undefined);
const sandbox = createSandboxContext({
buildExecSpec: async () => ({
argv: [process.execPath, "-e", "process.kill(process.pid, 'SIGKILL')"],
env: testExecEnv(),
stdinMode: "pipe-open",
}),
finalizeExec,
});
const client = createClient();
await ensureCodexSandboxExecServerEnvironment({ client: client as never, sandbox });
const socket = await openSocket(execServerUrlFromClient(client));
const notifications = collectNotifications(socket);
await rpc(socket, "initialize", {});
await rpc(socket, "process/start", {
processId: "killed-pty",
argv: ["ignored"],
cwd: "file:///workspace",
tty: true,
});
const read = await readUntilClosed(socket, "killed-pty");
expect(read.exitCode).toBe(1);
expect(notifications).toContainEqual({
method: "process/exited",
params: expect.objectContaining({ processId: "killed-pty", exitCode: 1 }),
});
await vi.waitFor(() =>
expect(finalizeExec).toHaveBeenCalledWith(expect.objectContaining({ exitCode: 1 })),
);
socket.close();
},
);
it("does not let Codex env policy inherit host secret variables", async () => {
await withEnvAsync(
{
OPENCLAW_TEST_SECRET_TOKEN: "host-secret",
OPENCLAW_TEST_DATABASE_PASSWORD: "host-password",
OPENCLAW_TEST_PRIVATE_KEY: "host-private-key",
},
async () => {
const buildExecSpec = vi.fn(async () => ({
argv: [process.execPath, "-e", ""],
env: {},
stdinMode: "pipe-closed" as const,
}));
const sandbox = createSandboxContext({ buildExecSpec });
const client = createClient();
await ensureCodexSandboxExecServerEnvironment({
client: client as never,
sandbox,
});
const socket = await openSocket(execServerUrlFromClient(client));
await rpc(socket, "initialize", { clientName: "test" });
socket.send(JSON.stringify({ method: "initialized" }));
await rpc(socket, "process/start", {
processId: "proc-secret-env",
argv: ["/bin/sh", "-lc", "true"],
cwd: "file:///workspace",
env: {},
envPolicy: {
inherit: "all",
ignoreDefaultExcludes: true,
exclude: [],
set: {},
includeOnly: [],
},
tty: false,
pipeStdin: false,
arg0: null,
});
const [{ env: execEnv }] = buildExecSpec.mock.calls[0] as unknown as [
{ env: Record<string, string> },
];
expect(execEnv).toEqual({ CODEX_SANDBOX_EXEC_ID: expect.any(String) });
socket.close();
},
);
});
it("keeps process/read cursors at the last returned byte-limited chunk", async () => {
const sandbox = createSandboxContext({
buildExecSpec: async () => ({
argv: [
process.execPath,
"-e",
"process.stdout.write('aaaa'); process.stderr.write('bbbb');",
],
env: testExecEnv(),
stdinMode: "pipe-closed",
}),
});
const client = createClient();
await ensureCodexSandboxExecServerEnvironment({
client: client as never,
sandbox,
});
const socket = await openSocket(execServerUrlFromClient(client));
await rpc(socket, "initialize", { clientName: "test" });
socket.send(JSON.stringify({ method: "initialized" }));
await rpc(socket, "process/start", {
processId: "proc-cursor",
argv: [process.execPath, "-e", "ignored"],
cwd: "file:///workspace",
env: {},
tty: false,
pipeStdin: false,
arg0: null,
});
const complete = await readUntilClosed(socket, "proc-cursor");
expect(complete.chunks?.length ?? 0).toBeGreaterThanOrEqual(2);
const firstRead = (await rpc(socket, "process/read", {
processId: "proc-cursor",
afterSeq: 0,
maxBytes: 4,
})) as { chunks?: Array<{ seq: number }>; nextSeq?: number };
expect(firstRead.chunks).toHaveLength(1);
expect(firstRead.nextSeq).toBe((firstRead.chunks?.[0]?.seq ?? 0) + 1);
expect(firstRead.nextSeq ?? 0).toBeLessThan(complete.nextSeq ?? 0);
const secondRead = (await rpc(socket, "process/read", {
processId: "proc-cursor",
afterSeq: (firstRead.nextSeq ?? 1) - 1,
maxBytes: 4,
})) as { chunks?: Array<{ seq: number }> };
expect(secondRead.chunks?.length ?? 0).toBeGreaterThanOrEqual(1);
socket.close();
});
it("returns protocol statuses for unsupported process writes and unknown termination", async () => {
const sandbox = createSandboxContext({});
const client = createClient();
await ensureCodexSandboxExecServerEnvironment({
client: client as never,
sandbox,
});
const socket = await openSocket(execServerUrlFromClient(client));
await rpc(socket, "initialize", { clientName: "test" });
socket.send(JSON.stringify({ method: "initialized" }));
await expect(
rpc(socket, "process/write", {
processId: "missing",
chunk: Buffer.from("hello").toString("base64"),
}),
).resolves.toEqual({ status: "unknownProcess" });
await expect(
rpc(socket, "process/terminate", {
processId: "missing",
}),
).resolves.toEqual({ running: false });
socket.close();
});
it("distinguishes unsupported exec methods from missing filesystem resources", async () => {
const sandbox = createSandboxContext({ stat: async () => null });
const client = createClient();
await ensureCodexSandboxExecServerEnvironment({
client: client as never,
sandbox,
});
const socket = await openSocket(execServerUrlFromClient(client));
await rpc(socket, "initialize", { clientName: "test" });
socket.send(JSON.stringify({ method: "initialized" }));
for (const method of ["fs/walk", "unsupported/method"]) {
await expect(rpc(socket, method, {})).rejects.toMatchObject({
code: -32601,
message: `Unsupported OpenClaw sandbox exec-server method: ${method}`,
});
}
await expect(
rpc(socket, "fs/getMetadata", { path: "file:///workspace/missing" }),
).rejects.toMatchObject({
code: -32004,
message: "file not found",
});
await expect(rpc(socket, "environment/status", {})).resolves.toEqual({
status: "ready",
});
socket.close();
});
it("rejects WebSocket clients that do not know the exec-server capability path", async () => {
const sandbox = createSandboxContext({});
const client = createClient();
await ensureCodexSandboxExecServerEnvironment({
client: client as never,
sandbox,
});
const unauthorizedUrl = execServerUrlFromClient(client).replace(
/\/openclaw-[^/?#]+/u,
"/wrong",
);
const socket = await openSocket(unauthorizedUrl);
await expect(waitForSocketClose(socket)).resolves.toEqual({ code: 1008 });
});
it("handles oversized frames from unauthorized WebSocket clients", async () => {
const sandbox = createSandboxContext({});
const client = createClient();
await ensureCodexSandboxExecServerEnvironment({
client: client as never,
sandbox,
});
const unauthorizedUrl = execServerUrlFromClient(client).replace(
/\/openclaw-[^/?#]+/u,
"/wrong",
);
const socket = await openSocket(unauthorizedUrl);
const closed = waitForSocketClose(socket);
socket.send(Buffer.alloc(CODEX_SANDBOX_EXEC_SERVER_MAX_INBOUND_MESSAGE_BYTES + 1));
const closeResult = await closed;
expect([1008, 1009]).toContain(closeResult.code);
});
it("closes the exec-server when its sandbox environment is released", async () => {
const sandbox = createSandboxContext({});
const client = createClient();
await ensureCodexSandboxExecServerEnvironment({
client: client as never,
sandbox,
});
const execServerUrl = execServerUrlFromClient(client);
await releaseCodexSandboxExecServerEnvironment(sandbox);
await expect(openSocket(execServerUrl)).rejects.toThrow();
});
it.runIf(process.platform !== "win32")(
"reaps TERM-resistant process groups on socket loss and turn environment release",
async () => {
for (const cleanup of ["socket", "environment"] as const) {
const finalizeExec = vi.fn(async () => undefined);
const sandbox = createSandboxContext({
buildExecSpec: async () => ({
argv: [
"/bin/sh",
"-c",
'echo "PID=$$"; trap -- "" TERM; while :; do echo heartbeat; sleep 0.1; done',
],
env: testExecEnv(),
finalizeToken: `${cleanup}-lease`,
stdinMode: "pipe-closed",
}),
finalizeExec,
});
sandbox.runtimeId = `openclaw-test-runtime-${cleanup}`;
const client = createClient();
await ensureCodexSandboxExecServerEnvironment({
client: client as never,
sandbox,
});
const socket = await openSocket(execServerUrlFromClient(client));
let pid: number | undefined;
try {
await rpc(socket, "initialize", { clientName: "test" });
socket.send(JSON.stringify({ method: "initialized" }));
await rpc(socket, "process/start", {
processId: `process-${cleanup}`,
argv: ["ignored"],
cwd: "file:///workspace",
env: {},
tty: false,
pipeStdin: false,
arg0: null,
});
pid = await readStartedPid(socket, `process-${cleanup}`);
if (cleanup === "socket") {
const closed = waitForSocketClose(socket);
socket.terminate();
await closed;
await vi.waitFor(() => expect(finalizeExec).toHaveBeenCalledOnce(), {
timeout: 3_000,
});
} else {
await releaseCodexSandboxExecServerEnvironment(sandbox);
}
expect(() => process.kill(pid!, 0)).toThrow();
expect(finalizeExec).toHaveBeenCalledOnce();
expect(finalizeExec).toHaveBeenCalledWith({
status: "completed",
exitCode: 1,
timedOut: false,
token: `${cleanup}-lease`,
});
} finally {
if (pid) {
try {
process.kill(-pid, "SIGKILL");
} catch {
// The owner already reaped the process group.
}
}
await releaseCodexSandboxExecServerEnvironment(sandbox);
}
}
},
);
it("keeps a shared exec-server open when another turn reacquires during release", async () => {
const sandbox = createSandboxContext({});
const client = createClient();
await ensureCodexSandboxExecServerEnvironment({
client: client as never,
sandbox,
});
const firstExecServerUrl = execServerUrlFromClient(client);
const release = releaseCodexSandboxExecServerEnvironment(sandbox);
await ensureCodexSandboxExecServerEnvironment({
client: client as never,
sandbox,
});
await release;
const secondExecServerUrl = execServerUrlFromClient(client, 1);
expect(secondExecServerUrl).toBe(firstExecServerUrl);
const socket = await openSocket(secondExecServerUrl);
await expect(rpc(socket, "initialize", { clientName: "test" })).resolves.toEqual({
sessionId: expect.any(String),
});
socket.close();
});
});