@bitkyc08/opencodex 2.91.0 → 2.92.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 (148) hide show
  1. package/bin/ocx.mjs +37 -3
  2. package/gui/dist/assets/{App-nZ34cR5G.js → App-sUf_8afJ.js} +18 -18
  3. package/gui/dist/assets/{Tray-BuLy7hL1.js → Tray-BSsxznDm.js} +1 -1
  4. package/gui/dist/assets/index-BlFE_VB_.js +94 -0
  5. package/gui/dist/assets/quota-summary-BRlbXgw0.js +1 -0
  6. package/gui/dist/index.html +1 -1
  7. package/gui/dist/provider-icons/workbuddy-color.svg +29 -0
  8. package/package.json +4 -3
  9. package/src/adapters/google-vertex-endpoint.ts +9 -1
  10. package/src/adapters/google-vertex-key-project.ts +223 -0
  11. package/src/adapters/google.ts +13 -3
  12. package/src/adapters/openai-chat/tool-name-registry.ts +3 -1
  13. package/src/adapters/openai-chat.ts +2 -2
  14. package/src/adapters/openai-responses/request-strips.ts +60 -0
  15. package/src/adapters/registry.ts +5 -0
  16. package/src/adapters/workbuddy-schema.ts +61 -0
  17. package/src/adapters/workbuddy.ts +68 -0
  18. package/src/bridge/response-json.ts +1 -1
  19. package/src/bridge/sse.ts +1 -1
  20. package/src/chat/outbound.ts +11 -4
  21. package/src/cli/capabilities-agents-routing.ts +3 -3
  22. package/src/cli/codex-cli-update.ts +8 -0
  23. package/src/cli/combo-result.ts +6 -0
  24. package/src/cli/combo-stats.ts +3 -0
  25. package/src/cli/combo.ts +66 -42
  26. package/src/cli/launcher-context.ts +49 -2
  27. package/src/cli/link.ts +45 -32
  28. package/src/cli/provider.ts +2 -1
  29. package/src/cli/usage-compare.ts +68 -12
  30. package/src/client/link-ingress.ts +1 -1
  31. package/src/client/link-readiness.ts +97 -0
  32. package/src/client/link-relay-transport.ts +79 -11
  33. package/src/client/link-relay.ts +74 -15
  34. package/src/client/link-tunnel.ts +45 -18
  35. package/src/client/machine-listener.ts +20 -2
  36. package/src/client/runtime.ts +20 -2
  37. package/src/codex/account-store.ts +17 -3
  38. package/src/codex/app-server-restart-service.ts +6 -5
  39. package/src/codex/catalog/effort.ts +9 -2
  40. package/src/codex/catalog/gather-capture.ts +4 -2
  41. package/src/codex/catalog/provider-models.ts +14 -8
  42. package/src/codex/chatgpt-refresh-failure.ts +6 -2
  43. package/src/codex/cli-install-provenance.ts +418 -0
  44. package/src/codex/cli-installation-targets.ts +5 -1
  45. package/src/codex/cli-update-plan.ts +43 -16
  46. package/src/codex/desktop-app/picker-freshness.ts +10 -8
  47. package/src/codex/desktop-compatibility/usage-activation.ts +2 -1
  48. package/src/codex/desktop-compatibility/usage-controller.ts +7 -1
  49. package/src/codex/pool-refresh-backoff.ts +3 -1
  50. package/src/codex/user-identity.ts +35 -6
  51. package/src/codex/windows-current-sid.ts +162 -0
  52. package/src/combos/jev-quota-activation.ts +5 -1
  53. package/src/combos/jev-quota-route.ts +1 -1
  54. package/src/combos/jev-quota.ts +9 -1
  55. package/src/combos/jev-session-pin-config.ts +51 -0
  56. package/src/combos/jev.ts +6 -0
  57. package/src/combos/types.ts +19 -0
  58. package/src/config/provider-validation.ts +7 -0
  59. package/src/config/schema/leaf-validators.ts +1 -0
  60. package/src/generated/compatibility-version.json +194 -122
  61. package/src/lib/decision-quota-sink.ts +10 -0
  62. package/src/lib/translator-budget.ts +4 -4
  63. package/src/lib/upstream-retry.ts +4 -1
  64. package/src/link/diagnostics.ts +193 -0
  65. package/src/link/relay-auth.ts +6 -0
  66. package/src/link/status-projection.ts +39 -0
  67. package/src/link/supervisor.ts +45 -12
  68. package/src/link/tunnel-history.ts +109 -0
  69. package/src/link/tunnel-state.ts +80 -17
  70. package/src/messaging/remote-files.ts +24 -1
  71. package/src/oauth/generic-account-failover.ts +91 -7
  72. package/src/oauth/index.ts +15 -0
  73. package/src/oauth/model-discovery-auth.ts +5 -0
  74. package/src/oauth/store.ts +4 -1
  75. package/src/oauth/token-guardian.ts +7 -0
  76. package/src/oauth/types.ts +9 -0
  77. package/src/oauth/workbuddy.ts +139 -0
  78. package/src/providers/antigravity-quota-identity.ts +37 -0
  79. package/src/providers/base-url-choices.ts +8 -5
  80. package/src/providers/derive.ts +23 -3
  81. package/src/providers/model-discovery.ts +2 -0
  82. package/src/providers/model-rename-fields.ts +1 -0
  83. package/src/providers/quota/account-cache.ts +3 -2
  84. package/src/providers/quota/antigravity.ts +25 -14
  85. package/src/providers/quota/report-cache.ts +13 -4
  86. package/src/providers/quota/vendor-probes-key.ts +60 -0
  87. package/src/providers/quota/workbuddy.ts +83 -0
  88. package/src/providers/quota-decision-antigravity.ts +64 -0
  89. package/src/providers/quota-decision-publication.ts +11 -0
  90. package/src/providers/quota-decision-snapshot.ts +21 -4
  91. package/src/providers/quota-types.ts +2 -0
  92. package/src/providers/quota.ts +3 -1
  93. package/src/providers/registry/entries-core.ts +9 -0
  94. package/src/providers/registry/entries-extended.ts +20 -7
  95. package/src/providers/registry/model-ids.ts +3 -0
  96. package/src/providers/registry/model-seeds.ts +27 -28
  97. package/src/providers/registry/types.ts +19 -1
  98. package/src/providers/resolved-model-policy.ts +4 -4
  99. package/src/providers/routed-config.ts +15 -0
  100. package/src/providers/stale-model-roster-migration.ts +83 -15
  101. package/src/providers/workbuddy-credit-balance.ts +81 -0
  102. package/src/providers/workbuddy-models.ts +37 -0
  103. package/src/providers/workbuddy.ts +60 -0
  104. package/src/responses/reasoning-replay-cache.ts +18 -14
  105. package/src/router.ts +2 -0
  106. package/src/routing/analytics.ts +2 -3
  107. package/src/routing/fallback-policy.ts +3 -3
  108. package/src/server/auth-cors.ts +1 -0
  109. package/src/server/chat-completions.ts +14 -1
  110. package/src/server/chat-native-reasoning.ts +10 -3
  111. package/src/server/chat-native.ts +13 -2
  112. package/src/server/claude-messages.ts +3 -0
  113. package/src/server/index/link-relay-sessions.ts +9 -5
  114. package/src/server/index/optional-listeners.ts +4 -0
  115. package/src/server/local-management-read-client.ts +3 -1
  116. package/src/server/management/combo-routes.ts +10 -4
  117. package/src/server/management/link-routes.ts +5 -2
  118. package/src/server/management/provider-routes.ts +1 -1
  119. package/src/server/port-reclaim.ts +51 -9
  120. package/src/server/request-log-conversation.ts +49 -0
  121. package/src/server/request-log.ts +2 -0
  122. package/src/server/responses/anthropic-thinking-recovery.ts +1 -0
  123. package/src/server/responses/compact.ts +10 -2
  124. package/src/server/responses/core-combo.ts +53 -31
  125. package/src/server/responses/core-lifetime.ts +2 -2
  126. package/src/server/responses/core-normalize.ts +6 -3
  127. package/src/server/responses/core-opaque-recovery.ts +1 -0
  128. package/src/server/responses/core-options.ts +5 -0
  129. package/src/server/responses/core-replay.ts +2 -1
  130. package/src/server/responses/jev-combo-choice.ts +39 -0
  131. package/src/server/responses/jev-session-pin.ts +203 -0
  132. package/src/server/responses/passthrough-delivery.ts +4 -0
  133. package/src/server/responses/request-prepare.ts +10 -5
  134. package/src/server/responses/request-transport.ts +25 -6
  135. package/src/server/responses/run-turn-execution.ts +2 -4
  136. package/src/server/responses/sidecar-execution.ts +2 -4
  137. package/src/server/responses-phase-inference.ts +267 -0
  138. package/src/server/sse-payload-rewrite.ts +14 -1
  139. package/src/service/definition.ts +20 -8
  140. package/src/types/config.ts +6 -0
  141. package/src/types/provider.ts +9 -1
  142. package/src/types/request.ts +1 -0
  143. package/src/types/wire.ts +1 -0
  144. package/src/update/codex-cli-update-launch-policy.d.mts +9 -0
  145. package/src/update/codex-cli-update-launch-policy.mjs +30 -0
  146. package/src/usage/jev-stats.ts +54 -14
  147. package/gui/dist/assets/index-DGXS51sp.js +0 -94
  148. package/gui/dist/assets/quota-summary-DO1Sx5TM.js +0 -1
