Skip to content

Commit 84d3b7a

Browse files
committed
fix(codex): isolate shared app-server clients
1 parent 80848fc commit 84d3b7a

5 files changed

Lines changed: 165 additions & 50 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ Docs: https://docs.openclaw.ai
88

99
- Security/audit: add `security.audit.suppressions` for intentionally accepted audit findings, keeping suppressed matches out of the active summary while preserving them in JSON output with an active suppression notice. (#76949) Thanks @100menotu001.
1010
- Agents/subagents: label delegated task and subagent completion handoffs as ready for parent review, and tell requester agents to review/verify results before calling them done. (#78985) Thanks @100menotu001.
11+
- Maintainer tooling: warn before running JS package commands on raw Crabbox AWS boxes, pointing maintainers to Actions hydration or Blacksmith Testbox for CI-like proof.
1112
- Control UI: show provider quota usage in the Overview card and Chat header, and recover stale Chat in-progress state after missed terminal events. (#82647)
1213
- Mac app remote setup can now be preconfigured from `openclaw-mac configure-remote`, skips onboarding when config is already complete, supports direct LAN/Tailnet gateway URLs, allows private same-origin Control UI loads, and owns the SSH tunnel process when SSH is selected.
1314
- Providers/xAI: add xAI Grok OAuth login for SuperGrok subscribers, letting `xai/*` models and xAI media/tool providers authenticate without `XAI_API_KEY`.
@@ -34,6 +35,7 @@ Docs: https://docs.openclaw.ai
3435
- Gateway/pairing: reject forged loopback Control UI origins from non-local proxy paths, and keep mobile pairing setup on Tailscale bind mode pointing users to Tailscale Serve/Funnel instead of cleartext tailnet WebSockets.
3536
- Telegram/Gateway: persist isolated polling offsets only after main-thread dispatch and preserve gateway caller scopes for Telegram message actions, fixing consumed-but-unrouted polling updates and recursive CLI send scope approvals. Fixes #82277. (#82705) Thanks @udaymanish6.
3637
- Channels/stream previews: contain rejected background draft-stream flushes so preview send failures do not surface as fatal unhandled rejections. Fixes #82712. (#82713) Thanks @coygeek.
38+
- Codex/app-server: keep shared native app-server clients isolated per agent runtime key so starting one agent no longer closes another agent's active Codex turn. Fixes #82758. Thanks @PashaGanson.
3739
- Providers/OpenAI Codex: include base `gpt-5.5` and `gpt-5.4` reasoning metadata in the bundled Codex catalog so `/think xhigh` remains available for those models. Fixes #82744.
3840
- Providers/MiniMax: declare CN endpoint auth aliases in the plugin manifest so `minimax-cn` and `minimax-portal-cn` reuse the correct base auth profiles instead of falling back to unrelated models after 401s. Fixes #63823. Thanks @kamusis.
3941
- Secrets/audit: treat `$VAR` auth-profile values as env SecretRefs and stop reporting env-ref credentials as plaintext, including mixed `keyRef` plus env-ref profile states. Fixes #53998. Thanks @schirloc and @artwalker.

extensions/codex/src/app-server/run-attempt.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,7 @@ function createAppServerHarness(
238238
const requests: Array<{ method: string; params: unknown }> = [];
239239
let notify: (notification: CodexServerNotification) => Promise<void> = async () => undefined;
240240
let handleServerRequest: AppServerRequestHandler | undefined;
241+
const closeHandlers = new Set<() => void>();
241242
const request = vi.fn(async (method: string, params?: unknown) => {
242243
requests.push({ method, params });
243244
return requestImpl(method, params);
@@ -255,6 +256,10 @@ function createAppServerHarness(
255256
handleServerRequest = handler;
256257
return () => undefined;
257258
},
259+
addCloseHandler: (handler: () => void) => {
260+
closeHandlers.add(handler);
261+
return () => closeHandlers.delete(handler);
262+
},
258263
} as never;
259264
});
260265

@@ -302,6 +307,11 @@ function createAppServerHarness(
302307
},
303308
});
304309
},
310+
close() {
311+
for (const handler of closeHandlers) {
312+
handler();
313+
}
314+
},
305315
};
306316
}
307317

@@ -5022,6 +5032,23 @@ describe("runCodexAppServerAttempt", () => {
50225032
expect(harness.request.mock.calls.some(([method]) => method === "turn/interrupt")).toBe(false);
50235033
});
50245034

