@bitkyc08/opencodex 2.78.0 → 2.80.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 (91) hide show
  1. package/gui/dist/assets/App-CSJvmjNs.css +1 -0
  2. package/gui/dist/assets/App-MbDVfUeC.js +54 -0
  3. package/gui/dist/assets/{Tray-DiJpSub0.js → Tray-CzkxK82A.js} +1 -1
  4. package/gui/dist/assets/{index-DmQB8D_R.css → index-Brk6F3fn.css} +1 -1
  5. package/gui/dist/assets/index-UJt6npXk.js +94 -0
  6. package/gui/dist/assets/{quota-summary-B_a96nXj.js → quota-summary-DU3sD6Z-.js} +1 -1
  7. package/gui/dist/index.html +2 -2
  8. package/package.json +3 -2
  9. package/src/adapters/cursor/live-transport.ts +5 -3
  10. package/src/adapters/cursor/native-exec-fs.ts +12 -9
  11. package/src/adapters/cursor/native-exec.ts +13 -5
  12. package/src/adapters/cursor/protobuf-request.ts +13 -7
  13. package/src/adapters/cursor/request-builder.ts +23 -10
  14. package/src/adapters/cursor/tool-definitions.ts +9 -6
  15. package/src/adapters/cursor/tool-guidance.ts +29 -8
  16. package/src/adapters/cursor/tool-naming.ts +40 -7
  17. package/src/adapters/cursor/tool-wording.ts +46 -0
  18. package/src/adapters/cursor.ts +3 -0
  19. package/src/adapters/devin.ts +37 -2
  20. package/src/adapters/openai-chat/messages.ts +3 -1
  21. package/src/claude/desktop-picker.ts +11 -9
  22. package/src/claude/inbound-content-options.ts +29 -2
  23. package/src/claude/inbound.ts +9 -0
  24. package/src/claude/intercept/local-ca-files.ts +265 -0
  25. package/src/claude/intercept/local-ca-windows.ts +73 -0
  26. package/src/claude/intercept/local-ca.ts +12 -24
  27. package/src/claude/intercept/picker-ca-persistence.ts +106 -0
  28. package/src/claude/intercept/picker-ca-startup.ts +16 -0
  29. package/src/claude/intercept/picker-ca-store.ts +70 -0
  30. package/src/claude/intercept/picker-ca.ts +43 -9
  31. package/src/claude/intercept/picker-runtime.ts +14 -4
  32. package/src/claude/intercept/runtime.ts +32 -23
  33. package/src/cli/ensure-readiness.ts +48 -0
  34. package/src/cli/gui-pair-client.ts +15 -7
  35. package/src/cli/gui.ts +4 -1
  36. package/src/cli/hub.ts +1 -0
  37. package/src/cli/index.ts +10 -11
  38. package/src/cli/status.ts +2 -2
  39. package/src/cli/update-restart-child.ts +4 -1
  40. package/src/codex/account-credit-use.ts +8 -2
  41. package/src/codex/auth-api/account-pause-group.ts +113 -0
  42. package/src/codex/auth-api/routes.ts +18 -12
  43. package/src/codex/auth-context.ts +45 -3
  44. package/src/codex/autostart-health.ts +13 -3
  45. package/src/codex/journal.ts +36 -5
  46. package/src/codex/pool-credit-policy.ts +67 -0
  47. package/src/codex/shim-overlay.ts +9 -1
  48. package/src/combos/jev.ts +3 -1
  49. package/src/generated/compatibility-version.json +135 -63
  50. package/src/lib/gui-pair-capability.ts +1 -1
  51. package/src/lib/gui-pair-intent.ts +170 -0
  52. package/src/lib/windows-owner-acl.ts +143 -0
  53. package/src/providers/openai-sidecar-credit.ts +26 -0
  54. package/src/providers/openai-sidecar.ts +35 -0
  55. package/src/server/claude-messages.ts +1 -0
  56. package/src/server/gui-pair-delivery.ts +23 -0
  57. package/src/server/index/serve-options.ts +5 -8
  58. package/src/server/index.ts +12 -14
  59. package/src/server/live-native-calls.ts +94 -0
  60. package/src/server/live.ts +28 -5
  61. package/src/server/local-management-read-client.ts +15 -11
  62. package/src/server/management/oauth-account-routes.ts +15 -0
  63. package/src/server/management/route-registry.ts +1 -0
  64. package/src/server/management/shared.ts +9 -3
  65. package/src/server/messages-native-scope.ts +60 -0
  66. package/src/server/messages-native.ts +9 -0
  67. package/src/server/responses/adapter-dispatch.ts +31 -2
  68. package/src/server/responses/combo-requested-effort.ts +14 -0
  69. package/src/server/responses/compact.ts +17 -2
  70. package/src/server/responses/core-auth.ts +18 -1
  71. package/src/server/responses/core-codex-account.ts +18 -8
  72. package/src/server/responses/core-combo.ts +5 -7
  73. package/src/server/responses/non-replayable-error.ts +48 -0
  74. package/src/server/responses/passthrough-delivery.ts +5 -1
  75. package/src/server/responses/passthrough-dispatch.ts +21 -19
  76. package/src/server/responses/request-prepare.ts +11 -1
  77. package/src/server/responses/sidecar-execution.ts +1 -0
  78. package/src/server/search.ts +4 -0
  79. package/src/server/startup-health-cache.ts +2 -6
  80. package/src/service/desktop-startup.ts +100 -3
  81. package/src/vision/describe.ts +17 -10
  82. package/src/vision/index.ts +1 -0
  83. package/src/web-search/executor.ts +17 -10
  84. package/src/web-search/format-result.ts +304 -20
  85. package/src/web-search/header-deadline.ts +67 -0
  86. package/src/web-search/loop.ts +7 -6
  87. package/src/web-search/passthrough-bridge.ts +1 -0
  88. package/src/web-search/run-turn-loop.ts +1 -1
  89. package/gui/dist/assets/App-BtfG5id-.js +0 -54
  90. package/gui/dist/assets/App-lCIslZ1L.css +0 -1
  91. package/gui/dist/assets/index-Btc3Ogw4.js +0 -94
