@bitkyc08/opencodex 2.50.0 → 2.51.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 (95) hide show
  1. package/bin/ocx.mjs +222 -71
  2. package/gui/dist/assets/{index-C39tnjXO.js → index-D7BdZpZm.js} +1 -1
  3. package/gui/dist/index.html +1 -1
  4. package/package.json +1 -1
  5. package/src/adapters/qoder/adapter.ts +69 -1
  6. package/src/adapters/qoder/scaffold-guard.ts +233 -0
  7. package/src/claude/agents-inject.ts +29 -5
  8. package/src/claude/desktop-3p.ts +31 -3
  9. package/src/claude/gateway-cache.ts +12 -21
  10. package/src/cli/capabilities.ts +28 -0
  11. package/src/cli/claude-agent-startup-sync.ts +26 -1
  12. package/src/cli/claude.ts +138 -20
  13. package/src/cli/config-command.ts +67 -1
  14. package/src/cli/connect.ts +181 -14
  15. package/src/cli/dispatch.ts +53 -9
  16. package/src/cli/doctor.ts +9 -2
  17. package/src/cli/ensure-desired-integrations.ts +10 -0
  18. package/src/cli/gui-pair-client.ts +1 -12
  19. package/src/cli/help.ts +4 -1
  20. package/src/cli/hub.ts +367 -0
  21. package/src/cli/index.ts +94 -30
  22. package/src/cli/launcher-context.ts +1 -1
  23. package/src/cli/registry.ts +43 -3
  24. package/src/cli/status.ts +325 -5
  25. package/src/cli/version-skew.ts +4 -1
  26. package/src/cli.ts +2 -2
  27. package/src/client/catalog-compatibility.ts +192 -0
  28. package/src/client/connect.ts +31 -0
  29. package/src/client/hub-client.ts +52 -0
  30. package/src/client/hub-state.ts +214 -0
  31. package/src/codex/account-usability.ts +48 -12
  32. package/src/codex/auth-api.ts +49 -5
  33. package/src/codex/catalog/effort.ts +67 -8
  34. package/src/codex/catalog/sync.ts +85 -0
  35. package/src/codex/codex-write-lock.ts +11 -2
  36. package/src/codex/desired-state.ts +47 -1
  37. package/src/codex/inject-coordination.ts +10 -5
  38. package/src/codex/inject.ts +26 -10
  39. package/src/codex/loopback-target.ts +45 -0
  40. package/src/codex/routing.ts +48 -1
  41. package/src/codex/runtime.ts +37 -3
  42. package/src/codex/sync.ts +29 -9
  43. package/src/codex/warmup.ts +21 -4
  44. package/src/config/pending-teardown.ts +1 -1
  45. package/src/config.ts +126 -12
  46. package/src/generated/compatibility-version.json +136 -76
  47. package/src/grok/status.ts +9 -1
  48. package/src/integrations/config-io.ts +54 -1
  49. package/src/lib/bun-runtime.ts +1 -1
  50. package/src/lib/gui-pair-capability.ts +27 -0
  51. package/src/lib/local-destinations.ts +162 -0
  52. package/src/lib/package-tree-integrity.ts +1 -1
  53. package/src/lib/process-control.ts +130 -20
  54. package/src/lib/service-secrets.ts +28 -0
  55. package/src/lib/test-home-guard.ts +49 -0
  56. package/src/providers/opencode-go-transport.ts +9 -1
  57. package/src/providers/quota.ts +5 -1
  58. package/src/providers/registry.ts +34 -5
  59. package/src/remote/hub-state.ts +182 -0
  60. package/src/server/auth-cors.ts +5 -0
  61. package/src/server/chat-completions.ts +6 -3
  62. package/src/server/claude-messages.ts +7 -1
  63. package/src/server/hub-state.ts +98 -0
  64. package/src/server/index.ts +124 -6
  65. package/src/server/management/api-access.ts +14 -3
  66. package/src/server/management/config-routes.ts +2 -2
  67. package/src/server/management/cursor-integration-routes.ts +13 -4
  68. package/src/server/proxy-liveness.ts +7 -1
  69. package/src/server/request-log-conversation.ts +41 -1
  70. package/src/server/responses/codex-auth-error.ts +18 -1
  71. package/src/server/responses/codex-ws-exchange.ts +36 -4
  72. package/src/server/responses/codex-ws-wire.ts +75 -4
  73. package/src/server/responses/compact.ts +20 -9
  74. package/src/server/responses/core.ts +57 -10
  75. package/src/server/responses/policy-fallback.ts +7 -1
  76. package/src/server/system-env-shell.ts +14 -2
  77. package/src/server/system-env.ts +106 -14
  78. package/src/service.ts +906 -94
  79. package/src/types/config.ts +57 -4
  80. package/src/update/badge.ts +3 -2
  81. package/src/update/index.ts +317 -64
  82. package/src/update/install-detection.d.mts +6 -0
  83. package/src/update/install-detection.mjs +73 -0
  84. package/src/update/job.ts +101 -49
  85. package/src/update/pnpm-global-install.d.mts +144 -0
  86. package/src/update/pnpm-global-install.mjs +591 -0
  87. package/src/update/pnpm-invocation.d.mts +43 -0
  88. package/src/update/pnpm-invocation.mjs +141 -0
  89. package/src/update/registry-integrity.d.mts +16 -0
  90. package/src/update/registry-integrity.mjs +37 -0
  91. package/src/update/transactional-install.d.mts +1 -1
  92. package/src/update/transactional-install.mjs +101 -7
  93. package/src/update/tray-update-plan.mjs +1 -1
  94. package/src/vision/plan.ts +13 -3
  95. package/src/vision/routed-describe.ts +51 -20
