@bitkyc08/opencodex 2.44.0 → 2.45.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/gui/dist/assets/index-CCfD72yq.js +115 -0
  2. package/gui/dist/assets/index-J96sug5C.css +1 -0
  3. package/gui/dist/index.html +2 -2
  4. package/package.json +1 -1
  5. package/src/adapters/openai-responses.ts +14 -0
  6. package/src/chat/outbound.ts +286 -35
  7. package/src/claude/compatibility.ts +192 -0
  8. package/src/claude/model-info.ts +14 -2
  9. package/src/cli/account-extended.ts +24 -1
  10. package/src/cli/init.ts +70 -13
  11. package/src/codex/catalog/provider-fetch.ts +38 -9
  12. package/src/codex/catalog/sync.ts +34 -2
  13. package/src/codex/catalog.ts +1 -1
  14. package/src/config/initialize.ts +132 -0
  15. package/src/config/rebase-provenance.ts +26 -0
  16. package/src/config.ts +50 -1
  17. package/src/generated/compatibility-version.json +41 -29
  18. package/src/lib/windows-secret-acl.ts +8 -4
  19. package/src/providers/quota.ts +26 -15
  20. package/src/responses/state.ts +48 -3
  21. package/src/server/chat-completions.ts +32 -36
  22. package/src/server/chat-native-sse.ts +23 -3
  23. package/src/server/chat-native.ts +15 -8
  24. package/src/server/claude-messages.ts +27 -0
  25. package/src/server/index.ts +5 -1
  26. package/src/server/management/agent-settings-routes.ts +101 -14
  27. package/src/server/management/logs-usage-routes.ts +9 -2
  28. package/src/server/request-log-cursor.ts +84 -0
  29. package/src/server/request-log.ts +46 -3
  30. package/src/server/responses/agent-task-recovery.ts +50 -14
  31. package/src/server/responses/codex-ws-exchange.ts +79 -0
  32. package/src/server/responses/compact.ts +39 -33
  33. package/src/server/responses/core.ts +43 -27
  34. package/src/storage/cleanup.ts +49 -35
  35. package/src/types/config.ts +4 -0
  36. package/src/usage/log.ts +22 -0
  37. package/gui/dist/assets/index-B7_K1Hsj.js +0 -115
  38. package/gui/dist/assets/index-ltx3L-WS.css +0 -1
