Files
openclaw/extensions/codex/src/native-thread-coexistence.live.test.ts
Peter Steinberger 3d6a2216ea feat(codex): share native threads across Codex clients (#99821)
* feat(codex): share native threads across clients

* test(codex): track coexistence temp dirs

* fix(codex): preserve native source on thread forks

* test(codex): use public temp fixtures

* fix(codex): preserve owner context for deferred tools

* fix(codex): forward owner identity to dynamic tools

* fix(codex): forward owner status to harness attempts

* docs(security): document shared Codex home

* docs(security): document shared Codex home

* docs(security): document shared Codex home
2026-07-04 01:43:21 -07:00

208 lines
7.1 KiB
TypeScript

import fs from "node:fs/promises";
import path from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import { withTempDir } from "openclaw/plugin-sdk/test-env";
import { describe, expect, it } from "vitest";
import type { CodexAppServerClient } from "./app-server/client.js";
import { resolveCodexAppServerRuntimeOptions } from "./app-server/config.js";
import { CODEX_INTERACTIVE_THREAD_SOURCE_KINDS } from "./app-server/protocol.js";
import { createIsolatedCodexAppServerClient } from "./app-server/shared-client.js";
const LIVE =
process.env.OPENCLAW_LIVE_TEST === "1" &&
process.env.OPENCLAW_LIVE_CODEX_THREAD_COEXISTENCE === "1";
const describeLive = LIVE ? describe : describe.skip;
async function withClient<T>(
options: Parameters<typeof createIsolatedCodexAppServerClient>[0],
run: (client: CodexAppServerClient) => Promise<T>,
): Promise<T> {
const client = await createIsolatedCodexAppServerClient(options);
try {
return await run(client);
} finally {
await client.closeAndWait();
}
}
describeLive("native Codex thread coexistence", () => {
it("shares thread storage across independent app-server processes", async () => {
await withTempDir("openclaw-codex-coexistence-", async (root) => {
try {
const codexHome = path.join(root, "codex-home");
const agentDir = path.join(root, "agent");
const workspace = path.join(root, "workspace");
await fs.mkdir(workspace, { recursive: true });
const runtime = resolveCodexAppServerRuntimeOptions({
pluginConfig: { appServer: { homeScope: "user" } },
env: {},
});
const startOptions = {
...runtime.start,
env: { CODEX_HOME: codexHome },
clearEnv: ["CODEX_API_KEY", "OPENAI_API_KEY"],
};
const clientOptions = {
startOptions,
agentDir,
authProfileId: null,
timeoutMs: 60_000,
};
const started = await withClient(clientOptions, async (first) => {
const response = await first.request(
"thread/start",
{
cwd: workspace,
approvalPolicy: "never",
sandbox: "danger-full-access",
threadSource: "user",
},
{ timeoutMs: 60_000 },
);
const turn = await first.request(
"turn/start",
{
threadId: response.thread.id,
input: [{ type: "text", text: "Cross-client visibility probe" }],
},
{ timeoutMs: 60_000 },
);
let materialized = false;
for (let attempt = 0; attempt < 50; attempt += 1) {
const listed = await first.request(
"thread/list",
{
archived: false,
limit: 100,
modelProviders: [],
sortKey: "recency_at",
sortDirection: "desc",
sourceKinds: [...CODEX_INTERACTIVE_THREAD_SOURCE_KINDS],
},
{ timeoutMs: 60_000 },
);
materialized = listed.data.some((thread) => thread.id === response.thread.id);
if (materialized) {
break;
}
await delay(100);
}
if (!materialized) {
throw new Error("user turn did not materialize shared thread");
}
try {
await first.request(
"turn/interrupt",
{ threadId: response.thread.id, turnId: turn.turn.id },
{ timeoutMs: 60_000 },
);
} catch (error) {
if (
!(error instanceof Error) ||
!error.message.includes("no active turn to interrupt")
) {
throw error;
}
}
await first.request(
"thread/name/set",
{ threadId: response.thread.id, name: "OpenClaw coexistence source" },
{ timeoutMs: 60_000 },
);
return response;
});
const forked = await withClient(clientOptions, async (second) => {
const read = await second.request(
"thread/read",
{ threadId: started.thread.id, includeTurns: true },
{ timeoutMs: 60_000 },
);
expect(read.thread.id).toBe(started.thread.id);
let listedSource;
let listedThreads: Array<{ id: string; source?: unknown }> = [];
for (let attempt = 0; attempt < 50; attempt += 1) {
const listed = await second.request(
"thread/list",
{
archived: false,
limit: 100,
modelProviders: [],
sortKey: "recency_at",
sortDirection: "desc",
sourceKinds: [...CODEX_INTERACTIVE_THREAD_SOURCE_KINDS],
},
{ timeoutMs: 60_000 },
);
listedThreads = listed.data;
listedSource = listed.data.find((thread) => thread.id === started.thread.id);
if (listedSource) {
break;
}
await delay(100);
}
if (!listedSource) {
throw new Error(
`shared thread missing from list: ${JSON.stringify({
threadId: started.thread.id,
readSource: read.thread.source,
listed: listedThreads.map((thread) => ({ id: thread.id, source: thread.source })),
})}`,
);
}
const response = await second.request(
"thread/fork",
{ threadId: started.thread.id, threadSource: "user" },
{ timeoutMs: 60_000 },
);
expect(response.thread.id).not.toBe(started.thread.id);
await second.request(
"thread/name/set",
{ threadId: response.thread.id, name: "OpenClaw coexistence fork" },
{ timeoutMs: 60_000 },
);
await second.request(
"thread/archive",
{ threadId: response.thread.id },
{ timeoutMs: 60_000 },
);
await second.request(
"thread/unarchive",
{ threadId: response.thread.id },
{ timeoutMs: 60_000 },
);
return response;
});
await withClient(clientOptions, async (third) => {
const finalList = await third.request(
"thread/list",
{
archived: false,
limit: 100,
modelProviders: [],
sortKey: "recency_at",
sortDirection: "desc",
sourceKinds: [...CODEX_INTERACTIVE_THREAD_SOURCE_KINDS],
},
{ timeoutMs: 60_000 },
);
expect(finalList.data).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: forked.thread.id,
name: "OpenClaw coexistence fork",
}),
]),
);
});
} finally {
await fs.rm(root, { recursive: true, force: true, maxRetries: 20, retryDelay: 100 });
}
});
}, 120_000);
});