@@ -110,7 +110,7 @@ import { applySystemEnvToggle } from "../system-env";
110
110
  import { getCachedStartupHealth, invalidateStartupHealthCache } from "../startup-health-cache";
111
111
  import { runWindowsTrayAction } from "../windows-tray-control";
112
112
  import { runStartupInstallAction, type StartupInstallAction } from "../startup-action-control";
113
- import { displayCodexRuntimePath, effortClampAppliesToRuntime, loadLastEffortClamp, resolveCodexRuntime } from "../../codex/runtime";
113
+ import { displayCodexRuntimePath, effortClampAppliesToRuntime, liveRemovedEfforts, loadLastEffortClamp, resolveCodexRuntime } from "../../codex/runtime";
114
114
 
115
115
  import { isPlainRecord, parseDebugLogQuery, tokPerSecondResult, unavailableCostReason, costResult, requestLogDto, stripRegistryOnlyStaticHeaders, fetchAllModels } from "./shared";
116
116
  import type { MetricUnavailableReason, TokPerSecondResult, CostEstimateReason, CostResult, MetricSource } from "./shared";
@@ -344,7 +344,7 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
344
344
  : null,
345
345
  catalogClamp: {
346
346
  active: clampActive,
347
- removedEfforts: clampActive ? (lastClamp?.removedEfforts ?? []) : [],
347
+ removedEfforts: clampActive ? [...liveRemovedEfforts(lastClamp)] : [],
348
348
  runtimeVersion: clampActive ? (lastClamp?.runtimeVersion ?? null) : null,
349
349
  },
350
350
  warning: warningParts.length > 0 ? warningParts.join(" ") : null,
@@ -14,6 +14,7 @@ import { cursorLastSeen, type CursorSeen } from "../../integrations/cursor-seen"
14
14
  import { detectCursorInstalls, type CursorInstall } from "../../integrations/cursor-detect";
15
15
  import { loadCursorEffortTable } from "../../integrations/cursor-effort-table";
16
16
  import { configuredApiAuthToken, isApiAuthRequired, jsonResponse } from "../auth-cors";
17
+ import { localInferenceDestination } from "../../lib/local-destinations";
17
18
  import { fetchAllModels } from "../management-api";
18
19
  import { predictCursorEffort } from "../models-capabilities";
19
20
  import { expandCursorEffortRow, knownEffortRowIds } from "../effort-row";
