@bitkyc08/opencodex 2.90.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 (132) hide show
  1. package/gui/dist/assets/App-DMps4OLk.css +1 -0
  2. package/gui/dist/assets/{App-DgxkE6Md.js → App-nZ34cR5G.js} +18 -18
  3. package/gui/dist/assets/{Tray-DbTNc_88.js → Tray-BuLy7hL1.js} +1 -1
  4. package/gui/dist/assets/{index-TmaHa54u.js → index-DGXS51sp.js} +12 -12
  5. package/gui/dist/assets/{quota-summary-B16x5DZZ.js → quota-summary-DO1Sx5TM.js} +1 -1
  6. package/gui/dist/index.html +1 -1
  7. package/package.json +1 -1
  8. package/src/adapters/anthropic.ts +2 -5
  9. package/src/adapters/claude-cli/usage-admission.ts +14 -0
  10. package/src/adapters/openai-responses/passthrough.ts +2 -1
  11. package/src/adapters/openai-responses/request-strips.ts +32 -0
  12. package/src/adapters/openai-responses/transport.ts +6 -0
  13. package/src/chat/inbound.ts +20 -2
  14. package/src/chat/outbound.ts +36 -4
  15. package/src/chat/reasoning-details.ts +137 -0
  16. package/src/cli/capabilities-agents-routing.ts +50 -0
  17. package/src/cli/capabilities-base.ts +15 -2
  18. package/src/cli/capabilities-observe-system.ts +4 -4
  19. package/src/cli/codex-shim-autorestore.ts +5 -0
  20. package/src/cli/dispatch.ts +30 -0
  21. package/src/cli/fallback.ts +127 -0
  22. package/src/cli/help.ts +1 -0
  23. package/src/cli/index.ts +1 -2
  24. package/src/cli/link-apply-restart.ts +72 -0
  25. package/src/cli/link-child.ts +6 -2
  26. package/src/cli/link-cleanup.ts +95 -0
  27. package/src/cli/link-enrollment.ts +4 -0
  28. package/src/cli/link.ts +84 -9
  29. package/src/cli/provider.ts +44 -2
  30. package/src/cli/registry.ts +13 -1
  31. package/src/cli/restart-failure.ts +4 -0
  32. package/src/cli/runtime-api.ts +1 -1
  33. package/src/cli/status-codex-settings.ts +56 -0
  34. package/src/cli/status-fallback.ts +29 -0
  35. package/src/cli/status-install.ts +52 -0
  36. package/src/cli/status.ts +20 -1
  37. package/src/cli/system-restart-client.ts +32 -1
  38. package/src/cli/update-restart-child.ts +4 -3
  39. package/src/cli/update-restart-home.ts +20 -8
  40. package/src/client/connect.ts +82 -6
  41. package/src/client/home-unreachable.ts +16 -0
  42. package/src/client/hub-relay.ts +8 -2
  43. package/src/client/link-home.ts +119 -6
  44. package/src/client/link-parity-reporter.ts +107 -0
  45. package/src/client/link-relay-transport.ts +8 -1
  46. package/src/client/link-relay.ts +27 -3
  47. package/src/client/machine-listener.ts +83 -1
  48. package/src/client/runtime-restart.ts +127 -0
  49. package/src/client/runtime.ts +137 -25
  50. package/src/codex/auth-context.ts +10 -6
  51. package/src/codex/catalog/parsing.ts +4 -0
  52. package/src/codex/desktop-compatibility/windows-key-protection.ts +18 -6
  53. package/src/codex/quota-history.ts +7 -0
  54. package/src/codex/quota.ts +10 -0
  55. package/src/codex/subagent-model-fallback.ts +24 -1
  56. package/src/combos/index.ts +1 -0
  57. package/src/combos/resolve.ts +12 -0
  58. package/src/config/diagnostics.ts +5 -0
  59. package/src/config/load-degrade.ts +2 -0
  60. package/src/config/schema/config-schema.ts +3 -0
  61. package/src/config/schema/fallback-policy.ts +40 -0
  62. package/src/generated/compatibility-version.json +205 -97
  63. package/src/lib/authorization-scheme.ts +95 -0
  64. package/src/lib/provider-client-headers.ts +1 -0
  65. package/src/lib/redact.ts +2 -2
  66. package/src/lib/system-restart-contract.ts +24 -0
  67. package/src/lib/test-home-guard.ts +78 -11
  68. package/src/link/child-parity.ts +124 -0
  69. package/src/link/codex-settings.ts +37 -0
  70. package/src/link/compatibility.ts +119 -0
  71. package/src/link/management-routes.ts +2 -0
  72. package/src/link/peer-report.ts +100 -0
  73. package/src/link/remote-platform.ts +78 -0
  74. package/src/link/ssh-argv.ts +19 -0
  75. package/src/link/status-projection.ts +24 -2
  76. package/src/oauth/index.ts +1 -1
  77. package/src/oauth/kimi.ts +2 -0
  78. package/src/oauth/nous.ts +4 -1
  79. package/src/oauth/store.ts +2 -1
  80. package/src/protocols/codecs/chat.ts +3 -3
  81. package/src/protocols/encoders/adapter-events.ts +26 -2
  82. package/src/protocols/encoders/chat.ts +55 -3
  83. package/src/providers/mistral-transport.ts +37 -4
  84. package/src/providers/quota/antigravity.ts +2 -8
  85. package/src/quota-pool/assemble.ts +326 -0
  86. package/src/quota-pool/contract.ts +58 -0
  87. package/src/quota-pool/identity.ts +203 -0
  88. package/src/remote/features.ts +74 -0
  89. package/src/remote/protocol.ts +2 -0
  90. package/src/remote/served-by.ts +57 -0
  91. package/src/responses/parser.ts +7 -0
  92. package/src/responses/reasoning-envelope.ts +12 -0
  93. package/src/routing/fallback-policy.ts +290 -0
  94. package/src/routing/fallback-state.ts +179 -0
  95. package/src/server/audio-client.ts +2 -1
  96. package/src/server/audio-dictation.ts +2 -1
  97. package/src/server/audio-upstream.ts +2 -1
  98. package/src/server/auth-cors.ts +6 -5
  99. package/src/server/background-lifecycle.ts +4 -0
  100. package/src/server/chat-completions.ts +8 -1
  101. package/src/server/gui-session.ts +2 -1
  102. package/src/server/index/link-listener.ts +11 -1
  103. package/src/server/index/link-management.ts +47 -0
  104. package/src/server/index/link-relay-sessions.ts +11 -2
  105. package/src/server/index/optional-listeners.ts +7 -2
  106. package/src/server/index/serve-options.ts +19 -2
  107. package/src/server/live.ts +5 -0
  108. package/src/server/management/context.ts +1 -1
  109. package/src/server/management/fallback-routes.ts +75 -0
  110. package/src/server/management/link-enrollment.ts +21 -3
  111. package/src/server/management/link-route-state.ts +2 -0
  112. package/src/server/management/link-routes.ts +12 -1
  113. package/src/server/management/quota-pool-routes.ts +24 -0
  114. package/src/server/management/route-registry.ts +3 -0
  115. package/src/server/management-api.ts +16 -0
  116. package/src/server/management-auth.ts +2 -1
  117. package/src/server/responses/agent-task-recovery.ts +1 -1
  118. package/src/server/responses/compact.ts +2 -2
  119. package/src/server/responses/core-auth.ts +11 -1
  120. package/src/server/responses/core-combo.ts +72 -1
  121. package/src/server/responses/encrypted-payload.ts +3 -3
  122. package/src/server/responses/main-hard-lock-routing.ts +54 -2
  123. package/src/server/responses/policy-fallback.ts +44 -1
  124. package/src/server/responses/request-prepare.ts +9 -1
  125. package/src/server/restart-replacement.ts +50 -2
  126. package/src/server/ws-bridge.ts +5 -0
  127. package/src/service/cli.ts +25 -2
  128. package/src/service/diagnostics.ts +20 -6
  129. package/src/service/status-json.ts +116 -0
  130. package/src/types/config.ts +7 -0
  131. package/src/types/fallback.ts +38 -0
  132. package/gui/dist/assets/App-dnkJ85te.css +0 -1