5035+
it("releases completion when the app-server client closes during an active turn", async () => {
5036+
const harness = createStartedThreadHarness();
5037+
const run = runCodexAppServerAttempt(
5038+
createParams(path.join(tempDir, "session.jsonl"), path.join(tempDir, "workspace")),
5039+
{ turnTerminalIdleTimeoutMs: 60_000 },
5040+
);
5041+
5042+
await harness.waitForMethod("turn/start");
5043+
await new Promise<void>((resolve) => setImmediate(resolve));
5044+
harness.close();
5045+
5046+
const result = await run;
5047+
expect(result.promptError).toBe("codex app-server client closed before turn completed");
5048+
expect(result.aborted).toBe(false);
5049+
expect(result.timedOut).toBe(false);
5050+
});
5051+
50255052
it("does not treat a user prompt containing the interrupted marker as terminal", async () => {
50265053
const harness = createStartedThreadHarness();
50275054
const markerPrompt =

extensions/codex/src/app-server/run-attempt.ts

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1038,6 +1038,7 @@ export async function runCodexAppServerAttempt(
10381038
let timedOut = false;
10391039
let turnCompletionIdleTimedOut = false;
10401040
let turnCompletionIdleTimeoutMessage: string | undefined;
1041+
let clientClosedPromptError: string | undefined;
10411042
let lifecycleStarted = false;
10421043
let lifecycleTerminalEmitted = false;
10431044
let resolveCompletion: (() => void) | undefined;
@@ -1758,6 +1759,7 @@ export async function runCodexAppServerAttempt(
17581759
}
17591760
}
17601761
});
1762+
let closeCleanup: (() => void) | undefined;
17611763

