From 83a4208aed282edff1a290f960f01aa4b98d5b06 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 15 Jul 2026 22:12:35 -0700 Subject: [PATCH] fix(codex): full-auto approvals survive relay unavailability, typed failure classification, usage line after fallback (#108626) * fix(codex): full-auto approvals survive relay unavailability, typed failure classification, usage line after fallback - full-auto (approvalPolicy never + danger-full-access) exec approvals no longer get declined when the native hook relay is unreachable BEFORE invocation; an invoked relay's explicit deny, malformed output, or nonzero exit still fails closed (#107447) - startup timeout/abort and request-timeout classification use typed error discriminants instead of message prose; EPIPE detection walks error causes; compaction keeps the message-based thread-not-found gate because codex-rs exposes no dedicated code (its own contract test asserts the message) and the generic -32600 would over-match unrelated invalid requests (#99270) - /status keeps the Codex usage/quota line for sessions whose persisted agentHarnessId is codex even when the effective runtime fell back to OpenClaw Default; never-codex sessions remain excluded (#105184) * chore(codex): keep startup error reason type module-local --- .../src/app-server/approval-bridge.test.ts | 69 +++++++++++++++++ .../codex/src/app-server/approval-bridge.ts | 16 ++++ .../src/app-server/attempt-startup.test.ts | 7 +- .../codex/src/app-server/attempt-startup.ts | 32 ++++---- .../src/app-server/attempt-timeouts.test.ts | 13 +++- .../codex/src/app-server/attempt-timeouts.ts | 36 ++++++++- extensions/codex/src/app-server/client.ts | 27 ++++++- .../codex/src/app-server/compact.test.ts | 2 +- extensions/codex/src/app-server/compact.ts | 7 ++ .../codex/src/app-server/run-attempt.test.ts | 2 +- .../codex/src/app-server/shared-client.ts | 26 +++++-- extensions/codex/src/app-server/timeout.ts | 6 +- src/status/codex-synthetic-usage.test.ts | 27 ++++++- src/status/codex-synthetic-usage.ts | 5 +- src/status/status-text.test.ts | 77 +++++++++++++++++++ src/status/status-text.ts | 2 + 16 files changed, 318 insertions(+), 36 deletions(-) diff --git a/extensions/codex/src/app-server/approval-bridge.test.ts b/extensions/codex/src/app-server/approval-bridge.test.ts index 568a5c1fd6d3..a54b88cdeba6 100644 --- a/extensions/codex/src/app-server/approval-bridge.test.ts +++ b/extensions/codex/src/app-server/approval-bridge.test.ts @@ -1138,6 +1138,7 @@ describe("Codex app-server approval bridge", () => { paramsForRun: params, threadId: "thread-1", turnId: "turn-1", + autoApprove: true, nativeHookRelay: { relayId: "relay-1", generation: "generation-1", @@ -1433,6 +1434,7 @@ describe("Codex app-server approval bridge", () => { paramsForRun: params, threadId: "thread-1", turnId: "turn-1", + autoApprove: true, nativeHookRelay: { relayId: "relay-1", generation: "generation-1", @@ -1564,6 +1566,73 @@ describe("Codex app-server approval bridge", () => { }); }); + it("auto-approves when the expected native hook relay is unavailable in full-auto", async () => { + const params = createParams(); + mockInvokeNativeHookRelay.mockRejectedValueOnce(new Error("native hook relay not found")); + + const result = await handleCodexAppServerApprovalRequest({ + method: "item/commandExecution/requestApproval", + requestParams: { + threadId: "thread-1", + turnId: "turn-1", + itemId: "cmd-native-relay-full-auto-missing", + command: "pwd", + }, + paramsForRun: params, + threadId: "thread-1", + turnId: "turn-1", + autoApprove: true, + nativeHookRelay: { + relayId: "relay-missing", + generation: "generation-1", + allowedEvents: ["pre_tool_use"], + }, + }); + + expect(result).toEqual({ decision: "acceptForSession" }); + expect(mockRunBeforeToolCallHook).toHaveBeenCalledTimes(1); + expect(mockInvokeNativeHookRelay).toHaveBeenCalledTimes(1); + expect(mockCallGatewayTool).not.toHaveBeenCalled(); + findApprovalEvent(params, { + status: "approved", + message: "Codex app-server approval auto-approved by runtime policy.", + }); + }); + + it("fails closed when the native hook relay fails after invocation in full-auto", async () => { + const params = createParams(); + mockHasNativeHookRelayInvocation.mockReturnValueOnce(false).mockReturnValueOnce(true); + mockInvokeNativeHookRelay.mockRejectedValueOnce(new Error("native hook relay handler failed")); + + const result = await handleCodexAppServerApprovalRequest({ + method: "item/commandExecution/requestApproval", + requestParams: { + threadId: "thread-1", + turnId: "turn-1", + itemId: "cmd-native-relay-handler-failure", + command: "pwd", + }, + paramsForRun: params, + threadId: "thread-1", + turnId: "turn-1", + autoApprove: true, + nativeHookRelay: { + relayId: "relay-1", + generation: "generation-1", + allowedEvents: ["pre_tool_use"], + }, + }); + + expect(result).toEqual({ decision: "decline" }); + expect(mockRunBeforeToolCallHook).not.toHaveBeenCalled(); + expect(mockCallGatewayTool).not.toHaveBeenCalled(); + findApprovalEvent(params, { + status: "denied", + message: + "OpenClaw native hook relay unavailable for Codex app-server approval: native hook relay handler failed", + }); + }); + it("keeps non-command approvals on the app-server approval route when a native relay is registered", async () => { const params = createParams(); mockCallGatewayTool diff --git a/extensions/codex/src/app-server/approval-bridge.ts b/extensions/codex/src/app-server/approval-bridge.ts index 945db76a2de3..10ec613273b7 100644 --- a/extensions/codex/src/app-server/approval-bridge.ts +++ b/extensions/codex/src/app-server/approval-bridge.ts @@ -103,6 +103,7 @@ export async function handleCodexAppServerApprovalRequest(params: { paramsForRun: params.paramsForRun, context, nativeHookRelay: params.nativeHookRelay, + autoApprove: params.autoApprove, signal: params.signal, }); if (policyOutcome?.outcome === "denied") { @@ -406,6 +407,7 @@ async function runOpenClawToolPolicyForApprovalRequest(params: { NativeHookRelayRegistrationHandle, "allowedEvents" | "generation" | "relayId" >; + autoApprove?: boolean; signal?: AbortSignal; }): Promise { const policyRequest = buildOpenClawToolPolicyRequest(params.method, params.requestParams); @@ -419,6 +421,7 @@ async function runOpenClawToolPolicyForApprovalRequest(params: { context: params.context, policyRequest, nativeHookRelay: params.nativeHookRelay, + autoApprove: params.autoApprove, cwd, signal: params.signal, }); @@ -499,6 +502,7 @@ async function runNativeRelayToolPolicyForApprovalRequest(params: { NativeHookRelayRegistrationHandle, "allowedEvents" | "generation" | "relayId" >; + autoApprove?: boolean; cwd?: string; signal?: AbortSignal; }): Promise< @@ -597,6 +601,18 @@ async function runNativeRelayToolPolicyForApprovalRequest(params: { } return { handled: true }; } catch (error) { + // Only a relay that failed before invocation is unavailable. Once invoked, + // handler failures join explicit denials and malformed replies in failing closed. + if ( + params.autoApprove === true && + !hasNativeHookRelayInvocation({ + relayId: params.nativeHookRelay.relayId, + event: "pre_tool_use", + toolUseId: params.context.approvalId, + }) + ) { + return undefined; + } return { handled: true, blocked: true, diff --git a/extensions/codex/src/app-server/attempt-startup.test.ts b/extensions/codex/src/app-server/attempt-startup.test.ts index 2056dfc92b00..a99f28ea7000 100644 --- a/extensions/codex/src/app-server/attempt-startup.test.ts +++ b/extensions/codex/src/app-server/attempt-startup.test.ts @@ -9,7 +9,8 @@ import type { } from "openclaw/plugin-sdk/agent-harness-runtime"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { startCodexAttemptThread } from "./attempt-startup.js"; -import { CodexAppServerClient } from "./client.js"; +import { isCodexAppServerStartupError } from "./attempt-timeouts.js"; +import { CodexAppServerClient, isCodexAppServerRequestTimeoutError } from "./client.js"; import { CODEX_PLUGINS_MARKETPLACE_NAME, type CodexPluginConfig, @@ -479,6 +480,7 @@ describe("startCodexAttemptThread", () => { timeout: 1_000, }); expect(error).toBeInstanceOf(Error); + expect(isCodexAppServerStartupError(error, "timed_out")).toBe(true); expect((error as Error).message).toBe("codex app-server startup timed out"); expect(harness.stdinDestroyed).toBe(true); }); @@ -529,6 +531,7 @@ describe("startCodexAttemptThread", () => { const error = await runError; expect(error).toBeInstanceOf(Error); + expect(isCodexAppServerStartupError(error, "timed_out")).toBe(true); expect((error as Error).message).toBe("codex app-server initialize timed out"); await vi.waitFor(() => expect(harness.stdinDestroyed).toBe(true), { interval: 1, @@ -738,6 +741,7 @@ describe("startCodexAttemptThread", () => { const error = await runError; expect(error).toBeInstanceOf(Error); + expect(isCodexAppServerStartupError(error, "aborted")).toBe(true); expect((error as Error).message).toBe("codex app-server startup aborted"); expect(harness.process.stdin.destroyed).toBe(true); }); @@ -800,6 +804,7 @@ describe("startCodexAttemptThread", () => { const error = await runError; expect(error).toBeInstanceOf(Error); + expect(isCodexAppServerRequestTimeoutError(error)).toBe(true); expect((error as Error).message).toBe("plugin/list timed out"); expect(harness.process.stdin.destroyed).toBe(true); }); diff --git a/extensions/codex/src/app-server/attempt-startup.ts b/extensions/codex/src/app-server/attempt-startup.ts index a8e1e669d740..788558cfadb9 100644 --- a/extensions/codex/src/app-server/attempt-startup.ts +++ b/extensions/codex/src/app-server/attempt-startup.ts @@ -17,9 +17,18 @@ import { unsubscribeCodexThreadBestEffort, } from "./attempt-client-cleanup.js"; import { buildCodexPluginThreadConfigEligibilityLogData } from "./attempt-diagnostics.js"; -import { withCodexStartupTimeout } from "./attempt-timeouts.js"; +import { + CodexAppServerStartupError, + isCodexAppServerStartupError, + withCodexStartupTimeout, +} from "./attempt-timeouts.js"; import { ensureCodexAppServerClientRuntime } from "./client-runtime.js"; -import { isCodexAppServerConnectionClosedError, type CodexAppServerClient } from "./client.js"; +import { + isCodexAppServerBrokenPipeError, + isCodexAppServerConnectionClosedError, + isCodexAppServerRequestTimeoutError, + type CodexAppServerClient, +} from "./client.js"; import { startCodexComputerUseHealthMonitor } from "./computer-use-health.js"; import { ensureCodexComputerUse } from "./computer-use.js"; import { @@ -257,10 +266,10 @@ export async function startCodexAttemptThread(params: { attemptedClient = activeStartupClient; startupClientForAbandonedRequestCleanup = activeStartupClient; if (startupAbandoned) { - throw new Error("codex app-server startup timed out"); + throw new CodexAppServerStartupError("timed_out"); } if (startupAbandonController.signal.aborted) { - throw new Error("codex app-server startup aborted"); + throw new CodexAppServerStartupError("aborted"); } let runtimeArtifact: AgentHarnessRuntimeArtifactBinding | undefined; if (params.runtimeArtifactRequest) { @@ -372,7 +381,7 @@ export async function startCodexAttemptThread(params: { startupSandboxEnvironmentAcquired = Boolean(startupSandboxEnvironment); if (startupAbandonController.signal.aborted) { await releaseStartupSandboxEnvironment(); - throw new Error("codex app-server startup aborted"); + throw new CodexAppServerStartupError("aborted"); } if ( params.sandbox?.enabled && @@ -493,7 +502,7 @@ export async function startCodexAttemptThread(params: { throw error; } if (startupAbandonController.signal.aborted) { - throw new Error("codex app-server startup aborted"); + throw new CodexAppServerStartupError("aborted"); } const startupRoute = startupReservation; if (!startupRoute) { @@ -672,17 +681,12 @@ export async function startCodexAttemptThread(params: { } function shouldClearSharedClientAfterStartupAbandon(error: unknown): boolean { - return ( - error instanceof Error && - (error.message === "codex app-server startup timed out" || - error.message === "codex app-server startup aborted") - ); + return isCodexAppServerStartupError(error); } function shouldClearSharedClientAfterStartupRace(error: unknown): boolean { return ( - error instanceof Error && - (shouldClearSharedClientAfterStartupAbandon(error) || error.message.endsWith(" timed out")) + shouldClearSharedClientAfterStartupAbandon(error) || isCodexAppServerRequestTimeoutError(error) ); } @@ -693,7 +697,7 @@ function shouldClearSharedClientAfterStartupFailure(params: { if (!(params.error instanceof Error)) { return !params.spawnedBy; } - if (params.error.message.includes("write EPIPE")) { + if (isCodexAppServerBrokenPipeError(params.error)) { return true; } return !params.spawnedBy; diff --git a/extensions/codex/src/app-server/attempt-timeouts.test.ts b/extensions/codex/src/app-server/attempt-timeouts.test.ts index 44bc5b96846b..7dd5548b3e9d 100644 --- a/extensions/codex/src/app-server/attempt-timeouts.test.ts +++ b/extensions/codex/src/app-server/attempt-timeouts.test.ts @@ -2,6 +2,7 @@ import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime"; import { afterEach, describe, expect, it, vi } from "vitest"; import { + isCodexAppServerStartupError, resolveCodexPostToolRawAssistantCompletionIdleTimeoutMs, resolveCodexGatewayTimeoutWithGraceMs, resolveCodexStartupTimeoutMs, @@ -170,12 +171,14 @@ describe("Codex app-server attempt timeouts", () => { }, operation: async () => new Promise(() => {}), }); - const rejected = expect(run).rejects.toThrow("codex app-server startup timed out"); + const errorResult = run.catch((error: unknown) => error); await vi.advanceTimersByTimeAsync(10); expect(events).toEqual(["cleanup-start"]); await vi.advanceTimersByTimeAsync(5); - await rejected; + const error = await errorResult; + expect(isCodexAppServerStartupError(error, "timed_out")).toBe(true); + expect((error as Error).message).toBe("codex app-server startup timed out"); expect(events).toEqual(["cleanup-start", "cleanup-done"]); }); @@ -187,10 +190,12 @@ describe("Codex app-server attempt timeouts", () => { signal: controller.signal, operation: async () => new Promise(() => {}), }); - const rejected = expect(run).rejects.toThrow("codex app-server startup aborted"); + const errorResult = run.catch((error: unknown) => error); controller.abort(); - await rejected; + const error = await errorResult; + expect(isCodexAppServerStartupError(error, "aborted")).toBe(true); + expect((error as Error).message).toBe("codex app-server startup aborted"); }); }); diff --git a/extensions/codex/src/app-server/attempt-timeouts.ts b/extensions/codex/src/app-server/attempt-timeouts.ts index bd6c818d57a7..a7cea2675183 100644 --- a/extensions/codex/src/app-server/attempt-timeouts.ts +++ b/extensions/codex/src/app-server/attempt-timeouts.ts @@ -20,6 +20,36 @@ export const CODEX_POST_REASONING_REPLY_IDLE_TIMEOUT_MS = 5 * 60_000; /** Long terminal idle watch for app-server turns that never send completion. */ const CODEX_TURN_TERMINAL_IDLE_TIMEOUT_MS = 30 * 60_000; +type CodexAppServerStartupErrorReason = "aborted" | "timed_out"; + +export class CodexAppServerStartupError extends Error { + readonly code = "CODEX_APP_SERVER_STARTUP_CANCELLED"; + + constructor( + readonly reason: CodexAppServerStartupErrorReason, + message = reason === "timed_out" + ? "codex app-server startup timed out" + : "codex app-server startup aborted", + ) { + super(message); + this.name = "CodexAppServerStartupError"; + } +} + +export function isCodexAppServerStartupError( + error: unknown, + reason?: CodexAppServerStartupErrorReason, +): error is CodexAppServerStartupError { + return ( + error instanceof Error && + "code" in error && + error.code === "CODEX_APP_SERVER_STARTUP_CANCELLED" && + "reason" in error && + (error.reason === "aborted" || error.reason === "timed_out") && + (reason === undefined || error.reason === reason) + ); +} + function resolvePositiveIntegerTimeoutMs(value: number | undefined, fallbackMs: number): number { const fallback = resolveTimerTimeoutMs(fallbackMs, 1); return resolveTimerTimeoutMs(value, fallback); @@ -33,7 +63,7 @@ export async function withCodexStartupTimeout(params: { operation: () => Promise; }): Promise { if (params.signal.aborted) { - throw new Error("codex app-server startup aborted"); + throw new CodexAppServerStartupError("aborted"); } let timeout: NodeJS.Timeout | undefined; let abortCleanup: (() => void) | undefined; @@ -51,7 +81,7 @@ export async function withCodexStartupTimeout(params: { reject(error); }; timeout = setTimeout(() => { - timeoutError = new Error("codex app-server startup timed out"); + timeoutError = new CodexAppServerStartupError("timed_out"); timeoutCleanup = Promise.resolve(params.onTimeout?.()).then( () => undefined, () => undefined, @@ -60,7 +90,7 @@ export async function withCodexStartupTimeout(params: { rejectOnce(timeoutError!); }); }, params.timeoutMs); - const abortListener = () => rejectOnce(new Error("codex app-server startup aborted")); + const abortListener = () => rejectOnce(new CodexAppServerStartupError("aborted")); params.signal.addEventListener("abort", abortListener, { once: true }); abortCleanup = () => params.signal.removeEventListener("abort", abortListener); }), diff --git a/extensions/codex/src/app-server/client.ts b/extensions/codex/src/app-server/client.ts index 5eec26f5961f..8d73900a4366 100644 --- a/extensions/codex/src/app-server/client.ts +++ b/extensions/codex/src/app-server/client.ts @@ -62,12 +62,14 @@ export function getCodexAppServerClientInstanceId(client: object): string { export class CodexAppServerRpcError extends Error { readonly code?: number; readonly data?: JsonValue; + readonly method: string; constructor(error: { code?: number; message: string; data?: JsonValue }, method: string) { super(formatCodexAppServerRpcErrorMessage(error, method)); this.name = "CodexAppServerRpcError"; this.code = error.code; this.data = error.data; + this.method = method; } } @@ -76,7 +78,7 @@ class CodexAppServerLocalRequestCancellationError extends Error { constructor( method: string, - reason: "aborted" | "timed out", + readonly reason: "aborted" | "timed out", readonly mayHaveWritten: boolean, ) { super(`${method} ${reason}`); @@ -84,6 +86,29 @@ class CodexAppServerLocalRequestCancellationError extends Error { } } +export function isCodexAppServerRequestTimeoutError(error: unknown): boolean { + return ( + error instanceof Error && + "code" in error && + error.code === "CODEX_APP_SERVER_LOCAL_REQUEST_CANCELLED" && + "reason" in error && + error.reason === "timed out" + ); +} + +export function isCodexAppServerBrokenPipeError(error: unknown): boolean { + const seen = new Set(); + let current = error; + while (current && typeof current === "object" && !seen.has(current)) { + seen.add(current); + if ("code" in current && current.code === "EPIPE") { + return true; + } + current = "cause" in current ? current.cause : undefined; + } + return false; +} + class CodexAppServerIndeterminateTransportError extends Error { readonly code = "CODEX_APP_SERVER_REQUEST_TRANSPORT_INDETERMINATE"; readonly mayHaveWritten = true; diff --git a/extensions/codex/src/app-server/compact.test.ts b/extensions/codex/src/app-server/compact.test.ts index 8e1899f9a4e1..a0de46ec0100 100644 --- a/extensions/codex/src/app-server/compact.test.ts +++ b/extensions/codex/src/app-server/compact.test.ts @@ -1406,7 +1406,7 @@ describe("maybeCompactCodexAppServerSession", () => { const fake = createFakeCodexClient(); fake.request.mockRejectedValueOnce( new CodexAppServerRpcError( - { code: -32_602, message: "thread not found: thread-1" }, + { code: -32_600, message: "thread not found: thread-1" }, "thread/compact/start", ), ); diff --git a/extensions/codex/src/app-server/compact.ts b/extensions/codex/src/app-server/compact.ts index 096f722726df..5dc015c718b4 100644 --- a/extensions/codex/src/app-server/compact.ts +++ b/extensions/codex/src/app-server/compact.ts @@ -905,6 +905,13 @@ function isSameNativeCompactionBinding( } function isCodexThreadNotFoundError(error: unknown): boolean { + // codex-rs exposes no dedicated error code for a missing compaction thread: + // thread/compact/start returns generic INVALID_REQUEST (-32600), and the + // app-server's own contract/test asserts the "thread not found" MESSAGE as + // the discriminator (thread_processor.rs load_thread → invalid_request; + // compaction.rs asserts message.contains("thread not found")). So the message + // is the authoritative positive signal here, not the generic code. This is a + // self-heal recovery gate, not user-facing classification. return formatCompactionError(error).toLowerCase().includes("thread not found"); } diff --git a/extensions/codex/src/app-server/run-attempt.test.ts b/extensions/codex/src/app-server/run-attempt.test.ts index b439928e84dd..88cb8a2555f7 100644 --- a/extensions/codex/src/app-server/run-attempt.test.ts +++ b/extensions/codex/src/app-server/run-attempt.test.ts @@ -5590,7 +5590,7 @@ describe("runCodexAppServerAttempt", () => { ...mockClientRuntimeMethods(), request: vi.fn(async (method: string) => { if (method === "thread/start") { - throw new Error("write EPIPE"); + throw Object.assign(new Error("write EPIPE"), { code: "EPIPE" }); } return {}; }), diff --git a/extensions/codex/src/app-server/shared-client.ts b/extensions/codex/src/app-server/shared-client.ts index 9d566a8c1807..17c01406209a 100644 --- a/extensions/codex/src/app-server/shared-client.ts +++ b/extensions/codex/src/app-server/shared-client.ts @@ -7,6 +7,7 @@ import path from "node:path"; import type { AgentHarnessRuntimeArtifactBinding } from "openclaw/plugin-sdk/agent-harness-runtime"; import { resolveDefaultAgentDir, type AuthProfileStore } from "openclaw/plugin-sdk/agent-runtime"; import { createDeferred } from "openclaw/plugin-sdk/extension-shared"; +import { CodexAppServerStartupError } from "./attempt-timeouts.js"; import { applyCodexAppServerAuthProfile, bridgeCodexAppServerStartOptions, @@ -454,11 +455,14 @@ export async function withLeasedCodexAppServerClientStartSelectionRetry(param const signal = params.signal ?? params.options?.abandonSignal; const requestOptions = () => { if (signal?.aborted) { - throw new Error("Codex app-server selection retry aborted"); + throw new CodexAppServerStartupError("aborted", "Codex app-server selection retry aborted"); } const remainingTimeoutMs = deadline - Date.now(); if (remainingTimeoutMs <= 0) { - throw new Error("Codex app-server selection retry timed out"); + throw new CodexAppServerStartupError( + "timed_out", + "Codex app-server selection retry timed out", + ); } return { timeoutMs: remainingTimeoutMs, @@ -507,7 +511,7 @@ async function acquireSharedCodexAppServerClient( leaseOptions?: { leased: true }, ): Promise<{ client: CodexAppServerClient; release?: () => void }> { if (options?.abandonSignal?.aborted) { - throw new Error("codex app-server initialize aborted"); + throw new CodexAppServerStartupError("aborted", "codex app-server initialize aborted"); } const acquireStartedAt = Date.now(); const timeoutMs = options?.timeoutMs ?? 0; @@ -653,14 +657,20 @@ async function withCodexAppServerAcquireDeadline( timeoutMessage = "codex app-server initialize timed out", ): Promise { if (signal?.aborted) { - throw new Error("codex app-server initialize aborted"); + throw new CodexAppServerStartupError("aborted", "codex app-server initialize aborted"); } - const timed = withTimeout(promise, timeoutMs, timeoutMessage); + const timed = withTimeout( + promise, + timeoutMs, + timeoutMessage, + () => new CodexAppServerStartupError("timed_out", timeoutMessage), + ); if (!signal) { return await timed; } return await new Promise((resolve, reject) => { - const onAbort = () => reject(new Error("codex app-server initialize aborted")); + const onAbort = () => + reject(new CodexAppServerStartupError("aborted", "codex app-server initialize aborted")); signal.addEventListener("abort", onAbort, { once: true }); timed.then(resolve, reject).finally(() => signal.removeEventListener("abort", onAbort)); }); @@ -672,7 +682,7 @@ function resolveRemainingAcquireTimeout(timeoutMs: number, startedAt: number): n } const remaining = timeoutMs - (Date.now() - startedAt); if (remaining <= 0) { - throw new Error("codex app-server initialize timed out"); + throw new CodexAppServerStartupError("timed_out", "codex app-server initialize timed out"); } return remaining; } @@ -738,7 +748,7 @@ export async function createIsolatedCodexAppServerClient( options?: CodexAppServerClientOptions, ): Promise { if (options?.abandonSignal?.aborted) { - throw new Error("codex app-server initialize aborted"); + throw new CodexAppServerStartupError("aborted", "codex app-server initialize aborted"); } const acquireStartedAt = Date.now(); const timeoutMs = options?.timeoutMs ?? 0; diff --git a/extensions/codex/src/app-server/timeout.ts b/extensions/codex/src/app-server/timeout.ts index 8ca3d6366873..c7dc9c696b57 100644 --- a/extensions/codex/src/app-server/timeout.ts +++ b/extensions/codex/src/app-server/timeout.ts @@ -9,6 +9,10 @@ export async function withTimeout( promise: Promise, timeoutMs: number, timeoutMessage: string, + createError?: () => Error, ): Promise { - return await withSharedTimeout(promise, timeoutMs, { message: timeoutMessage }); + return await withSharedTimeout(promise, timeoutMs, { + message: timeoutMessage, + ...(createError ? { createError } : {}), + }); } diff --git a/src/status/codex-synthetic-usage.test.ts b/src/status/codex-synthetic-usage.test.ts index f4c46ffc0ee2..fabc79cc1b48 100644 --- a/src/status/codex-synthetic-usage.test.ts +++ b/src/status/codex-synthetic-usage.test.ts @@ -1,5 +1,30 @@ import { describe, expect, it } from "vitest"; -import { mergeUsageSummaries } from "./codex-synthetic-usage.js"; +import { + mergeUsageSummaries, + shouldUseCodexSyntheticUsageForRuntime, +} from "./codex-synthetic-usage.js"; + +describe("shouldUseCodexSyntheticUsageForRuntime", () => { + it("keeps Codex usage enabled after the effective runtime falls back", () => { + expect( + shouldUseCodexSyntheticUsageForRuntime({ + provider: "openai", + effectiveHarness: "openclaw", + sessionHarnessId: "codex", + }), + ).toBe(true); + }); + + it("does not enable Codex usage for a never-Codex session", () => { + expect( + shouldUseCodexSyntheticUsageForRuntime({ + provider: "openai", + effectiveHarness: "openclaw", + sessionHarnessId: "openclaw", + }), + ).toBe(false); + }); +}); describe("mergeUsageSummaries", () => { it("preserves OAuth plan and billing when synthetic Codex windows win", () => { diff --git a/src/status/codex-synthetic-usage.ts b/src/status/codex-synthetic-usage.ts index fced5862019a..cf64346f99f7 100644 --- a/src/status/codex-synthetic-usage.ts +++ b/src/status/codex-synthetic-usage.ts @@ -46,11 +46,14 @@ export function buildCodexSyntheticUsageAuth( export function shouldUseCodexSyntheticUsageForRuntime(params: { provider?: string; effectiveHarness?: string; + sessionHarnessId?: string; }): boolean { const harness = normalizeOptionalLowercaseString(params.effectiveHarness); + const sessionHarness = normalizeOptionalLowercaseString(params.sessionHarnessId); const provider = normalizeOptionalLowercaseString(params.provider); return ( - harness === CODEX_SYNTHETIC_USAGE_HOOK_PROVIDER && + (harness === CODEX_SYNTHETIC_USAGE_HOOK_PROVIDER || + sessionHarness === CODEX_SYNTHETIC_USAGE_HOOK_PROVIDER) && (provider === CODEX_SYNTHETIC_USAGE_PROVIDER || provider === "codex") ); } diff --git a/src/status/status-text.test.ts b/src/status/status-text.test.ts index bb52ee27f7dd..88220f93da54 100644 --- a/src/status/status-text.test.ts +++ b/src/status/status-text.test.ts @@ -5,6 +5,7 @@ import { buildStatusText } from "./status-text.js"; const mocks = vi.hoisted(() => ({ loadSessionCostSummariesFromCache: vi.fn(), + loadProviderUsageSummary: vi.fn(), })); vi.mock("../infra/session-cost-usage.js", async (importOriginal) => { @@ -15,6 +16,14 @@ vi.mock("../infra/session-cost-usage.js", async (importOriginal) => { }; }); +vi.mock("../infra/provider-usage.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadProviderUsageSummary: mocks.loadProviderUsageSummary, + }; +}); + type StatusTextParams = Parameters[0]; async function renderTelegramStatus(params: { @@ -109,6 +118,74 @@ describe("buildStatusText channel features", () => { }); }); +describe("Codex usage after runtime fallback", () => { + beforeEach(() => { + mocks.loadProviderUsageSummary.mockReset(); + mocks.loadProviderUsageSummary.mockImplementation(async (params) => ({ + updatedAt: Date.now(), + providers: params.auth + ? [ + { + provider: "openai", + displayName: "Codex", + windows: [{ label: "5h", usedPercent: 25 }], + }, + ] + : [], + })); + }); + + async function renderFallbackStatus(agentHarnessId: "codex" | "openclaw"): Promise { + return await buildStatusText({ + cfg: {}, + sessionEntry: { + sessionId: `fallback-${agentHarnessId}`, + updatedAt: 0, + agentRuntimeOverride: "openclaw", + agentHarnessId, + }, + sessionKey: "agent:main:main", + statusChannel: "mobilechat", + provider: "openai", + model: "gpt-5.4-mini", + resolvedHarness: "openclaw", + resolvedVerboseLevel: "off", + resolvedReasoningLevel: "off", + resolveDefaultThinkingLevel: async () => undefined, + isGroup: false, + defaultGroupActivation: () => "mention", + pluginHealthLineOverride: "Plugins: test", + taskLineOverride: "", + skipDefaultTaskLookup: true, + primaryModelLabelOverride: "openai/gpt-5.4-mini", + modelAuthOverride: "oauth", + activeModelAuthOverride: "oauth", + includeTranscriptUsage: false, + }); + } + + it("shows Codex rate-limit usage for a Codex-bound session on OpenClaw Default", async () => { + const text = await renderFallbackStatus("codex"); + + expect(text).toContain("📊 Usage: 5h 75% left"); + expect(mocks.loadProviderUsageSummary).toHaveBeenCalledWith( + expect.objectContaining({ + providers: ["openai"], + auth: [expect.objectContaining({ provider: "openai", hookProvider: "codex" })], + }), + ); + }); + + it("omits Codex rate-limit usage for a never-Codex session", async () => { + const text = await renderFallbackStatus("openclaw"); + + expect(text).not.toContain("📊 Usage:"); + expect(mocks.loadProviderUsageSummary).toHaveBeenCalledWith( + expect.not.objectContaining({ auth: expect.anything() }), + ); + }); +}); + describe("session status cost line", () => { const sessionEntry = { sessionId: "cost-session", diff --git a/src/status/status-text.ts b/src/status/status-text.ts index 34ff47d2c12b..fe46839f3fb6 100644 --- a/src/status/status-text.ts +++ b/src/status/status-text.ts @@ -419,6 +419,8 @@ export async function buildStatusText(params: BuildStatusTextParams): Promise