mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 11:55:47 -06:00
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
This commit is contained in:
committed by
GitHub
parent
5bfcd779b1
commit
83a4208aed
@@ -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
|
||||
|
||||
@@ -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<ApprovalPolicyOutcome | undefined> {
|
||||
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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<never>(() => {}),
|
||||
});
|
||||
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<never>(() => {}),
|
||||
});
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<T>(params: {
|
||||
operation: () => Promise<T>;
|
||||
}): Promise<T> {
|
||||
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<T>(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<T>(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);
|
||||
}),
|
||||
|
||||
@@ -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<unknown>();
|
||||
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;
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {};
|
||||
}),
|
||||
|
||||
@@ -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<T>(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<T>(
|
||||
timeoutMessage = "codex app-server initialize timed out",
|
||||
): Promise<T> {
|
||||
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<T>((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<CodexAppServerClient> {
|
||||
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;
|
||||
|
||||
@@ -9,6 +9,10 @@ export async function withTimeout<T>(
|
||||
promise: Promise<T>,
|
||||
timeoutMs: number,
|
||||
timeoutMessage: string,
|
||||
createError?: () => Error,
|
||||
): Promise<T> {
|
||||
return await withSharedTimeout(promise, timeoutMs, { message: timeoutMessage });
|
||||
return await withSharedTimeout(promise, timeoutMs, {
|
||||
message: timeoutMessage,
|
||||
...(createError ? { createError } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<typeof import("../infra/provider-usage.js")>();
|
||||
return {
|
||||
...actual,
|
||||
loadProviderUsageSummary: mocks.loadProviderUsageSummary,
|
||||
};
|
||||
});
|
||||
|
||||
type StatusTextParams = Parameters<typeof buildStatusText>[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<string> {
|
||||
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",
|
||||
|
||||
@@ -419,6 +419,8 @@ export async function buildStatusText(params: BuildStatusTextParams): Promise<st
|
||||
shouldUseCodexSyntheticUsageForRuntime({
|
||||
provider: usageStatusProvider,
|
||||
effectiveHarness,
|
||||
// A runtime fallback does not erase the session's Codex binding or its rate limits.
|
||||
sessionHarnessId: sessionEntry?.agentHarnessId,
|
||||
});
|
||||
const codexUsageAuthProfileId = useCodexSyntheticUsage
|
||||
? resolveCodexSyntheticUsageAuthProfileId({
|
||||
|
||||
Reference in New Issue
Block a user