17621764
const forceContextEngineCompactionForCodexOverflow = async (error: unknown): Promise<boolean> => {
17631765
if (!activeContextEngine?.info.ownsCompaction) {
@@ -2016,6 +2018,30 @@ export async function runCodexAppServerAttempt(
20162018
nativePostToolUseRelayEnabled:
20172019
nativeHookRelay?.allowedEvents.includes("post_tool_use") === true,
20182020
});
2021+
closeCleanup = (
2022+
client as {
2023+
addCloseHandler?: (handler: (client: CodexAppServerClient) => void) => () => void;
2024+
}
2025+
).addCloseHandler?.(() => {
2026+
if (completed || runAbortController.signal.aborted) {
2027+
return;
2028+
}
2029+
clientClosedPromptError = "codex app-server client closed before turn completed";
2030+
trajectoryRecorder?.recordEvent("turn.client_closed", {
2031+
threadId: thread.threadId,
2032+
turnId: activeTurnId,
2033+
});
2034+
embeddedAgentLog.warn("codex app-server client closed before turn completed", {
2035+
threadId: thread.threadId,
2036+
turnId: activeTurnId,
2037+
});
2038+
completed = true;
2039+
clearTurnAttemptIdleTimer();
2040+
clearTurnCompletionIdleTimer();
2041+
clearTurnAssistantCompletionIdleTimer();
2042+
clearTurnTerminalIdleTimer();
2043+
resolveCompletion?.();
2044+
});
20192045
emitLifecycleStart();
20202046
const activeProjector = projector;
20212047
turnTerminalIdleWatchArmed = true;
@@ -2081,11 +2107,13 @@ export async function runCodexAppServerAttempt(
20812107
await completion;
20822108
const result = activeProjector.buildResult(toolBridge.telemetry, { yieldDetected });
20832109
const finalAborted = result.aborted || runAbortController.signal.aborted;
2084-
let finalPromptError = turnCompletionIdleTimedOut
2085-
? turnCompletionIdleTimeoutMessage
2086-
: timedOut
2087-
? "codex app-server attempt timed out"
2088-
: result.promptError;
2110+
let finalPromptError =
2111+
clientClosedPromptError ??
2112+
(turnCompletionIdleTimedOut
2113+
? turnCompletionIdleTimeoutMessage
2114+
: timedOut
2115+
? "codex app-server attempt timed out"
2116+
: result.promptError);
20892117
const finalPromptErrorMessage =
20902118
typeof finalPromptError === "string"
20912119
? finalPromptError
@@ -2104,7 +2132,8 @@ export async function runCodexAppServerAttempt(
21042132
signal: runAbortController.signal,
21052133
});
21062134
}
2107-
const finalPromptErrorSource = timedOut ? "prompt" : result.promptErrorSource;
2135+
const finalPromptErrorSource =
2136+
timedOut || clientClosedPromptError ? "prompt" : result.promptErrorSource;
21082137
recordCodexTrajectoryCompletion(trajectoryRecorder, {
21092138
attempt: params,
21102139
result,
@@ -2247,6 +2276,7 @@ export async function runCodexAppServerAttempt(
22472276
clearTurnTerminalIdleTimer();
22482277
notificationCleanup();
22492278
requestCleanup();
2279+
closeCleanup?.();
22502280
nativeHookRelay?.unregister();
22512281
runAbortController.signal.removeEventListener("abort", abortListener);
22522282
params.abortSignal?.removeEventListener("abort", abortFromUpstream);

extensions/codex/src/app-server/shared-client.test.ts

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,35 @@ describe("shared Codex app-server client", () => {
274274
expect(applyCall?.authProfileId).toBe("openai-codex:work");
275275
});
276276

277+
it("keeps an active shared client alive when another agent dir uses a different key", async () => {
278+
const first = createClientHarness();
279+
const second = createClientHarness();
280+
const startSpy = vi
281+
.spyOn(CodexAppServerClient, "start")
282+
.mockReturnValueOnce(first.client)
283+
.mockReturnValueOnce(second.client);
284+
285+
const firstList = listCodexAppServerModels({
286+
timeoutMs: 1000,
287+
agentDir: "/tmp/openclaw-agent-one",
288+
});
289+
await sendInitializeResult(first, "openclaw/0.125.0 (macOS; test)");
290+
await sendEmptyModelList(first);
291+
await expect(firstList).resolves.toEqual({ models: [] });
292+
293+
const secondList = listCodexAppServerModels({
294+
timeoutMs: 1000,
295+
agentDir: "/tmp/openclaw-agent-two",
296+
});
297+
await sendInitializeResult(second, "openclaw/0.125.0 (macOS; test)");
298+
await sendEmptyModelList(second);
299+
await expect(secondList).resolves.toEqual({ models: [] });
300+
301+
expect(startSpy).toHaveBeenCalledTimes(2);
302+
expect(first.process.stdin.destroyed).toBe(false);
303+
expect(second.process.stdin.destroyed).toBe(false);
304+
});
305+
277306
it("resolves the managed binary before bridging and spawning the shared client", async () => {
278307
const harness = createClientHarness();
279308
const startSpy = vi.spyOn(CodexAppServerClient, "start").mockReturnValue(harness.client);
@@ -299,7 +328,7 @@ describe("shared Codex app-server client", () => {
299328
expect(startCall?.commandSource).toBe("resolved-managed");
300329
});
301330

302-
it("restarts the shared client when the bridged auth token changes", async () => {
331+
it("starts an independent shared client when the bridged auth token changes", async () => {
303332
const first = createClientHarness();
304333
const second = createClientHarness();
305334
const startSpy = vi
@@ -338,10 +367,10 @@ describe("shared Codex app-server client", () => {
338367
await expect(secondList).resolves.toEqual({ models: [] });
339368

340369
expect(startSpy).toHaveBeenCalledTimes(2);
341-
expect(first.process.stdin.destroyed).toBe(true);
370+
expect(first.process.stdin.destroyed).toBe(false);
342371
});
343372

344-
it("does not let a superseded shared-client failure tear down the newer client", async () => {
373+
it("does not let one shared-client failure tear down another keyed client", async () => {
345374
const first = createClientHarness();
346375
const second = createClientHarness();
347376
vi.spyOn(CodexAppServerClient, "start")
@@ -375,12 +404,13 @@ describe("shared Codex app-server client", () => {
375404
});
376405
await vi.waitFor(() => expect(second.writes.length).toBeGreaterThanOrEqual(1));
377406

378-
await expect(firstFailure).resolves.toBeInstanceOf(Error);
379-
380407
await sendInitializeResult(second, "openclaw/0.125.0 (macOS; test)");
381408
await sendEmptyModelList(second);
382409
await expect(secondList).resolves.toEqual({ models: [] });
383410

411+
first.client.close();
412+
await expect(firstFailure).resolves.toBeInstanceOf(Error);
413+
384414
expect(second.process.kill).not.toHaveBeenCalled();
385415
});
386416

0 commit comments

Comments
 (0)