@@ -1,6 +1,8 @@
1
1
  import { createHash, randomUUID, X509Certificate } from "node:crypto";
2
2
  import { chmodSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
+ import { canonicalPickerConfigDir, ensurePersistentPickerCa } from "./picker-ca-persistence";
5
+ import type { PickerCaStore } from "./picker-ca-store";
4
6
  import { withClientLifecycleSync } from "../../client/lifecycle-lock";
5
7
  import {
6
8
  ALL_IP_ADDRESS_BASES,
@@ -405,29 +407,58 @@ export function acknowledgePendingPickerCaUntrust(
405
407
  * unconditional so a later failed rotation can never leave that key behind.
406
408
  */
407
409
  export function discardPickerCaKey(configDir: string): void {
410
+ try {
411
+ const stat = lstatSync(pickerStateDir(configDir));
412
+ if (!stat.isDirectory() || (process.platform !== "win32" && stat.uid !== process.getuid!())) {
413
+ throw new Error("picker_ca_directory_unsafe");
414
+ }
415
+ } catch (error) {
416
+ if (error !== null && typeof error === "object" && (error as { code?: unknown }).code === "ENOENT") return;
417
+ throw error;
418
+ }
408
419
  rmSync(join(pickerStateDir(configDir), "ca.key"), { force: true });
409
420
  }
410
421
 
411
- export function ensurePickerCa(configDir: string, options: { rotation?: "startup" } = {}): PickerCa {
422
+ export function ensurePickerCa(configDir: string, options: { rotation?: "startup"; persistent?: boolean; store?: PickerCaStore } = {}): PickerCa {
423
+ mkdirSync(configDir, { recursive: true, mode: 0o700 });
424
+ configDir = canonicalPickerConfigDir(configDir);
412
425
  const dir = pickerStateDir(configDir);
413
- // The signing key must never survive this process: another process under the same user could
414
- // otherwise steal it and later take over the predictable loopback proxy. Drop a key left (or
415
- // restored) by an older release on every call, including cache hits.
426
+ // Persistent startup stores the signing key in the OS credential store; plain calls keep an
427
+ // in-process authority. Both paths remove legacy plaintext ca.key files, including cache hits.
416
428
  discardPickerCaKey(configDir);
417
429
  const cached = processAuthorities.get(dir);
418
- const pickerCa = cached ?? (() => {
430
+ const create = () => cached ?? (() => {
419
431
  const ca = createCertificateAuthority({ commonName: PICKER_CA_COMMON_NAME, permittedDnsNames: [PICKER_HOST] });
420
432
  return { ...ca, fingerprint: pickerCaFingerprints(ca.certPem).sha256 };
421
433
  })();
422
434
  mkdirSync(dir, { recursive: true, mode: 0o700 });
423
- lockedPickerCa(configDir, () => {
435
+ const pickerCa = lockedPickerCa(configDir, () => {
424
436
  if (readPendingPickerCaUntrust(configDir)) throw new Error("picker_ca_pending_untrust");
437
+ if (options.persistent) {
438
+ return ensurePersistentPickerCa({
439
+ configDir, stateDir: dir, store: options.store, acceptsAuthority: acceptsPickerAuthority, create,
440
+ predecessor: ca => {
441
+ const published = publishedPickerCa(configDir);
442
+ if (published === ca.certPem || published === null) return null;
443
+ if (livePublishedOwner(configDir, published)) throw new Error("picker_ca_live_owner");
444
+ const outgoing = publicCertificate(published);
445
+ if (outgoing && options.rotation !== "startup") throw new Error("picker_ca_rotation_requires_startup");
446
+ return outgoing?.certPem ?? null;
447
+ },
448
+ publishMetadata: publishPem,
449
+ publish: (ca, predecessor) => {
450
+ if (predecessor) writePendingPickerCaUntrust(configDir, publicCertificate(predecessor)!);
451
+ publishAuthority(configDir, ca);
452
+ },
453
+ });
454
+ }
455
+ const pickerCa = create();
425
456
  const published = publishedPickerCa(configDir);
426
457
  if (published === pickerCa.certPem) {
427
458
  // The signing key is still in this process. A missing/stale owner file must not let a
428
459
  // second process rotate this live authority after an otherwise harmless cached ensure.
429
460
  if (!currentProcessOwnsPublishedCa(configDir, pickerCa)) publishOwner(configDir, pickerCa);
430
- return;
461
+ return pickerCa;
431
462
  }
432
463
  if (published !== null && livePublishedOwner(configDir, published)) {
433
464
  throw new Error("picker_ca_live_owner");
@@ -438,14 +469,17 @@ export function ensurePickerCa(configDir: string, options: { rotation?: "startup
438
469
  writePendingPickerCaUntrust(configDir, outgoing);
439
470
  }
440
471
  publishAuthority(configDir, pickerCa);
472
+ return pickerCa;
441
473
  });
442
- if (!cached) processAuthorities.set(dir, pickerCa);
474
+ if (!cached || options.persistent) processAuthorities.set(dir, pickerCa);
443
475
  return pickerCa;
444
476
  }
445
477
 
446
478
  /** Fingerprint of this process's authority only — null until ensurePickerCa has run. */
447
479
  export function publishedPickerCaSha256(configDir: string): string | null {
448
- const cached = processAuthorities.get(pickerStateDir(configDir));
480
+ let canonical: string;
481
+ try { canonical = canonicalPickerConfigDir(configDir); } catch { return null; }
482
+ const cached = processAuthorities.get(pickerStateDir(canonical));
449
483
  return cached ? pickerCaFingerprints(cached.certPem).sha256 : null;
450
484
  }
451
485
 
@@ -19,6 +19,8 @@ import type { ClaudeDesktopMode } from "../desktop-first-party";
19
19
  import type { TunnelDecision } from "./connect-proxy";
20
20
  import type { PemKeyPair } from "./local-ca";
21
21
  import { PICKER_HOST, ensurePickerCa, issuePickerLeaf, pickerCaFingerprints, pickerLeafCertPath, pickerStateDir, type PickerCa } from "./picker-ca";
22
+ import { preparePersistentPickerAuthority } from "./picker-ca-startup";
23
+ import type { PickerCaStore } from "./picker-ca-store";
22
24
  import { startPickerListener, type PickerListenerHandle } from "./picker-listener";
23
25
  import { createPickerModelSnapshot, type PickerModelSnapshot, type PickerRouteInput } from "./picker-models";
24
26
  import { inspectPickerTrust, type PickerTrustState, type SecurityRunner } from "./picker-trust";
@@ -81,6 +83,8 @@ export interface CreatePickerRuntimeOptions {
81
83
  isBusy?: () => boolean;
82
84
  configDir: string;
83
85
  loadRoutes: () => Promise<PickerRouteInput>;
86
+ /** Production opt-in; pure/direct runtime callers remain ephemeral. */
87
+ persistentAuthority?: { store?: PickerCaStore };
84
88
  security?: SecurityRunner;
85
89
  platform?: NodeJS.Platform;
86
90
  now?: () => number;
@@ -161,9 +165,11 @@ export function createPickerRuntime(options: CreatePickerRuntimeOptions): Picker
161
165
  if (pending) void pending.then(handle => handle.close()).catch(() => {});
162
166
  }
163
167
 
164
- function ensureMaterial(): { ca: PickerCa; leaf: PemKeyPair; sha1: string } {
168
+ async function ensureMaterial(): Promise<{ ca: PickerCa; leaf: PemKeyPair; sha1: string }> {
165
169
  if (!ca || !leaf || !caSha1) {
166
- ca = ensurePickerCa(options.configDir);
170
+ ca = options.persistentAuthority
171
+ ? await preparePersistentPickerAuthority({ configDir: options.configDir, store: options.persistentAuthority.store, security: options.security, platform })
172
+ : ensurePickerCa(options.configDir);
167
173
  leaf = issuePickerLeaf(ca, options.configDir);
168
174
  caSha1 = pickerCaFingerprints(ca.certPem).sha1;
169
175
  }
@@ -176,7 +182,7 @@ export function createPickerRuntime(options: CreatePickerRuntimeOptions): Picker
176
182
  }
177
183
 
178
184
  async function inspectTrust(force: boolean): Promise<PickerTrustState> {
179
- const { sha1 } = ensureMaterial();
185
+ const { sha1 } = await ensureMaterial();
180
186
  if (!force && trustFor === sha1 && now() - trustCheckedAt < trustTtlMs) return trust;
181
187
  trust = await inspectPickerTrust(pickerLeafCertPath(options.configDir), sha1, options.security, platform);
182
188
  trustFor = sha1;
@@ -186,7 +192,8 @@ export function createPickerRuntime(options: CreatePickerRuntimeOptions): Picker
186
192
 
187
193
  /** CA, leaf, snapshot and listener. Resolves false when a disarm overtook it. */
188
194
  async function startPieces(gen: number): Promise<boolean> {
189
- const material = ensureMaterial();
195
+ const material = await ensureMaterial();
196
+ if (gen !== generation || latched || stopped) return false;
190
197
  ensureSnapshot().refreshIfStale(PICKER_MODELS_MAX_AGE_MS);
191
198
  if (!listenerPromise) {
192
199
  const current = startListener({
@@ -266,6 +273,9 @@ export function createPickerRuntime(options: CreatePickerRuntimeOptions): Picker
266
273
  });
267
274
  },
268
275
  async refreshTrust() {
276
+ // A dormant production picker has no authority to inspect. Status/off must not initialize
277
+ // credentials; an already initialized issuer still gets authoritative OS trust inspection.
278
+ if (options.persistentAuthority && ca === null) return trust;
269
279
  return inspectTrust(true);
270
280
  },
271
281
  async refresh() {
@@ -8,10 +8,11 @@ import { createCliCatalogProvider } from "./cli-picker";
8
8
  import { CLAUDE_INTERCEPT_HOSTS, isBrowserConnect, startConnectProxy, type ConnectProxyHandle } from "./connect-proxy";
9
9
  import { startClaudeInterceptListener } from "./listener";
10
10
  import { claudeInterceptCaCertPath, ensureLocalInterceptCaForStartup, issueLocalInterceptLeaf } from "./local-ca";
11
- import { discardPickerCaKey, ensurePickerCa } from "./picker-ca";
12
- import { drainPendingPickerCaUntrust } from "./picker-ca-cleanup";
11
+ import { discardPickerCaKey } from "./picker-ca";
12
+ import { preparePersistentPickerAuthority } from "./picker-ca-startup";
13
+ import type { PickerCaStore } from "./picker-ca-store";
13
14
  import type { PickerRouteInput } from "./picker-models";
14
- import { createPickerRuntime, type CreatePickerRuntimeOptions, type PickerRuntime } from "./picker-runtime";
15
+ import { createPickerRuntime, pickerDesired, type CreatePickerRuntimeOptions, type PickerRuntime } from "./picker-runtime";
15
16
  import type { SecurityRunner } from "./picker-trust";
16
17
  import { ensureClaudeInterceptProxyToken, readClaudeInterceptProxyToken } from "./proxy-auth";
17
18
  import { buildClaudeInterceptEnv, migrateClaudeInterceptSettings } from "./settings";
@@ -31,6 +32,15 @@ import { buildClaudeInterceptEnv, migrateClaudeInterceptSettings } from "./setti
31
32
  * tunnels may be terminated by the picker runtime (src/claude/intercept/picker-runtime.ts).
32
33
  */
33
34
 
35
+ const PICKER_AUTHORITY_ERROR_CODES = new Set([
36
+ "picker_ca_store_unavailable", "picker_ca_store_missing", "picker_ca_store_invalid",
37
+ "picker_ca_store_readback_failed", "picker_ca_metadata_mismatch", "picker_ca_metadata_unsafe",
38
+ "picker_ca_metadata_invalid", "picker_ca_live_owner", "picker_ca_pending_untrust",
39
+ "picker_ca_pending_untrust_invalid", "picker_ca_pending_untrust_unsafe", "picker_ca_pending_untrust_changed",
40
+ "picker_ca_config_unsafe", "picker_ca_directory_unsafe", "picker_ca_rotation_requires_startup",
41
+ "picker_ca_unsupported_platform",
42
+ ]);
43
+
34
44
  export const CLAUDE_INTERCEPT_PORT_OFFSET = 100;
35
45
 
36
46
  export function claudeInterceptEnabled(config: Pick<OcxConfig, "claudeCode" | "runtimeRole">): boolean {
@@ -135,6 +145,7 @@ export interface StartClaudeInterceptOptions<T> {
135
145
  /** Test seam: bind real CONNECT handlers on kernel-assigned ports without probe-and-release races. */
136
146
  startProxy?: typeof startConnectProxy;
137
147
  /** Test seams: the macOS `security` runner and platform for the picker runtime and controller. */
148
+ pickerCaStore?: PickerCaStore;
138
149
  pickerSecurity?: SecurityRunner;
139
150
  pickerPlatform?: NodeJS.Platform;
140
151
  }
@@ -200,10 +211,8 @@ export async function startClaudeIntercept<T>(options: StartClaudeInterceptOptio
200
211
  let pickerReason: ClaudeInterceptState["pickerReason"] = null;
201
212
  let pickerFailurePort: number | undefined;
202
213
  try {
203
- if (options.loadPickerRoutes) {
204
- // A picker authority is process-scoped, and older releases persisted an exportable ca.key
205
- // next to the published certificate. Drop that key before anything else: a cleanup failure
206
- // below must never leave a signing key on disk that outlives this process.
214
+ if (options.loadPickerRoutes && (options.pickerPlatform ?? process.platform) === "darwin") {
215
+ // Remove any legacy plaintext key before inspecting or activating picker state.
207
216
  discardPickerCaKey(configDir);
208
217
  const { inspectDesktopPickerProfile } = await import("../desktop-picker-profile");
209
218
  // The applied profile row is durable evidence of the user's picker selection. Rotation keeps
@@ -217,22 +226,23 @@ export async function startClaudeIntercept<T>(options: StartClaudeInterceptOptio
217
226
  const pickerProfileApplied = pickerProfile.kind === "applied";
218
227
  let pickerBlocked = false;
219
228
  try {
220
- // A prior process may have died after publishing the journal but before cleanup. Finish
221
- // that entry before rotation can replace it, then drain the newly queued predecessor.
222
- const drain = () => drainPendingPickerCaUntrust(configDir, options.pickerSecurity, options.pickerPlatform);
223
- if (!await drain()) pickerBlocked = true;
224
- if (!pickerBlocked) {
225
- ensurePickerCa(configDir, { rotation: "startup" });
226
- if (!await drain()) pickerBlocked = true;
229
+ const { observeClaudeDesktopMode, resolveClaudeDesktopMode } = await import("../desktop-first-party");
230
+ const fresh = options.config;
231
+ const platform = options.pickerPlatform ?? process.platform;
232
+ if (pickerProfileApplied || pickerDesired(fresh, resolveClaudeDesktopMode(fresh, observeClaudeDesktopMode(fresh)), platform)) {
233
+ await preparePersistentPickerAuthority({ configDir, store: options.pickerCaStore, security: options.pickerSecurity, platform });
227
234
  }
228
235
  } catch (error) {
229
236
  pickerBlocked = true;
230
237
  pickerReason = "failed";
231
- console.warn(`⚠ Claude Desktop picker CA cleanup deferred: ${error instanceof Error ? error.message : String(error)}`);
238
+ // Native and filesystem errors may include private contents or paths. Emit only an exact
239
+ // application-owned code; arbitrary diagnostic text never crosses this boundary.
240
+ const code = error instanceof Error && PICKER_AUTHORITY_ERROR_CODES.has(error.message) ? error.message : null;
241
+ console.warn(code === "picker_ca_pending_untrust"
242
+ ? "⚠ Claude Desktop picker disabled: the previous certificate could not be untrusted"
243
+ : `⚠ Claude Desktop picker disabled: picker authority unavailable${code ? ` (${code})` : ""}`);
232
244
  }
233
245
  if (pickerBlocked) {
234
- pickerReason = "failed";
235
- console.warn("⚠ Claude Desktop picker disabled: the previous certificate could not be untrusted");
236
246
  if (pickerProfile.kind === "applied") {
237
247
  // Keep Desktop's actual pinned egress alive without ever constructing a TLS terminator.
238
248
  const port = Number(new URL(pickerProfile.proxyUrl).port);
@@ -253,6 +263,7 @@ export async function startClaudeIntercept<T>(options: StartClaudeInterceptOptio
253
263
  picker = pickerBlocked ? null : (options.createPicker ?? createPickerRuntime)({
254
264
  config: options.config,
255
265
  configDir,
266
+ persistentAuthority: { store: options.pickerCaStore },
256
267
  loadRoutes: options.loadPickerRoutes,
257
268
  // The controller's lock: while it is held, periodic refreshes never arm.
258
269
  isBusy: () => controller?.busy() ?? false,
@@ -312,17 +323,15 @@ export async function startClaudeIntercept<T>(options: StartClaudeInterceptOptio
312
323
  persistPreference,
313
324
  proxyPort: () => (pickerProxyLive && boundProxy ? boundProxy.port : null),
314
325
  configDir,
326
+ persistentAuthority: { store: options.pickerCaStore },
315
327
  ...(options.pickerSecurity ? { security: options.pickerSecurity } : {}),
316
328
  ...(options.pickerPlatform ? { platform: options.pickerPlatform } : {}),
317
329
  });
318
330
  await picker.start();
319
- // The rotated authority still needs the user's consent in the login keychain — a decline
320
- // reports trust_pending, and the picker stays a blind tunnel until trust is granted. When
321
- // Desktop was pinned to the picker before the restart, the regular enable flow re-trusts
322
- // the new authority and rewrites the profile row in place, so a restart does not silently
323
- // turn the picker off.
331
+ // Restore inspects the retained identity without installing trust. Explicit enable/trust
332
+ // owns consent when trust was revoked or legacy migration introduced a new authority.
324
333
  if (pickerProfileApplied && controller) {
325
- void controller.enable({ persist: false, context: "server" })
334
+ void controller.enable({ persist: false, context: "server", allowTrustPrompt: false })
326
335
  .catch(error => console.warn(`⚠ Claude Desktop picker restore failed: ${error instanceof Error ? error.message : String(error)}`));
327
336
  }
328
337
  }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Readiness wait for a proxy this process just asked to start.
3
+ *
4
+ * ocx ensure spawns a detached ocx start and returns once this home's proxy answers.
5
+ * It used to give up after a flat 8 s. A cold start can take longer than that on a
6
+ * busy Windows host (Windows CI measured 10-45 s for the first proxy child of a
7
+ * batch), so ensure reported "did not become healthy" for a proxy that came up
8
+ * moments later, and the Codex shim or autostart that called it saw a failure.
9
+ *
10
+ * The wait is now as long as the service start's follow-up (40 s) while the
11
+ * spawned child is alive. A child that has exited keeps the old 8 s bound, so a
12
+ * start that failed outright is reported no later than before.
13
+ */
14
+
15
+ export const ENSURE_READY_TIMEOUT_MS = 40_000;
16
+ export const ENSURE_EXITED_CHILD_TIMEOUT_MS = 8_000;
17
+ const POLL_MS = 150;
18
+
19
+ export interface LiveProxyWaitOptions<T> {
20
+ find: () => Promise<T | null>;
21
+ timeoutMs: number;
22
+ /** Return false to stop polling early; one observation is always made first. */
23
+ keepWaiting?: () => boolean;
24
+ now?: () => number;
25
+ sleep?: (ms: number) => Promise<void>;
26
+ }
27
+
28
+ export async function waitForLiveProxy<T>(options: LiveProxyWaitOptions<T>): Promise<T | null> {
29
+ const now = options.now ?? Date.now;
30
+ const sleep = options.sleep ?? ((ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms)));
31
+ const deadline = now() + options.timeoutMs;
32
+ while (now() < deadline) {
33
+ const live = await options.find();
34
+ if (live) return live;
35
+ if (options.keepWaiting && !options.keepWaiting()) return null;
36
+ await sleep(POLL_MS);
37
+ }
38
+ return null;
39
+ }
40
+
41
+ /** Keep waiting while the spawned child lives; once it exits, only inside the old 8 s bound. */
42
+ export function ensureKeepWaiting(
43
+ spawnedAt: number,
44
+ childExited: () => boolean,
45
+ now: () => number = Date.now,
46
+ ): () => boolean {
47
+ return () => !childExited() || now() - spawnedAt < ENSURE_EXITED_CHILD_TIMEOUT_MS;
48
+ }
@@ -1,5 +1,6 @@
1
1
  import { readRuntimePort, type RuntimePortState } from "../config/process-state";
2
2
  import { timingSafeEqual } from "node:crypto";
3
+ import { createGuiPairIntent, GUI_PAIR_INTENT_HEADER, type GuiPairIntent } from "../lib/gui-pair-intent";
3
4
  import {
4
5
  LOCAL_ATTESTATION_CHALLENGE_HEADER,
5
6
  LOCAL_ATTESTATION_PROOF_HEADER,
@@ -30,7 +31,7 @@ import {
30
31
 
31
32
  export type GuiPairRequestResult =
32
33
  | { kind: "created"; grant: string; browserOrigin: string; serverOrigin: string; expiresAt: number }
33
- | { kind: "unavailable"; reason: "unattested-target" | "runtime-mismatch" | "attestation" | "capability" | "transport" | "rejected" };
34
+ | { kind: "unavailable"; reason: "unattested-target" | "runtime-mismatch" | "attestation" | "capability" | "transport" | "rejected" | "local-intent" };
34
35
 
35
36
  export interface GuiPairClientDeps {
36
37
  fetchImpl?: typeof fetch;
@@ -38,6 +39,9 @@ export interface GuiPairClientDeps {
38
39
  createChallenge?: () => string;
39
40
  now?: () => number;
40
41
  timeoutMs?: number;
42
+ /** Hub invitation policy is unchanged; standalone commands require local write intent. */
43
+ requireLocalIntent?: boolean;
44
+ createIntent?: typeof createGuiPairIntent;
41
45
  }
42
46
 
43
47
  const GUI_PAIR_REQUEST_TIMEOUT_MS = 10_000;
@@ -136,9 +140,12 @@ export async function requestBoundGuiPairingGrant(
136
140
  expiresAt,
137
141
  );
138
142
  if (!capability) return { kind: "unavailable", reason: "capability" };
139
- let response: Response;
143
+ let intent: GuiPairIntent | undefined;
140
144
  try {
141
- response = await fetchImpl(`${baseUrl}${GUI_PAIR_PATH}`, {
145
+ if (deps.requireLocalIntent !== false) intent = (deps.createIntent ?? createGuiPairIntent)(capability);
146
+ } catch { return { kind: "unavailable", reason: "local-intent" }; }
147
+ try {
148
+ const response = await fetchImpl(`${baseUrl}${GUI_PAIR_PATH}`, {
142
149
  method: GUI_PAIR_METHOD,
143
150
  headers: {
144
151
  "Content-Length": "0",
@@ -147,13 +154,14 @@ export async function requestBoundGuiPairingGrant(
147
154
  [GUI_PAIR_EXPIRES_AT_HEADER]: String(expiresAt),
148
155
  [GUI_PAIR_BROWSER_ORIGIN_HEADER]: browserOrigin,
149
156
  [GUI_PAIR_CAPABILITY_HEADER]: capability,
157
+ ...(intent ? { [GUI_PAIR_INTENT_HEADER]: intent.proof } : {}),
150
158
  },
151
159
  signal: AbortSignal.timeout(timeoutMs),
152
160
  });
161
+ if (!response.ok) return { kind: "unavailable", reason: "rejected" };
162
+ const result = parseCreatedResult(await response.json().catch(() => null), browserOrigin);
163
+ return result ?? { kind: "unavailable", reason: "rejected" };
153
164
  } catch {
154
165
  return { kind: "unavailable", reason: "transport" };
155
- }
156
- if (!response.ok) return { kind: "unavailable", reason: "rejected" };
157
- const result = parseCreatedResult(await response.json().catch(() => null), browserOrigin);
158
- return result ?? { kind: "unavailable", reason: "rejected" };
166
+ } finally { intent?.dispose(); }
159
167
  }
package/src/cli/gui.ts CHANGED
@@ -77,9 +77,12 @@ export async function runGuiCommand(args: string[], deps: GuiCommandDeps): Promi
77
77
  }
78
78
  const result = await (deps.requestPairingGrant ?? requestBoundGuiPairingGrant)(target, canonicalOrigin, {
79
79
  ...(deps.fetchImpl ? { fetchImpl: deps.fetchImpl } : {}),
80
+ requireLocalIntent: config.runtimeRole !== "hub",
80
81
  });
81
82
  if (result.kind !== "created") {
82
- console.error(`GUI pairing failed (${result.reason}).`);
83
+ console.error(result.reason === "local-intent"
84
+ ? "GUI pairing requires write access to the private OpenCodex home; no code was issued."
85
+ : `GUI pairing failed (${result.reason}).`);
83
86
  return 1;
84
87
  }
85
88
  if (parsed.json) {
package/src/cli/hub.ts CHANGED
@@ -328,6 +328,7 @@ async function runInvite(args: string[], deps: HubCommandDeps): Promise<number>
328
328
  const dataUrl = resolved.dataUrl;
329
329
  const result = await (deps.requestPairingGrant ?? requestBoundGuiPairingGrant)(target, browserOrigin, {
330
330
  ...(deps.fetchImpl ? { fetchImpl: deps.fetchImpl } : {}),
331
+ requireLocalIntent: false,
331
332
  });
332
333
  if (result.kind !== "created") {
333
334
  console.error(`Minting a pairing code failed (${result.reason}).`);
package/src/cli/index.ts CHANGED
@@ -152,6 +152,7 @@ import {
152
152
  grokSyncFailureMessage,
153
153
  reconcileEnsureDesiredIntegrations,
154
154
  } from "./ensure-desired-integrations";
155
+ import { ENSURE_READY_TIMEOUT_MS, ensureKeepWaiting, waitForLiveProxy } from "./ensure-readiness";
155
156
  import { refreshOwnedCatalogIntegrations } from "../integrations/catalog-refresh";
156
157
  import { loadExportModels } from "../server/management/model-rows";
157
158
 
@@ -235,16 +236,10 @@ function parseStartCliOptions(): ReturnType<typeof parseStartOptions> {
235
236
  }
236
237
  }
237
238
 
238
- async function waitForProxy(timeoutMs = 8_000): Promise<LiveProxy | null> {
239
- const deadline = Date.now() + timeoutMs;
240
- while (Date.now() < deadline) {
241
- // Runtime-state-first with identity: finds the proxy even when it started on a
242
- // fallback port, and never mistakes a foreign 200 for our proxy.
243
- const live = await findLiveProxy();
244
- if (live) return live;
245
- await new Promise(resolve => setTimeout(resolve, 150));
246
- }
247
- return null;
239
+ async function waitForProxy(timeoutMs = 8_000, keepWaiting?: () => boolean): Promise<LiveProxy | null> {
240
+ // Runtime-state-first with identity: finds the proxy even when it started on a
241
+ // fallback port, and never mistakes a foreign 200 for our proxy.
242
+ return waitForLiveProxy({ find: findLiveProxy, timeoutMs, keepWaiting });
248
243
  }
249
244
 
250
245
  class StartCommandExit extends Error {
@@ -846,9 +841,13 @@ async function handleEnsure(options: { existingIsSuccess?: boolean; forceStart?:
846
841
  env: detachedStartEnvironment(),
847
842
  });
848
843
  options.onSpawn?.(child);
844
+ const spawnedAt = Date.now();
845
+ let childExited = false;
846
+ child.once("exit", () => { childExited = true; });
849
847
  child.unref();
850
848
 
851
- const port = (await waitForProxy())?.port;
849
+ // A cold start can outlast 8 s on a busy Windows host; see ensure-readiness.ts.
850
+ const port = (await waitForProxy(ENSURE_READY_TIMEOUT_MS, ensureKeepWaiting(spawnedAt, () => childExited)))?.port;
852
851
  if (!port) {
853
852
  console.error("❌ Proxy did not become healthy after starting.");
854
853
  process.exitCode = 1;
package/src/cli/status.ts CHANGED
@@ -7,7 +7,7 @@ import { diagnoseCodexBundledPlugins, type CodexPluginsDiagnostic } from "../cod
7
7
  import { findLiveProxy, probeHostname } from "../server/proxy-liveness";
8
8
  import type { OcxConfig } from "../types";
9
9
  import { diagnoseService, serviceLogPath } from "../service";
10
- import { collectStartupHealth, type StartupHealth } from "../codex/autostart-health";
10
+ import { collectStartupHealth, startupHealthReadBudgetMs, type StartupHealth } from "../codex/autostart-health";
11
11
  import { getCodexRoutingKind } from "../codex/inject";
12
12
  import { missingOwnedCatalogPath } from "../codex/inject/config-toml";
13
13
  import { CODEX_CONFIG_PATH } from "../codex/paths";
@@ -251,7 +251,7 @@ export async function fetchLiveStartupHealth(
251
251
  deps: Parameters<typeof fetchBoundLocalManagementRead>[2] = {},
252
252
  ): Promise<StartupHealth | null> {
253
253
  const result = await fetchBoundLocalManagementRead(
254
- live, LOCAL_MANAGEMENT_READ_PATHS.startupHealth, { timeoutMs: 1_500, ...deps, requireResponseProof: true },
254
+ live, LOCAL_MANAGEMENT_READ_PATHS.startupHealth, { timeoutMs: startupHealthReadBudgetMs(), ...deps, requireResponseProof: true },
255
255
  );
256
256
  if (result.kind !== "response" || !result.response.ok) return null;
257
257
  let payload: unknown;
@@ -18,8 +18,11 @@ function parseMarker(raw: string): UpdateRestartChildMarker {
18
18
  || value.version === "0.0.0" || !parseStrictSemver(value.version) || !value.home
19
19
  || !Number.isSafeInteger(value.home.revision)) throw new Error("update_restart_child_marker_invalid");
20
20
  for (const directory of [value.home.config, value.home.codex]) {
21
+ // Windows reports 64-bit NTFS file ids (MFT sequence in the high 16 bits) as doubles above
22
+ // 2^53. Parent and child read them through the same stat API, so any finite integer is a
23
+ // faithful identity token; assertUpdateRestartHome still compares path, dev and ino exactly.
21
24
  if (!directory || typeof directory.path !== "string" || !directory.path
22
- || !Number.isSafeInteger(directory.dev) || !Number.isSafeInteger(directory.ino)) {
25
+ || !Number.isInteger(directory.dev) || !Number.isInteger(directory.ino)) {
23
26
  throw new Error("update_restart_child_marker_invalid");
24
27
  }
25
28
  }
@@ -3,6 +3,7 @@ import { deleteConfigTopLevelKey } from "../config/rebase-provenance";
3
3
  import { isThirtyDayOnlyCodexPlan } from "./plan";
4
4
  import {
5
5
  CODEX_EXHAUSTED_USAGE_PERCENT,
6
+ TERMINAL_SHORT_WINDOW_FRESHNESS_MS,
6
7
  isTerminalShortWindow,
7
8
  hasSpendableCodexCredits,
8
9
  resetAtToMs,
@@ -73,8 +74,13 @@ export function codexUsageLimitResetAt(quota: StoredAccountQuota | null, plan: u
73
74
  if (!quota) return undefined;
74
75
  const full: number[] = [];
75
76
  if (isTerminalShortWindow(quota, now)) {
76
- // A reset-less burst reading counts while it is fresh, so there is no instant to report.
77
- full.push(typeof quota.shortResetAt === "number" && quota.shortResetAt > 0 ? resetAtToMs(quota.shortResetAt) : now);
77
+ // A reset-less burst reading holds until its observation goes stale: the freshness horizon
78
+ // is the deadline a retried request still meets, so report it rather than `now` (which
79
+ // collapsed the refusal's Retry-After to 1s against an account blocked for minutes).
80
+ // `isTerminalShortWindow` guarantees `shortObservedAt` is finite on this branch.
81
+ full.push(typeof quota.shortResetAt === "number" && quota.shortResetAt > 0
82
+ ? resetAtToMs(quota.shortResetAt)
83
+ : (quota.shortObservedAt ?? now) + TERMINAL_SHORT_WINDOW_FRESHNESS_MS);
78
84
  }
79
85
  const longWindows: Array<[number | undefined, number | undefined]> = isThirtyDayOnlyCodexPlan(plan)
80
86
  ? [[quota.monthlyPercent, quota.monthlyResetAt]]
@@ -0,0 +1,113 @@
1
+ import { lstatSync } from "node:fs";
2
+ import type { OcxConfig } from "../../types";
3
+ import { inspectChatGptDomainClaim, extractEmail } from "../../oauth/chatgpt";
4
+ import { MAIN_CODEX_ACCOUNT_ID, isSelectableCodexPoolAccount } from "../account-id";
5
+ import { readCodexTokensResult } from "../auth-collision";
6
+ import type { CodexTokenReadResult } from "../auth-collision";
7
+ import { getCodexAccountCredential } from "../account-store";
8
+ import { resolveCodexHomeDir } from "../home";
9
+ import { tryAcquireNativeMainProfileClaim } from "../native-main-admission";
10
+ import { withNativeMainSharedClaim } from "../native-main-claim";
11
+ import { MAX_AUTH_BYTES, readBounded, resolveNativeProfileContext } from "../native-profile-store";
12
+ import { NativeProfileError } from "../native-profile-types";
13
+ import { isNativeMainClaimUnavailable, nativeMainProfileBusyResponse } from "./http";
14
+
15
+ type AccountIdentity = { accountId: string; email: string };
16
+ type MainAuth = CodexTokenReadResult | { status: "api-key-only" };
17
+
18
+ /** A valid API-key login has no ChatGPT identity; anything else unreadable stays unknown. */
19
+ function readMainAuth(authPath: string): MainAuth {
20
+ const main = readCodexTokensResult(authPath, { bounded: true });
21
+ if (main.status !== "invalid") return main;
22
+ try {
23
+ const j: unknown = JSON.parse(readBounded(authPath, MAX_AUTH_BYTES).toString("utf-8"));
24
+ if (j === null || typeof j !== "object" || Array.isArray(j)) return main;
25
+ const envelope = j as { auth_mode?: unknown; OPENAI_API_KEY?: unknown; tokens?: unknown };
26
+ const apiKeyOnly = envelope.tokens == null
27
+ && (envelope.auth_mode === undefined || envelope.auth_mode === "api_key")
28
+ && typeof envelope.OPENAI_API_KEY === "string" && envelope.OPENAI_API_KEY.trim() !== "";
29
+ return apiKeyOnly ? { status: "api-key-only" } : main;
30
+ } catch {
31
+ return main;
32
+ }
33
+ }
34
+
35
+ function identity(accountId: unknown, email: unknown): AccountIdentity | undefined {
36
+ const normalizedEmail = typeof email === "string" ? email.trim().toLowerCase() : undefined;
37
+ return typeof accountId === "string" && accountId.trim() && normalizedEmail
38
+ ? { accountId: accountId.trim(), email: normalizedEmail }
39
+ : undefined;
40
+ }
41
+
42
+ /** Resolve only existing entries, under native-main ownership, never on the request path. */
43
+ function linkedAccountIds(config: OcxConfig, selectedId: string, main: MainAuth): string[] | undefined {
44
+ // Unknown main identity must not produce a successful but incomplete pause.
45
+ if (main.status === "unreadable" || main.status === "invalid") return undefined;
46
+ const identities = new Map<string, AccountIdentity | undefined>();
47
+ if (main.status === "ok") {
48
+ const tokens = main.tokens;
49
+ if (typeof tokens.access_token !== "string" || typeof tokens.account_id !== "string"
50
+ || (tokens.id_token !== undefined && typeof tokens.id_token !== "string")) return undefined;
51
+ const claims = [tokens.id_token, tokens.access_token]
52
+ .filter((token): token is string => typeof token === "string")
53
+ .map(inspectChatGptDomainClaim);
54
+ if (claims.some(claim => claim.kind === "invalid")) return undefined;
55
+ const claimIds = claims.flatMap(claim => claim.kind === "valid" ? [claim.accountId] : []);
56
+ // organizations[] describes memberships, not the selected workspace. Never use its first row.
57
+ const accountId = tokens.account_id.trim() || claimIds[0];
58
+ if (claimIds.some(claim => claim !== accountId)) return undefined;
59
+ // Both tokens must name the same member; otherwise the bearer's identity is unknown.
60
+ const emails = new Set([extractEmail(tokens.id_token), extractEmail(undefined, tokens.access_token)]
61
+ .flatMap(email => email?.trim() ? [email.trim().toLowerCase()] : []));
62
+ if (emails.size > 1) return undefined;
63
+ identities.set(MAIN_CODEX_ACCOUNT_ID, identity(accountId, [...emails][0]));
64
+ }
65
+ for (const account of config.codexAccounts ?? []) {
66
+ if (!isSelectableCodexPoolAccount(account)) continue;
67
+ const credential = getCodexAccountCredential(account.id);
68
+ identities.set(account.id, identity(credential?.chatgptAccountId, account.email));
69
+ }
70
+ const selected = identities.get(selectedId);
71
+ // Missing identity evidence never links unrelated logins, including members of one workspace.
72
+ if (!selected) return [selectedId];
73
+ return [selectedId, ...[...identities].flatMap(([id, candidate]) => (
74
+ id !== selectedId && candidate?.accountId === selected.accountId && candidate.email === selected.email
75
+ ? [id] : []
76
+ ))];
77
+ }
78
+
79
+ /** Hold admission throughout; claim the physical main whenever its home exists. */
80
+ export async function withCodexAccountPauseGroup(
81
+ config: OcxConfig,
82
+ selectedId: string,
83
+ publish: (accountIds: string[]) => Response,
84
+ ): Promise<Response> {
85
+ const lease = tryAcquireNativeMainProfileClaim();
86
+ if (!lease) return nativeMainProfileBusyResponse();
87
+ try {
88
+ const codexHome = resolveCodexHomeDir();
89
+ try {
90
+ lstatSync(codexHome);
91
+ } catch (error) {
92
+ // Only positive absence permits Pool-only publication. A dangling symlink or access
93
+ // failure is not absence; do not create a home just to coordinate an unused main login.
94
+ if ((error as NodeJS.ErrnoException)?.code !== "ENOENT") return nativeMainProfileBusyResponse();
95
+ const ids = linkedAccountIds(config, selectedId, { status: "missing" });
96
+ return ids ? publish(ids) : nativeMainProfileBusyResponse();
97
+ }
98
+ const context = resolveNativeProfileContext({ codexHome });
99
+ return await withNativeMainSharedClaim(context, async () => {
100
+ const main = readMainAuth(context.authPath);
101
+ const ids = linkedAccountIds(config, selectedId, main);
102
+ return ids ? publish(ids) : nativeMainProfileBusyResponse();
103
+ });
104
+ } catch (error) {
105
+ if (isNativeMainClaimUnavailable(error)
106
+ || (error instanceof NativeProfileError && error.code === "CODEX_HOME_UNAVAILABLE")) {
107
+ return nativeMainProfileBusyResponse();
108
+ }
109
+ throw error;
110
+ } finally {
111
+ lease.release();
112
+ }
113
+ }