diff --git a/CHANGELOG.md b/CHANGELOG.md index 1172c239ae9..09f2ebb487e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ Docs: https://docs.openclaw.ai - Proxy/audio: convert standard `FormData` bodies before proxy-backed undici fetches, so audio transcription and multipart uploads no longer send `[object FormData]` when `HTTP_PROXY` or `HTTPS_PROXY` is configured. Fixes #48554. Thanks @dco5. - Gateway/diagnostics: include a bounded redacted startup error message in stability bundles, so crash-loop reports identify the failing plugin or contract without exposing secrets. Refs #75797. Thanks @ymebosma. - Gateway/pricing: abort in-flight model pricing catalog fetches when Gateway shutdown stops the refresh loop, and avoid post-stop cache writes or refresh timers. Fixes #72208. Thanks @rzcq. +- Codex/app-server: make startup retry cleanup ownership-aware so concurrent Codex lanes cannot close another lane's freshly restarted shared app-server client. Thanks @vincentkoc. - Control UI/Talk: allow the OpenAI Realtime WebRTC offer endpoint through the Control UI CSP, configure browser sessions with explicit VAD/transcription input settings, and surface OpenAI realtime error/lifecycle events instead of leaving Talk stuck as live with no diagnostic. Fixes #73427. - Plugins: clarify config-selected duplicate plugin override diagnostics and document manifest schema updates for bundled-plugin forks. Fixes #8582. Thanks @sachah. - CLI backends/Claude: make live-session JSONL turn caps bounded and configurable via `reliability.outputLimits`, raising the default guard for tool-heavy Claude CLI turns while preserving memory limits. Fixes #75838. Thanks @hcordoba840. diff --git a/extensions/codex/src/app-server/run-attempt.ts b/extensions/codex/src/app-server/run-attempt.ts index 3f4ec9aa2c3..f4856592810 100644 --- a/extensions/codex/src/app-server/run-attempt.ts +++ b/extensions/codex/src/app-server/run-attempt.ts @@ -78,7 +78,7 @@ import { } from "./protocol.js"; import { readCodexAppServerBinding, type CodexAppServerThreadBinding } from "./session-binding.js"; import { readCodexMirroredSessionHistoryMessages } from "./session-history.js"; -import { clearSharedCodexAppServerClient } from "./shared-client.js"; +import { clearSharedCodexAppServerClientIfCurrent } from "./shared-client.js"; import { buildDeveloperInstructions, buildTurnStartParams, @@ -489,6 +489,7 @@ export async function runCodexAppServerAttempt( let thread: CodexAppServerThreadBinding; let trajectoryEndRecorded = false; let nativeHookRelay: NativeHookRelayRegistrationHandle | undefined; + let startupClientForCleanup: CodexAppServerClient | undefined; try { emitCodexAppServerEvent(params, { stream: "codex_app_server.lifecycle", @@ -516,12 +517,15 @@ export async function runCodexAppServerAttempt( timeoutFloorMs: options.startupTimeoutFloorMs, signal: runAbortController.signal, operation: async () => { + let attemptedClient: CodexAppServerClient | undefined; const startupAttempt = async () => { const startupClient = await clientFactory( appServer.start, startupAuthProfileId, agentDir, ); + attemptedClient = startupClient; + startupClientForCleanup = startupClient; await ensureCodexComputerUse({ client: startupClient, pluginConfig: options.pluginConfig, @@ -549,18 +553,24 @@ export async function runCodexAppServerAttempt( "codex app-server connection closed during startup; restarting app-server and retrying", { error }, ); - clearSharedCodexAppServerClient(); + const failedClient = attemptedClient; + clearSharedCodexAppServerClientIfCurrent(failedClient); + if (startupClientForCleanup === failedClient) { + startupClientForCleanup = undefined; + } + attemptedClient = undefined; return await startupAttempt(); } }, })); + startupClientForCleanup = undefined; emitCodexAppServerEvent(params, { stream: "codex_app_server.lifecycle", data: { phase: "thread_ready", threadId: thread.threadId }, }); } catch (error) { nativeHookRelay?.unregister(); - clearSharedCodexAppServerClient(); + clearSharedCodexAppServerClientIfCurrent(startupClientForCleanup); params.abortSignal?.removeEventListener("abort", abortFromUpstream); throw error; } diff --git a/extensions/codex/src/app-server/shared-client.test.ts b/extensions/codex/src/app-server/shared-client.test.ts index 66e74765e01..dd5318c3b1c 100644 --- a/extensions/codex/src/app-server/shared-client.test.ts +++ b/extensions/codex/src/app-server/shared-client.test.ts @@ -31,6 +31,7 @@ vi.mock("openclaw/plugin-sdk/provider-auth", () => ({ let listCodexAppServerModels: typeof import("./models.js").listCodexAppServerModels; let clearSharedCodexAppServerClient: typeof import("./shared-client.js").clearSharedCodexAppServerClient; +let clearSharedCodexAppServerClientIfCurrent: typeof import("./shared-client.js").clearSharedCodexAppServerClientIfCurrent; let createIsolatedCodexAppServerClient: typeof import("./shared-client.js").createIsolatedCodexAppServerClient; let resetSharedCodexAppServerClientForTests: typeof import("./shared-client.js").resetSharedCodexAppServerClientForTests; @@ -54,6 +55,7 @@ describe("shared Codex app-server client", () => { ({ listCodexAppServerModels } = await import("./models.js")); ({ clearSharedCodexAppServerClient, + clearSharedCodexAppServerClientIfCurrent, createIsolatedCodexAppServerClient, resetSharedCodexAppServerClientForTests, } = await import("./shared-client.js")); @@ -293,6 +295,32 @@ describe("shared Codex app-server client", () => { expect(second.process.kill).not.toHaveBeenCalled(); }); + it("only clears the shared client that is still current", async () => { + const first = createClientHarness(); + const second = createClientHarness(); + vi.spyOn(CodexAppServerClient, "start") + .mockReturnValueOnce(first.client) + .mockReturnValueOnce(second.client); + + const firstList = listCodexAppServerModels({ timeoutMs: 1000 }); + await sendInitializeResult(first, "openclaw/0.125.0 (macOS; test)"); + await sendEmptyModelList(first); + await expect(firstList).resolves.toEqual({ models: [] }); + + expect(clearSharedCodexAppServerClientIfCurrent(first.client)).toBe(true); + expect(first.process.kill).toHaveBeenCalledWith("SIGTERM"); + + const secondList = listCodexAppServerModels({ timeoutMs: 1000 }); + await sendInitializeResult(second, "openclaw/0.125.0 (macOS; test)"); + await sendEmptyModelList(second); + await expect(secondList).resolves.toEqual({ models: [] }); + + expect(clearSharedCodexAppServerClientIfCurrent(first.client)).toBe(false); + expect(second.process.kill).not.toHaveBeenCalled(); + expect(clearSharedCodexAppServerClientIfCurrent(second.client)).toBe(true); + expect(second.process.kill).toHaveBeenCalledWith("SIGTERM"); + }); + it("uses a fresh websocket Authorization header after shared-client token rotation", async () => { const server = new WebSocketServer({ host: "127.0.0.1", port: 0 }); const authHeaders: Array = []; diff --git a/extensions/codex/src/app-server/shared-client.ts b/extensions/codex/src/app-server/shared-client.ts index f29945d2fa1..1487b7bd84f 100644 --- a/extensions/codex/src/app-server/shared-client.ts +++ b/extensions/codex/src/app-server/shared-client.ts @@ -134,6 +134,23 @@ export function clearSharedCodexAppServerClient(): void { client?.close(); } +export function clearSharedCodexAppServerClientIfCurrent( + client: CodexAppServerClient | undefined, +): boolean { + if (!client) { + return false; + } + const state = getSharedCodexAppServerClientState(); + if (state.client !== client) { + return false; + } + state.client = undefined; + state.promise = undefined; + state.key = undefined; + client.close(); + return true; +} + export async function clearSharedCodexAppServerClientAndWait(options?: { exitTimeoutMs?: number; forceKillDelayMs?: number;