@bitkyc08/opencodex 2.78.0 → 2.79.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 (71) hide show
  1. package/gui/dist/assets/App-BOUa-ek3.js +54 -0
  2. package/gui/dist/assets/App-CSJvmjNs.css +1 -0
  3. package/gui/dist/assets/{Tray-DiJpSub0.js → Tray-DUtzB4zF.js} +1 -1
  4. package/gui/dist/assets/{index-DmQB8D_R.css → index-Brk6F3fn.css} +1 -1
  5. package/gui/dist/assets/index-DwHNDF4s.js +94 -0
  6. package/gui/dist/assets/{quota-summary-B_a96nXj.js → quota-summary-BuL4WIBo.js} +1 -1
  7. package/gui/dist/index.html +2 -2
  8. package/package.json +3 -2
  9. package/src/adapters/devin.ts +22 -1
  10. package/src/claude/desktop-picker.ts +11 -9
  11. package/src/claude/inbound-content-options.ts +29 -2
  12. package/src/claude/intercept/local-ca-files.ts +265 -0
  13. package/src/claude/intercept/local-ca-windows.ts +73 -0
  14. package/src/claude/intercept/local-ca.ts +12 -24
  15. package/src/claude/intercept/picker-ca-persistence.ts +106 -0
  16. package/src/claude/intercept/picker-ca-startup.ts +16 -0
  17. package/src/claude/intercept/picker-ca-store.ts +70 -0
  18. package/src/claude/intercept/picker-ca.ts +43 -9
  19. package/src/claude/intercept/picker-runtime.ts +14 -4
  20. package/src/claude/intercept/runtime.ts +32 -23
  21. package/src/cli/ensure-readiness.ts +48 -0
  22. package/src/cli/gui-pair-client.ts +15 -7
  23. package/src/cli/gui.ts +4 -1
  24. package/src/cli/hub.ts +1 -0
  25. package/src/cli/index.ts +10 -11
  26. package/src/cli/status.ts +2 -2
  27. package/src/cli/update-restart-child.ts +4 -1
  28. package/src/codex/account-credit-use.ts +8 -2
  29. package/src/codex/auth-api/account-pause-group.ts +113 -0
  30. package/src/codex/auth-api/routes.ts +18 -12
  31. package/src/codex/auth-context.ts +45 -3
  32. package/src/codex/autostart-health.ts +10 -0
  33. package/src/codex/pool-credit-policy.ts +67 -0
  34. package/src/generated/compatibility-version.json +106 -46
  35. package/src/lib/gui-pair-capability.ts +1 -1
  36. package/src/lib/gui-pair-intent.ts +170 -0
  37. package/src/lib/windows-owner-acl.ts +143 -0
  38. package/src/providers/openai-sidecar-credit.ts +26 -0
  39. package/src/providers/openai-sidecar.ts +35 -0
  40. package/src/server/claude-messages.ts +1 -0
  41. package/src/server/gui-pair-delivery.ts +23 -0
  42. package/src/server/index/serve-options.ts +5 -8
  43. package/src/server/live-native-calls.ts +94 -0
  44. package/src/server/live.ts +28 -5
  45. package/src/server/local-management-read-client.ts +15 -11
  46. package/src/server/management/oauth-account-routes.ts +15 -0
  47. package/src/server/management/route-registry.ts +1 -0
  48. package/src/server/management/shared.ts +9 -3
  49. package/src/server/messages-native-scope.ts +57 -0
  50. package/src/server/messages-native.ts +9 -0
  51. package/src/server/responses/adapter-dispatch.ts +31 -2
  52. package/src/server/responses/compact.ts +17 -2
  53. package/src/server/responses/core-auth.ts +18 -1
  54. package/src/server/responses/core-codex-account.ts +18 -8
  55. package/src/server/responses/non-replayable-error.ts +48 -0
  56. package/src/server/responses/passthrough-delivery.ts +5 -1
  57. package/src/server/responses/passthrough-dispatch.ts +21 -19
  58. package/src/server/responses/request-prepare.ts +11 -1
  59. package/src/server/responses/sidecar-execution.ts +1 -0
  60. package/src/server/search.ts +4 -0
  61. package/src/server/startup-health-cache.ts +2 -6
  62. package/src/vision/describe.ts +17 -10
  63. package/src/vision/index.ts +1 -0
  64. package/src/web-search/executor.ts +17 -10
  65. package/src/web-search/format-result.ts +304 -20
  66. package/src/web-search/loop.ts +2 -1
  67. package/src/web-search/passthrough-bridge.ts +1 -0
  68. package/src/web-search/run-turn-loop.ts +1 -1
  69. package/gui/dist/assets/App-BtfG5id-.js +0 -54
  70. package/gui/dist/assets/App-lCIslZ1L.css +0 -1
  71. package/gui/dist/assets/index-Btc3Ogw4.js +0 -94
