@bitkyc08/opencodex 2.84.0 → 2.86.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 (246) hide show
  1. package/README.md +0 -4
  2. package/bin/ocx.mjs +61 -3
  3. package/gui/dist/assets/App-BjlttVLy.js +58 -0
  4. package/gui/dist/assets/{Tray-C9TGtwad.js → Tray-DyR9yBee.js} +1 -1
  5. package/gui/dist/assets/index-BMssP4k3.js +94 -0
  6. package/gui/dist/assets/quota-summary-D8M1bc_R.js +1 -0
  7. package/gui/dist/index.html +1 -1
  8. package/package.json +1 -2
  9. package/src/adapters/anthropic/beta-allowlist.ts +87 -8
  10. package/src/adapters/anthropic/client-identity.ts +1 -1
  11. package/src/adapters/anthropic/passthrough.ts +33 -4
  12. package/src/adapters/azure.ts +19 -1
  13. package/src/adapters/client-fingerprint.ts +24 -0
  14. package/src/adapters/coding-agent/turn.ts +22 -9
  15. package/src/adapters/cursor/arg-codec.ts +17 -5
  16. package/src/adapters/cursor/catalog.ts +8 -0
  17. package/src/adapters/cursor/effort-map.ts +3 -0
  18. package/src/adapters/cursor/freeform-args.ts +75 -0
  19. package/src/adapters/cursor/protobuf-events.ts +36 -23
  20. package/src/adapters/google-antigravity-wire.ts +22 -1
  21. package/src/adapters/google.ts +2 -2
  22. package/src/adapters/openai-chat/response-events.ts +59 -1
  23. package/src/adapters/openai-chat.ts +8 -5
  24. package/src/adapters/qoder/adapter.ts +40 -1
  25. package/src/advisor/consult.ts +220 -0
  26. package/src/advisor/context.ts +226 -0
  27. package/src/advisor/disclosure.ts +20 -0
  28. package/src/advisor/runtime.ts +305 -0
  29. package/src/advisor/settings.ts +135 -0
  30. package/src/advisor/state.ts +369 -0
  31. package/src/advisor/synthetic-tool.ts +33 -0
  32. package/src/bridge/errors.ts +5 -0
  33. package/src/chat/inbound.ts +30 -6
  34. package/src/chat/outbound.ts +5 -0
  35. package/src/claude/desktop-3p-library.ts +43 -3
  36. package/src/claude/desktop-3p.ts +22 -8
  37. package/src/claude/desktop-picker-profile.ts +11 -4
  38. package/src/claude/desktop-profile.ts +10 -1
  39. package/src/claude/desktop-remote-store-io.ts +5 -3
  40. package/src/claude/inbound.ts +121 -50
  41. package/src/claude/intercept/local-ca.ts +8 -1
  42. package/src/claude/model-default-settings.ts +154 -0
  43. package/src/claude/model-info.ts +7 -7
  44. package/src/claude/outbound.ts +13 -6
  45. package/src/cli/advisor.ts +151 -0
  46. package/src/cli/auxiliary-listener-preflight.ts +37 -0
  47. package/src/cli/capabilities-agents-routing.ts +18 -0
  48. package/src/cli/capabilities-base.ts +1 -1
  49. package/src/cli/chatgpt-command.ts +35 -14
  50. package/src/cli/claude.ts +46 -6
  51. package/src/cli/dispatch.ts +5 -1
  52. package/src/cli/doctor.ts +9 -0
  53. package/src/cli/ensure-desktop-cold-start.ts +329 -0
  54. package/src/cli/export-command.ts +1 -1
  55. package/src/cli/help.ts +2 -1
  56. package/src/cli/index.ts +13 -4
  57. package/src/cli/integration-plan-dto.ts +2 -0
  58. package/src/cli/registry.ts +15 -3
  59. package/src/cli/restart-scope.ts +3 -1
  60. package/src/clients/config-export/contracts.ts +1 -0
  61. package/src/clients/config-export/model-metadata.ts +3 -2
  62. package/src/clients/config-export/qoder.ts +93 -0
  63. package/src/clients/config-export.ts +17 -4
  64. package/src/codex/auth-api/pool-mode-gate.ts +5 -2
  65. package/src/codex/auth-context.ts +79 -5
  66. package/src/codex/autostart-health.ts +4 -0
  67. package/src/codex/catalog/build-entries.ts +11 -2
  68. package/src/codex/catalog/configured-tiers.ts +50 -0
  69. package/src/codex/catalog/effort.ts +4 -1
  70. package/src/codex/catalog/model-hints.ts +21 -3
  71. package/src/codex/catalog/native-ultrafast.ts +106 -0
  72. package/src/codex/catalog/parsing.ts +5 -0
  73. package/src/codex/catalog/provider-models.ts +4 -0
  74. package/src/codex/catalog/retained-sync.ts +2 -0
  75. package/src/codex/catalog/routed-gather.ts +22 -4
  76. package/src/codex/catalog/vendor-context.ts +82 -0
  77. package/src/codex/config-write-lock.ts +40 -2
  78. package/src/codex/convergence.ts +2 -0
  79. package/src/codex/desktop-app/types.ts +2 -0
  80. package/src/codex/desktop-app/windows.ts +29 -16
  81. package/src/codex/desktop-app-restart.ts +4 -2
  82. package/src/codex/desktop-compatibility/certificate-service.ts +99 -0
  83. package/src/codex/desktop-compatibility/certificate-store.ts +193 -0
  84. package/src/codex/desktop-compatibility/connection-store.ts +87 -0
  85. package/src/codex/desktop-compatibility/installed-build.ts +55 -0
  86. package/src/codex/desktop-compatibility/json-body.ts +29 -0
  87. package/src/codex/desktop-compatibility/native-identity.ts +62 -0
  88. package/src/codex/desktop-compatibility/relay-listener.ts +93 -0
  89. package/src/codex/desktop-compatibility/routing-binding.ts +10 -0
  90. package/src/codex/desktop-compatibility/routing-preflight.ts +69 -0
  91. package/src/codex/desktop-compatibility/runtime-ownership.ts +28 -0
  92. package/src/codex/desktop-compatibility/runtime.ts +173 -0
  93. package/src/codex/desktop-compatibility/service.ts +30 -0
  94. package/src/codex/desktop-compatibility/startup-settings.ts +50 -0
  95. package/src/codex/desktop-compatibility/usage-activation.ts +86 -0
  96. package/src/codex/desktop-compatibility/usage-controlled-fetch.ts +59 -0
  97. package/src/codex/desktop-compatibility/usage-controller.ts +107 -0
  98. package/src/codex/desktop-compatibility/usage-policy.ts +59 -0
  99. package/src/codex/desktop-compatibility/usage-refresh.ts +33 -0
  100. package/src/codex/desktop-compatibility/usage-sse-controller.ts +86 -0
  101. package/src/codex/desktop-compatibility/windows-activation-source.ts +44 -0
  102. package/src/codex/desktop-compatibility/windows-certificate-trust.ts +81 -0
  103. package/src/codex/desktop-compatibility/windows-key-protection.ts +59 -0
  104. package/src/codex/desktop-compatibility/windows-package-command.ts +86 -0
  105. package/src/codex/desktop-compatibility/windows-package-launch.ts +28 -0
  106. package/src/codex/history-provider.ts +1 -1
  107. package/src/codex/inject/config-write-section.ts +4 -1
  108. package/src/codex/inject/remove.ts +18 -6
  109. package/src/codex/journal.ts +16 -1
  110. package/src/codex/main-account.ts +99 -15
  111. package/src/codex/native-main-refresh-mode.ts +217 -0
  112. package/src/codex/routing/health-store.ts +1 -1
  113. package/src/codex/routing/pool-recovery.ts +79 -0
  114. package/src/codex/shim-diagnostics.ts +6 -0
  115. package/src/codex/shim-overlay.ts +7 -2
  116. package/src/config/diagnostics.ts +2 -1
  117. package/src/config/live-reconcile.ts +13 -0
  118. package/src/config/load-degrade.ts +6 -1
  119. package/src/config/paths.ts +10 -3
  120. package/src/config/schema/config-schema.ts +17 -0
  121. package/src/config/schema/desktop-compatibility.ts +9 -0
  122. package/src/config/schema/leaf-validators.ts +1 -0
  123. package/src/generated/compatibility-version.json +451 -163
  124. package/src/generated/model-metadata.ts +1 -1
  125. package/src/integrations/catalog-refresh.ts +1 -1
  126. package/src/integrations/config-io.ts +20 -2
  127. package/src/integrations/journal.ts +15 -1
  128. package/src/integrations/mutation-plan.ts +1 -0
  129. package/src/integrations/registry.ts +7 -0
  130. package/src/integrations/store.ts +3 -0
  131. package/src/integrations/writer.ts +10 -1
  132. package/src/lib/advisor-activation.ts +16 -0
  133. package/src/lib/background-work.ts +65 -0
  134. package/src/lib/bounded-file-read.ts +58 -0
  135. package/src/lib/bun-path-runtime.d.mts +10 -1
  136. package/src/lib/bun-path-runtime.mjs +62 -3
  137. package/src/lib/desktop-proxy-route.ts +30 -0
  138. package/src/lib/desktop-upstream-tunnel.ts +107 -0
  139. package/src/lib/errors.ts +7 -0
  140. package/src/lib/local-internal-call-capability.ts +55 -0
  141. package/src/lib/package-tree-integrity.ts +2 -2
  142. package/src/lib/socks5-fetch.ts +48 -71
  143. package/src/lib/socks5-handshake.ts +112 -0
  144. package/src/lib/translator-budget.ts +7 -1
  145. package/src/oauth/generic-account-failover.ts +29 -5
  146. package/src/oauth/index.ts +4 -2
  147. package/src/oauth/kiro-device-login.ts +2 -1
  148. package/src/oauth/kiro.ts +7 -1
  149. package/src/protocols/codecs/chat-ir.ts +467 -0
  150. package/src/protocols/codecs/chat.ts +10 -1
  151. package/src/protocols/codecs/messages-ir.ts +500 -0
  152. package/src/protocols/features.ts +10 -2
  153. package/src/protocols/path.ts +11 -1
  154. package/src/protocols/plan-snapshot.ts +39 -16
  155. package/src/protocols/plan.ts +9 -1
  156. package/src/protocols/settings.ts +8 -0
  157. package/src/providers/antigravity-account-roster.ts +102 -0
  158. package/src/providers/antigravity-models.ts +85 -7
  159. package/src/providers/model-rename-fields.ts +2 -0
  160. package/src/providers/model-rename-migration.ts +5 -3
  161. package/src/providers/registry/entries-core.ts +7 -6
  162. package/src/providers/registry/entries-extended.ts +19 -0
  163. package/src/responses/parser.ts +30 -19
  164. package/src/responses/state.ts +32 -29
  165. package/src/routing/history/build-file.ts +92 -0
  166. package/src/routing/history/indexer.ts +248 -216
  167. package/src/routing/history/ingest.ts +238 -0
  168. package/src/routing/history/rebuild-job.ts +111 -0
  169. package/src/routing/history/rebuild-protocol.ts +15 -0
  170. package/src/routing/history/rebuild-worker.ts +34 -0
  171. package/src/server/admission-model-scope.ts +4 -1
  172. package/src/server/antigravity-direct-eligibility.ts +208 -0
  173. package/src/server/auth-cors.ts +4 -0
  174. package/src/server/chat-completions.ts +75 -9
  175. package/src/server/claude-messages.ts +110 -20
  176. package/src/server/index/desktop-compatibility-startup.ts +56 -0
  177. package/src/server/index/serve-options.ts +2 -2
  178. package/src/server/index/startup-warnings.ts +9 -0
  179. package/src/server/index/websocket-handler.ts +9 -0
  180. package/src/server/index.ts +13 -10
  181. package/src/server/inference/client-encoder-delivery.ts +6 -5
  182. package/src/server/login-shell-path.ts +146 -0
  183. package/src/server/management/advisor-routes.ts +191 -0
  184. package/src/server/management/agent-settings-routes.ts +22 -4
  185. package/src/server/management/companion-routes.ts +4 -0
  186. package/src/server/management/config-routes.ts +2 -2
  187. package/src/server/management/context.ts +4 -0
  188. package/src/server/management/desktop-compatibility-routes.ts +46 -0
  189. package/src/server/management/desktop-compatibility-runtime-routes.ts +46 -0
  190. package/src/server/management/desktop-compatibility-settings-routes.ts +27 -0
  191. package/src/server/management/model-routes.ts +2 -2
  192. package/src/server/management/protocol-settings-patch.ts +1 -0
  193. package/src/server/management/route-registry.ts +11 -0
  194. package/src/server/management/sibling-guard.ts +1 -0
  195. package/src/server/management-api.ts +6 -0
  196. package/src/server/messages-antigravity-direct.ts +195 -0
  197. package/src/server/messages-native.ts +5 -1
  198. package/src/server/request-log.ts +7 -0
  199. package/src/server/responses/adapter-continuation.ts +35 -18
  200. package/src/server/responses/adapter-delivery.ts +24 -2
  201. package/src/server/responses/adapter-dispatch.ts +29 -2
  202. package/src/server/responses/advisor-plan-slot.ts +27 -0
  203. package/src/server/responses/advisor-slot.ts +336 -0
  204. package/src/server/responses/code-mode-collab-surface.ts +116 -8
  205. package/src/server/responses/core-combo-failure.ts +10 -1
  206. package/src/server/responses/core-combo.ts +21 -0
  207. package/src/server/responses/core-lifetime.ts +2 -0
  208. package/src/server/responses/core-normalize.ts +11 -0
  209. package/src/server/responses/core-options.ts +46 -0
  210. package/src/server/responses/core-replay.ts +7 -4
  211. package/src/server/responses/core.ts +1 -1
  212. package/src/server/responses/passthrough-delivery.ts +16 -10
  213. package/src/server/responses/passthrough-dispatch.ts +14 -3
  214. package/src/server/responses/request-prepare.ts +68 -5
  215. package/src/server/responses/request-transport.ts +16 -0
  216. package/src/server/responses/run-turn-execution.ts +70 -8
  217. package/src/server/responses/sidecar-execution.ts +78 -3
  218. package/src/server/responses/terminal-guard.ts +2 -1
  219. package/src/server/responses-undeclared-tool-guard.ts +30 -9
  220. package/src/server/startup-action-control.ts +16 -2
  221. package/src/server/startup-health-cache.ts +1 -0
  222. package/src/service/desktop-startup.ts +14 -0
  223. package/src/service/health-sentinel-main.ts +23 -4
  224. package/src/service/health-sentinel-probe.ts +24 -7
  225. package/src/service/logging.ts +72 -0
  226. package/src/tray/windows-tray.ps1 +43 -24
  227. package/src/types/config.ts +63 -6
  228. package/src/types/provider.ts +7 -0
  229. package/src/types/request.ts +15 -0
  230. package/src/types/tools.ts +2 -0
  231. package/src/update/async-check.ts +1 -1
  232. package/src/update/badge.ts +2 -2
  233. package/src/update/index.ts +88 -15
  234. package/src/update/job-supervised-restart.ts +107 -0
  235. package/src/update/job.ts +32 -32
  236. package/src/update/notify.ts +1 -1
  237. package/src/update/refresh-scheduler.ts +2 -2
  238. package/src/update/supervised-recovery.d.mts +27 -0
  239. package/src/update/supervised-recovery.mjs +157 -0
  240. package/src/usage/cache-diagnostic.ts +2 -0
  241. package/src/usage/expected-prices.ts +12 -0
  242. package/src/web-search/gemini-executor.ts +2 -2
  243. package/assets/sponsors/packycode.png +0 -0
  244. package/gui/dist/assets/App-CZtqiGcw.js +0 -56
  245. package/gui/dist/assets/index-CTm4l-HQ.js +0 -94
  246. package/gui/dist/assets/quota-summary-BbEeRfPY.js +0 -1