@@ -0,0 +1,84 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+
3
+ const MAX_CURSOR_LENGTH = 512;
4
+ const MAX_WINDOW_ROWS = 2000;
5
+ // A restart must invalidate even an identical window hydrated from usage.jsonl.
6
+ const processEpoch = randomBytes(16).toString("hex");
7
+
8
+ interface SnapshotCursor {
9
+ v: 2;
10
+ e: string;
11
+ n: number;
12
+ q: string;
13
+ h: string;
14
+ }
15
+
16
+ interface LegacyCursor {
17
+ v: 1;
18
+ t: number;
19
+ id: string;
20
+ }
21
+
22
+ export type RequestLogCursor = SnapshotCursor | LegacyCursor;
23
+
24
+ /** A cursor is a bounded freshness hint, never an admission credential. */
25
+ export function decodeRequestLogCursor(raw: string): RequestLogCursor | null {
26
+ if (!raw || raw.length > MAX_CURSOR_LENGTH || !/^[A-Za-z0-9_-]+$/.test(raw)) return null;
27
+ try {
28
+ const bytes = Buffer.from(raw, "base64url");
29
+ if (bytes.toString("base64url") !== raw) return null;
30
+ const value: unknown = JSON.parse(bytes.toString("utf8"));
31
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
32
+ const row = value as Record<string, unknown>;
33
+ const keys = Object.keys(row).sort().join(",");
34
+ if (row.v === 1 && keys === "id,t,v"
35
+ && typeof row.t === "number" && Number.isFinite(row.t) && row.t >= 0
36
+ && typeof row.id === "string" && row.id.length > 0 && row.id.length <= 256) {
37
+ return { v: 1, t: row.t, id: row.id };
38
+ }
39
+ if (row.v !== 2 || keys !== "e,h,n,q,v"
40
+ || typeof row.e !== "string" || !/^[a-f0-9]{32}$/.test(row.e)
41
+ || typeof row.n !== "number" || !Number.isSafeInteger(row.n) || row.n < 0 || row.n > MAX_WINDOW_ROWS
42
+ || typeof row.q !== "string" || !/^[a-f0-9]{64}$/.test(row.q)
43
+ || typeof row.h !== "string" || !/^[a-f0-9]{64}$/.test(row.h)) return null;
44
+ return { v: 2, e: row.e, n: row.n, q: row.q, h: row.h };
45
+ } catch {
46
+ return null;
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Compare the current projected window, not ring identities: live entries and
52
+ * display-time pricing can change without append. This saves response bytes for
53
+ * stable prefixes; DTO projection and hashing still cost O(window bytes).
54
+ * No per-client rows or history are retained. The route calls this synchronously
55
+ * after projecting the full filtered/paginated window.
56
+ */
57
+ export function selectRequestLogPoll<T extends object>(
58
+ rows: readonly T[],
59
+ params: URLSearchParams,
60
+ cursor: RequestLogCursor | null,
61
+ epoch = processEpoch,
62
+ ): { logs: T[]; cursor: string; reset: boolean } {
63
+ const query = new URLSearchParams(params);
64
+ query.delete("cursor");
65
+ query.sort();
66
+ const queryDigest = createHash("sha256").update(query.toString()).digest("hex");
67
+ const candidate = cursor?.v === 2 && cursor.e === epoch && cursor.q === queryDigest
68
+ && cursor.n <= rows.length ? cursor : null;
69
+ const full = createHash("sha256");
70
+ const prefix = createHash("sha256");
71
+ for (let index = 0; index < rows.length; index++) {
72
+ // JSON escapes embedded newlines, so the delimiter frames each whole row.
73
+ const serialized = JSON.stringify(rows[index]) + "\n";
74
+ full.update(serialized);
75
+ if (candidate && index < candidate.n) prefix.update(serialized);
76
+ }
77
+ const unchangedPrefix = candidate !== null && prefix.digest("hex") === candidate.h;
78
+ const next: SnapshotCursor = { v: 2, e: epoch, n: rows.length, q: queryDigest, h: full.digest("hex") };
79
+ return {
80
+ logs: rows.slice(unchangedPrefix ? candidate.n : 0),
81
+ cursor: Buffer.from(JSON.stringify(next)).toString("base64url"),
82
+ reset: cursor !== null && !unchangedPrefix,
83
+ };
84
+ }
@@ -8,6 +8,7 @@ import {
8
8
  isClientClosedMessage,
9
9
  isCyberPolicyCode,
10
10
  isCyberPolicyMessage,
11
+ isRateLimitOrQuotaFailureMessage,
11
12
  upstreamErrorMessageFromPayload,
12
13
  } from "../lib/errors";
13
14
  import { CODEX_CONFIG_PATH, readRootTomlString } from "../codex/paths";
@@ -24,6 +25,7 @@ import {
24
25
  isKnownUsageSurface,
25
26
  isCodexUsageAccountLogLabel,
26
27
  isValidReasoningWireValue,
28
+ normalizeClaudeCompatibilityUsageLog,
27
29
  readRecentUsageEntries,
28
30
  usageForFinalLog,
29
31
  usageStatusForFinalLog,
@@ -31,6 +33,7 @@ import {
31
33
  type AttemptRecoveryKind,
32
34
  type PersistedUsageAttempt,
33
35
  type PersistedUsageEntry,
36
+ type PersistedClaudeCompatibilityLog,
34
37
  type UsageStatus,
35
38
  } from "../usage/log";
36
39
  import {
@@ -138,6 +141,8 @@ export interface RequestLogContext {
138
141
  terminalSource?: "upstream" | "synthetic";
139
142
  /** Bounded route-decision trace (RI-01); never contains secrets. */
140
143
  routeDecision?: RouteDecisionTraceV1;
144
+ /** Opt-in shadow evidence, normalized again at the logging boundary. */
145
+ claudeCompatibility?: PersistedClaudeCompatibilityLog;
141
146
  }
142
147
 
143
148
  export interface RequestLogEntry {
@@ -203,6 +208,8 @@ export interface RequestLogEntry {
203
208
  terminalSource?: "upstream" | "synthetic";
204
209
  /** Bounded route-decision trace (RI-01); never contains secrets. */
205
210
  routeDecision?: RouteDecisionTraceV1;
211
+ /** Closed Claude protocol codes; no request or header values. */
212
+ claudeCompatibility?: PersistedClaudeCompatibilityLog;
206
213
  }
207
214
 
208
215
  const requestLog: RequestLogEntry[] = [];
@@ -271,6 +278,7 @@ export function requestLogEntryFromPersistedUsage(entry: PersistedUsageEntry): R
271
278
  const terminalStatus = asTerminalStatus(entry.terminalStatus);
272
279
  const closeReason = asCloseReason(entry.closeReason);
273
280
  const routeDecision = normalizeRouteDecisionTraceForLog(entry.routeDecision);
281
+ const claudeCompatibility = normalizeClaudeCompatibilityUsageLog(entry.claudeCompatibility);
274
282
  return {
275
283
  requestId: entry.requestId,
276
284
  timestamp: entry.timestamp,
@@ -313,6 +321,7 @@ export function requestLogEntryFromPersistedUsage(entry: PersistedUsageEntry): R
313
321
  ...(entry.totalTokens !== undefined ? { totalTokens: entry.totalTokens } : {}),
314
322
  ...(entry.attempts !== undefined ? { attempts: entry.attempts } : {}),
315
323
  ...(routeDecision ? { routeDecision } : {}),
324
+ ...(claudeCompatibility ? { claudeCompatibility } : {}),
316
325
  };
317
326
  }
318
327
 
@@ -368,10 +377,13 @@ export function addRequestLog(entry: RequestLogEntry) {
368
377
  // line-oriented viewer — while `usage.jsonl` looked clean, which is the worst shape for a
369
378
  // sanitization bug because the safe surface is the one you check.
370
379
  const shadowCallRewrittenFrom = sanitizeLogMetadataString(entry.shadowCallRewrittenFrom);
371
- const retained: RequestLogEntry = shadowCallRewrittenFrom === entry.shadowCallRewrittenFrom
380
+ const claudeCompatibility = normalizeClaudeCompatibilityUsageLog(entry.claudeCompatibility);
381
+ const retained: RequestLogEntry = shadowCallRewrittenFrom === entry.shadowCallRewrittenFrom && entry.claudeCompatibility === undefined
372
382
  ? entry
373
383
  : { ...entry, ...(shadowCallRewrittenFrom ? { shadowCallRewrittenFrom } : {}) };
374
384
  if (!shadowCallRewrittenFrom && retained !== entry) delete retained.shadowCallRewrittenFrom;
385
+ if (claudeCompatibility) retained.claudeCompatibility = claudeCompatibility;
386
+ else if (retained !== entry) delete retained.claudeCompatibility;
375
387
  entry = retained;
376
388
  retainRequestLogEntry(entry);
377
389
  try {
@@ -431,6 +443,7 @@ export function addRequestLog(entry: RequestLogEntry) {
431
443
  ...(entry.attempts !== undefined ? { attempts: entry.attempts } : {}),
432
444
  ...failureDiagnostics,
433
445
  ...(entry.routeDecision ? { routeDecision: entry.routeDecision } : {}),
446
+ ...(entry.claudeCompatibility ? { claudeCompatibility: entry.claudeCompatibility } : {}),
434
447
  });
435
448
  } catch {
436
449
  /* request logging must never fail a user request */
@@ -852,7 +865,7 @@ function captureTerminalHttpStatus(
852
865
  last_error?: { type?: unknown; code?: unknown; message?: unknown };
853
866
  response?: {
854
867
  error?: { type?: unknown; code?: unknown; message?: unknown };
855
- incomplete_details?: { code?: unknown; message?: unknown };
868
+ incomplete_details?: { code?: unknown; message?: unknown; reason?: unknown };
856
869
  };
857
870
  },
858
871
  ): void {
@@ -861,7 +874,9 @@ function captureTerminalHttpStatus(
861
874
  if (type !== "response.failed" && type !== "response.incomplete" && type !== "error") return;
862
875
  const responseError = json.response?.error;
863
876
  const responseDetails = json.response?.incomplete_details;
864
- const candidates = [json.error, json.last_error, responseError, responseDetails, json];
877
+ const candidates: Array<{ type?: unknown; code?: unknown; message?: unknown } | undefined> = [
878
+ json.error, json.last_error, responseError, responseDetails, json,
879
+ ];
865
880
  const policy = candidates.some(candidate => (
866
881
  candidate?.code === null || typeof candidate?.code === "string"
867
882
  ) && isCyberPolicyCode(candidate.code as string | null | undefined))
@@ -875,6 +890,29 @@ function captureTerminalHttpStatus(
875
890
  logCtx.terminalHttpStatus = 400;
876
891
  return;
877
892
  }
893
+ // A quota terminal can carry only a structured reason, without an error message.
894
+ // Keep this separate from normal output limits and from the policy precedence above.
895
+ const quotaTag = (value: unknown): boolean => value === "usage_limit_reached"
896
+ || value === "rate_limit_exceeded" || value === "insufficient_quota";
897
+ const structuredRefusal = candidates.some(candidate => [400, 401, 403, 499].includes(
898
+ httpStatusFromTerminalError({
899
+ type: typeof candidate?.type === "string" ? candidate.type : undefined,
900
+ code: typeof candidate?.code === "string" ? candidate.code : undefined,
901
+ }),
902
+ ));
903
+ const ordinaryIncompleteReason = typeof responseDetails?.reason === "string"
904
+ && ["max_output_tokens", "content_filter", "steered", "upstream_stall_timeout", "adapter_eof"].includes(responseDetails.reason);
905
+ if (type === "response.incomplete" && !structuredRefusal && (quotaTag(responseDetails?.reason) || candidates.some(candidate =>
906
+ quotaTag(candidate?.code)
907
+ || quotaTag(candidate?.type) || candidate?.type === "rate_limit_error"
908
+ || (!ordinaryIncompleteReason && typeof candidate?.message === "string" && isRateLimitOrQuotaFailureMessage(candidate.message))
909
+ ))) {
910
+ // The shared quota classifier also accepts a numeric HTTP status as its message.
911
+ // Preserve explicit payment-required evidence rather than relabeling it as 429.
912
+ logCtx.terminalHttpStatus = candidates.some(candidate => typeof candidate?.message === "string"
913
+ && Number(candidate.message.trim()) === 402) ? 402 : 429;
914
+ return;
915
+ }
878
916
  if (type !== "response.failed" || !responseError || typeof responseError !== "object") return;
879
917
  const responseCode = responseError.code === null || typeof responseError.code === "string"
880
918
  ? responseError.code
@@ -903,6 +941,9 @@ export function httpStatusForRequestLogTerminal(
903
941
  status: ResponsesTerminalStatus,
904
942
  logCtx?: RequestLogContext,
905
943
  ): number {
944
+ if (status === "incomplete" && (logCtx?.terminalHttpStatus === 429 || logCtx?.terminalHttpStatus === 402)) {
945
+ return logCtx.terminalHttpStatus;
946
+ }
906
947
  /**
907
948
  * [Decision Log]
908
949
  * - 목적과 의도: Keep request logs aligned with the successful HTTP/SSE contract.
@@ -985,6 +1026,7 @@ export function addFinalRequestLog(
985
1026
  // means a future caller cannot reintroduce the hole by forgetting to sanitize first, and
986
1027
  // the in-memory /api/logs row matches what usage.jsonl already stores.
987
1028
  const shadowCallRewrittenFrom = sanitizeLogMetadataString(logCtx.shadowCallRewrittenFrom);
1029
+ const claudeCompatibility = normalizeClaudeCompatibilityUsageLog(logCtx.claudeCompatibility);
988
1030
  addLog({
989
1031
  requestId,
990
1032
  timestamp: start,
@@ -1034,6 +1076,7 @@ export function addFinalRequestLog(
1034
1076
  ...(logCtx.transportPhase ? { transportPhase: logCtx.transportPhase } : {}),
1035
1077
  ...(logCtx.terminalSource ? { terminalSource: logCtx.terminalSource } : {}),
1036
1078
  ...(logCtx.routeDecision ? { routeDecision: logCtx.routeDecision } : {}),
1079
+ ...(claudeCompatibility ? { claudeCompatibility } : {}),
1037
1080
  });
1038
1081
  if (isUsageDebugEnabled()) {
1039
1082
  appendUsageDebug({
@@ -41,6 +41,18 @@ export interface AgentTaskRecoveryOptions {
41
41
  cacheEntries?: number;
42
42
  }
43
43
 
44
+ export type AgentTaskRecoveryFailureReason =
45
+ | "unsupported_envelope"
46
+ | "admission_denied"
47
+ // Includes cache capacity rejection; does not imply an upstream request was attempted.
48
+ | "recovery_unavailable"
49
+ | "caller_cancelled"
50
+ | "input_changed";
51
+
52
+ export type AgentTaskRecoveryResult =
53
+ | { readonly recovered: true }
54
+ | { readonly recovered: false; readonly reason: AgentTaskRecoveryFailureReason };
55
+
44
56
  export function agentTaskRecoveryConfig(config: OcxConfig): AgentTaskRecoveryOptions | null {
45
57
  const raw = config.agentTaskRecovery;
46
58
  if (!raw || raw.enabled !== true) return null;
@@ -275,16 +287,20 @@ interface AdmittedRecovery {
275
287
  cacheKey: string;
276
288
  }
277
289
 
290
+ type RecoveryAdmissionResult =
291
+ | { admitted: true; recovery: AdmittedRecovery }
292
+ | { admitted: false; reason: "unsupported_envelope" | "admission_denied" };
293
+
278
294
  function admittedRecovery(
279
295
  req: Request,
280
296
  input: unknown,
281
297
  config: OcxConfig,
282
298
  parentThreadId?: string | null,
283
- ): AdmittedRecovery | null {
299
+ ): RecoveryAdmissionResult {
284
300
  const envelope = findEnvelope(input);
285
- if (!envelope) return null;
301
+ if (!envelope) return { admitted: false, reason: "unsupported_envelope" };
286
302
  const admission = recoveryAdmission(req, config);
287
- if (!admission) return null;
303
+ if (!admission) return { admitted: false, reason: "admission_denied" };
288
304
  const cacheKey = createHash("sha256")
289
305
  .update(admission.cacheScope)
290
306
  .update("\0")
@@ -298,7 +314,7 @@ function admittedRecovery(
298
314
  .update("\0")
299
315
  .update(envelope.ciphertext)
300
316
  .digest("hex");
301
- return { envelope, admission, cacheKey };
317
+ return { admitted: true, recovery: { envelope, admission, cacheKey } };
302
318
  }
303
319
 
304
320
  function recoveryPayload(envelope: AgentEnvelope, model: string): string {
@@ -465,23 +481,43 @@ export async function recoverEncryptedAgentTask(
465
481
  config: OcxConfig,
466
482
  context: { parentThreadId?: string | null; abortSignal?: AbortSignal } = {},
467
483
  ): Promise<boolean> {
484
+ return (await recoverEncryptedAgentTaskWithResult(req, input, options, config, context)).recovered;
485
+ }
486
+
487
+ /** Returns only bounded, caller-local diagnostics; no native error or payload content. */
488
+ export async function recoverEncryptedAgentTaskWithResult(
489
+ req: Request,
490
+ input: unknown,
491
+ options: AgentTaskRecoveryOptions,
492
+ config: OcxConfig,
493
+ context: { parentThreadId?: string | null; abortSignal?: AbortSignal } = {},
494
+ ): Promise<AgentTaskRecoveryResult> {
468
495
  // Admission is deliberately checked before cache access. A cache hit must not
469
496
  // turn this process into a plaintext oracle for an unauthenticated caller.
470
497
  const admitted = admittedRecovery(req, input, config, context.parentThreadId);
471
- if (!admitted) return false;
472
- const { admission, cacheKey, envelope } = admitted;
498
+ if (!admitted.admitted) return { recovered: false, reason: admitted.reason };
499
+ const { admission, cacheKey, envelope } = admitted.recovery;
473
500
  const assignment = await resolveCachedAgentTaskRecovery(
474
501
  cacheKey,
475
502
  options.cacheEntries ?? 200,
476
503
  signal => requestRecovery(admission, envelope, options, signal),
477
504
  context.abortSignal,
478
505
  );
479
- if (!assignment) return false;
480
- if (context.abortSignal?.aborted || !injectAssignment(input, envelope, assignment)) {
506
+ if (!assignment) {
507
+ return {
508
+ recovered: false,
509
+ reason: context.abortSignal?.aborted ? "caller_cancelled" : "recovery_unavailable",
510
+ };
511
+ }
512
+ if (context.abortSignal?.aborted) {
481
513
  discardCachedAgentTaskRecovery(cacheKey);
482
- return false;
514
+ return { recovered: false, reason: "caller_cancelled" };
483
515
  }
484
- return true;
516
+ if (!injectAssignment(input, envelope, assignment)) {
517
+ discardCachedAgentTaskRecovery(cacheKey);
518
+ return { recovered: false, reason: "input_changed" };
519
+ }
520
+ return { recovered: true };
485
521
  }
486
522
 
487
523
  export function discardEncryptedAgentTaskRecovery(
@@ -491,7 +527,7 @@ export function discardEncryptedAgentTaskRecovery(
491
527
  context: { parentThreadId?: string | null } = {},
492
528
  ): void {
493
529
  const admitted = admittedRecovery(req, input, config, context.parentThreadId);
494
- if (admitted) discardCachedAgentTaskRecovery(admitted.cacheKey);
530
+ if (admitted.admitted) discardCachedAgentTaskRecovery(admitted.recovery.cacheKey);
495
531
  }
496
532
 
497
533
  export function resetAgentTaskRecoveryState(): void {
@@ -510,9 +546,9 @@ export function restoreCachedEncryptedAgentTasks(
510
546
  const single = [item];
511
547
  // Revalidates caller credentials and the exact supported agent envelope before cache access.
512
548
  const admitted = admittedRecovery(req, single, config, context.parentThreadId);
513
- if (!admitted) continue;
514
- const assignment = cachedAgentTaskRecovery(admitted.cacheKey);
515
- if (assignment && injectAssignment(single, admitted.envelope, assignment)) restored += 1;
549
+ if (!admitted.admitted) continue;
550
+ const assignment = cachedAgentTaskRecovery(admitted.recovery.cacheKey);
551
+ if (assignment && injectAssignment(single, admitted.recovery.envelope, assignment)) restored += 1;
516
552
  }
517
553
  return restored;
518
554
  }
@@ -1,4 +1,5 @@
1
1
  import { MAX_CLIENT_SSE_FRAME_BYTES } from "../sse-frame-buffer";
2
+ import { isSafeResponseHeader } from "../safe-response-headers";
2
3
  import { CodexWsMetadata, type CodexWsQuotaObserver } from "./codex-ws-metadata";
3
4
  import { CODEX_RESPONSES_HTTP_URL, type PreparedCodexWsRequest } from "./codex-ws-request";
4
5
  import { CodexWsCorrelation } from "./codex-ws-correlation";
@@ -16,6 +17,69 @@ interface ExchangeOptions {
16
17
  beforeDispatch?: (headers: Headers) => void;
17
18
  }
18
19
 
20
+ const HTTP_HEADER_TOKEN = /^[!#$%&'*+.^_`|~0-9a-z-]+$/i;
21
+
22
+ function record(value: unknown): value is Record<string, unknown> {
23
+ return value !== null && typeof value === "object" && !Array.isArray(value);
24
+ }
25
+
26
+ /** Rebuild only permitted metadata: upstream framing describes a different body. */
27
+ function rejectionHeaders(source: Record<string, unknown>, prelude: Headers): Headers {
28
+ const connectionHeaders = new Set<string>();
29
+ for (const [name, value] of Object.entries(source)) {
30
+ if (name.toLowerCase() !== "connection" || typeof value !== "string") continue;
31
+ for (const token of value.split(",")) {
32
+ const lower = token.trim().toLowerCase();
33
+ if (HTTP_HEADER_TOKEN.test(lower)) connectionHeaders.add(lower);
34
+ }
35
+ }
36
+ // Reuse the metadata owner's count/value/family budgets and window freshness
37
+ // rules, without publishing quota twice. The unmarked HTTP response owns it.
38
+ const projected = new CodexWsMetadata();
39
+ try {
40
+ for (const values of [Object.fromEntries(prelude), source]) {
41
+ const headers = Object.fromEntries(Object.entries(values).filter(([name, value]) => {
42
+ if (!HTTP_HEADER_TOKEN.test(name) || !isSafeResponseHeader(name)
43
+ || connectionHeaders.has(name.toLowerCase())) return false;
44
+ if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") return false;
45
+ return !(typeof value === "number" && !Number.isFinite(value)) && !/[\r\n\0]/.test(String(value));
46
+ }));
47
+ if (Object.keys(headers).length === 0) continue;
48
+ const event = { type: "codex.response.metadata", headers };
49
+ // Bound the combined serialized seed and updates, even for replacements.
50
+ projected.consume(event, Buffer.byteLength(JSON.stringify(event)));
51
+ }
52
+ const headers = projected.snapshot();
53
+ headers.set("content-type", "application/json");
54
+ headers.set("cache-control", "no-store");
55
+ return headers;
56
+ } finally {
57
+ projected.finish();
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Carry #3740's refused-create status back to the HTTP recovery path. Codex's
63
+ * responses_websocket.rs accepts status/status_code and scalar header values;
64
+ * unlike its native client, this relay converts only precommit 4xx. Returning a
65
+ * post-send 5xx or fetch rejection could cause the outer retry wrapper to resend.
66
+ */
67
+ function wrappedRejectionResponse(payload: Record<string, unknown>, prelude: Headers): Response | null {
68
+ if (payload.type !== "error" || payload.stream_id !== undefined) return null;
69
+ // The native typed wrapper has one aliased field, not two competing statuses.
70
+ if (Object.hasOwn(payload, "status_code") && Object.hasOwn(payload, "status")) return null;
71
+ const status = Object.hasOwn(payload, "status_code") ? payload.status_code : payload.status;
72
+ if (typeof status !== "number" || !Number.isInteger(status) || status < 400 || status > 499) return null;
73
+ const error = payload.error;
74
+ if (error != null && (!record(error)
75
+ || [error.code, error.message].some(value => value != null && typeof value !== "string"))) return null;
76
+ if (payload.headers != null && !record(payload.headers)) return null;
77
+ const headers = rejectionHeaders(record(payload.headers) ? payload.headers : {}, prelude);
78
+ return new Response(JSON.stringify({
79
+ error: error ?? { type: "upstream_error", message: "Upstream rejected the request" },
80
+ }), { status, headers });
81
+ }
82
+
19
83
  /** The sole SSE exchange state machine for both one-shot and retained sockets. */
20
84
  export function codexWsExchange(options: ExchangeOptions): Promise<Response> {
21
85
  const { session, url, init, prepared, sseFallback, onQuota, beforeDispatch } = options;
@@ -193,6 +257,21 @@ export function codexWsExchange(options: ExchangeOptions): Promise<Response> {
193
257
  if (!controlFrame && !type.startsWith("response.") && type !== "error") return;
194
258
  if (!controlFrame) {
195
259
  try { correlation?.accept(normalized.payload); } catch (error) { failStream(error); return; }
260
+ // Correlation must run first: a reused socket's foreign-stream error
261
+ // must not become an HTTP refusal that could authorize account replay.
262
+ if (metadata && sent && !responseCommitted && type === "error") {
263
+ let rejection: Response | null;
264
+ try { rejection = wrappedRejectionResponse(normalized.payload, metadata.snapshot()); }
265
+ catch (error) { failStream(error); return; }
266
+ if (rejection) {
267
+ terminal = true;
268
+ cleanup();
269
+ try { controller.close(); } catch { /* unused stream already closed */ }
270
+ session.dispose();
271
+ resolve(rejection);
272
+ return;
273
+ }
274
+ }
196
275
  commitResponse();
197
276
  }
198
277
  const prefix = encoder.encode(`event: ${type}\ndata: `);
@@ -112,7 +112,8 @@ import type { WsData } from "../ws-bridge";
112
112
  import { codexAccountSelectionForTurn, registerTurn, trackStreamLifetime, unregisterTurn } from "../lifecycle";
113
113
  import type { AdmissionLease } from "../../lib/admission";
114
114
  import { redactSecretString } from "../../lib/redact";
115
- import { readBoundedResponseBody } from "../../lib/bounded-body";
115
+ import { readBoundedResponseBytes } from "../../lib/bounded-body";
116
+ import { resolveStallTimeoutSec } from "../../stall-timeout";
116
117
  import { isRateLimitOrQuotaFailureMessage } from "../../lib/errors";
117
118
  import { supportedLadderFor } from "../effort-policy";
118
119
  import {
@@ -212,6 +213,8 @@ function compactHandoffRoute(req: Request, previousModel: string, now = Date.now
212
213
 
213
214
  export interface HandleResponsesCompactOptions {
214
215
  nativeMainRefreshDependencies?: NativeMainRefreshDependencies;
216
+ /** Release the listener's idle guard only after the complete request body is accepted. */
217
+ onRequestBodyRead?: () => void;
215
218
  }
216
219
 
217
220
  export function compactResponseTooLargeError(): Response {
@@ -464,43 +467,45 @@ function compactResponseHeaders(upstream: Response): Headers {
464
467
  return headers;
465
468
  }
466
469
 
467
- export async function bufferCompactResponse(upstream: Response, signal: AbortSignal): Promise<Response> {
468
- const reader = upstream.body?.getReader();
470
+ export async function bufferCompactResponse(
471
+ upstream: Response,
472
+ signal: AbortSignal,
473
+ stallTimeoutSec?: number,
474
+ ): Promise<Response> {
469
475
  const headers = compactResponseHeaders(upstream);
470
- if (!reader) return new Response(null, { status: upstream.status, statusText: upstream.statusText, headers });
471
- const declaredLength = Number(upstream.headers.get("content-length"));
472
- if (Number.isFinite(declaredLength) && declaredLength > COMPACT_RESPONSE_MAX_BYTES) {
473
- await reader.cancel("compact_response_too_large").catch(() => undefined);
474
- return compactResponseTooLargeError();
475
- }
476
- const chunks: Uint8Array[] = [];
477
- let total = 0;
478
476
  try {
479
- while (true) {
480
- if (signal.aborted) {
481
- await reader.cancel(signal.reason).catch(() => undefined);
482
- return formatErrorResponse(499, "client_cancelled", "Client cancelled compact request");
483
- }
484
- const { done, value } = await reader.read();
485
- if (done) break;
486
- total += value.byteLength;
487
- if (total > COMPACT_RESPONSE_MAX_BYTES) {
488
- await reader.cancel("compact_response_too_large").catch(() => undefined);
489
- return compactResponseTooLargeError();
490
- }
491
- chunks.push(value);
477
+ if (signal.aborted) {
478
+ // No reader is attached yet. Cancellation must not wait for a broken source's cleanup.
479
+ void upstream.body?.cancel(signal.reason).catch(() => undefined);
480
+ return formatErrorResponse(499, "client_cancelled", "Client cancelled compact request");
492
481
  }
493
- } catch {
482
+ if (!upstream.body) return new Response(null, { status: upstream.status, statusText: upstream.statusText, headers });
483
+ const declaredLength = Number(upstream.headers.get("content-length"));
484
+ if (Number.isFinite(declaredLength) && declaredLength > COMPACT_RESPONSE_MAX_BYTES) {
485
+ void upstream.body.cancel("compact_response_too_large").catch(() => undefined);
486
+ return compactResponseTooLargeError();
487
+ }
488
+ // Header admission has finished; only non-empty body chunks re-arm this deadline.
489
+ // The raw reader preserves bytes and cancels/releases without awaiting source cleanup.
490
+ const result = await readBoundedResponseBytes(upstream, {
491
+ signal,
492
+ maxBytes: COMPACT_RESPONSE_MAX_BYTES,
493
+ inactivityTimeoutMs: resolveStallTimeoutSec(stallTimeoutSec) * 1_000,
494
+ });
495
+ if (signal.aborted) return formatErrorResponse(499, "client_cancelled", "Client cancelled compact request");
496
+ if (result.oversized) return compactResponseTooLargeError();
497
+ return new Response(result.bytes, { status: upstream.status, statusText: upstream.statusText, headers });
498
+ } catch (error) {
494
499
  if (signal.aborted) return formatErrorResponse(499, "client_cancelled", "Client cancelled compact request");
500
+ if (error instanceof DOMException && error.name === "TimeoutError") {
501
+ return Response.json({ error: {
502
+ message: "Compact response body stalled",
503
+ type: "upstream_stall_timeout",
504
+ code: "upstream_stall_timeout",
505
+ } }, { status: 504 });
506
+ }
495
507
  return formatErrorResponse(502, "upstream_error", "Failed to read compact response");
496
508
  }
497
- const body = new Uint8Array(total);
498
- let offset = 0;
499
- for (const chunk of chunks) {
500
- body.set(chunk, offset);
501
- offset += chunk.byteLength;
502
- }
503
- return new Response(body, { status: upstream.status, statusText: upstream.statusText, headers });
504
509
  }
505
510
 
506
511
 
@@ -526,6 +531,7 @@ export async function handleResponsesCompact(
526
531
  if (typeof raw.model !== "string" || raw.model.length === 0) {
527
532
  return formatErrorResponse(400, "invalid_request_error", "compaction request requires a model");
528
533
  }
534
+ options.onRequestBodyRead?.();
529
535
  // Correct the IDENTITY before routing, or the synthetic id does not route at all. Held in
530
536
  // a local rather than written back to `raw.model`: assigning to the property widens it out
531
537
  // of the `string` narrowing the guard above just established.
@@ -1037,7 +1043,7 @@ export async function handleResponsesCompact(
1037
1043
  upstream.headers.get("x-codex-secondary-reset-at"),
1038
1044
  upstream.headers.get("x-codex-tertiary-reset-at"),
1039
1045
  ].filter(Boolean);
1040
- const buffered = await bufferCompactResponse(upstream, req.signal);
1046
+ const buffered = await bufferCompactResponse(upstream, req.signal, config.stallTimeoutSec);
1041
1047
  const bufferedErrorText = buffered.ok
1042
1048
  ? ""
1043
1049
  : await buffered.clone().text().catch(() => "");