@@ -1,4 +1,5 @@
1
- import { fetchBoundLinkRelay, LinkRelayAuthenticationError } from "./link-relay-transport";
1
+ import { randomUUID } from "node:crypto";
2
+ import { fetchBoundLinkRelay, LinkRelayAuthenticationError, LinkRelayAuthTimeoutError } from "./link-relay-transport";
2
3
  import { homeUnreachableBody } from "./home-unreachable";
3
4
  import {
4
5
  boundedRelayResponseStream,
@@ -6,6 +7,7 @@ import {
6
7
  headersWithinLimit,
7
8
  validateHubRelayRequestHeaders,
8
9
  } from "./hub-relay";
10
+ import type { LinkDiagnosticRecord, LinkDiagnosticSink } from "../link/diagnostics";
9
11
  import { linkRouteAllowed } from "../link/routes";
10
12
  import { isLinkPort } from "../link/ports";
11
13
  import { resolveInboundBodyLimitBytes } from "../server/request-decompress";
@@ -26,14 +28,23 @@ export interface LinkRelayClock {
26
28
  clearTimeout: typeof clearTimeout;
27
29
  }
28
30
 
31
+ /** A fresh admission verdict plus optional diagnostic evidence; metadata never grants admission. */
32
+ export interface LinkAdmissionResult {
33
+ kind: "match" | "mismatch" | "lookup_error" | "deadline" | "stale" | "identity_unknown" | "identity_mismatch" | "unavailable";
34
+ elapsedMs?: number;
35
+ generation?: number;
36
+ state?: string;
37
+ checkId?: string;
38
+ }
39
+
29
40
  /**
30
41
  * The Child's tunnel as the relay sees it (the client link supervisor implements it). A request
31
42
  * waits on it only while the tunnel is being (re)established. Every relayed fetch waits for a
32
43
  * fresh bounded asynchronous ownership proof, including after a reconnect or refused retry.
33
44
  */
34
45
  export interface LinkTunnelGate {
35
- /** True only while a keyed probe has established this live tunnel. */
36
- connected(): boolean | Promise<boolean>;
46
+ /** Admission and its evidence are returned together; only match permits a fetch. */
47
+ connected(context?: { requestId?: string }): LinkAdmissionResult | Promise<LinkAdmissionResult>;
37
48
  /** True while the tunnel is connecting or reconnecting. */
38
49
  pending(): boolean;
39
50
  /**
@@ -51,12 +62,13 @@ export interface LinkTunnelGate {
51
62
  * supervisor, and a relay with no gate at all still refuses every request.
52
63
  */
53
64
  export const HOME_INITIATED_LINK_TUNNEL: LinkTunnelGate = {
54
- connected: () => true,
65
+ connected: () => ({ kind: "match" }),
55
66
  pending: () => false,
56
67
  waitForConnected: async () => false,
57
68
  };
58
69
 
59
70
  export interface LinkRelayDeps {
71
+ diagnostic?: LinkDiagnosticSink;
60
72
  /** Trusted complete-send test seam. Production always uses the connection-bound transport. */
61
73
  fetchImpl?: typeof fetch;
62
74
  clock?: LinkRelayClock;
@@ -136,6 +148,7 @@ function relayAuthenticationFailed(error: string): Response {
136
148
  });
137
149
  }
138
150
 
151
+ /** Preserve path/query on a fixed IPv4 loopback origin; throw when the tunnel port is invalid. */
139
152
  export function linkRelayDestination(url: URL, target: Pick<LinkRelayTarget, "tunnelPort">): string {
140
153
  if (!isLinkPort(target.tunnelPort)) {
141
154
  throw new RangeError("invalid link tunnel port");
@@ -180,6 +193,7 @@ function linkRequestHeaders(
180
193
  return headers;
181
194
  }
182
195
 
196
+ /** Drop hop-by-hop and stale length/encoding headers before exposing the decoded response. */
183
197
  export function sanitizeLinkResponseHeaders(source: Headers): Headers {
184
198
  const connectionNamed = new Set((source.get("connection") ?? "")
185
199
  .split(",").map(value => value.trim().toLowerCase()).filter(Boolean));
@@ -328,6 +342,11 @@ function idleBoundedStream(
328
342
  });
329
343
  }
330
344
 
345
+ /**
346
+ * Admit an allowlisted route and stream it through the authenticated tunnel with bounded bodies.
347
+ * Owner/auth preflight deadlines share one recovery attempt before data dispatch; an ambiguous
348
+ * post-dispatch failure never earns that recovery. Diagnostic failures cannot change admission.
349
+ */
331
350
  export async function relayLinkDataRequest(
332
351
  req: Request,
333
352
  target: LinkRelayTarget,
@@ -352,6 +371,16 @@ export async function relayLinkDataRequest(
352
371
  return jsonError(431, "link relay request headers too large");
353
372
  }
354
373
 
374
+ const requestId = randomUUID();
375
+ let admission: LinkAdmissionResult = { kind: "unavailable" };
376
+ let recoveryAttempts = 0;
377
+ const diagnostic = (event: LinkDiagnosticRecord["event"], outcome: LinkDiagnosticRecord["outcome"], elapsedMs?: number) => {
378
+ try { deps.diagnostic?.({ event, outcome, requestId,
379
+ checkId: admission.checkId, generation: admission.generation,
380
+ state: admission.state, elapsedMs: admission.elapsedMs,
381
+ ...(elapsedMs !== undefined ? { elapsedMs } : {}), attempt: recoveryAttempts }); }
382
+ catch { /* diagnostics never alter admission */ }
383
+ };
355
384
  const relayAbort = new AbortController();
356
385
  const clock = deps.clock ?? defaultClock;
357
386
  let headerTimer: ReturnType<typeof setTimeout> | undefined = clock.setTimeout(() => {
@@ -390,20 +419,45 @@ export async function relayLinkDataRequest(
390
419
  const remaining = holdUntil - now;
391
420
  return remaining > 0 && await gate.waitForConnected(remaining, relayAbort.signal);
392
421
  };
393
- const tunnelUnavailable = async (): Promise<Response> => {
422
+ const tunnelUnavailable = async (
423
+ reason: LinkDiagnosticRecord["outcome"] = admission.kind === "match" ? "unavailable" : admission.kind,
424
+ message = "link tunnel unavailable",
425
+ elapsedMs?: number,
426
+ ): Promise<Response> => {
427
+ diagnostic("relay_failure", relayAbort.signal.aborted
428
+ ? req.signal.aborted ? "client_abort" : "header_timeout" : reason, elapsedMs);
394
429
  cleanup();
395
430
  try { await body?.cancel(); } catch { /* best effort */ }
396
- return homeUnreachable("link tunnel unavailable");
431
+ const response = reason === "auth_failed" ? relayAuthenticationFailed(message) : homeUnreachable(message);
432
+ response.headers.set("x-opencodex-link-request-id", requestId);
433
+ return response;
434
+ };
435
+ const retryPreflight = (reason: "deadline" | "auth_timeout", elapsedMs?: number): boolean => {
436
+ if (recoveryAttempts >= 1 || relayAbort.signal.aborted || bodyOverflow || bodyCancelled || body?.locked) return false;
437
+ recoveryAttempts += 1;
438
+ diagnostic("relay_retry", reason, elapsedMs);
439
+ return true;
440
+ };
441
+ const checkConnected = async (): Promise<boolean> => {
442
+ admission = tunnel ? await tunnel.connected({ requestId }) : { kind: "unavailable" };
443
+ return admission.kind === "match";
397
444
  };
398
445
  const readyForFetch = async (): Promise<boolean> => {
399
- if (!tunnel) return false;
400
- if (await tunnel.connected()) return true;
401
- return tunnel.pending() && await holdForTunnel(tunnel) && await tunnel.connected();
446
+ if (!tunnel || relayAbort.signal.aborted) return false;
447
+ if (await checkConnected()) return !relayAbort.signal.aborted;
448
+ // No fetch or body read has occurred. A deadline proves neither a foreign owner nor a
449
+ // broken SSH process: give this request one fresh proof, without caching or restarting it.
450
+ if (admission.kind === "deadline" && retryPreflight("deadline")) {
451
+ if (await checkConnected()) return !relayAbort.signal.aborted;
452
+ }
453
+ return !relayAbort.signal.aborted && tunnel.pending() && await holdForTunnel(tunnel)
454
+ && await checkConnected() && !relayAbort.signal.aborted;
402
455
  };
403
456
 
404
457
  let upstream: Response | undefined;
405
458
  while (!upstream) {
406
459
  if (!await readyForFetch()) return await tunnelUnavailable();
460
+ const fetchStartedAt = performance.now();
407
461
  try {
408
462
  const init: RequestInit & { duplex?: "half" } = {
409
463
  method: req.method,
@@ -415,24 +469,28 @@ export async function relayLinkDataRequest(
415
469
  upstream = await (deps.fetchImpl ? deps.fetchImpl(destination, init) : fetchBoundLinkRelay(target, destination, init));
416
470
  } catch (error) {
417
471
  if (error instanceof LinkRelayAuthenticationError) {
418
- cleanup();
419
- try { await body?.cancel(); } catch { /* an upload might already be drained */ }
420
- return relayAuthenticationFailed(error.message);
472
+ return await tunnelUnavailable("auth_failed", error.message);
421
473
  }
422
474
  if (bodyOverflow) {
423
475
  cleanup();
424
476
  return jsonError(413, "link relay request body too large");
425
477
  }
478
+ if (error instanceof LinkRelayAuthTimeoutError) {
479
+ // The transport certifies that only its credential-free challenge was sent. A new
480
+ // iteration obtains a fresh ownership proof and a new authenticated physical socket.
481
+ const elapsedMs = performance.now() - fetchStartedAt;
482
+ if (retryPreflight("auth_timeout", elapsedMs)) continue;
483
+ return await tunnelUnavailable("auth_timeout", "link tunnel unavailable", elapsedMs);
484
+ }
426
485
  // A refused connection sent nothing, and an untouched body can go again once the tunnel
427
486
  // is back inside the same hold window. Anything else is never replayed.
428
487
  const resendable = connectionRefused(error) && (body === null || (!body.locked && !bodyCancelled));
429
- if (!tunnel || !resendable || !tunnel.pending() || !await holdForTunnel(tunnel)) return await tunnelUnavailable();
488
+ if (!tunnel || !resendable || !tunnel.pending() || !await holdForTunnel(tunnel)) return await tunnelUnavailable("transport_error");
430
489
  }
431
490
  }
432
491
  if (relayAbort.signal.aborted) {
433
- cleanup();
434
492
  try { await upstream.body?.cancel(); } catch { /* best effort */ }
435
- return homeUnreachable("link tunnel unavailable");
493
+ return await tunnelUnavailable();
436
494
  }
437
495
 
438
496
  const sse = isSse(upstream.headers);
@@ -449,6 +507,7 @@ export async function relayLinkDataRequest(
449
507
  try { await upstream.body?.cancel(); } catch { /* best effort */ }
450
508
  return jsonError(502, "link relay response body too large");
451
509
  }
510
+ if (recoveryAttempts > 0 && upstream.ok) diagnostic("relay_recovered", "recovered");
452
511
  if (req.method === "HEAD" || !upstream.body) {
453
512
  cleanup();
454
513
  try { await upstream.body?.cancel(); } catch { /* best effort */ }
@@ -1,3 +1,4 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import { chmodSync, mkdirSync, readFileSync, statSync, unlinkSync } from "node:fs";
2
3
  import { execFile } from "node:child_process";
3
4
  import { readFile as readFileAsync } from "node:fs/promises";
@@ -13,17 +14,18 @@ import {
13
14
  CLIENT_TUNNEL_RETRY_POLICY,
14
15
  classifySshStderr,
15
16
  dueForSpawn,
16
- failedTunnel,
17
+ failedSpawnTunnel,
17
18
  IDLE,
18
19
  reduceTunnel,
19
20
  type StderrClass,
20
21
  type TunnelState,
21
22
  } from "../link/tunnel-state";
22
23
  import { isLinkPort } from "../link/ports";
23
- import { ownsIpv4LoopbackListener } from "../server/port-reclaim";
24
+ import { inspectIpv4LoopbackListener, type LoopbackOwnerLookupResult } from "../server/port-reclaim";
25
+ import type { LinkDiagnosticSink } from "../link/diagnostics";
24
26
  import { isLinkConnection, readClientConnectionState } from "./state";
25
27
  import { clientLinkStatePath, readClientLinkState, type ClientLinkState } from "./link-state";
26
- import type { LinkTunnelGate } from "./link-relay";
28
+ import type { LinkTunnelGate, LinkAdmissionResult } from "./link-relay";
27
29
 
28
30
  /**
29
31
  * The client-owned `ssh -N -L 127.0.0.1:<tunnelPort>:127.0.0.1:<peerListenerPort> <alias>`
@@ -134,7 +136,8 @@ export interface ClientLinkSupervisorDeps extends ClientLinkTunnelDeps, OrphanRe
134
136
  /** The link key for the keyed readiness probe. The runtime passes its cached key source. */
135
137
  linkKey?: () => string | null;
136
138
  /** Async local IPv4 LISTEN ownership; an unknown or foreign owner never receives the link key. */
137
- ownsLoopbackListener?: (port: number, pid: number) => Promise<boolean>;
139
+ inspectLoopbackListener?: (port: number, pid: number) => Promise<LoopbackOwnerLookupResult>;
140
+ diagnostic?: LinkDiagnosticSink;
138
141
  fetchImpl?: typeof fetch;
139
142
  now?: () => number;
140
143
  random?: () => number;
@@ -563,6 +566,10 @@ async function probeTunnel(fetchImpl: typeof fetch, tunnelPort: number, key: str
563
566
  }
564
567
  }
565
568
 
569
+ /**
570
+ * Own the Child's SSH lifecycle and reconnect holds. Each connected() call checks fresh listener
571
+ * ownership and fences results against tunnel replacement or stop; diagnostics cannot admit it.
572
+ */
566
573
  export function createClientLinkSupervisor(deps: ClientLinkSupervisorDeps = {}): ClientLinkSupervisor {
567
574
  const sidecarPath = (): string => clientLinkStatePath(deps.configDir);
568
575
  const readSidecar = deps.readSidecar ?? readWhenFileChanges(sidecarPath, () => readClientLinkState(sidecarPath()));
@@ -573,7 +580,7 @@ export function createClientLinkSupervisor(deps: ClientLinkSupervisorDeps = {}):
573
580
  const policy = CLIENT_TUNNEL_RETRY_POLICY;
574
581
  const isAlive = deps.isAlive ?? defaultIsAlive;
575
582
  const linkKey = deps.linkKey ?? (() => null);
576
- const ownerLookup = deps.ownsLoopbackListener ?? ownsIpv4LoopbackListener;
583
+ const ownerLookup = deps.inspectLoopbackListener ?? inspectIpv4LoopbackListener;
577
584
  const fetchImpl = deps.fetchImpl ?? fetch;
578
585
  const setSupervisorTimer = deps.setTimer ?? ((callback: () => void, ms: number) => {
579
586
  const interval = setInterval(callback, ms);
@@ -620,9 +627,16 @@ export function createClientLinkSupervisor(deps: ClientLinkSupervisorDeps = {}):
620
627
  if (pid === null || pid === undefined || (adopted !== null && !adoptedRecord)) return null;
621
628
  return { pid, generation: ownerGeneration };
622
629
  };
623
- const listenerOwnedByTunnel = async (port: number): Promise<boolean> => {
630
+ const listenerOwnedByTunnel = async (port: number, context?: { requestId?: string }): Promise<LinkAdmissionResult> => {
624
631
  const candidate = ownerCandidate(port);
625
- if (!candidate) return false;
632
+ if (!candidate) return { kind: "unavailable" };
633
+ const checkId = randomUUID();
634
+ const report = (kind: LinkAdmissionResult["kind"], elapsedMs = 0): LinkAdmissionResult => {
635
+ const check = { kind, elapsedMs, checkId, generation: candidate.generation, state: state.kind };
636
+ if (kind !== "match") try { deps.diagnostic?.({ event: "owner_lookup", outcome: kind, requestId: context?.requestId,
637
+ checkId, generation: candidate.generation, state: state.kind, elapsedMs }); } catch { /* best effort */ }
638
+ return check;
639
+ };
626
640
  if (adopted !== null) {
627
641
  const record = adoptedRecord;
628
642
  const identity = record ? await inspectAdoptedIdentity(record, deps) : "unknown";
@@ -640,16 +654,28 @@ export function createClientLinkSupervisor(deps: ClientLinkSupervisorDeps = {}):
640
654
  invalidateOwnerProof();
641
655
  setState(IDLE);
642
656
  }
643
- return false;
657
+ return report(identity === "mismatch" ? "identity_mismatch" : "identity_unknown");
644
658
  }
645
659
  }
646
- const owned = await Promise.resolve().then(() => ownerLookup(port, candidate.pid)).catch(() => false);
647
- return owned && !stopping && ownerGeneration === candidate.generation
648
- && ownerCandidate(port)?.pid === candidate.pid;
660
+ const result = await Promise.resolve().then(() => ownerLookup(port, candidate.pid))
661
+ .catch((): LoopbackOwnerLookupResult => ({ kind: "lookup_error", elapsedMs: 0 }));
662
+ if (stopping || failure !== undefined || ownerGeneration !== candidate.generation || ownerCandidate(port)?.pid !== candidate.pid) {
663
+ return report("stale", result.elapsedMs);
664
+ }
665
+ return report(result.kind, result.elapsedMs);
666
+ };
667
+ const connected = async (context?: { requestId?: string }): Promise<LinkAdmissionResult> => {
668
+ if (!started || stopping || failure !== undefined || state.kind !== "connected" || linkId === null || tunnelPort === null) {
669
+ return { kind: "unavailable" };
670
+ }
671
+ const result = await listenerOwnedByTunnel(tunnelPort, context);
672
+ // State may change during the asynchronous lookup or an optional diagnostic callback.
673
+ if (result.kind === "match" && (!started || stopping || failure !== undefined
674
+ || state.kind !== "connected" || result.generation !== ownerGeneration)) {
675
+ return { ...result, kind: "stale" };
676
+ }
677
+ return result;
649
678
  };
650
- const connected = async (): Promise<boolean> => started && !stopping && failure === undefined
651
- && state.kind === "connected" && linkId !== null && tunnelPort !== null
652
- && await listenerOwnedByTunnel(tunnelPort);
653
679
 
654
680
  const settleWaiters = (): void => {
655
681
  if (waiters.size === 0) return;
@@ -733,7 +759,8 @@ export function createClientLinkSupervisor(deps: ClientLinkSupervisorDeps = {}):
733
759
  peerListenerPort: sidecar.peerListenerPort,
734
760
  }, deps);
735
761
  } catch {
736
- setState(failedTunnel("forward", timestamp, policy, state.kind === "failed" ? state.since : timestamp));
762
+ // A due retry that cannot start counts as a failed retry, so the backoff keeps its step.
763
+ setState(failedSpawnTunnel(state, "forward", timestamp, policy));
737
764
  deps.warn?.("client link tunnel could not be started");
738
765
  return;
739
766
  }
@@ -800,7 +827,7 @@ export function createClientLinkSupervisor(deps: ClientLinkSupervisorDeps = {}):
800
827
  probeFlight = flight;
801
828
  const wasConnected = state.kind === "connected";
802
829
  void (async () => {
803
- if (!await listenerOwnedByTunnel(tunnelPort)) return;
830
+ if ((await listenerOwnedByTunnel(tunnelPort)).kind !== "match") return;
804
831
  if (probeFlight !== flight || stopping || flight.abort.signal.aborted
805
832
  || child !== flight.child || adopted !== flight.adopted) return;
806
833
  const key = linkKey();
@@ -808,7 +835,7 @@ export function createClientLinkSupervisor(deps: ClientLinkSupervisorDeps = {}):
808
835
  const result = await probeTunnel(fetchImpl, tunnelPort, key, flight.abort.signal);
809
836
  // An aborted probe, or one whose listener changed while it ran, proves nothing.
810
837
  if (probeFlight !== flight || stopping || child !== flight.child || adopted !== flight.adopted
811
- || !await listenerOwnedByTunnel(tunnelPort)) return;
838
+ || (await listenerOwnedByTunnel(tunnelPort)).kind !== "match") return;
812
839
  applyProbe(result, wasConnected);
813
840
  })().catch(() => undefined).finally(() => {
814
841
  if (probeFlight === flight) probeFlight = undefined;
@@ -971,7 +998,7 @@ export function createClientLinkSupervisor(deps: ClientLinkSupervisorDeps = {}):
971
998
  pending,
972
999
  connected,
973
1000
  waitForConnected(timeoutMs: number, signal?: AbortSignal): Promise<boolean> {
974
- if (state.kind === "connected") return connected();
1001
+ if (state.kind === "connected") return connected().then(result => result.kind === "match");
975
1002
  if (!pending() || waiters.size >= CLIENT_LINK_MAX_HOLDS || signal?.aborted || !(timeoutMs > 0)) {
976
1003
  return Promise.resolve(false);
977
1004
  }
@@ -1,3 +1,4 @@
1
+ import { recordLinkDiagnostic } from "../link/diagnostics";
1
2
  import type { Server } from "bun";
2
3
  import { loadConfig } from "../config";
3
4
  import { browserSecurityHeaders, requestPolicyView } from "../server/auth-cors";
@@ -21,6 +22,7 @@ import type { LinkTunnelGate } from "./link-relay";
21
22
  import { readClientLinkState, type ClientLinkState } from "./link-state";
22
23
  import { projectClientLinkChild, type ClientLinkSidecarRead } from "./link-status";
23
24
  import type { ClientLinkSupervisorStatus } from "./link-tunnel";
25
+ import type { LinkReadiness } from "./link-readiness";
24
26
  import { packageVersion } from "../lib/package-version";
25
27
  import { createLocalAttestationProof, LOCAL_ATTESTATION_CHALLENGE_HEADER, LOCAL_ATTESTATION_PROOF_HEADER } from "../lib/local-management-attestation";
26
28
  import {
@@ -65,6 +67,11 @@ export interface MachineListenerDeps {
65
67
  linkTunnel?: LinkTunnelGate;
66
68
  /** Link mode: relay seams (deadline clock and byte cap). */
67
69
  linkRelay?: LinkIngress["relay"];
70
+ /**
71
+ * Link mode: whether relayed requests can be served now. `GET /readyz` answers 503 `pending`
72
+ * with `link.reason` while it is not; `/healthz` is unaffected (`link-readiness.ts`).
73
+ */
74
+ linkReadiness?: () => LinkReadiness | Promise<LinkReadiness>;
68
75
  /**
69
76
  * The bound restart (OCX-6). With it, `/healthz` proves this process's identity to a challenge,
70
77
  * advertises `restartCapability` and `startedAt`, and `POST /api/system/restart` accepts a
@@ -152,7 +159,7 @@ export function startMachineListener(
152
159
  apiKeyId: connection.apiKeyId,
153
160
  policy: requestPolicyView(config, "127.0.0.1"),
154
161
  linkKey: deps.linkKeySource ?? createLinkKeySource(connection.tokenFingerprint, deps.linkKey),
155
- relay: { fetchImpl: deps.fetchImpl, bodyLimitBytes: inboundBodyLimit, tunnel: deps.linkTunnel, ...deps.linkRelay },
162
+ relay: { diagnostic: recordLinkDiagnostic, fetchImpl: deps.fetchImpl, bodyLimitBytes: inboundBodyLimit, tunnel: deps.linkTunnel, ...deps.linkRelay },
156
163
  }
157
164
  : null;
158
165
  return (deps.serve ?? (options => Bun.serve(options)))({
@@ -186,7 +193,18 @@ export function startMachineListener(
186
193
  return await handleBoundRestart(req, server, deps.restart);
187
194
  }
188
195
  if (url.pathname === "/readyz" && req.method === "GET") {
189
- return Response.json({ service: "opencodex", version: VERSION, role: "client", status: "ready", uptime: process.uptime(), pid: process.pid, port: server.port, protocolVersion: 1 });
196
+ const identity = { service: "opencodex", version: VERSION, role: "client" } as const;
197
+ const facts = { uptime: process.uptime(), pid: process.pid, port: server.port, protocolVersion: 1 };
198
+ let link: LinkReadiness | null = null;
199
+ if (linkMode && deps.linkReadiness) {
200
+ try { link = await deps.linkReadiness(); } catch { link = { ready: false, reason: "link_unverified" }; }
201
+ }
202
+ if (link && !link.ready) {
203
+ // The /readyz contract: not ready is 503 with Retry-After and status pending or failed.
204
+ return Response.json({ ...identity, status: "pending", ...facts, link: { state: "unavailable", reason: link.reason } },
205
+ { status: 503, headers: { "Retry-After": "1", "Cache-Control": "no-store" } });
206
+ }
207
+ return Response.json({ ...identity, status: "ready", ...facts, ...(link ? { link: { state: "available" } } : {}) });
190
208
  }
191
209
  if (url.pathname.startsWith("/api/machine/hub-relay/")) {
192
210
  if (!relayEnabled) return json404(req);
@@ -1,3 +1,4 @@
1
+ import { flushLinkDiagnostics, recordLinkDiagnostic } from "../link/diagnostics";
1
2
  import { existsSync } from "node:fs";
2
3
  import type { Server } from "bun";
3
4
  import { siblingRuntimeField, withSiblingMarker } from "../codex/sibling-start";
@@ -20,6 +21,7 @@ import type { ReplacementStartRequest } from "../server/restart-replacement";
20
21
  import type { OcxClientConnectionConfig } from "../types";
21
22
  import { createLinkKeySource } from "./link-ingress";
22
23
  import { HOME_INITIATED_LINK_TUNNEL } from "./link-relay";
24
+ import { clientInitiatedLinkReadiness, createHomeInitiatedLinkReadiness } from "./link-readiness";
23
25
  import { createClientLinkSupervisor, type ClientLinkSupervisor } from "./link-tunnel";
24
26
  import { clientLinkStatePath, isChildInitiatedLink, recordChildInitiatedLink } from "./link-state";
25
27
  import { startMachineListener, type MachineListenerDeps } from "./machine-listener";
@@ -164,6 +166,7 @@ export async function recycleStandalone(
164
166
  console.warn(`[client] listener stop failed: ${error instanceof Error ? error.message : String(error)}`);
165
167
  }
166
168
  cleanup();
169
+ await flushLinkDiagnostics();
167
170
  // Recycling back to standalone after `ocx disconnect` must actually bring a standalone
168
171
  // proxy back, under every launch shape.
169
172
  //
@@ -227,7 +230,7 @@ export async function bindClientListener(
227
230
  preferred: number;
228
231
  explicitPort: boolean;
229
232
  configuredPort: number;
230
- } & Pick<MachineListenerDeps, "linkStatus" | "linkKeySource" | "linkTunnel" | "restart">,
233
+ } & Pick<MachineListenerDeps, "linkStatus" | "linkKeySource" | "linkTunnel" | "linkReadiness" | "restart">,
231
234
  io: ClientRuntimeIo = {},
232
235
  ): Promise<{ server: Server<unknown>; port: number }> {
233
236
  const { linkMode } = request;
@@ -267,6 +270,7 @@ export async function bindClientListener(
267
270
  ...(request.linkStatus ? { linkStatus: request.linkStatus } : {}),
268
271
  ...(request.linkKeySource ? { linkKeySource: request.linkKeySource } : {}),
269
272
  ...(request.linkTunnel ? { linkTunnel: request.linkTunnel } : {}),
273
+ ...(request.linkReadiness ? { linkReadiness: request.linkReadiness } : {}),
270
274
  ...(request.restart ? { restart: request.restart } : {}),
271
275
  });
272
276
  return { server, port: server.port ?? port };
@@ -318,10 +322,19 @@ export async function startClientRuntime(
318
322
  const serve = async (rollback?: { port: number; portWaitMs: number }): Promise<boolean> => {
319
323
  const supervisor = supervised
320
324
  ? createClientLinkSupervisor({
325
+ diagnostic: recordLinkDiagnostic,
321
326
  onLinkEnded: () => scheduleStandaloneRecycle(state.value.tokenFingerprint),
322
327
  linkKey,
323
328
  })
324
329
  : null;
330
+ // What `/readyz` reports about the link: the supervisor's tunnel, or for a Home-initiated
331
+ // link the shared relay preflight. A link with neither refuses every relayed request.
332
+ const link = state.value.link;
333
+ const linkReadiness: MachineListenerDeps["linkReadiness"] = !linkMode ? undefined
334
+ : supervisor ? () => clientInitiatedLinkReadiness(supervisor.status())
335
+ : homeInitiated && link && linkKey
336
+ ? createHomeInitiatedLinkReadiness({ target: { tunnelPort: link.tunnelPort, linkId: link.linkId, apiKeyId: state.value.apiKeyId }, linkKey })
337
+ : () => ({ ready: false, reason: "tunnel_unavailable" });
325
338
  const { server, port: boundPort } = await bindClientListener({
326
339
  state: state.value,
327
340
  linkMode,
@@ -332,6 +345,7 @@ export async function startClientRuntime(
332
345
  restart: { attestationSecret, startedAt: CLIENT_RUNTIME_STARTED_AT, request: () => restartController.request() },
333
346
  ...(linkMode ? { linkStatus: () => activeSupervisor?.status() ?? { kind: "stopped" as const }, linkKeySource: linkKey } : {}),
334
347
  ...(supervisor ? { linkTunnel: supervisor } : homeInitiated ? { linkTunnel: HOME_INITIATED_LINK_TUNNEL } : {}),
348
+ ...(linkReadiness ? { linkReadiness } : {}),
335
349
  }, rollback === undefined ? io : { ...io, portWaitMs: rollback.portWaitMs });
336
350
  const publish = (): void => {
337
351
  activeServer = server;
@@ -417,7 +431,10 @@ export async function startClientRuntime(
417
431
  await recycleStandalone(state.value.tokenFingerprint);
418
432
  }
419
433
  },
420
- exitProcess: code => { process.exit(code); },
434
+ exitProcess: async code => {
435
+ await flushLinkDiagnostics();
436
+ process.exit(code);
437
+ },
421
438
  });
422
439
 
423
440
  installCrashGuards();
@@ -444,6 +461,7 @@ export async function startClientRuntime(
444
461
  console.warn(`[client] listener stop failed: ${error instanceof Error ? error.message : String(error)}`);
445
462
  } finally {
446
463
  cleanup();
464
+ await flushLinkDiagnostics();
447
465
  // launchd relaunches only unsuccessful exits; an external signal must still count.
448
466
  process.exit(handledSignalExitCode(signal));
449
467
  }
@@ -12,13 +12,15 @@ import {
12
12
  backupInvalidConfig,
13
13
  hardenConfigDir,
14
14
  hardenExistingSecret,
15
+ loadConfig,
15
16
  withConfigMutationLockSync,
16
17
  } from "../config";
17
18
  import { notifyCodexStoreLoaded } from "../lib/decision-quota-sink";
18
19
  import { assertNotRealHomeUnderTest } from "../lib/test-home-guard";
19
- import type { CodexAccountCredentialRecord, CodexAccountCredentials } from "../types";
20
+ import type { CodexAccount, CodexAccountCredentialRecord, CodexAccountCredentials } from "../types";
20
21
  import { advanceCodexCredentialMutationEpoch } from "./credential-mutation-epoch";
21
22
  import { isValidCodexAccountId } from "./account-id";
23
+ import { codexAccountLogLabel, fallbackCodexAccountLogLabel } from "./account-label";
22
24
  import type { PoolQuotaWriter } from "./quota-types";
23
25
  import { CODEX_REFRESH_FLIGHT_CEILING_MS } from "./quota-recovery-timing";
24
26
 
@@ -183,6 +185,18 @@ function persistCredentialMutation(store: CodexAccountStore): void {
183
185
  advanceCodexCredentialMutationEpoch();
184
186
  }
185
187
 
188
+ /**
189
+ * The durable `p<hex6>` label the dashboard and usage log show for this pool account, so a refresh
190
+ * failure line can be matched to the account row (#7592). The id hash is only the label of a
191
+ * legacy row that never received one. A config read failure must not replace the refresh error
192
+ * being reported, so it degrades to that same hash.
193
+ */
194
+ function poolAccountLogLabel(id: string): string {
195
+ let account: CodexAccount | undefined;
196
+ try { account = loadConfig().codexAccounts?.find(row => row.id === id); } catch { account = undefined; }
197
+ return account ? codexAccountLogLabel(account) : fallbackCodexAccountLogLabel(id);
198
+ }
199
+
186
200
  /**
187
201
  * Validation metadata that survives a credential write.
188
202
  *
@@ -1055,7 +1069,7 @@ export async function forceRefreshCodexPoolToken(
1055
1069
  if (isTerminalCodexPoolRefreshFailure(error) || isOperationalCodexPoolRefreshFailure(error)) {
1056
1070
  if (isTerminalCodexPoolRefreshFailure(error)) clearCodexPoolRefreshFailure(id);
1057
1071
  } else {
1058
- noteCodexPoolRefreshFailure(id, classifyCodexPoolRefreshFailureReason(error), undefined, refreshFence);
1072
+ noteCodexPoolRefreshFailure(id, classifyCodexPoolRefreshFailureReason(error), undefined, refreshFence, poolAccountLogLabel);
1059
1073
  }
1060
1074
  settle({ kind: "failed", error });
1061
1075
  },
@@ -1376,7 +1390,7 @@ async function resolveCodexToken(
1376
1390
  .catch(() => undefined);
1377
1391
  const failure = classifyChatgptRefreshFailure(res.status, body?.displaySafe ? body.text : "");
1378
1392
  const { reason } = failure;
1379
- noteChatgptRefreshFailure("pool", res.status, failure);
1393
+ noteChatgptRefreshFailure("pool", res.status, failure, poolAccountLogLabel(id));
1380
1394
  throw new TokenRefreshError(reason, `Codex token refresh failed (${reason}); reauthenticate the account.`, res.status, failure.code);
1381
1395
  }
1382
1396
  const data = (await res.json()) as { access_token: string; refresh_token?: string; expires_in: number };
@@ -182,11 +182,12 @@ async function runCodexRestart(io: CodexRestartServiceIo): Promise<CodexRestartR
182
182
  // Decided here, before the early returns, because the model picker lives in the DESKTOP
183
183
  // app: "no app-server is running" is not a reason to leave a stale roster on screen.
184
184
  //
185
- // The shell restarts only when its picker predates the catalog (#4761). Quitting it
186
- // discards composer drafts, unsent picker selections and pending approvals, and when
187
- // the picker is already current the app-server restart below is all the operator
188
- // needs: the desktop respawns its app-server child, which rereads config and catalog.
189
- // An unreadable picker state takes the full restart, the behaviour before #4761.
185
+ // The shell gets a restart attempt whenever its picker cannot be proven current: at
186
+ // or before the catalog mtime it is stale, and newer is unknown because an earlier
187
+ // sync may have rebound an already-existing catalog file (#4761). Quitting it
188
+ // discards composer drafts, unsent picker selections and pending approvals. The
189
+ // attempt is explicit, not a guarantee: the restart helper can refuse or partially
190
+ // fail, and desktopApp reports that outcome.
190
191
  //
191
192
  // allowHandoff is false and that is deliberate. The handoff waits for the CALLING
192
193
  // process to exit, and this runs inside a long-lived proxy that does not, so every
@@ -55,6 +55,12 @@ import {
55
55
  export function nativeEffortClamp(slug: string, effort: string | undefined): string | null {
56
56
  if (!effort || (effort !== "max" && effort !== "ultra")) return null;
57
57
  if (slug.includes("/")) return null; // routed models map efforts in their adapters
58
+ // `ultra` is a client-facing catalog choice (max reasoning plus proactive delegation) that
59
+ // codex-rs converts to `max` before any request (core/src/client.rs
60
+ // `reasoning_effort_for_request`). The ChatGPT backend rejects it with HTTP 400 even for a
61
+ // model whose pinned ladder lists it (#7590), so it is never a native wire value: fold it to
62
+ // `max`, then clamp `max` like a direct selection.
63
+ const wire = "max";
58
64
  const entry = UPSTREAM_NATIVE_ENTRIES.get(slug);
59
65
  const levels = Array.isArray(entry?.supported_reasoning_levels)
60
66
  ? entry.supported_reasoning_levels as Array<{ effort?: string }>
@@ -65,10 +71,11 @@ export function nativeEffortClamp(slug: string, effort: string | undefined): str
65
71
  // a retired slug a client still asks for, and future old-ladder slugs)
66
72
  // really stops at xhigh — the ChatGPT backend error names exactly none..xhigh — so clamp
67
73
  // the synthetic top tier.
68
- return isGpt56NativeSlug(slug) ? null : "xhigh";
74
+ if (!isGpt56NativeSlug(slug)) return "xhigh";
75
+ return effort === wire ? null : wire;
69
76
  }
70
77
  const supported = levels.flatMap(l => typeof l.effort === "string" ? [l.effort] : []);
71
- if (supported.includes(effort)) return null;
78
+ if (supported.includes(wire)) return effort === wire ? null : wire;
72
79
  const rank = ["minimal", "low", "medium", "high", "xhigh", "max"];
73
80
  const highest = supported
74
81
  .filter(e => rank.includes(e))
@@ -119,6 +119,7 @@ export interface CatalogGatherProviderModelOutcome {
119
119
  readonly state: "authoritative" | "degraded";
120
120
  }
121
121
  export interface ModelsAuthResolution {
122
+ readonly workbuddy?: import("../../oauth/types").WorkBuddyOAuthMetadata;
122
123
  readonly apiKey: string | undefined;
123
124
  readonly observed: boolean;
124
125
  readonly oauthAccountId?: string;
@@ -334,8 +335,9 @@ export function captureModelsRequest(
334
335
  name: string,
335
336
  provider: OcxProviderConfig,
336
337
  oauthApiBaseUrl: string | undefined,
338
+ workbuddy?: import("../../oauth/types").WorkBuddyOAuthMetadata,
337
339
  ): CapturedModelsRequest {
338
- const observed = { oauthApiBaseUrl };
340
+ const observed = { oauthApiBaseUrl, workbuddy };
339
341
  const withoutCredential = buildModelsRequest(provider, undefined, name, observed);
340
342
  const withCredential = buildModelsRequest(provider, REQUEST_CREDENTIAL_SENTINEL, name, observed);
341
343
  const method = withoutCredential.method ?? "GET";
@@ -400,7 +402,7 @@ export function captureProviderGather(
400
402
  : authResolver.kind === "refreshing" && provider.authMode === "oauth" && mayResolveModelsOAuth(name, provider)
401
403
  ? getOAuthCredentialApiBaseUrl(name)
402
404
  : undefined;
403
- const request = captureModelsRequest(name, provider, oauthApiBaseUrl);
405
+ const request = captureModelsRequest(name, provider, oauthApiBaseUrl, observedAuth?.workbuddy);
404
406
  const refreshingOAuthAccountId = !observedAuth && authResolver.kind === "refreshing" && provider.authMode === "oauth" && mayResolveModelsOAuth(name, provider)
405
407
  ? getAccountSet(name)?.activeAccountId
406
408
  : undefined;