@@ -31,6 +31,12 @@ import { advanceCodexCredentialMutationEpoch } from "./credential-mutation-epoch
31
31
  import { withNativeMainExclusiveClaim } from "./native-main-claim";
32
32
  import { resolveNativeProfileContext } from "./native-profile-store";
33
33
  import { isNativeMainTrafficBlocked } from "./native-profile-startup";
34
+ import { loadConfig } from "../config";
35
+ import {
36
+ requestCodexAuthStatus,
37
+ resolveNativeMainRefreshMode,
38
+ type CodexAuthStatus,
39
+ } from "./native-main-refresh-mode";
34
40
 
35
41
  export { MAIN_CODEX_ACCOUNT_ID } from "./account-id";
36
42
 
@@ -48,13 +54,11 @@ type MainAuthJsonCredential = {
48
54
  path: string;
49
55
  rawSha256: string;
50
56
  /**
51
- * Filesystem identity of the file the hash was taken from (#2999).
52
- *
53
- * A content hash cannot tell "unchanged" from "replaced with a file that happens to
54
- * hash the same", and more importantly it is read at a different instant than the
55
- * rename. Carrying dev+ino lets the pre-rename guard ask the sharper question: is this
56
- * still the same file, not merely one with the same bytes. `null` when the target could
57
- * not be stat'ed, which is treated as "cannot prove identity" rather than "matches".
57
+ * Filesystem identity of the file the hash was taken from. It catches a writer that
58
+ * replaces auth.json by rename with identical bytes. It does not see Codex's own writes:
59
+ * Codex rewrites auth.json in place (truncate + write), which keeps dev and ino, so only
60
+ * the content hash notices those, and only once the new bytes are visible (#2999).
61
+ * `null` when the target could not be stat'ed, which fails closed.
58
62
  */
59
63
  identity: { dev: number; ino: number } | null;
60
64
  root: Record<string, unknown>;
@@ -66,6 +70,8 @@ type MainAuthJsonCredential = {
66
70
 
67
71
  export interface NativeMainRefreshDependencies {
68
72
  refreshToken?: (refreshToken: string, options: { signal: AbortSignal }) => Promise<OAuthCredentials>;
73
+ /** Codex-mode seam: one app-server `getAuthStatus` call. Used only when Codex owns the refresh. */
74
+ codexAuthStatus?: (options: { refresh: boolean; signal: AbortSignal }) => Promise<CodexAuthStatus>;
69
75
  signal?: AbortSignal;
70
76
  /** Metadata-only refresh must not retract a concurrent traffic reauth quarantine. */
71
77
  preserveReauth?: boolean;
@@ -264,10 +270,9 @@ function assertMainAuthJsonSnapshotUnchanged(expected: MainAuthJsonCredential):
264
270
  if (!current || current.path !== expected.path || current.rawSha256 !== expected.rawSha256) {
265
271
  throw new MainAuthJsonChangedDuringRefreshError();
266
272
  }
267
- // Identity, not just content (#2999). A writer can land between this check and the
268
- // rename, and rename(2) replaces unconditionally - so the narrower the question asked
269
- // here, the smaller the window where a Codex login gets silently overwritten. An
270
- // unreadable identity on either side fails closed: unprovable is not the same as equal.
273
+ // Also refuse a rename-based replacement with identical bytes. An unreadable identity on
274
+ // either side fails closed. This narrows the window; rename(2) still replaces
275
+ // unconditionally after this check returns.
271
276
  assertMainAuthJsonIdentityUnchanged(expected);
272
277
  }
273
278
 
@@ -288,9 +293,7 @@ function persistRefreshedMainAuthJson(
288
293
  assertNotRealCodexHomeUnderTest(resolveCodexHomeDir());
289
294
  const accessToken = refreshed.access;
290
295
  const refreshToken = refreshed.refresh || expected.refreshToken!;
291
- const chatgptAccountId = refreshed.accountId
292
- ?? extractAccountId(undefined, accessToken)
293
- ?? expected.chatgptAccountId;
296
+ const { chatgptAccountId } = inFlightMainAccountToken(expected, refreshed);
294
297
  const tokens = {
295
298
  ...expected.tokens,
296
299
  access_token: accessToken,
@@ -475,6 +478,12 @@ async function resolveMainAccountToken(
475
478
  const signal = dependencies.signal
476
479
  ? AbortSignal.any([dependencies.signal, refreshTimeout])
477
480
  : refreshTimeout;
481
+ // An injected exchange is the guarded writer by definition. Otherwise Codex refreshes its own
482
+ // login whenever a supported binary exists, unless the operator chose "opencodex" (#2999).
483
+ const refreshMode = dependencies.refreshToken
484
+ ? null
485
+ : await resolveNativeMainRefreshMode(loadConfig());
486
+ if (signal.aborted) throw signal.reason;
478
487
  const lockKey = refreshGrantFingerprintForToken(initial.refreshToken);
479
488
  // Two locks, because they guard two different things that live in two different
480
489
  // homes. `withCodexRefreshFileLock` is keyed on the grant fingerprint and lives
@@ -508,6 +517,11 @@ async function resolveMainAccountToken(
508
517
  && mainAccessTokenFresh(locked.accessToken, Date.now(), MAIN_TOKEN_REFRESH_SKEW_MS)) {
509
518
  return { accessToken: locked.accessToken!, chatgptAccountId: locked.chatgptAccountId };
510
519
  }
520
+ if (refreshMode?.mode === "codex") {
521
+ const command = refreshMode.command!;
522
+ return refreshThroughCodex(locked, rejectedAccessToken, dependencies, refreshTimeout,
523
+ dependencies.codexAuthStatus ?? (options => requestCodexAuthStatus(command, options)));
524
+ }
511
525
  const refresh = dependencies.refreshToken ?? refreshNativeMainGrant;
512
526
  let refreshed: OAuthCredentials;
513
527
  if (signal.aborted) throw signal.reason;
@@ -539,7 +553,19 @@ async function resolveMainAccountToken(
539
553
  // Once the exchange starts, only its own timeout may abort it: a successful
540
554
  // rotating grant retires the old refresh token. Save the only live grant before
541
555
  // honoring caller cancellation, while retaining the external-writer guards.
542
- const result = persistRefreshedMainAuthJson(locked, refreshed);
556
+ let result: { accessToken: string; chatgptAccountId: string };
557
+ try {
558
+ result = persistRefreshedMainAuthJson(locked, refreshed);
559
+ } catch (cause) {
560
+ if (!(cause instanceof MainAuthJsonChangedDuringRefreshError)) throw cause;
561
+ // Another writer owns auth.json now (#2999). The exchange already succeeded, so
562
+ // failing the request would waste it. Serve the new access token to this request
563
+ // only: leave the file as the other writer left it, keep nothing as the main
564
+ // credential, and do not retire the grant.
565
+ noteNativeMainRefresh("publish-refused-served-in-flight");
566
+ if (dependencies.signal?.aborted) throw dependencies.signal.reason;
567
+ return inFlightMainAccountToken(locked, refreshed);
568
+ }
543
569
  // A permitted in-flight success speaks only for its own profile and grant.
544
570
  clearMainRefreshGrantRejection(mainRefreshGrantKey(locked)!);
545
571
  if (dependencies.preserveReauth !== true) clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);
@@ -556,6 +582,64 @@ async function resolveMainAccountToken(
556
582
  }
557
583
  }
558
584
 
585
+ /** The refreshed bearer for one request whose save was refused; never stored anywhere. */
586
+ function inFlightMainAccountToken(
587
+ expected: MainAuthJsonCredential,
588
+ refreshed: OAuthCredentials,
589
+ ): { accessToken: string; chatgptAccountId: string } {
590
+ return {
591
+ accessToken: refreshed.access,
592
+ chatgptAccountId: refreshed.accountId
593
+ ?? extractAccountId(undefined, refreshed.access)
594
+ ?? expected.chatgptAccountId,
595
+ };
596
+ }
597
+
598
+ /**
599
+ * Codex mode (#2999): Codex refreshes and stores its own login; OpenCodex only uses the bearer.
600
+ *
601
+ * auth.json is not read again after the call. In keyring mode Codex deletes the file once the
602
+ * keyring save succeeds, so the RPC answer is the only place the new token exists for us.
603
+ * The call runs on the refresh timeout alone: killing app-server mid-exchange could retire
604
+ * the grant before Codex saves its successor.
605
+ */
606
+ async function refreshThroughCodex(
607
+ locked: MainAuthJsonCredential,
608
+ rejectedAccessToken: string | undefined,
609
+ dependencies: NativeMainRefreshDependencies,
610
+ refreshTimeout: AbortSignal,
611
+ authStatus: NonNullable<NativeMainRefreshDependencies["codexAuthStatus"]>,
612
+ ): Promise<{ accessToken: string; chatgptAccountId: string }> {
613
+ let status: CodexAuthStatus;
614
+ try {
615
+ status = await authStatus({ refresh: true, signal: refreshTimeout });
616
+ } catch (cause) {
617
+ noteNativeMainRefresh("codex-unavailable");
618
+ throw new MainAccountTokenRefreshError("transient", { cause });
619
+ }
620
+ if (status.authMethod === "chatgpt" && !status.authToken) {
621
+ // The only permanent-failure signal the response carries: Codex withholds the token once
622
+ // its refresh was refused for good. The specific OAuth code is not on the wire.
623
+ noteNativeMainRefresh("codex-reauth");
624
+ markMainRefreshGrantRejected(mainRefreshGrantKey(locked)!);
625
+ throw new MainAccountTokenRefreshError("reauth");
626
+ }
627
+ const token = status.authToken;
628
+ if (status.authMethod !== "chatgpt" || !token) {
629
+ noteNativeMainRefresh("codex-no-chatgpt-login");
630
+ throw new MainAccountTokenRefreshError("transient");
631
+ }
632
+ // A transient Codex refresh failure still answers with its cached bearer.
633
+ if (token === rejectedAccessToken || !mainAccessTokenFresh(token, Date.now(), 0)) {
634
+ noteNativeMainRefresh("codex-transient");
635
+ throw new MainAccountTokenRefreshError("transient");
636
+ }
637
+ noteNativeMainRefresh("codex-ok");
638
+ if (dependencies.preserveReauth !== true) clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);
639
+ if (dependencies.signal?.aborted) throw dependencies.signal.reason;
640
+ return { accessToken: token, chatgptAccountId: extractAccountId(undefined, token) ?? locked.chatgptAccountId };
641
+ }
642
+
559
643
  /** Refresh the CLI-owned native credential before upstream I/O and publish it atomically. */
560
644
  export function getValidMainAccountToken(
561
645
  dependencies: NativeMainRefreshDependencies = {},
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Who refreshes the native main ChatGPT credential (#2999).
3
+ *
4
+ * Codex owns `CODEX_HOME/auth.json`. It rewrites the file in place and serializes its own
5
+ * refreshes only inside one process, so any second writer can race it, and a lost race
6
+ * retires the grant. In "codex" mode OpenCodex never writes the file: it asks the Codex
7
+ * binary to refresh through `app-server` `getAuthStatus` and uses the bearer that call
8
+ * returns. In "opencodex" mode the guarded writer in `main-account.ts` refreshes as before.
9
+ *
10
+ * The default is "codex" only when a version-checked binary is available. Without one, or
11
+ * when the operator sets `nativeMainRefresh: "opencodex"`, the guarded writer stays.
12
+ */
13
+ import { codexExecInvocation } from "./exec-invocation";
14
+ import { resolveCodexHomeDir } from "./home";
15
+ import { compareCodexVersions, displayCodexRuntimePath, resolveCodexRuntimeAsync } from "./runtime";
16
+ import type { ResolveCodexRuntimeResult } from "./runtime";
17
+ import { isTestHomeGuardArmed } from "../lib/test-home-guard";
18
+ import type { OcxConfig } from "../types/config";
19
+
20
+ export type NativeMainRefreshMode = "codex" | "opencodex";
21
+
22
+ /**
23
+ * Supported floor for app-server `getAuthStatus` with `includeToken`/`refreshToken` that
24
+ * withholds the token after a permanent refresh failure, the only reauth signal on the wire.
25
+ * Releases from about 0.118 already withhold it; 0.130.0 is the first with the handler in
26
+ * `request_processors/account_processor.rs` and is chosen conservatively.
27
+ */
28
+ export const CODEX_AUTH_STATUS_MIN_VERSION = "0.130.0";
29
+
30
+ export type NativeMainRefreshReason =
31
+ | "codex-compatible"
32
+ | "configured-opencodex"
33
+ | "codex-missing"
34
+ | "codex-too-old"
35
+ | "test-guard";
36
+
37
+ export interface NativeMainRefreshResolution {
38
+ readonly mode: NativeMainRefreshMode;
39
+ readonly configured: NativeMainRefreshMode | "auto";
40
+ readonly reason: NativeMainRefreshReason;
41
+ readonly command?: string;
42
+ readonly version?: string | null;
43
+ }
44
+
45
+ export interface CodexAuthStatus {
46
+ readonly authMethod: string | null;
47
+ readonly authToken: string | null;
48
+ readonly requiresOpenaiAuth: boolean | null;
49
+ }
50
+
51
+ export class CodexAuthStatusError extends Error {
52
+ constructor(message: string, options?: ErrorOptions) {
53
+ super(message, options);
54
+ this.name = "CodexAuthStatusError";
55
+ }
56
+ }
57
+
58
+ let resolutionForTests: NativeMainRefreshResolution | null = null;
59
+
60
+ /** Test seam: pin the resolution so no test spawns the developer's real Codex binary. */
61
+ export function setNativeMainRefreshResolutionForTests(value: NativeMainRefreshResolution | null): void {
62
+ resolutionForTests = value;
63
+ }
64
+
65
+ function configuredMode(config: Pick<OcxConfig, "nativeMainRefresh"> | null | undefined): NativeMainRefreshMode | "auto" {
66
+ const value = config?.nativeMainRefresh;
67
+ return value === "codex" || value === "opencodex" ? value : "auto";
68
+ }
69
+
70
+ export async function resolveNativeMainRefreshMode(
71
+ config: Pick<OcxConfig, "nativeMainRefresh"> | null | undefined,
72
+ deps: { resolveRuntime?: () => Promise<ResolveCodexRuntimeResult> } = {},
73
+ ): Promise<NativeMainRefreshResolution> {
74
+ if (resolutionForTests) return resolutionForTests;
75
+ const configured = configuredMode(config);
76
+ if (configured === "opencodex") return { mode: "opencodex", configured, reason: "configured-opencodex" };
77
+ // Tests run against throwaway CODEX_HOMEs; delegating would spawn the real binary.
78
+ if (!deps.resolveRuntime && isTestHomeGuardArmed()) return { mode: "opencodex", configured, reason: "test-guard" };
79
+ const resolved = await (deps.resolveRuntime ?? (() => resolveCodexRuntimeAsync({ discoverAlternatives: false })))();
80
+ const { command, version, source } = resolved.runtime;
81
+ if (source === "fallback" && version === null) {
82
+ return { mode: "opencodex", configured, reason: "codex-missing" };
83
+ }
84
+ if (version === null || compareCodexVersions(version, CODEX_AUTH_STATUS_MIN_VERSION) < 0) {
85
+ return { mode: "opencodex", configured, reason: "codex-too-old", command, version };
86
+ }
87
+ return { mode: "codex", configured, reason: "codex-compatible", command, version };
88
+ }
89
+
90
+ /**
91
+ * One `getAuthStatus` round trip against a short-lived `codex app-server` on stdio.
92
+ *
93
+ * `model_provider="openai"` is forced because an authless Desktop injection sets
94
+ * `requires_openai_auth = false` on the OpenCodex provider, and then the server reports no
95
+ * credential at all. The process inherits CODEX_HOME so it reads the same login (file or
96
+ * keyring) Codex itself uses.
97
+ */
98
+ export async function requestCodexAuthStatus(
99
+ command: string,
100
+ options: { refresh: boolean; signal: AbortSignal; codexHome?: string },
101
+ ): Promise<CodexAuthStatus> {
102
+ if (options.signal.aborted) throw options.signal.reason;
103
+ const invocation = codexExecInvocation(command, ["-c", 'model_provider="openai"', "app-server"]);
104
+ let proc: ReturnType<typeof Bun.spawn<"pipe", "pipe", "ignore">>;
105
+ try {
106
+ proc = Bun.spawn([invocation.file, ...invocation.args], {
107
+ env: { ...process.env, CODEX_HOME: options.codexHome ?? resolveCodexHomeDir() },
108
+ stdin: "pipe",
109
+ stdout: "pipe",
110
+ stderr: "ignore",
111
+ windowsVerbatimArguments: invocation.options.windowsVerbatimArguments,
112
+ windowsHide: true,
113
+ });
114
+ } catch (cause) {
115
+ throw new CodexAuthStatusError("could not start codex app-server", { cause });
116
+ }
117
+ // Abort settles the call by itself. Waiting for stdout to close would hang on a child that
118
+ // ignores SIGTERM or a launcher descendant that keeps the pipe open.
119
+ let onAbort = () => {};
120
+ const aborted = new Promise<never>((_, reject) => {
121
+ onAbort = () => reject(options.signal.reason);
122
+ options.signal.addEventListener("abort", onAbort, { once: true });
123
+ });
124
+ aborted.catch(() => { /* observed through the race below */ });
125
+ try {
126
+ const send = (message: unknown) => proc.stdin.write(JSON.stringify(message) + "\n");
127
+ send({ id: 1, method: "initialize", params: { clientInfo: { name: "opencodex", title: null, version: "0" } } });
128
+ send({ method: "initialized" });
129
+ send({ id: 2, method: "getAuthStatus", params: { includeToken: true, refreshToken: options.refresh } });
130
+ await Promise.race([proc.stdin.flush(), aborted]);
131
+ const response = await Promise.race([readResponse(proc.stdout, 2), aborted]);
132
+ if (options.signal.aborted) throw options.signal.reason;
133
+ return parseAuthStatus(response);
134
+ } catch (cause) {
135
+ if (options.signal.aborted) throw options.signal.reason;
136
+ if (cause instanceof CodexAuthStatusError) throw cause;
137
+ throw new CodexAuthStatusError("codex app-server getAuthStatus failed", { cause });
138
+ } finally {
139
+ options.signal.removeEventListener("abort", onAbort);
140
+ stopAppServer(proc);
141
+ }
142
+ }
143
+
144
+ const APP_SERVER_KILL_GRACE_MS = 2_000;
145
+
146
+ /** SIGTERM now, SIGKILL if the child is still alive after a grace period; never awaited. */
147
+ function stopAppServer(proc: { stdin: { end(): unknown }; kill(signal?: number | NodeJS.Signals): void; exited: Promise<number> }): void {
148
+ try { proc.stdin.end(); } catch { /* already closed */ }
149
+ let exited = false;
150
+ void proc.exited.then(() => { exited = true; }, () => { exited = true; });
151
+ try { proc.kill("SIGTERM"); } catch { /* already gone */ }
152
+ const timer = setTimeout(() => {
153
+ if (!exited) try { proc.kill("SIGKILL"); } catch { /* already gone */ }
154
+ }, APP_SERVER_KILL_GRACE_MS);
155
+ (timer as { unref?: () => void }).unref?.();
156
+ }
157
+
158
+ const MAX_APP_SERVER_LINE_BYTES = 1_048_576;
159
+
160
+ async function readResponse(stream: ReadableStream<Uint8Array>, id: number): Promise<Record<string, unknown>> {
161
+ const decoder = new TextDecoder();
162
+ let buffered = "";
163
+ for await (const chunk of stream) {
164
+ buffered += decoder.decode(chunk, { stream: true });
165
+ let newline: number;
166
+ while ((newline = buffered.indexOf("\n")) >= 0) {
167
+ const line = buffered.slice(0, newline).trim();
168
+ buffered = buffered.slice(newline + 1);
169
+ if (!line) continue;
170
+ let message: unknown;
171
+ try { message = JSON.parse(line); } catch { continue; }
172
+ if (!message || typeof message !== "object") continue;
173
+ const record = message as Record<string, unknown>;
174
+ if (record.id !== id) continue;
175
+ if (record.error !== undefined) throw new CodexAuthStatusError("codex app-server rejected getAuthStatus");
176
+ if (!record.result || typeof record.result !== "object") {
177
+ throw new CodexAuthStatusError("codex app-server returned no getAuthStatus result");
178
+ }
179
+ return record.result as Record<string, unknown>;
180
+ }
181
+ if (buffered.length > MAX_APP_SERVER_LINE_BYTES) throw new CodexAuthStatusError("codex app-server line too long");
182
+ }
183
+ throw new CodexAuthStatusError("codex app-server exited before answering getAuthStatus");
184
+ }
185
+
186
+ function parseAuthStatus(result: Record<string, unknown>): CodexAuthStatus {
187
+ const authMethod = typeof result.authMethod === "string" ? result.authMethod : null;
188
+ const authToken = typeof result.authToken === "string" && result.authToken ? result.authToken : null;
189
+ const requiresOpenaiAuth = typeof result.requiresOpenaiAuth === "boolean" ? result.requiresOpenaiAuth : null;
190
+ return { authMethod, authToken, requiresOpenaiAuth };
191
+ }
192
+
193
+ /** `ocx doctor` lines for the active refresh owner. Never includes a credential. */
194
+ export function formatNativeMainRefreshDoctorLines(resolution: NativeMainRefreshResolution): string[] {
195
+ const configured = resolution.configured === "auto" ? "auto" : `nativeMainRefresh: "${resolution.configured}"`;
196
+ const binary = resolution.command
197
+ ? `${displayCodexRuntimePath(resolution.command)} (${resolution.version ?? "unknown"})`
198
+ : "none";
199
+ switch (resolution.reason) {
200
+ case "codex-compatible":
201
+ return [` ok codex: Codex refreshes the main login through app-server (${binary}; ${configured})`];
202
+ case "configured-opencodex":
203
+ return [` -- opencodex: OpenCodex refreshes auth.json with its guarded writer (${configured})`];
204
+ case "test-guard":
205
+ return [" -- opencodex: test guard is armed; Codex delegation is disabled"];
206
+ case "codex-missing":
207
+ return [
208
+ ` !! opencodex: no Codex binary found, so OpenCodex refreshes auth.json itself (${configured})`,
209
+ ` Install Codex ${CODEX_AUTH_STATUS_MIN_VERSION} or newer to let Codex own its login.`,
210
+ ];
211
+ case "codex-too-old":
212
+ return [
213
+ ` !! opencodex: Codex ${binary} is older than ${CODEX_AUTH_STATUS_MIN_VERSION}, so OpenCodex refreshes auth.json itself (${configured})`,
214
+ " Update Codex to let Codex own its login.",
215
+ ];
216
+ }
217
+ }
@@ -163,7 +163,7 @@ export function deleteScopedHealth(accountId: string, scope: CodexQuotaScope): v
163
163
  }
164
164
 
165
165
  /** Live quota-refusal avoidance for an account, including the lane the request belongs to. */
166
- function codexQuotaAvoidUntil(
166
+ export function codexQuotaAvoidUntil(
167
167
  accountId: string,
168
168
  quotaScope: CodexQuotaScope | undefined,
169
169
  now: number,
@@ -0,0 +1,79 @@
1
+ /**
2
+ * When another stored pool account can serve again, for a main-account hard-lock refusal (#7046).
3
+ *
4
+ * A pool request that fell through to the main login and met the main-account quota guard used
5
+ * to be told to come back at main's own reset, which can be days away while a sibling pool
6
+ * account recovers within hours. This module answers one question: the earliest KNOWN instant at
7
+ * which a stored pool account's every temporary hold ends. It changes no routing state, selects
8
+ * nothing, and never lifts the main guard. The client retries at that time, and ordinary selection
9
+ * decides again from fresh state; a reset timestamp is a hint, not proof of fresh quota.
10
+ *
11
+ * Conservative by construction (the policy Jun is asked to confirm in #7046):
12
+ * - An account is a candidate only when it is a selectable stored pool account that is not
13
+ * paused, plan-excluded, quarantined for sign-in, otherwise unusable, or denied for the model.
14
+ * Those are operator or credential states no clock lifts.
15
+ * - Its recovery time is the LATEST of every routing hold with a known end: quota-health cooldown,
16
+ * quota avoidance, transient soft avoidance, pool-refresh backoff, and the credits-off
17
+ * usage-limit reset. An account under a hold whose end is unknown, or under the sliding
18
+ * failure-window guard (whose end moves with every failure), is skipped rather than guessed.
19
+ * - For an account-gated model, only accounts the resolved entitlement roster admits count, and
20
+ * with no resolved roster nothing does.
21
+ * - An account with no recorded hold is skipped: nothing says when it became unavailable, so
22
+ * nothing says when it will be available again.
23
+ * - Main itself is never a candidate; its own reset stays on the refusal.
24
+ */
25
+ import { isCodexAccountPaused } from "../account-pause";
26
+ import { codexUsageLimitResetAt } from "../account-credit-use";
27
+ import { isSelectableCodexPoolAccount } from "../account-id";
28
+ import { isAccountNeedsReauth } from "../account-runtime-state";
29
+ import { isCodexAccountUsable } from "../account-usability";
30
+ import { getCodexPoolRefreshCooldownUntil } from "../pool-refresh-backoff";
31
+ import { getAccountQuota } from "../quota";
32
+ import type { OcxConfig } from "../../types";
33
+ import { failureWindowSteersNewThreads } from "./failure-window";
34
+ import {
35
+ codexQuotaAvoidUntil, getCodexAccountSoftAvoidUntil, getCodexQuotaHealthSnapshot, type CodexQuotaScope,
36
+ } from "./health-store";
37
+ import { getPoolAccountPlanForSelection, isCodexAccountHeldAtUsageLimit, isCodexAccountPlanExcluded } from "./selection";
38
+
39
+ export function nextCodexPoolRecoveryAt(
40
+ config: OcxConfig,
41
+ now: number,
42
+ quotaScope?: CodexQuotaScope,
43
+ options: {
44
+ deniedAccountIds?: ReadonlySet<string>;
45
+ /** When set, only these accounts may serve the model (account-gated models). */
46
+ eligibleAccountIds?: ReadonlySet<string>;
47
+ } = {},
48
+ ): number | undefined {
49
+ let earliest: number | undefined;
50
+ for (const account of config.codexAccounts ?? []) {
51
+ if (!isSelectableCodexPoolAccount(account)) continue;
52
+ const id = account.id;
53
+ if (options.deniedAccountIds?.has(id) || (options.eligibleAccountIds && !options.eligibleAccountIds.has(id))
54
+ || isCodexAccountPaused(config, id)
55
+ || isCodexAccountPlanExcluded(config, id) || isAccountNeedsReauth(id)
56
+ || !isCodexAccountUsable(config, id) || failureWindowSteersNewThreads(config, id, now)) continue;
57
+ const holds: number[] = [];
58
+ const health = getCodexQuotaHealthSnapshot(id, quotaScope, now);
59
+ if (health) {
60
+ if (typeof health.cooldownUntil !== "number" || !Number.isFinite(health.cooldownUntil)) continue;
61
+ holds.push(health.cooldownUntil);
62
+ }
63
+ const avoidUntil = codexQuotaAvoidUntil(id, quotaScope, now);
64
+ if (avoidUntil !== null) holds.push(avoidUntil);
65
+ const softAvoidUntil = getCodexAccountSoftAvoidUntil(id, now);
66
+ if (softAvoidUntil !== null) holds.push(softAvoidUntil);
67
+ const refreshCooldownUntil = getCodexPoolRefreshCooldownUntil(id, now);
68
+ if (refreshCooldownUntil !== null) holds.push(refreshCooldownUntil);
69
+ if (isCodexAccountHeldAtUsageLimit(config, id, now)) {
70
+ const resetAt = codexUsageLimitResetAt(getAccountQuota(id), getPoolAccountPlanForSelection(config, id), now);
71
+ if (resetAt === undefined) continue;
72
+ holds.push(resetAt);
73
+ }
74
+ if (holds.length === 0) continue;
75
+ const recoveryAt = Math.max(...holds);
76
+ if (recoveryAt > now && (earliest === undefined || recoveryAt < earliest)) earliest = recoveryAt;
77
+ }
78
+ return earliest;
79
+ }
@@ -29,6 +29,12 @@ export interface CodexShimDiagnostic {
29
29
  summary: string;
30
30
  runnable?: boolean;
31
31
  active?: boolean | null;
32
+ /**
33
+ * PATH overlay only: the wrapper and its private state are intact and runnable, but this process
34
+ * could not see it first on PATH. Reinstalling cannot change that; the shell must source the
35
+ * environment file. The dashboard's PATH can also differ from the user's shell (#6971).
36
+ */
37
+ activationPending?: boolean;
32
38
  }
33
39
 
34
40
  /** Structured, secret-free shim state for CLI/GUI lifecycle diagnostics. */
@@ -44,6 +44,10 @@ export function overlayActivationHint(configDir = getConfigDir()): string {
44
44
  function sameId(left: OverlayIdentity | null | undefined, right: OverlayIdentity | null | undefined): boolean {
45
45
  return !!left && !!right && left.dev === right.dev && left.ino === right.ino;
46
46
  }
47
+ /** APFS device numbers can change after reboot; persisted ownership still requires the same inode. */
48
+ function samePersistedInode(current: OverlayIdentity, recorded: OverlayIdentity | undefined): boolean {
49
+ return !!recorded && current.ino === recorded.ino;
50
+ }
47
51
  function lexical(path: string): Stats | null {
48
52
  try { return lstatSync(path); }
49
53
  catch (error) { if (fileErrorCode(error) === "ENOENT") return null; throw error; }
@@ -128,7 +132,7 @@ function shellEnvironment(): string {
128
132
  'unset ocx_shim_dir ocx_shim_rest ocx_shim_path ocx_shim_more ocx_shim_part', ""].join("\n");
129
133
  }
130
134
  function ownedWrapper(value: Snapshot, state: OverlayState): boolean {
131
- return sameId(value.identity, state.wrapperIdentity)
135
+ return samePersistedInode(value.identity, state.wrapperIdentity)
132
136
  && value.content.includes(SHIM_MARKER) && value.content.includes(`exec ${shQuote(state.launcherPath)} "$@"`);
133
137
  }
134
138
  function requireOverlayIdentities(state: OverlayState): void {
@@ -137,7 +141,7 @@ function requireOverlayIdentities(state: OverlayState): void {
137
141
  }
138
142
  }
139
143
  function ownedEnvironment(value: Snapshot, state: OverlayState): boolean {
140
- return sameId(value.identity, state.envIdentity) && value.content === shellEnvironment();
144
+ return samePersistedInode(value.identity, state.envIdentity) && value.content === shellEnvironment();
141
145
  }
142
146
  function revalidateOverlay(state: OverlayState, saved: Snapshot | null, wrapper: Snapshot | null, env: Snapshot | null): void {
143
147
  if (!unchanged(statePath(), saved) || !unchanged(state.wrapperPath, wrapper) || !unchanged(overlayPaths().env, env)) {
@@ -201,6 +205,7 @@ export function overlayDiagnostic(state: ShimState): CodexShimDiagnostic {
201
205
  inspection?.revalidate();
202
206
  } catch (error) { active = null; runnable = false; integrityMessage = failure(error).message; }
203
207
  return { installed: true, healthy: complete && runnable && active === true, runnable, active,
208
+ activationPending: complete && runnable && active !== true,
204
209
  summary: `Codex PATH shim: ${complete && runnable ? "ready" : "unhealthy"} at ${state.wrapperPath}; launcher ${state.launcherPath}; PATH ${active === null ? "unverified" : active ? "active" : "inactive"}. ${complete && runnable ? overlayActivationHint() : integrityMessage || "Run ocx codex-shim install to repair the shim."}` };
205
210
  }
206
211
 
@@ -6,6 +6,7 @@ import { lstatSync, readFileSync } from "node:fs";
6
6
  import { join } from "node:path";
7
7
  import * as z from "zod/v4";
8
8
  import { compactionRecoveryConfigError } from "./schema/compaction-recovery";
9
+ import { desktopCompatibilityConfigError } from "./schema/desktop-compatibility";
9
10
  import { blockedModelRedirectsError } from "./schema/blocked-model-redirects";
10
11
  import type { OcxConfig } from "../types";
11
12
  import { anthropicAccountPoolConfigError, anthropicOAuthInstanceConfigError, anthropicSidecarConfigError } from "./schema/anthropic-account-pool";
@@ -694,7 +695,7 @@ export function validateConfigCandidate(value: unknown): { ok: true; config: Ocx
694
695
  const anthropicError = anthropicOAuthInstanceConfigError(value) ?? anthropicAccountPoolConfigError(value) ?? anthropicSidecarConfigError(value);
695
696
  if (anthropicError) return { ok: false, error: anthropicError };
696
697
  const boundaryError = blockedModelRedirectsError(value)
697
- ?? compactionRecoveryConfigError(value) ?? configReasoningPinsConfigError(value)
698
+ ?? compactionRecoveryConfigError(value) ?? desktopCompatibilityConfigError(value) ?? configReasoningPinsConfigError(value)
698
699
  ?? blankHostnameError(value)
699
700
  ?? (rawSubagentModelForce(value) !== undefined && !isSubagentModelEntry(rawSubagentModelForce(value)) ? "schema_invalid: claudeCode.subagentModelForce: expected a safe roster-style model id" : null)
700
701
  ?? claudeSubagentEffortError(value)
@@ -138,6 +138,19 @@ export function adoptPersistedProviderIntoLiveConfig(
138
138
  if (persistedConfig) refreshPreservedProviderOwner(config, persistedConfig);
139
139
  }
140
140
 
141
+ /** Adopt only the committed next-start preference; preserve unrelated pending live edits. */
142
+ export function adoptPersistedDesktopCompatibility(config: OcxConfig, persisted: OcxConfig["desktopCompatibility"]): void {
143
+ const baseline = liveConfigBaseline.get(config);
144
+ const merged = reconcileConfigValue(baseline?.desktopCompatibility ?? MISSING_CONFIG_VALUE,
145
+ config.desktopCompatibility ?? MISSING_CONFIG_VALUE, persisted ?? MISSING_CONFIG_VALUE);
146
+ if (merged === MISSING_CONFIG_VALUE) delete config.desktopCompatibility;
147
+ else config.desktopCompatibility = merged as OcxConfig["desktopCompatibility"];
148
+ if (baseline) {
149
+ if (persisted === undefined) delete baseline.desktopCompatibility;
150
+ else baseline.desktopCompatibility = structuredClone(persisted);
151
+ }
152
+ }
153
+
141
154
  /** Test seam only: is this instance armed? */
142
155
  export function claudeCodeBaselineArmed(config: OcxConfig): boolean {
143
156
  return claudeCodeBaseline.has(config);
@@ -3,6 +3,7 @@ import { isSubagentModelEntry, rawSubagentModelForce } from "./subagent-models";
3
3
  import { chmodSync, existsSync } from "node:fs";
4
4
  import { join } from "node:path";
5
5
  import { compactionRecoveryConfigError } from "./schema/compaction-recovery";
6
+ import { desktopCompatibilityConfigError } from "./schema/desktop-compatibility";
6
7
  import { blockedModelRedirectsError } from "./schema/blocked-model-redirects";
7
8
  import { anthropicSidecarConfigError } from "./schema/anthropic-account-pool";
8
9
  import {
@@ -51,9 +52,12 @@ import { hasWarnedInheritedFastWireConflict, markWarnedInheritedFastWireConflict
51
52
 
52
53
  export function hardenExistingSecret(path: string): void {
53
54
  if (existsSync(path) && !isPassiveRead()) {
54
- try { chmodSync(path, 0o600); } catch { /* best-effort */ }
55
55
  if (process.platform === "win32") {
56
+ // Preserve the ACL helper's metadata observation across reads. Windows chmod does
57
+ // not establish a private DACL and can invalidate that observation before every load.
56
58
  hardenSecretPath(path, { required: false });
59
+ } else {
60
+ try { chmodSync(path, 0o600); } catch { /* best-effort */ }
57
61
  }
58
62
  }
59
63
  }
@@ -129,6 +133,7 @@ export function warnDegradedCompactionRouting(rawParsed: unknown, validated: Ocx
129
133
  export function warnDegradedTopLevelOptIns(rawParsed: unknown, validated: OcxConfig): void {
130
134
  setCodexWsReuseAcrossTurns(validated.codexWsReuseAcrossTurns === true);
131
135
  if (compactionRecoveryConfigError(rawParsed)) console.warn("⚠️ invalid compactionRecovery disabled; the original compaction failure is preserved");
136
+ if (desktopCompatibilityConfigError(rawParsed)) console.warn("Invalid desktopCompatibility startup preference ignored; no desktop compatibility service will start automatically.");
132
137
  if (blockedModelRedirectsError(rawParsed)) console.warn("⚠️ invalid blockedModelRedirects ignored; provider routing remains available");
133
138
  const chatgptDesktop = chatgptDesktopConfigIssue(rawParsed);
134
139
  if (chatgptDesktop) console.warn(`⚠️ config.json ${chatgptDesktop} — the whole chatgptDesktop block is ignored, so the ChatGPT desktop integration reads as off`);
@@ -4,6 +4,7 @@ import { join, resolve } from "node:path";
4
4
  import { flushWindowsSecretAclReapsBeforeRemoval, hardenSecretDirAsync, windowsSecretAclApplies } from "../lib/windows-secret-acl";
5
5
  import { assertNotRealHomeUnderTest } from "../lib/test-home-guard";
6
6
  import { isPassiveRead } from "../lib/passive-read";
7
+ import { trackBackgroundWork } from "../lib/background-work";
7
8
 
8
9
  /**
9
10
  * Expand a leading `~` in user-supplied paths without interpreting shell
@@ -37,17 +38,23 @@ export function hardenConfigDir(): void {
37
38
  // have changed the protected directory used by the test-home boundary.
38
39
  assertNotRealHomeUnderTest(dir);
39
40
  if (!existsSync(dir)) return;
40
- try { chmodSync(dir, 0o700); } catch { /* best-effort */ }
41
+ // Windows privacy is enforced by NTFS ACLs. chmod only changes DOS attributes there;
42
+ // doing it before the ACL lookup can invalidate its ctime-based successful observation.
43
+ if (!windowsSecretAclApplies()) {
44
+ try { chmodSync(dir, 0o700); } catch { /* best-effort */ }
45
+ }
41
46
  if (windowsSecretAclApplies() && !configDirHardeningFlights.has(dir)) {
42
47
  // This is an optional read-path harden. Waiting synchronously here used to stop the Bun
43
48
  // event loop (including /healthz) for the full icacls timeout. Required mutation paths keep
44
49
  // their own awaited/fail-closed hardening; ordinary config reads only start one soft flight.
45
- const flight = hardenSecretDirAsync(dir, { required: false })
50
+ // Tracked: icacls holds the directory open, and a config write or removal that lands on
51
+ // it before the child exits fails with a sharing violation on Windows.
52
+ const flight = trackBackgroundWork("config-dir-acl-harden", hardenSecretDirAsync(dir, { required: false })
46
53
  .then(() => undefined)
47
54
  .catch(() => undefined)
48
55
  .finally(() => {
49
56
  if (configDirHardeningFlights.get(dir) === flight) configDirHardeningFlights.delete(dir);
50
- });
57
+ }));
51
58
  configDirHardeningFlights.set(dir, flight);
52
59
  }
53
60
  }