@@ -54,11 +55,19 @@ export async function buildCursorIntegrationStatus(
54
55
  // The port the browser reached is the one Cursor on the same machine will reach too; the
55
56
  // runtime record and config.port are fallbacks for a request that carries no port.
56
57
  const port = runtime?.port ?? (Number(ctx.url?.port) || config.port);
57
- // Describes the public bind. A second unauthenticated loopback listener may exist, but the
58
- // value a user pastes into Cursor must work against the bind they will actually reach.
58
+ // Cursor runs on this machine, so the gateway URL it is told to paste is the LOCAL one: the
59
+ // unauthenticated loopback listener when one is enabled, and otherwise the bind address on the
60
+ // public port — 127.0.0.1 for a loopback or wildcard bind exactly as before, and the tailnet
61
+ // or LAN address on a hub, where no loopback socket exists to paste (#4236).
62
+ const gateway = localInferenceDestination(config, port ?? 10100);
63
+ // apiKeyMode describes the admission rule of the destination just resolved, which on the
64
+ // loopback listener is "no key needed" and on every other form is "a key is required".
65
+ // Pasting one into the listener is harmless; omitting one on a bind that demands it is not.
59
66
  const credentialConfigured = !!configuredApiAuthToken(config)
60
67
  || (config.apiKeys ?? []).some(entry => !!entry.key.trim());
61
- const apiKeyMode = isApiAuthRequired(config) || credentialConfigured ? "credential" : "placeholder";
68
+ const apiKeyMode = gateway.requiresAdmissionToken || isApiAuthRequired(config) || credentialConfigured
69
+ ? "credential"
70
+ : "placeholder";
62
71
 
63
72
  const limits = nativeContextLimits(config);
64
73
  // Same visibility rules as the raw /v1/models list Cursor will read: disabled models and
@@ -107,7 +116,7 @@ export async function buildCursorIntegrationStatus(
107
116
  },
108
117
  regularCursor: { installed: regular !== undefined, path: regular?.path ?? null },
109
118
  gateway: {
110
- baseUrl: `http://127.0.0.1:${port}/v1`,
119
+ baseUrl: `${gateway.origin}/v1`,
111
120
  apiKeyMode,
112
121
  placeholder: CURSOR_GATEWAY_PLACEHOLDER_KEY,
113
122
  },
@@ -10,6 +10,7 @@
10
10
  * Lives outside cli.ts (which dispatches argv at module top level) so tests can import it.
11
11
  */
12
12
  import { loadConfig } from "../config";
13
+ import { isWildcardHostname } from "../codex/loopback-target";
13
14
  import { readAlivePid, readRuntimePort, verifyPidIdentity } from "../config/process-state";
14
15
  import { directLocalHttpFetch } from "./direct-local-http";
15
16
 
@@ -82,10 +83,15 @@ export interface LiveProxy {
82
83
  /**
83
84
  * Host to probe for a given bind hostname: wildcards answer on IPv4 loopback, and raw
84
85
  * IPv6 addresses must be bracketed or the composed URL is invalid.
86
+ *
87
+ * The wildcard test is `isWildcardHostname`, not a list of spellings. This function used to
88
+ * know exactly three (`0.0.0.0`, `::`, `[::]`) while the bind-scope predicate knew every
89
+ * all-zero form, so `ocx` composed `http://0.0.0.0.:10100` or `http://*:10100` — unreachable
90
+ * URLs — for a config the server itself treated as a wildcard bind. One predicate, both sides.
85
91
  */
86
92
  export function probeHostname(hostname: string | undefined): string {
87
93
  const trimmed = (hostname ?? "").trim();
88
- if (!trimmed || trimmed === "0.0.0.0" || trimmed === "::" || trimmed === "[::]") return "127.0.0.1";
94
+ if (!trimmed || isWildcardHostname(trimmed)) return "127.0.0.1";
89
95
  if (trimmed.startsWith("[") && trimmed.endsWith("]")) return trimmed;
90
96
  return trimmed.includes(":") ? `[${trimmed}]` : trimmed;
91
97
  }
@@ -2,7 +2,7 @@
2
2
  * Best-effort chat/session correlation for Logs / usage.jsonl (#330).
3
3
  * Opaque ids only — never persist raw emails or Claude Desktop system-hash fallbacks.
4
4
  */
5
- import { createHash } from "node:crypto";
5
+ import { createHash, randomUUID } from "node:crypto";
6
6
 
7
7
  /** Reject absurdly long client strings before hashing (DoS / JSONL bloat). */
8
8
  export const LOG_CONVERSATION_ID_INPUT_MAX = 4096;
@@ -217,3 +217,43 @@ export function summarizeConversationLogs(entries: readonly TotalsSource[]): Con
217
217
  unmeteredRequests,
218
218
  };
219
219
  }
220
+
221
+ /**
222
+ * Request-scoped Go affinity for requests that carry no conversation identity.
223
+ *
224
+ * A sessionless request must still reach OpenCode Go with `x-opencode-session`, because the upstream
225
+ * began rejecting requests without it on 2026-09-06. It must not reuse one global value either, which
226
+ * would smear unrelated probes into a single conversation. So the lane is allocated once per admitted
227
+ * `Request` object and retained for that object's lifetime.
228
+ *
229
+ * The identity has to survive every place the proxy rebuilds a `Request`: translation to the internal
230
+ * Responses shape, compaction, and — the boundary that matters most — the policy fallback retry, where
231
+ * a second candidate would otherwise be handed a freshly minted lane after a retryable failure.
232
+ * `linkRequestSessionLane` carries the allocation across those boundaries.
233
+ */
234
+ const requestAllocatedSessionLanes = new WeakMap<Request, string>();
235
+
236
+ /**
237
+ * Resolve the session lane for a request: real conversation identity when the client supplied it,
238
+ * otherwise a per-request value allocated once and reused for retries on the same object.
239
+ */
240
+ export function getOrAllocateRequestSessionLane(req: Request): string {
241
+ const explicit = sessionLaneIdFromRequest(req.headers)
242
+ ?? normalizeLogConversationId(req.headers.get("x-opencode-session"));
243
+ if (explicit) return explicit;
244
+
245
+ const existing = requestAllocatedSessionLanes.get(req);
246
+ if (existing) return existing;
247
+ const allocated = randomUUID();
248
+ requestAllocatedSessionLanes.set(req, allocated);
249
+ return allocated;
250
+ }
251
+
252
+ /**
253
+ * Carry a source request's lane onto a request the proxy built from it, so a rebuilt request keeps
254
+ * the conversation it belongs to instead of looking sessionless again.
255
+ */
256
+ export function linkRequestSessionLane(sourceReq: Request, targetReq: Request): void {
257
+ requestAllocatedSessionLanes.set(targetReq, getOrAllocateRequestSessionLane(sourceReq));
258
+ }
259
+
@@ -29,10 +29,27 @@ export function nativeMainRefreshFailureResponse(error: unknown): Response {
29
29
  if (error instanceof MainAccountTokenRefreshError
30
30
  || error instanceof MainAuthJsonChangedDuringRefreshError
31
31
  || (error instanceof NativeProfileError && error.retryable)) {
32
+ // A bare "retry this request" reads as a transient server fault, which is how #4212's reporter
33
+ // concluded the proxy had broken while one account was the thing that needed them. The refusal
34
+ // stays a retryable 503 because the refresh genuinely may succeed, but it now names what is
35
+ // failing and what to do when retrying stops helping.
36
+ //
37
+ // It says "sign in to the main Codex account again" and deliberately does NOT say
38
+ // "reauthentication", for the same reason the pool counterpart does not — see
39
+ // `poolCredentialRefreshIncompleteResponse` in ./core.ts. `classifyError` runs
40
+ // `isAuthenticationMessage` before it reaches the `status === 503` arm, and that check is
41
+ // status-blind on the bare substring "authentication", which "reauthentication" contains.
42
+ // A body carrying that word is reclassified to `authentication_error` / `invalid_api_key`
43
+ // even though the HTTP status stays 503, and Codex keys its retry-after backoff on
44
+ // `server_is_overloaded` — so the word alone turns a transient refresh into what reads as a
45
+ // bad API key and the client stops retrying. The pool path documented this trap and this one
46
+ // walked into it anyway, which is why the test below now asserts the classification and not
47
+ // just the sentence.
32
48
  const response = formatErrorResponse(
33
49
  503,
34
50
  "server_busy",
35
- "Codex main credential refresh did not complete; retry this request",
51
+ "Codex main credential refresh did not complete; retry this request. "
52
+ + "If it keeps failing, sign in to the main Codex account again.",
36
53
  );
37
54
  const headers = new Headers(response.headers);
38
55
  headers.set("Retry-After", "1");
@@ -5,7 +5,8 @@ import { CODEX_RESPONSES_HTTP_URL, type PreparedCodexWsRequest } from "./codex-w
5
5
  import { CodexWsCorrelation } from "./codex-ws-correlation";
6
6
  import type { CodexWsSession } from "./codex-ws-session";
7
7
  import { UPGRADE_DEADLINE_MS, CODEX_WS_RESPONSE_PRELUDE_TIMEOUT_MS, MAX_CODEX_WS_FRAME_BYTES,
8
- MAX_CODEX_WS_QUEUE_BYTES, markCodexWsResponse, normalizeResponsesWsRelayEvent, closedBeforeTerminalMessage } from "./codex-ws-wire";
8
+ MAX_CODEX_WS_QUEUE_BYTES, markCodexWsResponse, normalizeResponsesWsRelayEvent, closedBeforeTerminalMessage,
9
+ codexWsFailureDetail, type CodexWsFailureStage } from "./codex-ws-wire";
9
10
 
10
11
  interface ExchangeOptions {
11
12
  session: CodexWsSession;
@@ -94,6 +95,14 @@ export function codexWsExchange(options: ExchangeOptions): Promise<Response> {
94
95
  let received = false;
95
96
  let responseCommitted = false;
96
97
  let terminal = false;
98
+ // #4191: the counters behind the failure classification. A user whose long
99
+ // thread died here could not tell an unanswered socket from one that carried
100
+ // only quota frames, because both arrived as the same one-line message.
101
+ let upstreamFrames = 0;
102
+ let controlFrames = 0;
103
+ let relayedEvents = 0;
104
+ let sentAt: number | null = null;
105
+ let firstFrameAt: number | null = null;
97
106
  let controller: ReadableStreamDefaultController<Uint8Array> | null = null;
98
107
  const encoder = new TextEncoder();
99
108
  const metadata = url === CODEX_RESPONSES_HTTP_URL ? new CodexWsMetadata(onQuota) : null;
@@ -123,6 +132,21 @@ export function codexWsExchange(options: ExchangeOptions): Promise<Response> {
123
132
  ws.removeEventListener("error", onError);
124
133
  };
125
134
 
135
+ /**
136
+ * Snapshot the stage for a failure message. Measuring the frame is deferred
137
+ * to here so the happy path never pays for it: a full-replay thread's frame
138
+ * runs to megabytes, and this is the only place its size is worth knowing.
139
+ */
140
+ const failureStage = (): CodexWsFailureStage => ({
141
+ requestBytes: Buffer.byteLength(frameText, "utf8"),
142
+ sent,
143
+ upstreamFrames,
144
+ controlFrames,
145
+ relayedEvents,
146
+ firstFrameMs: sentAt !== null && firstFrameAt !== null ? Math.max(0, firstFrameAt - sentAt) : null,
147
+ elapsedMs: sentAt !== null ? Math.max(0, Date.now() - sentAt) : null,
148
+ });
149
+
126
150
  const commitResponse = () => {
127
151
  if (responseCommitted) return;
128
152
  responseCommitted = true;
@@ -192,6 +216,7 @@ export function codexWsExchange(options: ExchangeOptions): Promise<Response> {
192
216
  sent = true;
193
217
  try {
194
218
  ws.send(frameText);
219
+ sentAt = Date.now();
195
220
  } catch {
196
221
  if (received || responseCommitted) {
197
222
  if (terminal) session.dispose();
@@ -211,13 +236,18 @@ export function codexWsExchange(options: ExchangeOptions): Promise<Response> {
211
236
  }
212
237
  if (!metadata) commitResponse();
213
238
  else if (!responseCommitted && !terminal) {
214
- preludeTimer = setTimeout(() => failStream("codex websocket response prelude timed out"), CODEX_WS_RESPONSE_PRELUDE_TIMEOUT_MS);
239
+ preludeTimer = setTimeout(
240
+ () => failStream(`codex websocket response prelude timed out${codexWsFailureDetail(failureStage())}`),
241
+ CODEX_WS_RESPONSE_PRELUDE_TIMEOUT_MS,
242
+ );
215
243
  }
216
244
  };
217
245
 
218
246
  const onMessage = (event: MessageEvent) => {
219
247
  if (!controller || terminal) return;
220
248
  received = true;
249
+ upstreamFrames += 1;
250
+ if (firstFrameAt === null) firstFrameAt = Date.now();
221
251
  const text = typeof event.data === "string" ? event.data : "";
222
252
  if (!text) return;
223
253
  // UTF-8 byte length is always at least the JS string length. Reject this
@@ -243,6 +273,7 @@ export function codexWsExchange(options: ExchangeOptions): Promise<Response> {
243
273
  if (sanitized !== null) {
244
274
  relayText = sanitized;
245
275
  controlFrame = true;
276
+ controlFrames += 1;
246
277
  }
247
278
  } catch (error) {
248
279
  failStream(error);
@@ -296,6 +327,7 @@ export function codexWsExchange(options: ExchangeOptions): Promise<Response> {
296
327
  failStream("codex websocket response stream closed while enqueueing");
297
328
  return;
298
329
  }
330
+ if (!controlFrame) relayedEvents += 1;
299
331
  if (type === "response.completed" || type === "response.failed" || type === "response.incomplete" || type === "error") {
300
332
  const completedId = correlation?.completed(normalized.payload) ?? null;
301
333
  terminal = true;
@@ -316,7 +348,7 @@ export function codexWsExchange(options: ExchangeOptions): Promise<Response> {
316
348
  resolve(sseFallback(url, init));
317
349
  return;
318
350
  }
319
- if (sent && !terminal) failStream(closedBeforeTerminalMessage(event));
351
+ if (sent && !terminal) failStream(closedBeforeTerminalMessage(event, failureStage()));
320
352
  };
321
353
 
322
354
  const onError = () => {
@@ -327,7 +359,7 @@ export function codexWsExchange(options: ExchangeOptions): Promise<Response> {
327
359
  cleanup();
328
360
  session.dispose();
329
361
  resolve(sseFallback(url, init));
330
- } else failStream("codex websocket transport error");
362
+ } else failStream(`codex websocket transport error${codexWsFailureDetail(failureStage())}`);
331
363
  };
332
364
  detachOwner = session.bindOwner(reason => cancelExchange(reason));
333
365
  ws.addEventListener("open", onOpen);
@@ -50,6 +50,72 @@ export function markCodexWsResponse(response: Response, observed: boolean): void
50
50
 
51
51
  const CLOSED_BEFORE_TERMINAL = "codex websocket closed before a Responses terminal event";
52
52
 
53
+ /**
54
+ * Content-free stage record for an exchange that ended without a Responses
55
+ * terminal event (#4191).
56
+ *
57
+ * The field report that drove this could not be told apart from a network
58
+ * outage, because every such failure reached the user as one of two bare
59
+ * sentences. Both are true of a socket that was never answered, a socket that
60
+ * carried only quota control frames, and a socket that died mid-response —
61
+ * three different upstream stories with three different owners. These counters
62
+ * are the smallest set that separates them, and every one of them is a size, a
63
+ * count, or a duration: no request body, no header, no account identifier, and
64
+ * no conversation text can reach a message built from this record.
65
+ */
66
+ export type CodexWsFailureStage = {
67
+ /** UTF-8 size of the `response.create` frame this exchange dialled with. */
68
+ requestBytes: number;
69
+ /** True once `ws.send()` returned, so the turn may be executing upstream. */
70
+ sent: boolean;
71
+ /** Frames the socket delivered, of any kind, including ones that did not parse. */
72
+ upstreamFrames: number;
73
+ /** Frames the metadata channel claimed (quota, response metadata). */
74
+ controlFrames: number;
75
+ /** Responses events actually written to the downstream SSE body. */
76
+ relayedEvents: number;
77
+ /** Milliseconds from send to the first upstream frame; null when none arrived. */
78
+ firstFrameMs: number | null;
79
+ /** Milliseconds from send to this failure; null when the failure predates the send. */
80
+ elapsedMs: number | null;
81
+ };
82
+
83
+ /**
84
+ * Which upstream story the counters tell. Ordered by how much the upstream had
85
+ * committed to, because that is what decides who owns the failure — and, for a
86
+ * future maintainer reading #4191, it is deliberately NOT a fallback-eligibility
87
+ * signal. `no-upstream-frame` does not mean the frame was not accepted; the
88
+ * no-replay-after-send contract in `codex-ws-exchange.ts` stands regardless of
89
+ * what this classifier says.
90
+ */
91
+ export type CodexWsFailureCause =
92
+ | "before-send"
93
+ | "no-upstream-frame"
94
+ | "no-response-event"
95
+ | "after-response-started";
96
+
97
+ export function classifyCodexWsFailure(stage: CodexWsFailureStage): CodexWsFailureCause {
98
+ if (!stage.sent) return "before-send";
99
+ if (stage.relayedEvents > 0) return "after-response-started";
100
+ if (stage.upstreamFrames === 0) return "no-upstream-frame";
101
+ return "no-response-event";
102
+ }
103
+
104
+ /**
105
+ * Render the stage as a suffix appended to an existing failure message.
106
+ *
107
+ * It is a suffix, not an interpolation, on purpose: the close-code tail these
108
+ * messages already carry is matched as a contiguous substring by the callers
109
+ * and tests that read it, so nothing may be inserted ahead of it.
110
+ */
111
+ export function codexWsFailureDetail(stage: CodexWsFailureStage): string {
112
+ const duration = (value: number | null): string => (value === null ? "n/a" : `${value}ms`);
113
+ return ` [cause=${classifyCodexWsFailure(stage)} request=${stage.requestBytes}B`
114
+ + ` sent=${stage.sent ? "yes" : "no"} frames=${stage.upstreamFrames}`
115
+ + ` control=${stage.controlFrames} relayed=${stage.relayedEvents}`
116
+ + ` first-frame=${duration(stage.firstFrameMs)} elapsed=${duration(stage.elapsedMs)}]`;
117
+ }
118
+
53
119
  export type ResponsesWsRelayEvent = {
54
120
  type: string;
55
121
  text: string;
@@ -111,18 +177,23 @@ export function normalizeResponsesWsRelayEvent(text: string): ResponsesWsRelayEv
111
177
  * inspector, so `/api/logs` keeps neither this message nor a specific code —
112
178
  * only `streamAborted`. Machine-readable typing would mean changing the error
113
179
  * taxonomy, which is deliberately out of scope for this transport fix.
180
+ *
181
+ * When a stage is supplied its detail is appended last, after the close-code
182
+ * tail, so the code and reason stay one contiguous substring.
114
183
  */
115
- export function closedBeforeTerminalMessage(event: unknown): string {
184
+ export function closedBeforeTerminalMessage(event: unknown, stage?: CodexWsFailureStage): string {
116
185
  const detail = event as { code?: unknown; reason?: unknown } | null | undefined;
117
186
  const code = typeof detail?.code === "number" ? detail.code : null;
118
187
  const reason = typeof detail?.reason === "string" ? detail.reason.trim() : "";
119
- if (code === null) return CLOSED_BEFORE_TERMINAL;
188
+ const stageDetail = stage ? codexWsFailureDetail(stage) : "";
189
+ if (code === null) return `${CLOSED_BEFORE_TERMINAL}${stageDetail}`;
120
190
  const suffix = reason ? ` ${code} ${reason}` : ` ${code}`;
121
191
  if (code === WS_CLOSE_MESSAGE_TOO_BIG) {
122
192
  return `codex websocket rejected the request frame as too large (close${suffix});`
123
- + ` requests at or above ${MAX_CODEX_WS_CREATE_FRAME_BYTES} bytes must use the HTTP SSE transport`;
193
+ + ` requests at or above ${MAX_CODEX_WS_CREATE_FRAME_BYTES} bytes must use the HTTP SSE transport`
194
+ + stageDetail;
124
195
  }
125
- return `${CLOSED_BEFORE_TERMINAL} (close${suffix})`;
196
+ return `${CLOSED_BEFORE_TERMINAL} (close${suffix})${stageDetail}`;
126
197
  }
127
198
 
128
199
  /**
@@ -152,12 +152,13 @@ import {
152
152
  decodeRequestErrorResponse,
153
153
  handleResponses,
154
154
  preAuthUpstreamHostCircuitKey,
155
+ poolCredentialRefreshIncompleteResponse,
155
156
  upstreamHostCircuitOpenResponse,
156
157
  usesCodexForwardPoolAuth,
157
158
  } from "./core";
158
159
  import { fetchWithHeaderTimeout, providerFetch, safeHostLabel, safeOriginLabel } from "./fetch-helpers";
159
160
  import { mapCodexAuthContextErrorToResponse, nativeMainRefreshFailureResponse } from "./codex-auth-error";
160
- import { sessionLaneIdFromRequest } from "../request-log-conversation";
161
+ import { linkRequestSessionLane, sessionLaneIdFromRequest } from "../request-log-conversation";
161
162
  import { recallComboForLane } from "./combo-session-recall";
162
163
 
163
164
  export const COMPACT_RESPONSE_MAX_BYTES = 32 * 1024 * 1024;
@@ -317,6 +318,13 @@ async function refreshPoolCompactContext(args: {
317
318
  authCtx: CodexAuthContext & { kind: "pool" };
318
319
  provider: OcxProviderConfig;
319
320
  codexAccountMode?: CodexAccountMode;
321
+ /**
322
+ * Public selector for the account this refresh is for, when the request carried one. The
323
+ * caller has it and this function does not, because compact takes no `RouteResult` — which
324
+ * is the whole reason the refusal here used to be less specific than the one core returns
325
+ * for the identical failure.
326
+ */
327
+ codexAccountNamespace?: string;
320
328
  substituteMainCredential: boolean;
321
329
  options: HandleResponsesCompactOptions;
322
330
  }): Promise<
@@ -377,14 +385,15 @@ async function refreshPoolCompactContext(args: {
377
385
  if (isTerminalCompactPoolRefreshFailure(error)) {
378
386
  return { ok: false, quarantine: true, response: reauthResponse() };
379
387
  }
380
- const response = formatErrorResponse(
381
- 503,
382
- "server_busy",
383
- "Codex credential refresh did not complete; retry this request",
384
- );
385
- const headers = new Headers(response.headers);
386
- headers.set("Retry-After", "1");
387
- return { ok: false, quarantine: false, response: new Response(response.body, { status: response.status, headers }) };
388
+ return {
389
+ ok: false,
390
+ quarantine: false,
391
+ response: poolCredentialRefreshIncompleteResponse({
392
+ authCtx,
393
+ config,
394
+ accountSelector: args.codexAccountNamespace,
395
+ }),
396
+ };
388
397
  }
389
398
  }
390
399
 
@@ -917,6 +926,7 @@ export async function handleResponsesCompact(
917
926
  authCtx: poolAuthCtx,
918
927
  provider: compactProvider,
919
928
  codexAccountMode: route.codexAccountMode,
929
+ codexAccountNamespace: route.codexAccountNamespace,
920
930
  substituteMainCredential,
921
931
  options,
922
932
  })
@@ -1149,6 +1159,7 @@ export async function handleResponsesCompact(
1149
1159
  headers: internalHeaders,
1150
1160
  body: JSON.stringify(internalBody),
1151
1161
  });
1162
+ linkRequestSessionLane(req, internalReq);
1152
1163
  const response = await handleResponses(internalReq, config, logCtx, { abortSignal: req.signal, turnAdmissionLease, ...(admission ? { admission } : {}) });
1153
1164
  if (!response.ok) return response;
1154
1165
  let json: { output?: unknown[]; status?: unknown; error?: unknown };
@@ -320,6 +320,8 @@ import {
320
320
  } from "../request-log";
321
321
  import {
322
322
  conversationIdFromResponsesRequest,
323
+ getOrAllocateRequestSessionLane,
324
+ linkRequestSessionLane,
323
325
  normalizeLogConversationId,
324
326
  reasoningReplayConversationIdFromResponsesRequest,
325
327
  sessionLaneIdFromRequest,
@@ -2260,6 +2262,50 @@ function isTerminalPoolRefreshFailure(error: unknown): boolean {
2260
2262
  return error instanceof TokenRefreshError && (error.reason === "revoked" || error.reason === "expired");
2261
2263
  }
2262
2264
 
2265
+ /**
2266
+ * The refusal an operator meets when a stored pool credential's forced refresh does not complete.
2267
+ *
2268
+ * A bare "retry this request" reads as a transient fault in the proxy, which is how #4212's
2269
+ * reporter spent an afternoon concluding OpenCodex had broken while one of their own accounts was
2270
+ * the thing that needed them. It stays a retryable 503 and stays non-quarantining, because the
2271
+ * refresh genuinely may succeed and a token-endpoint 5xx must not retire a healthy account
2272
+ * (#2887). What it adds is the account and the exit: when retrying stops helping, that account
2273
+ * has to be signed in again.
2274
+ *
2275
+ * The label is a public account selector when the request carried one, otherwise the durable
2276
+ * `p`-prefixed log label — never the raw pool id and never the email. Those are the identifiers
2277
+ * `responses-compaction-routing.test.ts` and `codex-auth-context.test.ts` already assert must not
2278
+ * reach an operator-facing surface, and an error body travels further than a log line, not less.
2279
+ * When neither is resolvable the sentence degrades to "the selected Codex pool account" rather
2280
+ * than naming something opaque, because a wrong name is worse than no name.
2281
+ *
2282
+ * The wording says "sign in to that account again" and deliberately does NOT say
2283
+ * "reauthentication". `classifyError` runs `isAuthenticationMessage` before it reaches the
2284
+ * `status === 503` arm, and that check is status-blind on the bare substring "authentication",
2285
+ * which "reauthentication" contains. A body carrying that word is reclassified to
2286
+ * `authentication_error` / `invalid_api_key` even though the HTTP status stays 503 — and Codex
2287
+ * applies retry-after backoff only for `server_is_overloaded`, so the friendlier sentence would
2288
+ * have quietly disabled the retry this refusal exists to ask for. `options.code` cannot buy the
2289
+ * classification back; only the wording can.
2290
+ */
2291
+ export function poolCredentialRefreshIncompleteResponse(args: {
2292
+ authCtx: CodexAuthContext;
2293
+ config: Pick<OcxConfig, "codexAccounts">;
2294
+ accountSelector?: string;
2295
+ }): Response {
2296
+ const label = args.accountSelector ?? codexAuthContextLogLabel(args.authCtx, args.config);
2297
+ const account = label ? `Codex pool account ${label}` : "the selected Codex pool account";
2298
+ const response = formatErrorResponse(
2299
+ 503,
2300
+ "server_busy",
2301
+ `Codex credential refresh did not complete for ${account}; retry this request. `
2302
+ + "If it keeps failing, sign in to that account again.",
2303
+ );
2304
+ const headers = new Headers(response.headers);
2305
+ headers.set("Retry-After", "1");
2306
+ return new Response(response.body, { status: response.status, headers });
2307
+ }
2308
+
2263
2309
  /**
2264
2310
  * One forced refresh and one same-account rebuild for a stored pool credential that
2265
2311
  * upstream rejected with a pre-stream 401. `quarantine` distinguishes a dead grant,
@@ -2330,14 +2376,15 @@ async function refreshPoolForwardAuth(args: {
2330
2376
  response: formatErrorResponse(401, "authentication_error", "Selected Codex account needs reauthentication"),
2331
2377
  };
2332
2378
  }
2333
- const response = formatErrorResponse(
2334
- 503,
2335
- "server_busy",
2336
- "Codex credential refresh did not complete; retry this request",
2337
- );
2338
- const headers = new Headers(response.headers);
2339
- headers.set("Retry-After", "1");
2340
- return { ok: false, quarantine: false, response: new Response(response.body, { status: response.status, headers }) };
2379
+ return {
2380
+ ok: false,
2381
+ quarantine: false,
2382
+ response: poolCredentialRefreshIncompleteResponse({
2383
+ authCtx,
2384
+ config,
2385
+ accountSelector: route.codexAccountNamespace,
2386
+ }),
2387
+ };
2341
2388
  }
2342
2389
  }
2343
2390
 
@@ -2451,8 +2498,7 @@ async function applyFinalRouteRequestNormalization(args: {
2451
2498
 
2452
2499
  // Settle the wire once so logging, fast-mode, auth, and sidecars read the adapter
2453
2500
  // this request will actually use (#404).
2454
- route.provider = resolveOpenCodeGoTransport(route.provider,
2455
- sessionLaneIdFromRequest(req.headers) ?? normalizeLogConversationId(req.headers.get("x-opencode-session")));
2501
+ route.provider = resolveOpenCodeGoTransport(route.provider, getOrAllocateRequestSessionLane(req));
2456
2502
  route.provider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire);
2457
2503
  if (preserveAnthropicResponseModel) parsed._responseModelId = responseModelId;
2458
2504
  logCtx.model = route.modelId;
@@ -2816,6 +2862,7 @@ export async function handleComboResponses(
2816
2862
  headers: childHeaders,
2817
2863
  body: JSON.stringify(childBody),
2818
2864
  });
2865
+ linkRequestSessionLane(req, childRequest);
2819
2866
  let resolvedAuth: CodexAuthContext | undefined;
2820
2867
  let terminalRecorder: ((status: ResponsesTerminalStatus, httpStatusOverride?: number) => void) | undefined;
2821
2868
  const started = Date.now();
@@ -2,6 +2,7 @@ import { comboFailureDecision } from "../../combos/failover";
2
2
  import { readBoundedResponseBody } from "../../lib/bounded-body";
3
3
  import { readJsonRequestBody, resolveInboundBodyLimitBytes } from "../request-decompress";
4
4
  import { finishRequestAttempt, type RequestLogContext } from "../request-log";
5
+ import { linkRequestSessionLane } from "../request-log-conversation";
5
6
  import type { OcxConfig } from "../../types";
6
7
  import type { RouteCandidateTrace, RouteDecisionTraceV1 } from "../../routing/trace";
7
8
  import { handleResponses as handleResponsesCore } from "./core";
@@ -56,12 +57,17 @@ function requestWithCandidate(
56
57
  headers.delete("content-encoding");
57
58
  headers.delete("content-length");
58
59
  headers.set("content-type", "application/json");
59
- return new Request(req.url, {
60
+ const retryRequest = new Request(req.url, {
60
61
  method: req.method,
61
62
  headers,
62
63
  body: JSON.stringify({ ...rawBody, model: `${candidate.provider}/${candidate.model}` }),
63
64
  signal: req.signal,
64
65
  });
66
+ // A sessionless request keeps the lane it was already allocated. Without this the second
67
+ // candidate reaches OpenCode Go under a different x-opencode-session than the first attempt,
68
+ // which is the same conversation split the header exists to prevent.
69
+ linkRequestSessionLane(req, retryRequest);
70
+ return retryRequest;
65
71
  }
66
72
 
67
73
  function errorCodeFromText(text: string): string | undefined {
@@ -7,6 +7,7 @@ import { resolveClaudeAuthMode } from "../claude/auth-mode";
7
7
  import { ANTHROPIC_PARENT_ENV_SLOTS, trustedNodeLauncherContext, type AnthropicParentEnvSlot } from "../cli/launcher-context";
8
8
  import type { OcxConfig } from "../types";
9
9
  import { recordOwnedConfigPath } from "../lib/config-ownership";
10
+ import { localAdmissionToken, localInferenceDestination } from "../lib/local-destinations";
10
11
 
11
12
  /**
12
13
  * Does the opencodex dummy marker belong in the system environment?
@@ -79,9 +80,14 @@ export function writeShellEnvFile(
79
80
  auto?: AutoContextMode,
80
81
  deps: SystemEnvDeps = {},
81
82
  ): void {
83
+ // Same local destination the launchd domain gets, resolved through the same resolver rather
84
+ // than re-derived: the unauthenticated loopback listener when one is enabled, otherwise the
85
+ // bind address (#4236). The two files must not disagree, or a new shell and a launchd-started
86
+ // `claude` would dial different sockets.
87
+ const destination = localInferenceDestination(config, port);
82
88
  const lines = [
83
89
  `# Generated by opencodex — do not edit manually`,
84
- `export ANTHROPIC_BASE_URL=${shellValue(`http://127.0.0.1:${port}`)}`,
90
+ `export ANTHROPIC_BASE_URL=${shellValue(destination.origin)}`,
85
91
  `export CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=${shellValue("1")}`,
86
92
  ];
87
93
  // New lever keys are CONDITIONAL exports (audit 139 R2#1): a value the user already
@@ -89,7 +95,13 @@ export function writeShellEnvFile(
89
95
  const conditional = (name: string, value: string) =>
90
96
  `[ -z "\${${name}+x}" ] && export ${name}=${shellValue(value)}`;
91
97
  if (systemEnvMarkerMode(config, deps) === "proxy") {
92
- if (config.apiKeys?.length) {
98
+ // On a bind that demands data-plane admission the credential may live only in
99
+ // `OPENCODEX_API_AUTH_TOKEN` or the hardened service token file, so the same ladder the
100
+ // launchd injection uses applies here. Never the admin token (reviewer constraint on #4236).
101
+ const hostAdmissionToken = destination.requiresAdmissionToken ? localAdmissionToken(config) : undefined;
102
+ if (hostAdmissionToken) {
103
+ lines.push(`export ANTHROPIC_AUTH_TOKEN=${shellValue(hostAdmissionToken)}`);
104
+ } else if (config.apiKeys?.length) {
93
105
  lines.push(`export ANTHROPIC_AUTH_TOKEN=${shellValue(config.apiKeys[0].key)}`);
94
106
  } else {
95
107
  lines.push(conditional("ANTHROPIC_AUTH_TOKEN", PROXY_MARKER));