@bitkyc08/opencodex 2.89.0 → 2.91.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 (242) hide show
  1. package/gui/dist/assets/App-DMps4OLk.css +1 -0
  2. package/gui/dist/assets/App-nZ34cR5G.js +58 -0
  3. package/gui/dist/assets/{Tray-7z1COnmz.js → Tray-BuLy7hL1.js} +1 -1
  4. package/gui/dist/assets/{index-uchD-ngf.js → index-DGXS51sp.js} +15 -15
  5. package/gui/dist/assets/quota-summary-DO1Sx5TM.js +1 -0
  6. package/gui/dist/index.html +1 -1
  7. package/package.json +2 -2
  8. package/src/adapters/anthropic.ts +2 -5
  9. package/src/adapters/base.ts +4 -0
  10. package/src/adapters/claude-cli/adapter.ts +13 -7
  11. package/src/adapters/claude-cli/usage-admission.ts +28 -2
  12. package/src/adapters/coding-agent/turn.ts +14 -4
  13. package/src/adapters/cursor/protobuf-request.ts +3 -0
  14. package/src/adapters/google-progress-history.ts +19 -1
  15. package/src/adapters/google.ts +28 -9
  16. package/src/adapters/image.ts +5 -3
  17. package/src/adapters/input-media-guard.ts +5 -2
  18. package/src/adapters/kiro-refusal.ts +41 -1
  19. package/src/adapters/ollama-native.ts +1 -0
  20. package/src/adapters/openai-chat/messages.ts +34 -4
  21. package/src/adapters/openai-chat/passthrough.ts +37 -4
  22. package/src/adapters/openai-chat/reasoning-replay.ts +98 -13
  23. package/src/adapters/openai-chat/serialized-tool-call-content.ts +95 -134
  24. package/src/adapters/openai-chat/serialized-tool-call-markup.ts +108 -0
  25. package/src/adapters/openai-chat/standalone-bare-tool-calls.ts +136 -0
  26. package/src/adapters/openai-chat.ts +7 -6
  27. package/src/adapters/openai-responses/opengateway-input.ts +139 -0
  28. package/src/adapters/openai-responses/passthrough.ts +7 -2
  29. package/src/adapters/openai-responses/request-strips.ts +32 -0
  30. package/src/adapters/openai-responses/transport.ts +19 -1
  31. package/src/chat/inbound.ts +20 -2
  32. package/src/chat/outbound.ts +36 -4
  33. package/src/chat/reasoning-details.ts +137 -0
  34. package/src/claude/desktop-3p-library.ts +43 -3
  35. package/src/claude/desktop-3p.ts +20 -10
  36. package/src/claude/desktop-picker-profile.ts +18 -9
  37. package/src/claude/desktop-remote-store-io.ts +2 -2
  38. package/src/cli/capabilities-agents-routing.ts +50 -0
  39. package/src/cli/capabilities-base.ts +15 -2
  40. package/src/cli/capabilities-observe-system.ts +4 -4
  41. package/src/cli/codex-shim-autorestore.ts +5 -0
  42. package/src/cli/dispatch.ts +30 -0
  43. package/src/cli/ensure-desktop-cold-start.ts +2 -2
  44. package/src/cli/fallback.ts +127 -0
  45. package/src/cli/help.ts +1 -0
  46. package/src/cli/index.ts +1 -2
  47. package/src/cli/link-apply-restart.ts +72 -0
  48. package/src/cli/link-child.ts +6 -2
  49. package/src/cli/link-cleanup.ts +95 -0
  50. package/src/cli/link-enrollment.ts +4 -0
  51. package/src/cli/link.ts +84 -9
  52. package/src/cli/provider.ts +44 -2
  53. package/src/cli/registry.ts +13 -1
  54. package/src/cli/restart-failure.ts +4 -0
  55. package/src/cli/runtime-api.ts +1 -1
  56. package/src/cli/status-codex-settings.ts +56 -0
  57. package/src/cli/status-fallback.ts +29 -0
  58. package/src/cli/status-install.ts +52 -0
  59. package/src/cli/status.ts +20 -1
  60. package/src/cli/system-restart-client.ts +32 -1
  61. package/src/cli/update-restart-child.ts +4 -3
  62. package/src/cli/update-restart-home.ts +20 -8
  63. package/src/client/connect.ts +82 -6
  64. package/src/client/home-unreachable.ts +16 -0
  65. package/src/client/hub-relay.ts +8 -2
  66. package/src/client/link-home.ts +119 -6
  67. package/src/client/link-parity-reporter.ts +107 -0
  68. package/src/client/link-relay-transport.ts +8 -1
  69. package/src/client/link-relay.ts +27 -3
  70. package/src/client/machine-listener.ts +83 -1
  71. package/src/client/runtime-restart.ts +127 -0
  72. package/src/client/runtime.ts +137 -25
  73. package/src/codex/account-lifecycle.ts +19 -6
  74. package/src/codex/account-store.ts +27 -8
  75. package/src/codex/account-usability.ts +4 -1
  76. package/src/codex/auth-api/account-list.ts +10 -5
  77. package/src/codex/auth-api/account-pause-group.ts +2 -22
  78. package/src/codex/auth-api/login-collision.ts +59 -0
  79. package/src/codex/auth-api/login-flow.ts +12 -7
  80. package/src/codex/auth-api/pool-mode-gate.ts +81 -13
  81. package/src/codex/auth-collision.ts +31 -4
  82. package/src/codex/auth-context.ts +58 -12
  83. package/src/codex/catalog/parsing.ts +4 -0
  84. package/src/codex/cli-update-plan.ts +18 -3
  85. package/src/codex/collision-identity.ts +78 -0
  86. package/src/codex/desktop-compatibility/windows-key-protection.ts +18 -6
  87. package/src/codex/main-account-hard-lock.ts +14 -0
  88. package/src/codex/quota-history.ts +7 -0
  89. package/src/codex/quota.ts +10 -0
  90. package/src/codex/routing/cooldown-math.ts +5 -0
  91. package/src/codex/routing/failure-window.ts +61 -9
  92. package/src/codex/routing.ts +4 -3
  93. package/src/codex/shim-diagnostics.ts +2 -1
  94. package/src/codex/shim-fingerprint.ts +11 -2
  95. package/src/codex/shim-templates.ts +16 -5
  96. package/src/codex/shim-windows-refresh.ts +224 -0
  97. package/src/codex/shim.ts +45 -21
  98. package/src/codex/subagent-model-fallback.ts +24 -1
  99. package/src/combos/failover.ts +8 -1
  100. package/src/combos/index.ts +1 -0
  101. package/src/combos/resolve.ts +12 -0
  102. package/src/config/diagnostics.ts +5 -0
  103. package/src/config/load-degrade.ts +2 -0
  104. package/src/config/provider-validation.ts +7 -2
  105. package/src/config/schema/config-schema.ts +3 -0
  106. package/src/config/schema/fallback-policy.ts +40 -0
  107. package/src/generated/compatibility-version.json +360 -192
  108. package/src/generated/model-metadata.ts +1 -1
  109. package/src/images/loop.ts +2 -2
  110. package/src/lib/authorization-scheme.ts +95 -0
  111. package/src/lib/bun-transpiler-cache-scope.mjs +31 -17
  112. package/src/lib/provider-admission-wait.ts +14 -0
  113. package/src/lib/provider-client-headers.ts +1 -0
  114. package/src/lib/redact.ts +2 -2
  115. package/src/lib/service-secrets.ts +37 -2
  116. package/src/lib/system-restart-contract.ts +24 -0
  117. package/src/lib/test-home-guard.ts +78 -11
  118. package/src/lib/translator-budget.ts +1 -1
  119. package/src/lib/upstream-retry.ts +15 -0
  120. package/src/link/child-parity.ts +124 -0
  121. package/src/link/codex-settings.ts +37 -0
  122. package/src/link/compatibility.ts +119 -0
  123. package/src/link/management-routes.ts +2 -0
  124. package/src/link/peer-report.ts +100 -0
  125. package/src/link/remote-platform.ts +78 -0
  126. package/src/link/ssh-argv.ts +19 -0
  127. package/src/link/status-projection.ts +24 -2
  128. package/src/oauth/account-import/google-antigravity-adapter.ts +38 -2
  129. package/src/oauth/account-import/parser.ts +15 -2
  130. package/src/oauth/account-import/types.ts +7 -0
  131. package/src/oauth/chatgpt-shared.ts +10 -1
  132. package/src/oauth/dsh.ts +45 -2
  133. package/src/oauth/index.ts +1 -1
  134. package/src/oauth/kimi.ts +2 -0
  135. package/src/oauth/kiro.ts +44 -1
  136. package/src/oauth/login-flow-state.ts +11 -2
  137. package/src/oauth/nous.ts +4 -1
  138. package/src/oauth/store.ts +2 -1
  139. package/src/protocols/codecs/chat.ts +3 -3
  140. package/src/protocols/encoders/adapter-events.ts +26 -2
  141. package/src/protocols/encoders/chat.ts +55 -3
  142. package/src/providers/claude-cli-usage.ts +20 -5
  143. package/src/providers/initial-model-selection.ts +2 -2
  144. package/src/providers/key-failover.ts +10 -2
  145. package/src/providers/key-store.ts +8 -3
  146. package/src/providers/mistral-transport.ts +240 -0
  147. package/src/providers/openai-sidecar.ts +5 -0
  148. package/src/providers/quota/anthropic-account-quota.ts +18 -6
  149. package/src/providers/quota/antigravity.ts +8 -10
  150. package/src/providers/quota/failure-codes.ts +18 -0
  151. package/src/providers/quota/vendor-probes-oauth.ts +12 -3
  152. package/src/providers/quota-routing-cache.ts +1 -1
  153. package/src/providers/quota.ts +5 -1
  154. package/src/providers/registry/entries-extended.ts +6 -1
  155. package/src/providers/routed-config.ts +1 -1
  156. package/src/providers/xai-transport.ts +2 -1
  157. package/src/quota-pool/assemble.ts +326 -0
  158. package/src/quota-pool/contract.ts +58 -0
  159. package/src/quota-pool/identity.ts +203 -0
  160. package/src/remote/features.ts +74 -0
  161. package/src/remote/protocol.ts +2 -0
  162. package/src/remote/served-by.ts +57 -0
  163. package/src/responses/audio.ts +46 -0
  164. package/src/responses/input-media.ts +37 -15
  165. package/src/responses/parser-content.ts +22 -27
  166. package/src/responses/parser.ts +7 -0
  167. package/src/responses/reasoning-envelope.ts +12 -0
  168. package/src/responses/reasoning-retention.ts +218 -11
  169. package/src/responses/schema.ts +3 -4
  170. package/src/responses/web-search-replay.ts +11 -2
  171. package/src/routing/fallback-policy.ts +290 -0
  172. package/src/routing/fallback-state.ts +179 -0
  173. package/src/server/audio-client.ts +2 -1
  174. package/src/server/audio-dictation.ts +2 -1
  175. package/src/server/audio-upstream.ts +2 -1
  176. package/src/server/auth-cors.ts +6 -5
  177. package/src/server/background-lifecycle.ts +4 -0
  178. package/src/server/chat-completions.ts +8 -1
  179. package/src/server/chat-native.ts +8 -7
  180. package/src/server/gui-session.ts +2 -1
  181. package/src/server/index/link-listener.ts +11 -1
  182. package/src/server/index/link-management.ts +47 -0
  183. package/src/server/index/link-relay-sessions.ts +11 -2
  184. package/src/server/index/optional-listeners.ts +7 -2
  185. package/src/server/index/serve-options.ts +27 -4
  186. package/src/server/index/websocket-handler.ts +10 -0
  187. package/src/server/lifecycle.ts +17 -1
  188. package/src/server/live.ts +5 -0
  189. package/src/server/management/context.ts +1 -1
  190. package/src/server/management/fallback-routes.ts +75 -0
  191. package/src/server/management/link-enrollment.ts +21 -3
  192. package/src/server/management/link-route-state.ts +2 -0
  193. package/src/server/management/link-routes.ts +12 -1
  194. package/src/server/management/oauth-account-routes.ts +2 -1
  195. package/src/server/management/quota-pool-routes.ts +24 -0
  196. package/src/server/management/route-registry.ts +3 -0
  197. package/src/server/management-api.ts +16 -0
  198. package/src/server/management-auth.ts +2 -1
  199. package/src/server/messages-native.ts +11 -0
  200. package/src/server/request-log.ts +16 -7
  201. package/src/server/responses/adapter-continuation.ts +2 -2
  202. package/src/server/responses/adapter-dispatch.ts +2 -2
  203. package/src/server/responses/agent-task-recovery.ts +1 -1
  204. package/src/server/responses/codex-ws-exchange.ts +3 -0
  205. package/src/server/responses/codex-ws-metadata.ts +44 -0
  206. package/src/server/responses/codex-ws-session.ts +21 -1
  207. package/src/server/responses/combo-stream-preflight.ts +7 -2
  208. package/src/server/responses/compact.ts +4 -2
  209. package/src/server/responses/core-auth.ts +31 -1
  210. package/src/server/responses/core-codex-account.ts +21 -14
  211. package/src/server/responses/core-combo.ts +75 -1
  212. package/src/server/responses/core-opaque-recovery.ts +20 -11
  213. package/src/server/responses/deepseek-image-files.ts +121 -0
  214. package/src/server/responses/empty-completion-guard.ts +8 -0
  215. package/src/server/responses/encrypted-payload.ts +3 -3
  216. package/src/server/responses/fetch-helpers.ts +30 -10
  217. package/src/server/responses/input-admission.ts +6 -2
  218. package/src/server/responses/main-hard-lock-routing.ts +85 -8
  219. package/src/server/responses/opengateway-completion-guard.ts +232 -0
  220. package/src/server/responses/passthrough-completion.ts +41 -0
  221. package/src/server/responses/passthrough-delivery.ts +2 -0
  222. package/src/server/responses/passthrough-dispatch.ts +13 -13
  223. package/src/server/responses/passthrough-key-rotation.ts +40 -0
  224. package/src/server/responses/policy-fallback.ts +44 -1
  225. package/src/server/responses/reasoning-archive-error.ts +21 -0
  226. package/src/server/responses/request-prepare.ts +19 -1
  227. package/src/server/responses/request-transport.ts +61 -3
  228. package/src/server/restart-replacement.ts +50 -2
  229. package/src/server/ws-bridge.ts +5 -0
  230. package/src/service/cli.ts +25 -2
  231. package/src/service/diagnostics.ts +20 -6
  232. package/src/service/orchestration.ts +17 -0
  233. package/src/service/status-json.ts +116 -0
  234. package/src/types/config.ts +7 -0
  235. package/src/types/fallback.ts +38 -0
  236. package/src/types/provider.ts +2 -0
  237. package/src/types/request.ts +16 -1
  238. package/src/types.ts +1 -0
  239. package/src/web-search/loop.ts +2 -2
  240. package/gui/dist/assets/App-BnRSujOq.js +0 -58
  241. package/gui/dist/assets/App-dnkJ85te.css +0 -1
  242. package/gui/dist/assets/quota-summary-BY_10IEz.js +0 -1