@@ -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
+ }
@@ -24,6 +24,7 @@ import { inspectResetCredits, consumeResetCredits } from "./reset-credit-service
24
24
  import { getRuntimeConfig, saveRuntimeConfig, configuredPoolAccount } from "./runtime-config";
25
25
  import { captureConfigTopLevelRollback } from "../../config/rebase-provenance";
26
26
  import { getEffectiveCodexAutoSwitchThreshold, isCodexAccountAutoSwitchThresholdKey, parseCodexAutoSwitchThreshold, setCodexAccountAutoSwitchThresholdOverride } from "../account-auto-switch";
27
+ import { withCodexAccountPauseGroup } from "./account-pause-group";
27
28
 
28
29
  export async function handleCodexAuthAPI(
29
30
  req: Request,
@@ -100,18 +101,23 @@ export async function handleCodexAuthAPI(
100
101
  || (runtimeConfig.codexAccounts ?? []).some(account => isSelectableCodexPoolAccount(account) && account.id === id);
101
102
  if (!exists) return jsonResponse({ error: "Account not found" }, 404);
102
103
 
103
- setCodexAccountPaused(runtimeConfig, id, body.paused);
104
- if (body.paused) {
105
- clearThreadAccountMapForAccount(id);
106
- selectFallbackAfterPause(runtimeConfig, id);
107
- }
108
- saveRuntimeConfig(config, runtimeConfig);
109
- return jsonResponse({
110
- ok: true,
111
- id,
112
- paused: body.paused,
113
- activeCodexAccountId: getEffectiveActiveCodexAccountId(runtimeConfig) ?? null,
114
- appliesImmediately: true,
104
+ const paused = body.paused;
105
+ return withCodexAccountPauseGroup(runtimeConfig, id, accountIds => {
106
+ // Publish every exclusion before reconciling: a duplicate must never become the fallback.
107
+ for (const accountId of accountIds) setCodexAccountPaused(runtimeConfig, accountId, paused);
108
+ if (paused) {
109
+ for (const accountId of accountIds) clearThreadAccountMapForAccount(accountId);
110
+ for (const accountId of accountIds) selectFallbackAfterPause(runtimeConfig, accountId);
111
+ }
112
+ saveRuntimeConfig(config, runtimeConfig);
113
+ return jsonResponse({
114
+ ok: true,
115
+ id,
116
+ paused,
117
+ affectedAccountIds: accountIds,
118
+ activeCodexAccountId: getEffectiveActiveCodexAccountId(runtimeConfig) ?? null,
119
+ appliesImmediately: true,
120
+ });
115
121
  });
116
122
  }
117
123
 
@@ -1,3 +1,4 @@
1
+ import { bindPoolCreditPolicy, poolCreditHoldResetAt, poolContextCreditHoldResetAt } from "./pool-credit-policy";
1
2
  import { hasSpendableCodexCredits } from "./quota-types";
2
3
  import { noteMainAccountActivity } from "./main-account-external-usage";
3
4
  import { codexAccountPriorityFailbackEnabled } from "./account-priority";
@@ -525,6 +526,23 @@ export class CodexMainAccountHardLockError extends CodexAccountCooldownError {
525
526
  }
526
527
  }
527
528
 
529
+ /** A stored pool account's spending policy is independent of account selection. */
530
+ export class CodexPoolAccountCreditsOffError extends CodexAccountCooldownError {
531
+ readonly resetAt: number;
532
+
533
+ constructor(accountId: string, resetAt: number) {
534
+ super(accountId, resetAt);
535
+ this.name = "CodexPoolAccountCreditsOffError";
536
+ this.resetAt = resetAt;
537
+ this.message = "The selected Codex account reached its usage limit, and spending credits is off or no fresh spendable balance is available."
538
+ + " Wait for the limit to reset, choose another account, or explicitly allow credits for this account in Codex Auth.";
539
+ }
540
+ }
541
+
542
+ function assertPoolAccountCredits(accountId: string, resetAt: number | undefined): void {
543
+ if (resetAt !== undefined) throw new CodexPoolAccountCreditsOffError(accountId, resetAt);
544
+ }
545
+
528
546
  /** The main login may not spend credits and one of its usage windows is full (#6334). */
529
547
  export class CodexMainAccountCreditsOffError extends CodexAccountCooldownError {
530
548
  readonly resetAt?: number;
@@ -597,7 +615,7 @@ export class CodexRecoveryWithheldError extends CodexAccountCooldownError {
597
615
 
598
616
  export type CodexAuthPolicyConfig = Readonly<Pick<OcxConfig,
599
617
  "codexMainAccountHardLock" | "codexMainAccountHardLockThresholds" | "codexDesktopAuthless" | "runtimeRole" | "pausedCodexAccountIds"
600
- | "creditCodexAccountIds"
618
+ | "creditCodexAccountIds" | "codexAccounts"
601
619
  >>;
602
620
 
603
621
  interface CodexAuthMaterializationOptions {
@@ -725,6 +743,22 @@ export function unwrapUpstreamRetryEvidenceError(error: unknown): unknown {
725
743
  * Same evidence the hard lock reads, and no plan lookup: the plan can live in the physical auth
726
744
  * file, which several callers are forbidden to open, so every long window counts instead.
727
745
  */
746
+ /** Recheck live account policy at the physical send, after pacing or retry backoff. */
747
+ export function createCodexAuthDispatchGuard(
748
+ ctx: CodexAuthContext,
749
+ config: CodexAuthPolicyConfig,
750
+ modelId: string,
751
+ admission?: Pick<DataPlaneAdmission, "source">,
752
+ terminalHelper = false,
753
+ ): ((headers: Headers) => void) | undefined {
754
+ const reserveGuard = createCodexReserveDispatchGuard(ctx, config, modelId, admission, terminalHelper);
755
+ if (ctx.kind !== "pool") return reserveGuard;
756
+ return headers => {
757
+ assertPoolAccountCredits(ctx.accountId, poolContextCreditHoldResetAt(ctx, config));
758
+ reserveGuard?.(headers);
759
+ };
760
+ }
761
+
728
762
  function mainCreditsHoldResetAt(config: Pick<OcxConfig, "creditCodexAccountIds">): number | undefined {
729
763
  if (codexAccountUsesCreditsAfterLimit(config, MAIN_CODEX_ACCOUNT_ID)
730
764
  && hasSpendableCodexCredits(getMainPolicyQuota())) return undefined;
@@ -868,6 +902,7 @@ export function cooldownErrorMessage(err: CodexAccountCooldownError, accountSele
868
902
  // A credits-off refusal is a configuration policy, not a cooldown: clearing a cooldown
869
903
  // cannot lift it, so its own wording (wait for the reset or allow credits) is the remedy.
870
904
  || err instanceof CodexMainAccountCreditsOffError
905
+ || err instanceof CodexPoolAccountCreditsOffError
871
906
  || err instanceof CodexReserveUnavailableError
872
907
  // A transient-hold refusal is not a quota cooldown. Its own wording is the only accurate
873
908
  // one, and the quota recovery advice below would send the operator after a cooldown that
@@ -917,6 +952,7 @@ export class CodexThreadAffinityExpiredError extends Error {
917
952
  export function shouldMarkAccountNeedsReauthForCodexAuthFailure(cause: unknown): boolean {
918
953
  return !(cause instanceof CodexMainAccountHardLockError)
919
954
  && !(cause instanceof CodexMainAccountCreditsOffError)
955
+ && !(cause instanceof CodexPoolAccountCreditsOffError)
920
956
  && !(cause instanceof CodexAccountValidationPendingError)
921
957
  && !(cause instanceof CodexReserveUnavailableError)
922
958
  && !(cause instanceof CodexCredentialGenerationConflictError)
@@ -1408,6 +1444,7 @@ export async function resolveCodexAuthContext(
1408
1444
  // deferred credential must never become request auth through that fallback.
1409
1445
  try {
1410
1446
  assertCodexAccountValidationReady(accountId);
1447
+ assertPoolAccountCredits(accountId, poolCreditHoldResetAt(policy, accountId, config));
1411
1448
  } catch (cause) {
1412
1449
  // Nothing will reach upstream, so give the trial back instead of leaving the held account
1413
1450
  // unprobeable until the lease deadline lapses (#4701).
@@ -1533,7 +1570,8 @@ export async function resolveCodexAuthContext(
1533
1570
  try {
1534
1571
  const token = await getValidCodexToken(accountId, { signal: options.signal });
1535
1572
  assertCodexAccountValidationReady(accountId);
1536
- return {
1573
+ assertPoolAccountCredits(accountId, poolCreditHoldResetAt(policy, accountId, config));
1574
+ return bindPoolCreditPolicy({
1537
1575
  kind: "pool",
1538
1576
  accountId,
1539
1577
  writerGeneration,
@@ -1548,11 +1586,12 @@ export async function resolveCodexAuthContext(
1548
1586
  ...(probeQuotaScope ? { probeQuotaScope } : {}),
1549
1587
  ...(affinityDecision ? { affinityDecision } : {}),
1550
1588
  ...(transientProbe ? { transientProbe } : {}),
1551
- };
1589
+ }, config, policy);
1552
1590
  } catch (cause) {
1553
1591
  releaseTransientProbeGrant();
1554
1592
  if (probeLeaseId && probeQuotaScope) releaseCodexQuotaScopeProbeLease(accountId, probeQuotaScope, probeLeaseId);
1555
1593
  else if (probeLeaseId) releaseCodexQuotaProbeLease(accountId, probeLeaseId);
1594
+ if (cause instanceof CodexPoolAccountCreditsOffError) throw cause;
1556
1595
  if (!options.signal?.aborted && shouldMarkAccountNeedsReauthForCodexAuthFailure(cause)) {
1557
1596
  markAccountNeedsReauth(accountId, writerGeneration);
1558
1597
  }
@@ -1562,6 +1601,7 @@ export async function resolveCodexAuthContext(
1562
1601
 
1563
1602
  export function assertCodexAuthContextNotCooled(ctx: CodexAuthContext | undefined): void {
1564
1603
  if (ctx?.kind !== "pool" && ctx?.kind !== "main-pool") return;
1604
+ if (ctx.kind === "pool") assertPoolAccountCredits(ctx.accountId, poolContextCreditHoldResetAt(ctx));
1565
1605
  // A context holding the probe lease was deliberately admitted through the cooldown.
1566
1606
  if (ctx.probeLeaseId) return;
1567
1607
  const cooldown = getCodexQuotaHealthSnapshot(ctx.accountId, ctx.quotaScope);
@@ -1577,6 +1617,7 @@ export function applyCodexAuthContextToProvider(
1577
1617
  ): OcxRuntimeProviderConfig {
1578
1618
  if (mode !== "pool" || (ctx.kind !== "pool" && ctx.kind !== "main-pool") || provider.authMode !== "forward") return provider;
1579
1619
  assertCodexAccountValidationReady(ctx.accountId);
1620
+ if (ctx.kind === "pool") assertPoolAccountCredits(ctx.accountId, poolContextCreditHoldResetAt(ctx));
1580
1621
  return {
1581
1622
  ...provider,
1582
1623
  _codexAccountOverride: {
@@ -1620,6 +1661,7 @@ export function materializeCodexUpstreamAuth(
1620
1661
  }
1621
1662
  if (ctx.kind === "pool" || ctx.kind === "main-pool") {
1622
1663
  assertCodexAccountValidationReady(ctx.accountId);
1664
+ if (ctx.kind === "pool") assertPoolAccountCredits(ctx.accountId, poolContextCreditHoldResetAt(ctx, options.config));
1623
1665
  selected.set("authorization", `Bearer ${ctx.accessToken}`);
1624
1666
  selected.set("chatgpt-account-id", ctx.chatgptAccountId);
1625
1667
  if (ctx.kind === "main-pool") {
@@ -10,6 +10,16 @@ export type StartupProtection = "service" | "desktop" | "shim" | "none";
10
10
  export type StartupHealthStatus = "native" | "protected" | "at-risk";
11
11
  export type ShimCoverage = "full" | "cli-only" | "none";
12
12
 
13
+ /** Bound the isolated service-manager probe and let its reader outlive that probe. */
14
+ export function startupHealthProbeBudgetMs(platform: NodeJS.Platform = process.platform): number {
15
+ return platform === "win32" ? 15_000 : 5_000;
16
+ }
17
+
18
+ export function startupHealthReadBudgetMs(platform: NodeJS.Platform = process.platform): number {
19
+ // The endpoint waits an extra 500ms for child settlement; reserve another second for HTTP.
20
+ return startupHealthProbeBudgetMs(platform) + 1_500;
21
+ }
22
+
13
23
  export interface StartupHealthInputs {
14
24
  desktop?: DesktopStartupDiagnostic;
15
25
  routingKind: CodexRoutingKind;
@@ -0,0 +1,67 @@
1
+ import type { OcxConfig } from "../types";
2
+ import { codexUsageLimitResetAt, isCodexAccountHeldForCredits } from "./account-credit-use";
3
+ import { MAIN_CODEX_ACCOUNT_ID } from "./main-account";
4
+ import { isSelectableCodexPoolAccount } from "./account-id";
5
+ import { getAccountQuota } from "./quota";
6
+
7
+ export type PoolCreditPolicyConfig = Readonly<Pick<OcxConfig, "creditCodexAccountIds" | "codexAccounts">>;
8
+ type PoolContext = { kind: "pool"; accountId: string };
9
+
10
+ // Keep live policy references private: they must not enter DTOs or serialized auth context.
11
+ const policies = new WeakMap<object, {
12
+ config: PoolCreditPolicyConfig;
13
+ policy: PoolCreditPolicyConfig;
14
+ }>();
15
+
16
+ /** Read cached usage only. This helper never opens or refreshes a credential. */
17
+ export function poolCreditHoldResetAt(
18
+ policy: PoolCreditPolicyConfig,
19
+ accountId: string,
20
+ catalog: PoolCreditPolicyConfig = policy,
21
+ now = Date.now(),
22
+ ): number | undefined {
23
+ // Native main has an independent identity-bound quota policy. Never read its physical plan.
24
+ if (accountId === MAIN_CODEX_ACCOUNT_ID) return undefined;
25
+ const quota = getAccountQuota(accountId);
26
+ const plan = catalog.codexAccounts
27
+ ?.find(account => isSelectableCodexPoolAccount(account) && account.id === accountId)?.plan;
28
+ if (!isCodexAccountHeldForCredits(policy, accountId, quota, plan, now)) return undefined;
29
+ return codexUsageLimitResetAt(quota, plan, now);
30
+ }
31
+
32
+ /** Remember policy by reference so later credential materialization rechecks current values. */
33
+ export function bindPoolCreditPolicy<T extends PoolContext>(
34
+ context: T,
35
+ config: PoolCreditPolicyConfig,
36
+ policy: PoolCreditPolicyConfig = config,
37
+ ): T {
38
+ policies.set(context, { config, policy });
39
+ return context;
40
+ }
41
+
42
+ /**
43
+ * Carry the resolver's live policy onto a context rebuilt by spread copy. The WeakMap keys on
44
+ * object identity, so `{ ...ctx }` alone loses the binding and every caller that omits an
45
+ * explicit override would skip credit-hold enforcement on the copy. A source with no binding
46
+ * leaves the target unbound rather than inventing a policy.
47
+ */
48
+ export function rebindPoolCreditPolicy<S extends PoolContext, T extends object>(source: S, target: T): T {
49
+ const bound = policies.get(source);
50
+ if (bound) policies.set(target, bound);
51
+ return target;
52
+ }
53
+
54
+ /** Explicit policy wins; omitted materialization options retain the resolver's policy. */
55
+ export function poolContextCreditHoldResetAt(
56
+ context: PoolContext,
57
+ override?: PoolCreditPolicyConfig,
58
+ now = Date.now(),
59
+ ): number | undefined {
60
+ const bound = policies.get(context);
61
+ const policy = override ?? bound?.policy;
62
+ // Unbound contexts skip credit-hold enforcement entirely: callers that require
63
+ // enforcement must supply `override` (or resolve through bindPoolCreditPolicy).
64
+ if (!policy) return undefined;
65
+ const catalog = override?.codexAccounts ? override : bound?.config ?? policy;
66
+ return poolCreditHoldResetAt(policy, context.accountId, catalog, now);
67
+ }