@@ -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 {
@@ -98,6 +98,7 @@ import { getMainReserveAuthorization, isMainReserveAuthorizationLive, nativeUser
98
98
  import { UpstreamRetryEvidenceError } from "../lib/upstream-retry";
99
99
  import { getEffectiveCodexAutoSwitchThreshold } from "./account-auto-switch";
100
100
  import { CODEX_MAIN_DUPLICATE_ACTION, configuredCodexMainDuplicate } from "./collision-identity";
101
+ import { AgentAssertionUnsupportedError, authorizationCredential, hasAgentAssertionAuthorization } from "../lib/authorization-scheme";
101
102
 
102
103
  /**
103
104
  * A request-owned bearer cannot inspect the physical main credential for its plan, but cached
@@ -701,7 +702,7 @@ async function authorizeReserveCredential(
701
702
 
702
703
  function selectedCodexToken(headers: Headers): { accessToken: string; chatgptAccountId: string } {
703
704
  return {
704
- accessToken: headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim() ?? "",
705
+ accessToken: authorizationCredential(headers) ?? "",
705
706
  chatgptAccountId: headers.get("chatgpt-account-id") ?? "",
706
707
  };
707
708
  }
@@ -716,7 +717,7 @@ function selectedCodexToken(headers: Headers): { accessToken: string; chatgptAcc
716
717
  export function callerCodexWorkspaceAccountId(headers: Headers): string | undefined {
717
718
  const explicit = headers.get("chatgpt-account-id");
718
719
  if (explicit) return explicit;
719
- const bearer = headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim();
720
+ const bearer = authorizationCredential(headers);
720
721
  return bearer ? extractAccountId(undefined, bearer) : undefined;
721
722
  }
722
723
 
@@ -807,7 +808,7 @@ function assertMainAccountPolicy(
807
808
 
808
809
  /** No auth-file I/O: an unsigned claim alone never identifies a caller as stored main. */
809
810
  function callerMatchesObservedMain(headers: Headers): boolean {
810
- const bearer = headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim();
811
+ const bearer = authorizationCredential(headers);
811
812
  if (!bearer) return false;
812
813
  const effectiveAccountId = headers.get("chatgpt-account-id")
813
814
  ?? extractAccountId(undefined, bearer);
@@ -831,7 +832,7 @@ function sameCredentialMaterial(a: string, b: string): boolean {
831
832
  * cooldown — the safe direction.
832
833
  */
833
834
  function callerIsCooledPoolAccount(headers: Headers, config: OcxConfig, accountId: string): boolean {
834
- const bearer = headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim();
835
+ const bearer = authorizationCredential(headers);
835
836
  if (!bearer) return true;
836
837
  const callerAccountId = headers.get("chatgpt-account-id") ?? extractAccountId(undefined, bearer);
837
838
  if (callerAccountId === undefined) return true;
@@ -1767,7 +1768,7 @@ export class CodexMainSubstitutionUnavailableError extends Error {
1767
1768
 
1768
1769
  /** Dispatch identity comes only from an owned observation of the selected credential. */
1769
1770
  function selectedMainQuotaDispatch(selected: Headers): MainQuotaDispatch | undefined {
1770
- const bearer = selected.get("authorization")?.replace(/^Bearer\s+/i, "").trim();
1771
+ const bearer = authorizationCredential(selected);
1771
1772
  if (!bearer) return undefined;
1772
1773
  const accountId = selected.get("chatgpt-account-id") ?? extractAccountId(undefined, bearer);
1773
1774
  return captureMainQuotaDispatch(bearer, accountId, captureConfigGeneration());
@@ -1792,6 +1793,8 @@ export function materializeCodexUpstreamAuth(
1792
1793
  ctx: CodexAuthContext,
1793
1794
  options: CodexAuthMaterializationOptions = {},
1794
1795
  ): Headers {
1796
+ // assertion_only must never be answered with a pool, stored-main, or other substituted Bearer.
1797
+ if (hasAgentAssertionAuthorization(headers)) throw new AgentAssertionUnsupportedError();
1795
1798
  if (ctx.kind === "main") ctx.mainQuotaDispatch = undefined;
1796
1799
  const selected = new Headers();
1797
1800
  for (const name of FORWARD_HEADERS) {
@@ -1814,7 +1817,7 @@ export function materializeCodexUpstreamAuth(
1814
1817
  }
1815
1818
  if (ctx.kind === "main" && options.substituteMainCredential !== true
1816
1819
  && !selected.has("chatgpt-account-id")) {
1817
- const bearer = selected.get("authorization")?.replace(/^Bearer\s+/i, "").trim();
1820
+ const bearer = authorizationCredential(selected);
1818
1821
  const accountId = bearer ? extractAccountId(undefined, bearer) : undefined;
1819
1822
  if (accountId) selected.set("chatgpt-account-id", accountId);
1820
1823
  }
@@ -1892,6 +1895,7 @@ export async function materializeCodexUpstreamAuthAsync(
1892
1895
  ctx: CodexAuthContext,
1893
1896
  options: CodexAuthMaterializationOptions = {},
1894
1897
  ): Promise<Headers> {
1898
+ if (hasAgentAssertionAuthorization(headers)) throw new AgentAssertionUnsupportedError();
1895
1899
  if (ctx.kind === "main") ctx.mainQuotaDispatch = undefined;
1896
1900
  if (requiresReserveAuthorization(options.config, options.modelId, options.admission)) {
1897
1901
  return materializeReserveUpstreamAuth(headers, ctx, options);
@@ -809,6 +809,10 @@ export function normalizeRoutedCatalogEntry(
809
809
  delete entry.multi_agent_version;
810
810
  delete entry.multi_agent_reasoning_effort;
811
811
  delete entry.use_responses_lite;
812
+ // Codex 0.164 sends positional `configuration_update` effort items only for rows declaring this
813
+ // and freezes the request-level effort for the context window. A routed provider never
814
+ // negotiated that contract, so a row cloned from a GPT-6 template must not inherit it.
815
+ delete entry.supports_reasoning_effort_updates;
812
816
  delete entry.supports_websockets;
813
817
  // Routed rows cloned from native templates must not inherit OpenAI-only experimental context
814
818
  // delivery. Codex reads the flag as "this model accepts experimental context history" and drives
@@ -10,17 +10,24 @@ const ENTROPY = new TextEncoder().encode(PURPOSE);
10
10
  // Only the PowerShell fallback is bounded by a clock. A cold Windows PowerShell that must also load
11
11
  // System.Security has been measured past 10s on a loaded machine (Windows CI run 38591626343).
12
12
  const DPAPI_TIMEOUT_MS = 20_000;
13
+ // Cmdlet-free on purpose. The first cmdlet a PowerShell session runs (Add-Type, ConvertFrom-Json)
14
+ // sends it through command discovery and module auto-loading, which without a module-analysis cache
15
+ // for the profile scans the modules on PSModulePath. The test runner gives every run a fresh profile
16
+ // and CI images carry large module sets, so that scan, not DPAPI, is what cold runs waited on.
17
+ // Assembly loading, string splitting and base64 are plain .NET calls outside command discovery.
18
+ // The request is "<operation>\n<base64 data>" on stdin.
13
19
  const SCRIPT = [
14
20
  "$ErrorActionPreference='Stop'",
15
- "Add-Type -AssemblyName System.Security",
16
- "$r=[Console]::In.ReadToEnd()|ConvertFrom-Json",
17
- "$data=[Convert]::FromBase64String($r.data)",
21
+ "[void][Reflection.Assembly]::Load('System.Security, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a')",
22
+ "$r=[Console]::In.ReadToEnd().Split([char]10)",
23
+ "if($r.Length -ne 2){throw 'Invalid request'}",
24
+ "$data=[Convert]::FromBase64String($r[1])",
18
25
  `if($data.Length -eq 0 -or $data.Length -gt ${MAX_INPUT}){throw 'Invalid data size'}`,
19
26
  `$entropy=[Text.Encoding]::UTF8.GetBytes('${PURPOSE}')`,
20
27
  "$scope=[Security.Cryptography.DataProtectionScope]::CurrentUser",
21
- "if($r.operation -eq 'protect'){",
28
+ "if($r[0] -ceq 'protect'){",
22
29
  " $out=[Security.Cryptography.ProtectedData]::Protect($data,$entropy,$scope)",
23
- "}elseif($r.operation -eq 'unprotect'){",
30
+ "}elseif($r[0] -ceq 'unprotect'){",
24
31
  " $out=[Security.Cryptography.ProtectedData]::Unprotect($data,$entropy,$scope)",
25
32
  "}else{throw 'Invalid operation'}",
26
33
  "[Console]::Out.Write([Convert]::ToBase64String($out))",
@@ -126,7 +133,7 @@ function runPowerShellDpapi(operation: Operation, data: Uint8Array, timeoutMs =
126
133
  resolve(bytes);
127
134
  });
128
135
  child.stdin?.on("error", () => { /* execFile's completion reports the failed child. */ });
129
- child.stdin?.end(JSON.stringify({ operation, data: Buffer.from(data).toString("base64") }));
136
+ child.stdin?.end(`${operation}\n${Buffer.from(data).toString("base64")}`);
130
137
  });
131
138
  }
132
139
 
@@ -163,3 +170,8 @@ export function powerShellAuthorityKeyProtectionForTests(timeoutMs: number): Aut
163
170
  export function usesNativeDpapiForTests(): boolean {
164
171
  return process.platform === "win32" && loadNativeDpapi() !== null;
165
172
  }
173
+
174
+ /** Test-only: the PowerShell helper's script, so its cmdlet-free contract can be checked anywhere. */
175
+ export function powerShellDpapiScriptForTests(): string {
176
+ return SCRIPT;
177
+ }
@@ -92,6 +92,13 @@ export class CodexQuotaHistory {
92
92
  return { samples: structuredClone(bucket.samples.slice(-capped)), truncated: bucket.samples.length > capped };
93
93
  }
94
94
 
95
+ /** The newest sample's origin, without pruning or clearing: a passive read changes nothing. */
96
+ peekLatestSource(accountId: string, identity: string | undefined): QuotaHistorySample["source"] | undefined {
97
+ const bucket = this.accounts.get(accountId);
98
+ if (!identity || !bucket || bucket.identity !== identity) return undefined;
99
+ return bucket.samples.at(-1)?.source;
100
+ }
101
+
95
102
  clear(accountId?: string): number {
96
103
  if (accountId === undefined) {
97
104
  const count = this.accounts.size;
@@ -1099,3 +1099,13 @@ export function getAccountQuotaHistory(accountId: string, limit: number = QUOTA_
1099
1099
  return { observations: result.samples.map(({ credentialGeneration: _generation, ...sample }) => sample),
1100
1100
  truncated: result.truncated, retention: { maxObservations: QUOTA_HISTORY_LIMITS.perAccount, maxAgeDays: 30 } };
1101
1101
  }
1102
+
1103
+ /**
1104
+ * Where the newest stored reading for a pool account came from (a WHAM usage body or upstream
1105
+ * response headers), or undefined when no current history exists. Read-only: unlike
1106
+ * `getAccountQuotaHistory` it never prunes or clears a mismatched bucket.
1107
+ */
1108
+ export function latestCodexQuotaObservationSource(accountId: string): "wham" | "response-header" | undefined {
1109
+ hydrateAccountQuotasFromDisk();
1110
+ return quotaHistory.peekLatestSource(accountId, poolQuotaHistoryIdentity(accountId));
1111
+ }