@@ -1,4 +1,5 @@
1
1
  import { fetchBoundLinkRelay, LinkRelayAuthenticationError } from "./link-relay-transport";
2
+ import { homeUnreachableBody } from "./home-unreachable";
2
3
  import {
3
4
  boundedRelayResponseStream,
4
5
  filterRelayHeaders,
@@ -112,6 +113,29 @@ function jsonError(status: number, error: string, retry = false): Response {
112
113
  return Response.json({ error }, { status, headers });
113
114
  }
114
115
 
116
+ /** A retryable 503 for a Home this Child cannot reach (OCX-11 `home_unreachable`). */
117
+ function homeUnreachable(error: string): Response {
118
+ return Response.json(homeUnreachableBody(error), {
119
+ status: 503,
120
+ headers: { "Retry-After": String(LINK_RELAY_RETRY_AFTER_SECONDS) },
121
+ });
122
+ }
123
+
124
+ /**
125
+ * The peer answered but the connection-bound relay authentication failed. That is not a Home
126
+ * outage (an unrecognized protocol needs an upgrade or a re-link), so it must never read as
127
+ * `home_unreachable`, which GOAT's device-local fallback keys on. The status and Retry-After it
128
+ * carried before OCX-11 are kept: a lost bound socket can succeed on a fresh connection.
129
+ */
130
+ export const LINK_RELAY_AUTH_FAILED_CODE = "link_relay_auth_failed";
131
+
132
+ function relayAuthenticationFailed(error: string): Response {
133
+ return Response.json({ error, code: LINK_RELAY_AUTH_FAILED_CODE }, {
134
+ status: 503,
135
+ headers: { "Retry-After": String(LINK_RELAY_RETRY_AFTER_SECONDS) },
136
+ });
137
+ }
138
+
115
139
  export function linkRelayDestination(url: URL, target: Pick<LinkRelayTarget, "tunnelPort">): string {
116
140
  if (!isLinkPort(target.tunnelPort)) {
117
141
  throw new RangeError("invalid link tunnel port");
@@ -369,7 +393,7 @@ export async function relayLinkDataRequest(
369
393
  const tunnelUnavailable = async (): Promise<Response> => {
370
394
  cleanup();
371
395
  try { await body?.cancel(); } catch { /* best effort */ }
372
- return jsonError(503, "link tunnel unavailable", true);
396
+ return homeUnreachable("link tunnel unavailable");
373
397
  };
374
398
  const readyForFetch = async (): Promise<boolean> => {
375
399
  if (!tunnel) return false;
@@ -393,7 +417,7 @@ export async function relayLinkDataRequest(
393
417
  if (error instanceof LinkRelayAuthenticationError) {
394
418
  cleanup();
395
419
  try { await body?.cancel(); } catch { /* an upload might already be drained */ }
396
- return jsonError(503, error.message, true);
420
+ return relayAuthenticationFailed(error.message);
397
421
  }
398
422
  if (bodyOverflow) {
399
423
  cleanup();
@@ -408,7 +432,7 @@ export async function relayLinkDataRequest(
408
432
  if (relayAbort.signal.aborted) {
409
433
  cleanup();
410
434
  try { await upstream.body?.cancel(); } catch { /* best effort */ }
411
- return jsonError(503, "link tunnel unavailable", true);
435
+ return homeUnreachable("link tunnel unavailable");
412
436
  }
413
437
 
414
438
  const sse = isSse(upstream.headers);
@@ -22,9 +22,23 @@ import { readClientLinkState, type ClientLinkState } from "./link-state";
22
22
  import { projectClientLinkChild, type ClientLinkSidecarRead } from "./link-status";
23
23
  import type { ClientLinkSupervisorStatus } from "./link-tunnel";
24
24
  import { packageVersion } from "../lib/package-version";
25
+ import { createLocalAttestationProof, LOCAL_ATTESTATION_CHALLENGE_HEADER, LOCAL_ATTESTATION_PROOF_HEADER } from "../lib/local-management-attestation";
26
+ import {
27
+ CLIENT_RESTART_BUDGET_MS,
28
+ parseExpectedSystemRestartPid,
29
+ RESTART_SUPERVISED_ERROR,
30
+ SYSTEM_RESTART_CAPABILITY_HEADER,
31
+ SYSTEM_RESTART_CAPABILITY_VERSION,
32
+ SYSTEM_RESTART_EXPECTED_PID_HEADER,
33
+ SYSTEM_RESTART_METHOD,
34
+ SYSTEM_RESTART_NONCE_HEADER,
35
+ SYSTEM_RESTART_PATH,
36
+ verifySystemRestartCapability,
37
+ } from "../lib/system-restart-contract";
25
38
  import { linkRouteAllowed } from "../link/routes";
26
39
  import { linkHomeName, linkHomePhrase } from "./link-home";
27
40
  import { relayLinkManagementRead } from "./link-management-relay";
41
+ import type { ClientRestartAdmission } from "./runtime-restart";
28
42
 
29
43
  const VERSION = packageVersion("0.0.0");
30
44
  const GUI_SPA_PATHS = new Set([
@@ -51,6 +65,17 @@ export interface MachineListenerDeps {
51
65
  linkTunnel?: LinkTunnelGate;
52
66
  /** Link mode: relay seams (deadline clock and byte cap). */
53
67
  linkRelay?: LinkIngress["relay"];
68
+ /**
69
+ * The bound restart (OCX-6). With it, `/healthz` proves this process's identity to a challenge,
70
+ * advertises `restartCapability` and `startedAt`, and `POST /api/system/restart` accepts a
71
+ * capability signed with `attestationSecret` for this pid and port, and nothing else.
72
+ */
73
+ restart?: {
74
+ attestationSecret: string;
75
+ /** ISO start time of this runtime's process. */
76
+ startedAt: string;
77
+ request: () => Promise<ClientRestartAdmission>;
78
+ };
54
79
  serve?: (options: Parameters<typeof Bun.serve>[0]) => Server<unknown>;
55
80
  }
56
81
 
@@ -68,6 +93,8 @@ export function machineRouteAllowed(url: URL, req: Request, relayEnabled: boolea
68
93
  if (linkMode && linkRouteAllowed(url, req)) return true;
69
94
  const path = url.pathname;
70
95
  if (req.method === "GET" && (path === "/healthz" || path === "/readyz" || path === "/" || path === "/opencodex-session")) return true;
96
+ // The bound restart. Only a capability signed for this exact process admits it (see the handler).
97
+ if (req.method === SYSTEM_RESTART_METHOD && path === SYSTEM_RESTART_PATH) return true;
71
98
  if ((req.method === "GET" || req.method === "HEAD") && (path === "/api/machine/status" || path === "/api/machine/clients" || path === "/api/machine/shim")) return true;
72
99
  // The one link route a connected Child serves: its own read-only link status.
73
100
  if (linkMode && (req.method === "GET" || req.method === "HEAD") && path === "/api/link/status") return true;
@@ -143,7 +170,20 @@ export function startMachineListener(
143
170
  }
144
171
  if (!machineRouteAllowed(url, req, relayEnabled, linkMode)) return json404(req);
145
172
  if (url.pathname === "/healthz" && req.method === "GET") {
146
- return Response.json({ service: "opencodex", version: VERSION, role: "client", uptime: process.uptime(), pid: process.pid, port: server.port });
173
+ const restart = deps.restart;
174
+ const response = Response.json({
175
+ service: "opencodex", version: VERSION, role: "client", uptime: process.uptime(), pid: process.pid, port: server.port,
176
+ ...(restart ? { startedAt: restart.startedAt, restartCapability: SYSTEM_RESTART_CAPABILITY_VERSION, restartBudgetMs: CLIENT_RESTART_BUDGET_MS } : {}),
177
+ });
178
+ const challenge = restart ? req.headers.get(LOCAL_ATTESTATION_CHALLENGE_HEADER) : null;
179
+ const proof = challenge && server.port
180
+ ? createLocalAttestationProof(restart!.attestationSecret, challenge, process.pid, server.port)
181
+ : null;
182
+ if (proof) response.headers.set(LOCAL_ATTESTATION_PROOF_HEADER, proof);
183
+ return response;
184
+ }
185
+ if (url.pathname === SYSTEM_RESTART_PATH && req.method === SYSTEM_RESTART_METHOD) {
186
+ return await handleBoundRestart(req, server, deps.restart);
147
187
  }
148
188
  if (url.pathname === "/readyz" && req.method === "GET") {
149
189
  return Response.json({ service: "opencodex", version: VERSION, role: "client", status: "ready", uptime: process.uptime(), pid: process.pid, port: server.port, protocolVersion: 1 });
@@ -232,3 +272,45 @@ export function startMachineListener(
232
272
  },
233
273
  } as Parameters<typeof Bun.serve>[0]);
234
274
  }
275
+
276
+ /**
277
+ * `POST /api/system/restart` on the client listener (OCX-6). The only accepted principal is the
278
+ * process-bound restart capability `ocx restart` derives from this runtime's attestation secret:
279
+ * no dashboard session, admin token or link key reaches it, and a browser cannot mint it.
280
+ */
281
+ async function handleBoundRestart(
282
+ req: Request,
283
+ server: Server<unknown>,
284
+ restart: MachineListenerDeps["restart"],
285
+ ): Promise<Response> {
286
+ const noStore = { "cache-control": "no-store" };
287
+ if (!restart || !server.port) return json404(req);
288
+ const expected = parseExpectedSystemRestartPid(req.headers.get(SYSTEM_RESTART_EXPECTED_PID_HEADER));
289
+ if (expected.kind !== "present" || expected.pid !== process.pid) {
290
+ return Response.json({ success: false, error: "Restart target identity changed." }, { status: 409, headers: noStore });
291
+ }
292
+ const authorized = verifySystemRestartCapability(
293
+ restart.attestationSecret,
294
+ req.headers.get(SYSTEM_RESTART_NONCE_HEADER),
295
+ SYSTEM_RESTART_METHOD,
296
+ SYSTEM_RESTART_PATH,
297
+ process.pid,
298
+ server.port,
299
+ req.headers.get(SYSTEM_RESTART_CAPABILITY_HEADER),
300
+ );
301
+ if (!authorized) return Response.json({ success: false, error: "restart capability required" }, { status: 401, headers: noStore });
302
+ const admission = await restart.request();
303
+ if (admission.kind === "supervised") {
304
+ return Response.json({
305
+ success: false,
306
+ error: { code: RESTART_SUPERVISED_ERROR, supervisor: admission.supervisor },
307
+ }, { status: 409, headers: noStore });
308
+ }
309
+ return Response.json({
310
+ success: true,
311
+ message: admission.alreadyRestarting ? "Restart already in progress." : "Restarting the client runtime.",
312
+ activeTurnCount: 0,
313
+ drainTimeoutMs: CLIENT_RESTART_BUDGET_MS,
314
+ alreadyDraining: admission.alreadyRestarting,
315
+ }, { status: 202, headers: noStore });
316
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * The connected client runtime's answer to the bound `POST /api/system/restart` (OCX-6).
3
+ *
4
+ * It is the same restart `ocx restart` drives on a standalone proxy, with one difference the
5
+ * Remote Link parity work requires: a failed restart keeps the old runtime. That is only possible
6
+ * when this process starts its own replacement, so:
7
+ *
8
+ * - Under the desktop app or a viable service supervisor the restart is refused (409
9
+ * `restart_supervised`) and the runtime keeps serving. The supervisor owns this process and its
10
+ * port; handing off to it means exiting before anything proves the replacement can start.
11
+ * - Otherwise the listener drains, a replacement `ocx start` is spawned and must answer within the
12
+ * budget. Success exits this process. A replacement that exited, or never answered and was then
13
+ * stopped, leaves this process to bind the port again, start a fresh tunnel supervisor and
14
+ * republish its records: the old runtime keeps serving and the CLI, still seeing its pid,
15
+ * reports the restart as failed.
16
+ *
17
+ * Everything runs inside {@link CLIENT_RESTART_BUDGET_MS} from acceptance.
18
+ */
19
+ import {
20
+ CLIENT_RESTART_DRAIN_MS,
21
+ CLIENT_RESTART_READY_BY_MS,
22
+ isDesktopSupervised,
23
+ } from "../lib/system-restart-contract";
24
+ import type { OcxClientConnectionConfig } from "../types";
25
+ import { sameClientConnectionOwner, type readClientConnectionState } from "./state";
26
+
27
+ type ClientConnectionState = ReturnType<typeof readClientConnectionState>;
28
+
29
+ export type ClientRestartSupervisor = "service" | "desktop";
30
+
31
+ export type ClientRestartAdmission =
32
+ | { kind: "accepted"; alreadyRestarting: boolean }
33
+ | { kind: "supervised"; supervisor: ClientRestartSupervisor };
34
+
35
+ export interface ClientRestartIo {
36
+ /** Which supervisor owns this process, or null when it starts its own replacement. */
37
+ supervisor(): Promise<ClientRestartSupervisor | null>;
38
+ /** Stop the tunnel supervisor and the listener, giving in-flight requests up to `drainMs`. */
39
+ stop(drainMs: number): Promise<void>;
40
+ /** Start the replacement; resolves once it answers, rejects when none survives. */
41
+ spawnReplacement(deadlineAt: number): Promise<void>;
42
+ /** Serve again on this process: listener, fresh tunnel supervisor, records. */
43
+ resume(): Promise<void>;
44
+ exitProcess(code: number): void;
45
+ now?: () => number;
46
+ sleep?: (ms: number) => Promise<void>;
47
+ warn?: (message: string) => void;
48
+ }
49
+
50
+ export interface ClientRestartController {
51
+ request(): Promise<ClientRestartAdmission>;
52
+ /** Settles when the restart in flight (if any) has finished or rolled back. Tests only. */
53
+ settled(): Promise<void>;
54
+ }
55
+
56
+ /** Stable, path-free label for a failure (an error message can carry a path). */
57
+ function failureCode(error: unknown): string {
58
+ const code = error && typeof error === "object" && "code" in error ? (error as { code?: unknown }).code : undefined;
59
+ return typeof code === "string" && /^[A-Za-z0-9_]{1,64}$/.test(code) ? code : "failed";
60
+ }
61
+
62
+ export function createClientRestartController(io: ClientRestartIo): ClientRestartController {
63
+ const now = io.now ?? Date.now;
64
+ const sleep = io.sleep ?? Bun.sleep;
65
+ const warn = io.warn ?? ((message: string) => console.warn(message));
66
+ let flight: Promise<void> | null = null;
67
+
68
+ const run = async (acceptedAt: number): Promise<void> => {
69
+ // Let the 202 reach the CLI before the listener starts closing.
70
+ await sleep(200);
71
+ try {
72
+ await io.stop(CLIENT_RESTART_DRAIN_MS);
73
+ } catch (error) {
74
+ warn(`[client] restart: stopping the runtime failed (${failureCode(error)}); continuing`);
75
+ }
76
+ try {
77
+ await io.spawnReplacement(acceptedAt + CLIENT_RESTART_READY_BY_MS);
78
+ } catch (error) {
79
+ warn(`[client] restart: the replacement did not start (${failureCode(error)}); this runtime keeps serving`);
80
+ try {
81
+ await io.resume();
82
+ } catch (resumeError) {
83
+ warn(`[client] restart: this runtime could not serve again (${failureCode(resumeError)}); run 'ocx start'`);
84
+ io.exitProcess(1);
85
+ }
86
+ return;
87
+ }
88
+ io.exitProcess(0);
89
+ };
90
+
91
+ return {
92
+ async request() {
93
+ const supervisor = await io.supervisor();
94
+ if (supervisor) return { kind: "supervised", supervisor };
95
+ if (flight) return { kind: "accepted", alreadyRestarting: true };
96
+ flight = run(now()).finally(() => { flight = null; });
97
+ return { kind: "accepted", alreadyRestarting: false };
98
+ },
99
+ async settled() {
100
+ await flight;
101
+ },
102
+ };
103
+ }
104
+
105
+ /** Production supervision check: the desktop app, then a service that will really relaunch us. */
106
+ export async function clientRestartSupervisor(): Promise<ClientRestartSupervisor | null> {
107
+ if (isDesktopSupervised()) return "desktop";
108
+ if (process.env.OCX_SERVICE !== "1") return null;
109
+ // Same viability test as the standalone restart: stale service assets would not relaunch.
110
+ const { isServiceViable } = await import("../service/orchestration");
111
+ return isServiceViable() ? "service" : null;
112
+ }
113
+
114
+ /**
115
+ * After a failed replacement: serve the same connection again only while it is still the durable
116
+ * one (same owner and link). Anything else, a disconnect or a different connection, goes to the
117
+ * recycle, which starts what the current state calls for instead of a stale client runtime.
118
+ */
119
+ export function clientRestartRollbackAction(
120
+ served: OcxClientConnectionConfig,
121
+ current: ClientConnectionState,
122
+ ): "resume" | "recycle" {
123
+ return current.kind === "connected" && sameClientConnectionOwner(current.value, served)
124
+ && current.value.link?.linkId === served.link?.linkId
125
+ ? "resume"
126
+ : "recycle";
127
+ }
@@ -9,6 +9,7 @@ import { createLocalAttestationSecret } from "../lib/local-management-attestatio
9
9
  import { loadServiceTokenFromFile, serviceApiTokenFingerprint } from "../lib/service-secrets";
10
10
  import { handledSignalExitCode } from "../lib/handled-signal-exit";
11
11
  import {
12
+ CLIENT_RESTART_REBIND_WAIT_MS,
12
13
  DESKTOP_RESTART_EXIT_CODE,
13
14
  DESKTOP_SUPERVISED_ENV,
14
15
  DESKTOP_SUPERVISED_PORT_WAIT_MS,
@@ -23,12 +24,22 @@ import { createClientLinkSupervisor, type ClientLinkSupervisor } from "./link-tu
23
24
  import { clientLinkStatePath, isChildInitiatedLink, recordChildInitiatedLink } from "./link-state";
24
25
  import { startMachineListener, type MachineListenerDeps } from "./machine-listener";
25
26
  import { isLinkConnection, readClientConnectionState } from "./state";
27
+ import { withClientLifecycleSync } from "./lifecycle-lock";
28
+ import { clientRestartRollbackAction, clientRestartSupervisor, createClientRestartController, type ClientRestartController } from "./runtime-restart";
29
+ import { createLinkParityReporter, type LinkParityReporter } from "./link-parity-reporter";
26
30
 
27
31
  let activeServer: Server<unknown> | null = null;
28
32
  let activePort: number | null = null;
29
33
  let activeSupervisor: ClientLinkSupervisor | null = null;
34
+ let activeReporter: LinkParityReporter | null = null;
30
35
  let recycleScheduled = false;
31
36
 
37
+ /**
38
+ * When this runtime's process started (OCX-6, P5): what `/healthz` reports as `startedAt` and the
39
+ * Child reports to its Home. A Home settings apply recorded after it means this runtime predates it.
40
+ */
41
+ export const CLIENT_RUNTIME_STARTED_AT = new Date(Date.now() - process.uptime() * 1000).toISOString();
42
+
32
43
  /**
33
44
  * How long link mode waits for its configured port: the budget a hard-pinned `ocx start --port`
34
45
  * gives `reclaimListenPort` (`src/cli/index.ts`).
@@ -53,6 +64,8 @@ export interface ClientRuntimeIo {
53
64
  /** Link-mode port budget; defaults to {@link LINK_PORT_WAIT_MS}. */
54
65
  portWaitMs?: number;
55
66
  startListener?: typeof startMachineListener;
67
+ /** Which supervisor owns this process for a bound restart; tests replace the production check. */
68
+ restartSupervisor?: () => Promise<"service" | "desktop" | null>;
56
69
  }
57
70
 
58
71
  export interface StandaloneRecycleIo {
@@ -214,7 +227,7 @@ export async function bindClientListener(
214
227
  preferred: number;
215
228
  explicitPort: boolean;
216
229
  configuredPort: number;
217
- } & Pick<MachineListenerDeps, "linkStatus" | "linkKeySource" | "linkTunnel">,
230
+ } & Pick<MachineListenerDeps, "linkStatus" | "linkKeySource" | "linkTunnel" | "restart">,
218
231
  io: ClientRuntimeIo = {},
219
232
  ): Promise<{ server: Server<unknown>; port: number }> {
220
233
  const { linkMode } = request;
@@ -254,6 +267,7 @@ export async function bindClientListener(
254
267
  ...(request.linkStatus ? { linkStatus: request.linkStatus } : {}),
255
268
  ...(request.linkKeySource ? { linkKeySource: request.linkKeySource } : {}),
256
269
  ...(request.linkTunnel ? { linkTunnel: request.linkTunnel } : {}),
270
+ ...(request.restart ? { restart: request.restart } : {}),
257
271
  });
258
272
  return { server, port: server.port ?? port };
259
273
  } catch (error) {
@@ -286,33 +300,129 @@ export async function startClientRuntime(
286
300
  if (linkMode && state.value.link) {
287
301
  try { recordChildInitiatedLink(state.value.link.linkId); } catch { /* the sidecar check below still applies */ }
288
302
  }
289
- const supervisor = linkMode && existsSync(clientLinkStatePath())
290
- ? createClientLinkSupervisor({
291
- onLinkEnded: () => scheduleStandaloneRecycle(state.value.tokenFingerprint),
292
- linkKey,
293
- })
294
- : null;
303
+ const supervised = linkMode && existsSync(clientLinkStatePath());
295
304
  // A Child with no sidecar and no record of joining itself was connected by its Home over
296
305
  // `ssh -R`; that link keeps 2.67.0's unproven forward. A Child-initiated link that lost its
297
306
  // sidecar gets no gate at all, so the relay refuses it.
298
- const homeInitiated = linkMode && !supervisor && !!state.value.link
307
+ const homeInitiated = linkMode && !supervised && !!state.value.link
299
308
  && !isChildInitiatedLink(state.value.link.linkId);
300
- const { server, port: boundPort } = await bindClientListener({
301
- state: state.value,
302
- linkMode,
303
- preferred,
304
- explicitPort: options.port !== undefined,
305
- configuredPort: config.port,
306
- ...(linkMode ? { linkStatus: () => supervisor?.status() ?? { kind: "stopped" as const }, linkKeySource: linkKey } : {}),
307
- ...(supervisor ? { linkTunnel: supervisor } : homeInitiated ? { linkTunnel: HOME_INITIATED_LINK_TUNNEL } : {}),
308
- }, io);
309
- activeServer = server;
310
- activePort = boundPort;
311
- activeSupervisor = supervisor;
312
- supervisor?.start();
309
+ // One secret for the life of this process: a rollback republishes the same identity.
310
+ const attestationSecret = createLocalAttestationSecret();
311
+
312
+ // Bind, start a fresh tunnel supervisor and publish. Runs once at start and again when a failed
313
+ // restart rolls back (OCX-6); the stopped supervisor cannot be started twice, so each call builds one.
314
+ // The port this process actually serves on; a rollback binds exactly this one again.
315
+ let servedPort = preferred;
316
+ // Returns false only for a rollback whose connection changed while it was binding: the new
317
+ // listener is then closed unpublished and the caller recycles.
318
+ const serve = async (rollback?: { port: number; portWaitMs: number }): Promise<boolean> => {
319
+ const supervisor = supervised
320
+ ? createClientLinkSupervisor({
321
+ onLinkEnded: () => scheduleStandaloneRecycle(state.value.tokenFingerprint),
322
+ linkKey,
323
+ })
324
+ : null;
325
+ const { server, port: boundPort } = await bindClientListener({
326
+ state: state.value,
327
+ linkMode,
328
+ // A rollback takes back exactly the port it gave up: never the preferred one, never another.
329
+ preferred: rollback?.port ?? preferred,
330
+ explicitPort: rollback !== undefined || options.port !== undefined,
331
+ configuredPort: config.port,
332
+ restart: { attestationSecret, startedAt: CLIENT_RUNTIME_STARTED_AT, request: () => restartController.request() },
333
+ ...(linkMode ? { linkStatus: () => activeSupervisor?.status() ?? { kind: "stopped" as const }, linkKeySource: linkKey } : {}),
334
+ ...(supervisor ? { linkTunnel: supervisor } : homeInitiated ? { linkTunnel: HOME_INITIATED_LINK_TUNNEL } : {}),
335
+ }, rollback === undefined ? io : { ...io, portWaitMs: rollback.portWaitMs });
336
+ const publish = (): void => {
337
+ activeServer = server;
338
+ activePort = boundPort;
339
+ servedPort = boundPort;
340
+ activeSupervisor = supervisor;
341
+ supervisor?.start();
342
+ writePid(process.pid);
343
+ writeRuntimePort(clientRuntimeRecord(process.pid, boundPort, attestationSecret));
344
+ if (linkMode && state.value.link && linkKey) {
345
+ activeReporter = createLinkParityReporter({ connection: state.value, linkKey, runtimeStartedAt: CLIENT_RUNTIME_STARTED_AT });
346
+ activeReporter.start();
347
+ }
348
+ };
349
+ if (!rollback) {
350
+ publish();
351
+ return true;
352
+ }
353
+ // A rollback publishes under the client lifecycle lock, after rechecking the connection there,
354
+ // so a disconnect or connect that committed during the bind can never be served over. A lock
355
+ // held by such an operation counts as a change.
356
+ let published = false;
357
+ try {
358
+ published = withClientLifecycleSync(() => {
359
+ if (clientRestartRollbackAction(state.value, readClientConnectionState()) === "recycle") return false;
360
+ publish();
361
+ return true;
362
+ });
363
+ } catch {
364
+ published = false;
365
+ }
366
+ if (!published) {
367
+ try { await server.stop(true); } catch { /* never published */ }
368
+ }
369
+ return published;
370
+ };
371
+
372
+ // Stop everything this process serves, giving in-flight requests up to `drainMs`.
373
+ const stopServing = async (drainMs: number): Promise<void> => {
374
+ activeReporter?.stop();
375
+ activeReporter = null;
376
+ try {
377
+ await activeSupervisor?.stop();
378
+ } catch (error) {
379
+ console.warn(`[client] link supervisor stop failed: ${error instanceof Error ? error.message : String(error)}`);
380
+ }
381
+ activeSupervisor = null;
382
+ const server = activeServer;
383
+ activeServer = null;
384
+ if (!server) return;
385
+ let timer: ReturnType<typeof setTimeout> | undefined;
386
+ try {
387
+ await Promise.race([
388
+ Promise.resolve(server.stop(false)),
389
+ new Promise<void>(resolve => { timer = setTimeout(resolve, drainMs); }),
390
+ ]);
391
+ } catch { /* closed below */ } finally {
392
+ if (timer) clearTimeout(timer);
393
+ }
394
+ try { await server.stop(true); } catch { /* already closed */ }
395
+ };
396
+
397
+ const restartController: ClientRestartController = createClientRestartController({
398
+ supervisor: io.restartSupervisor ?? clientRestartSupervisor,
399
+ stop: stopServing,
400
+ spawnReplacement: async deadlineAt => {
401
+ const { replacementStartEnvironment } = await import("../server/management/system-restart");
402
+ const { spawnReplacementStart } = await import("../server/restart-replacement");
403
+ await spawnReplacementStart(
404
+ { port: activePort ?? config.port, waitForHealth: true, env: replacementStartEnvironment(true), notReady: "terminate" },
405
+ { deadlineAt },
406
+ );
407
+ },
408
+ resume: async () => {
409
+ // The connection may have changed while the replacement was starting (`ocx disconnect`, a
410
+ // new connect). Serving the old one again would be a stale runtime nothing recycles; hand
411
+ // off to the recycle, which starts whatever the durable state now calls for.
412
+ if (clientRestartRollbackAction(state.value, readClientConnectionState()) === "recycle") {
413
+ await recycleStandalone(state.value.tokenFingerprint);
414
+ return;
415
+ }
416
+ if (!(await serve({ port: servedPort, portWaitMs: CLIENT_RESTART_REBIND_WAIT_MS }))) {
417
+ await recycleStandalone(state.value.tokenFingerprint);
418
+ }
419
+ },
420
+ exitProcess: code => { process.exit(code); },
421
+ });
422
+
313
423
  installCrashGuards();
314
- writePid(process.pid);
315
- writeRuntimePort(clientRuntimeRecord(process.pid, boundPort));
424
+ // serve() publishes the pid and runtime record; the ownership lease is released only after it.
425
+ await serve();
316
426
  options.afterPublish?.();
317
427
 
318
428
  let shuttingDown = false;
@@ -320,14 +430,16 @@ export async function startClientRuntime(
320
430
  if (shuttingDown) return;
321
431
  shuttingDown = true;
322
432
  void (async () => {
433
+ activeReporter?.stop();
434
+ activeReporter = null;
323
435
  try {
324
- await supervisor?.stop();
436
+ await activeSupervisor?.stop();
325
437
  } catch (error) {
326
438
  console.warn(`[client] link supervisor stop failed: ${error instanceof Error ? error.message : String(error)}`);
327
439
  }
328
440
  activeSupervisor = null;
329
441
  try {
330
- server.stop(true);
442
+ activeServer?.stop(true);
331
443
  } catch (error) {
332
444
  console.warn(`[client] listener stop failed: ${error instanceof Error ? error.message : String(error)}`);
333
445
  } finally {
@@ -1,4 +1,5 @@
1
1
  import { observeSignedInCodexIdentity } from "./signed-in-identity";
2
+ import { observeMainCodexCollisionIdentity } from "./collision-identity";
2
3
  import { existsSync, readFileSync } from "node:fs";
3
4
  import {
4
5
  deleteConfigTopLevelKey,
@@ -9,11 +10,12 @@ import {
9
10
  import { captureConfigTopLevelRollback } from "../config/rebase-provenance";
10
11
  import { removeCodexAccountCredential } from "./account-store";
11
12
  import { clearAccountNeedsReauth } from "./account-runtime-state";
12
- import { readCodexTokensResult } from "./auth-collision";
13
+ import { readMainAuth } from "./auth-collision";
13
14
  import { MAIN_CODEX_ACCOUNT_ID, setMainAccountPlan } from "./main-account";
14
15
  import { clearAccountQuota } from "./quota";
15
16
  import { forgetManualPreference } from "./routing/active-account";
16
17
  import { deleteAllHealthForAccount } from "./routing/health-store";
18
+ import { forgetCodexFailureWindow } from "./routing/failure-window";
17
19
  import { clearThreadAccountMapForAccount } from "./routing/thread-affinity";
18
20
 
19
21
  import { clearCodexPoolRefreshFailure } from "./pool-refresh-backoff";
@@ -49,11 +51,15 @@ export function purgeCodexAccountRuntimeState(accountId: string): void {
49
51
  clearAccountNeedsReauth(accountId);
50
52
  clearAccountQuota(accountId);
51
53
  clearThreadAccountMapForAccount(accountId);
52
- // Deliberately not via `./routing`: the account-delete path only needs the health rows and
53
- // the manual preference gone, and importing the router here closed a cycle through
54
- // routing/health-store and routing/active-account.
54
+ // Deliberately not via `./routing`: the account-delete path only needs the health rows
55
+ // and their sibling routing state gone, and importing the router barrel here closed a
56
+ // cycle through routing/health-store and routing/active-account. The leaf imports below
57
+ // carry every per-account forget that barrel helper used to perform — including the
58
+ // failure window, whose degrade and pin-warning state must not leak into a replacement
59
+ // account or a new physical login behind __main__.
55
60
  deleteAllHealthForAccount(accountId);
56
61
  forgetManualPreference(accountId);
62
+ forgetCodexFailureWindow(accountId);
57
63
  clearCodexPoolRefreshFailure(accountId);
58
64
  if (accountId === MAIN_CODEX_ACCOUNT_ID) {
59
65
  clearMainAccountInfoCache();
@@ -74,7 +80,8 @@ function purgeMainCodexAccountRuntimeState(): void {
74
80
  * cooldown, quota, reauth, and thread affinity do not leak across accounts.
75
81
  */
76
82
  export function reconcileMainCodexAccountRuntimeState(): boolean {
77
- const read = readCodexTokensResult();
83
+ const read = readMainAuth();
84
+ if (read.status === "missing" || read.status === "api-key-only") observeMainCodexCollisionIdentity();
78
85
  const currentAccountId = read.status === "ok"
79
86
  ? extractAccountId(read.tokens.id_token, read.tokens.access_token) ?? (read.tokens.account_id || null)
80
87
  : null;
@@ -86,11 +93,13 @@ export function reconcileMainCodexAccountRuntimeState(): boolean {
86
93
  observedMainChatgptAccountId = currentAccountId;
87
94
  if (previousAccountId === undefined || previousAccountId === currentAccountId) {
88
95
  observeMainQuotaIdentity(currentAccountId);
96
+ if (read.status === "ok") observeMainCodexCollisionIdentity(read.tokens);
89
97
  return false;
90
98
  }
91
99
 
92
100
  purgeMainCodexAccountRuntimeState();
93
101
  observeMainQuotaIdentity(currentAccountId);
102
+ if (read.status === "ok") observeMainCodexCollisionIdentity(read.tokens);
94
103
  return true;
95
104
  }
96
105
 
@@ -103,7 +112,8 @@ export function reconcileMainCodexAccountRuntimeState(): boolean {
103
112
  export function initializeMainAccountPolicyBinding(authPath: string): boolean {
104
113
  // Startup observes the pinned owned path inside the exclusive claim: bound the read so a
105
114
  // replaced non-regular or oversized file cannot stall startup inside that claim.
106
- const result = readCodexTokensResult(authPath, { bounded: true });
115
+ const result = readMainAuth(authPath);
116
+ if (result.status === "missing" || result.status === "api-key-only") observeMainCodexCollisionIdentity();
107
117
  if (result.status !== "ok") return false;
108
118
  const { tokens } = result;
109
119
  if (typeof tokens.access_token !== "string" || !tokens.access_token
@@ -124,6 +134,7 @@ export function initializeMainAccountPolicyBinding(authPath: string): boolean {
124
134
  if (previousAccountId !== undefined && previousAccountId !== accountId) purgeMainCodexAccountRuntimeState();
125
135
  observeSignedInCodexIdentity(tokens);
126
136
  observeMainQuotaIdentity(accountId);
137
+ observeMainCodexCollisionIdentity(tokens);
127
138
  return observeMainQuotaCredential(tokens.access_token, accountId) !== undefined;
128
139
  }
129
140
 
@@ -136,6 +147,7 @@ export function applyConfirmedMainCodexAccountTransition(
136
147
  toAccountId: string,
137
148
  ): boolean {
138
149
  observeSignedInCodexIdentity();
150
+ observeMainCodexCollisionIdentity();
139
151
  if (!fromAccountId || !toAccountId || fromAccountId === toAccountId) {
140
152
  if (toAccountId) {
141
153
  observedMainChatgptAccountId = toAccountId;
@@ -150,6 +162,7 @@ export function applyConfirmedMainCodexAccountTransition(
150
162
  }
151
163
 
152
164
  export function resetMainCodexAccountIdentityTrackingForTests(): void {
165
+ observeMainCodexCollisionIdentity();
153
166
  observedMainChatgptAccountId = undefined;
154
167
  observeSignedInCodexIdentity();
155
168
  clearMainAccountCredentialPresence();