mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-22 02:15:26 -06:00
752 lines
25 KiB
TypeScript
752 lines
25 KiB
TypeScript
// Codex tests cover sandbox exec server plugin behavior.
|
|
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,
|
|
createClient,
|
|
createSandboxContext,
|
|
execServerUrlFromClient,
|
|
openSocket,
|
|
readUntilClosed,
|
|
rpc,
|
|
waitForSocketClose,
|
|
} from "./sandbox-exec-server.test-helpers.js";
|
|
|
|
afterEach(async () => {
|
|
vi.unstubAllEnvs();
|
|
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("reports unavailable app-server remote environment support without exposing an environment", async () => {
|
|
const sandbox = createSandboxContext({});
|
|
const client = {
|
|
getServerVersion: vi.fn(() => CODEX_APP_SERVER_VERSION),
|
|
request: vi.fn(async () => {
|
|
throw new Error("unknown variant environment/add");
|
|
}),
|
|
};
|
|
|
|
await expect(
|
|
ensureCodexSandboxExecServerEnvironment({
|
|
client: client as never,
|
|
sandbox,
|
|
}),
|
|
).resolves.toBeUndefined();
|
|
});
|
|
|
|
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" });
|
|
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({
|
|
command: "'/bin/sh' '-lc' 'printf ok'",
|
|
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("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("keeps tty process starts pipe-backed for sandbox backends", async () => {
|
|
const buildExecSpec = vi.fn(async () => ({
|
|
argv: [process.execPath, "-e", echoFirstInputLineScript("tty:")],
|
|
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 expect(
|
|
rpc(socket, "process/write", {
|
|
processId: "proc-tty",
|
|
chunk: Buffer.from("hello\n").toString("base64"),
|
|
}),
|
|
).resolves.toEqual({ status: "accepted" });
|
|
const read = await readUntilClosed(socket, "proc-tty");
|
|
|
|
expect(buildExecSpec).toHaveBeenCalledWith(expect.objectContaining({ usePty: false }));
|
|
expect(read.chunks?.[0]?.stream).toBe("pty");
|
|
expect(Buffer.from(read.chunks?.[0]?.chunk ?? "", "base64").toString("utf8")).toBe(
|
|
"tty:hello\n",
|
|
);
|
|
socket.close();
|
|
});
|
|
|
|
it("does not let Codex env policy inherit host secret variables", async () => {
|
|
vi.stubEnv("HOME", "/gateway-home");
|
|
vi.stubEnv("USER", "gateway-user");
|
|
vi.stubEnv("TMPDIR", "/gateway-tmp");
|
|
vi.stubEnv("OPENCLAW_TEST_SECRET_TOKEN", "host-secret");
|
|
vi.stubEnv("OPENCLAW_TEST_DATABASE_PASSWORD", "host-password");
|
|
vi.stubEnv("OPENCLAW_TEST_PRIVATE_KEY", "host-private-key");
|
|
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", "process/signal", "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();
|
|
});
|
|
});
|