@bitkyc08/opencodex 2.52.0-preview.20260912 → 2.53.0-preview.20260913

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 (236) hide show
  1. package/gui/dist/assets/index-BBOZWGB6.css +1 -0
  2. package/gui/dist/assets/index-D7ynYo2K.js +128 -0
  3. package/gui/dist/index.html +2 -2
  4. package/native/remote-workspace-helper/Cargo.lock +130 -0
  5. package/native/remote-workspace-helper/Cargo.toml +24 -0
  6. package/native/remote-workspace-helper/src/main.rs +49 -0
  7. package/native/remote-workspace-helper/src/protocol.rs +246 -0
  8. package/native/remote-workspace-helper/src/sandbox/macos.rs +19 -0
  9. package/native/remote-workspace-helper/src/sandbox/mod.rs +77 -0
  10. package/native/remote-workspace-helper/src/sandbox/windows.rs +15 -0
  11. package/package.json +6 -1
  12. package/src/adapters/anthropic-image-normalize.ts +30 -2
  13. package/src/adapters/anthropic.ts +1 -1
  14. package/src/adapters/base.ts +8 -2
  15. package/src/adapters/cursor/cursor-errors.ts +12 -0
  16. package/src/adapters/cursor/thread-continuity.ts +93 -0
  17. package/src/adapters/cursor.ts +104 -73
  18. package/src/adapters/devin/cloud-direct/chat.ts +312 -23
  19. package/src/adapters/devin/cloud-direct/metadata.ts +31 -2
  20. package/src/adapters/devin/live-models.ts +70 -3
  21. package/src/adapters/devin.ts +281 -21
  22. package/src/adapters/google-wire-compiler.ts +14 -6
  23. package/src/adapters/google.ts +22 -8
  24. package/src/adapters/kiro/adapter.ts +316 -0
  25. package/src/adapters/kiro/conversation.ts +136 -0
  26. package/src/adapters/kiro/payload.ts +432 -0
  27. package/src/adapters/kiro/reasoning.ts +56 -0
  28. package/src/adapters/kiro/stream.ts +1153 -0
  29. package/src/adapters/kiro/usage.ts +223 -0
  30. package/src/adapters/kiro/wire.ts +76 -0
  31. package/src/adapters/kiro.ts +8 -2319
  32. package/src/adapters/mimo-free.ts +1 -1
  33. package/src/adapters/openai-chat-images.ts +101 -0
  34. package/src/adapters/openai-chat.ts +201 -181
  35. package/src/adapters/openai-responses.ts +92 -224
  36. package/src/adapters/registry.ts +0 -7
  37. package/src/adapters/run-turn-queue.ts +13 -6
  38. package/src/bridge.ts +14 -15
  39. package/src/chat/inbound.ts +29 -4
  40. package/src/chat/outbound.ts +145 -107
  41. package/src/claude/desktop-profile.ts +4 -6
  42. package/src/cli/account-api.ts +14 -0
  43. package/src/cli/account-extended.ts +1 -1
  44. package/src/cli/account-history.ts +60 -0
  45. package/src/cli/account-main.ts +80 -0
  46. package/src/cli/account.ts +11 -3
  47. package/src/cli/capabilities.ts +113 -0
  48. package/src/cli/catalog.ts +109 -0
  49. package/src/cli/dispatch.ts +9 -0
  50. package/src/cli/help.ts +2 -0
  51. package/src/cli/index.ts +2 -2
  52. package/src/cli/observe.ts +28 -1
  53. package/src/cli/opencode.ts +42 -8
  54. package/src/cli/provider-runtime.ts +11 -1
  55. package/src/cli/provider.ts +22 -2
  56. package/src/cli/registry.ts +21 -0
  57. package/src/cli/remote-workspace.ts +154 -0
  58. package/src/cli/status.ts +39 -7
  59. package/src/cli/usage-report.ts +14 -2
  60. package/src/client/hub-client.ts +34 -0
  61. package/src/client/hub-state.ts +9 -1
  62. package/src/codex/account-store.ts +78 -0
  63. package/src/codex/auth-api.ts +81 -54
  64. package/src/codex/auth-context.ts +45 -16
  65. package/src/codex/catalog/effort.ts +1 -1
  66. package/src/codex/catalog/metadata.ts +3 -6
  67. package/src/codex/catalog/native-models.ts +4 -4
  68. package/src/codex/catalog/parsing.ts +2 -20
  69. package/src/codex/catalog/provider-fetch.ts +10 -1
  70. package/src/codex/catalog/remote.ts +233 -0
  71. package/src/codex/catalog/sync.ts +403 -35
  72. package/src/codex/convergence.ts +1 -1
  73. package/src/codex/history-manifest.ts +36 -0
  74. package/src/codex/history-provider.ts +32 -5
  75. package/src/codex/inject.ts +9 -0
  76. package/src/codex/main-account.ts +113 -0
  77. package/src/codex/main-device-reauth-api.ts +89 -0
  78. package/src/codex/main-device-reauth.ts +217 -0
  79. package/src/codex/native-residue.ts +9 -2
  80. package/src/codex/quota-auto-refresh.ts +3 -2
  81. package/src/codex/quota-capacity.ts +98 -0
  82. package/src/codex/quota-history.ts +160 -0
  83. package/src/codex/quota-types.ts +8 -0
  84. package/src/codex/quota.ts +118 -91
  85. package/src/codex/refresh.ts +2 -1
  86. package/src/codex/routing.ts +90 -17
  87. package/src/codex/sync.ts +33 -4
  88. package/src/combos/request.ts +19 -1
  89. package/src/config/multi-agent-surface.ts +61 -0
  90. package/src/config/provider-validation.ts +176 -0
  91. package/src/config.ts +213 -11
  92. package/src/generated/compatibility-version.json +436 -168
  93. package/src/images/loop.ts +119 -36
  94. package/src/lib/admission.ts +12 -6
  95. package/src/lib/redact.ts +7 -0
  96. package/src/lib/translator-budget.ts +4 -3
  97. package/src/lib/windows-atomic-replace.ts +1 -0
  98. package/src/lib/windows-elevation.ts +1 -1
  99. package/src/oauth/chatgpt-device.ts +62 -5
  100. package/src/oauth/devin/cli-import.ts +130 -0
  101. package/src/oauth/devin.ts +63 -8
  102. package/src/oauth/index.ts +29 -14
  103. package/src/oauth/kiro.ts +18 -6
  104. package/src/oauth/login-cli.ts +9 -1
  105. package/src/oauth/meta-muse-device.ts +464 -0
  106. package/src/oauth/meta-muse.ts +123 -32
  107. package/src/oauth/pool-kernel.ts +9 -0
  108. package/src/oauth/pool-settings-capability.ts +2 -2
  109. package/src/oauth/store.ts +57 -0
  110. package/src/oauth/types.ts +31 -0
  111. package/src/providers/derive.ts +13 -3
  112. package/src/providers/devin-cli-authmode-migration.ts +57 -35
  113. package/src/providers/devin-provider-merge-migration.ts +240 -0
  114. package/src/providers/muse-key-quota.ts +117 -0
  115. package/src/providers/muse-subscription-usage.ts +14 -2
  116. package/src/providers/openai-sidecar.ts +25 -3
  117. package/src/providers/opencode-zen-rate-limit.ts +58 -0
  118. package/src/providers/provider-id-rewrite.ts +20 -5
  119. package/src/providers/quota-types.ts +12 -0
  120. package/src/providers/quota.ts +143 -102
  121. package/src/providers/reasoning-metadata.ts +543 -0
  122. package/src/providers/registry.ts +80 -49
  123. package/src/reasoning-effort.ts +26 -2
  124. package/src/remote/hub-usage.ts +32 -0
  125. package/src/remote-control/index.ts +192 -41
  126. package/src/remote-control/workspace-activation.ts +9 -0
  127. package/src/remote-control/workspace-agent-connection.ts +366 -0
  128. package/src/remote-control/workspace-claude-runtime.ts +243 -0
  129. package/src/remote-control/workspace-codex-runtime.ts +531 -0
  130. package/src/remote-control/workspace-codex-sandbox.ts +115 -0
  131. package/src/remote-control/workspace-command-runner.ts +748 -0
  132. package/src/remote-control/workspace-coordinator.ts +231 -0
  133. package/src/remote-control/workspace-device.ts +585 -0
  134. package/src/remote-control/workspace-executable.ts +43 -0
  135. package/src/remote-control/workspace-executor.ts +397 -0
  136. package/src/remote-control/workspace-hub.ts +519 -0
  137. package/src/remote-control/workspace-pi-runtime.ts +382 -0
  138. package/src/remote-control/workspace-process.ts +129 -0
  139. package/src/remote-control/workspace-rpc.ts +304 -0
  140. package/src/remote-control/workspace-runtime.ts +60 -0
  141. package/src/remote-control/workspace-secret-store.ts +39 -0
  142. package/src/remote-control/workspace-sessions.ts +799 -0
  143. package/src/remote-control/workspace-tool-bridge.ts +192 -0
  144. package/src/responses/code-mode-helper-compat.ts +22 -3
  145. package/src/responses/hosted-tool-policy.ts +0 -1
  146. package/src/responses/muse-tool-name-alias.ts +379 -0
  147. package/src/responses/plaintext-v2-agent-messages.ts +902 -0
  148. package/src/router.ts +7 -0
  149. package/src/routing/compatibility/behavior.ts +0 -1
  150. package/src/server/audio-client.ts +64 -0
  151. package/src/server/audio-dictation.ts +91 -0
  152. package/src/server/audio-live.ts +185 -0
  153. package/src/server/audio-transcriptions.ts +183 -0
  154. package/src/server/audio-upstream.ts +153 -0
  155. package/src/server/auth-cors.ts +61 -2
  156. package/src/server/chat-completions.ts +1 -1
  157. package/src/server/chat-native-sse.ts +92 -48
  158. package/src/server/chat-native.ts +37 -15
  159. package/src/server/hub-usage.ts +57 -0
  160. package/src/server/images.ts +4 -0
  161. package/src/server/index.ts +722 -57
  162. package/src/server/lifecycle.ts +5 -6
  163. package/src/server/live-call-bindings.ts +60 -0
  164. package/src/server/live.ts +12 -1
  165. package/src/server/management/agent-settings-routes.ts +25 -4
  166. package/src/server/management/api-access.ts +37 -0
  167. package/src/server/management/api-key-usage.ts +7 -2
  168. package/src/server/management/config-routes.ts +1 -18
  169. package/src/server/management/context.ts +15 -0
  170. package/src/server/management/logs-usage-routes.ts +2 -0
  171. package/src/server/management/oauth-account-routes.ts +39 -12
  172. package/src/server/management/provider-routes.ts +125 -2
  173. package/src/server/management/remote-workspace-routes.ts +140 -0
  174. package/src/server/management/route-registry.ts +15 -0
  175. package/src/server/management/usage-aggregate-cache.ts +14 -15
  176. package/src/server/management/usage-summary-cache.ts +2 -0
  177. package/src/server/management-api.ts +23 -0
  178. package/src/server/ports.ts +17 -0
  179. package/src/server/relay-eager.ts +4 -1
  180. package/src/server/relay.ts +70 -10
  181. package/src/server/request-decompress.ts +6 -3
  182. package/src/server/responses/agent-task-recovery.ts +25 -32
  183. package/src/server/responses/codex-auth-error.ts +11 -0
  184. package/src/server/responses/codex-ws-exchange.ts +52 -3
  185. package/src/server/responses/codex-ws-wire.ts +55 -0
  186. package/src/server/responses/compact.ts +9 -1
  187. package/src/server/responses/core.ts +337 -73
  188. package/src/server/responses/encrypted-payload.ts +45 -2
  189. package/src/server/responses/ws-upstream.ts +4 -1
  190. package/src/server/responses-self-named-namespace-scrub.ts +1 -3
  191. package/src/server/responses-undeclared-tool-guard.ts +1 -1
  192. package/src/server/search.ts +3 -0
  193. package/src/server/sse-payload-rewrite.ts +136 -51
  194. package/src/server/ws-bridge.ts +35 -1
  195. package/src/service/cli.ts +372 -0
  196. package/src/service/diagnostics.ts +340 -0
  197. package/src/service/guards.ts +303 -0
  198. package/src/service/health.ts +222 -0
  199. package/src/service/launchd.ts +853 -0
  200. package/src/service/orchestration.ts +617 -0
  201. package/src/service/repair.ts +334 -0
  202. package/src/service/state.ts +363 -0
  203. package/src/service/systemd.ts +229 -0
  204. package/src/service/windows-ops.ts +690 -0
  205. package/src/service/windows-scheduler.ts +769 -0
  206. package/src/service/windows-taskxml.ts +613 -0
  207. package/src/service.ts +22 -5550
  208. package/src/storage/cleanup/db.ts +258 -0
  209. package/src/storage/cleanup/execute.ts +358 -0
  210. package/src/storage/cleanup/paths.ts +189 -0
  211. package/src/storage/cleanup/pending.ts +140 -0
  212. package/src/storage/cleanup/preview.ts +292 -0
  213. package/src/storage/cleanup/reconcile.ts +347 -0
  214. package/src/storage/cleanup/restore.ts +932 -0
  215. package/src/storage/cleanup/satellite.ts +474 -0
  216. package/src/storage/cleanup/staging.ts +129 -0
  217. package/src/storage/cleanup/types.ts +98 -0
  218. package/src/storage/cleanup.ts +49 -3127
  219. package/src/types/accounts.ts +2 -0
  220. package/src/types/config.ts +13 -12
  221. package/src/types/provider.ts +37 -0
  222. package/src/types/request.ts +2 -0
  223. package/src/types/tools.ts +17 -5
  224. package/src/types.ts +1 -0
  225. package/src/usage/expected-prices.ts +127 -0
  226. package/src/usage/log.ts +58 -1
  227. package/src/vision/eligibility.ts +13 -2
  228. package/src/web-search/loop.ts +56 -3
  229. package/gui/dist/assets/index-D_t6sCWs.js +0 -115
  230. package/gui/dist/assets/index-EdoPnm9_.css +0 -1
  231. package/src/adapters/devin-cli/acp.ts +0 -204
  232. package/src/adapters/devin-cli/adapter.ts +0 -345
  233. package/src/adapters/devin-cli/binary.ts +0 -69
  234. package/src/adapters/devin-cli/models.ts +0 -57
  235. package/src/oauth/devin-cli.ts +0 -149
  236. package/src/server/responses-reasoning-summary-rewrite.ts +0 -178
@@ -8,6 +8,7 @@ import { atomicWriteFile, getConfigDir } from "../config";
8
8
  import {
9
9
  CODEX_HISTORY_RESUMABLE_SOURCES,
10
10
  codexHistoryBackupId,
11
+ legacyCodexHistoryBackupId,
11
12
  sameCodexHistoryPath,
12
13
  validateCodexHistoryBackupManifest,
13
14
  type CodexHistoryBackupEntry,
@@ -32,6 +33,32 @@ export function historyBackupPathFor(stateDbPath: string): string {
32
33
  return join(getConfigDir(), `codex-history-backup-${codexHistoryBackupId(stateDbPath)}.json`);
33
34
  }
34
35
 
36
+ /**
37
+ * Manifest name a database path spelled with the Win32 extended-length prefix
38
+ * (`\\?\C:\...`) received before that prefix was normalized out of the identity
39
+ * (#4442). Identical to historyBackupPathFor for every other spelling.
40
+ */
41
+ export function legacyHistoryBackupPathFor(stateDbPath: string): string {
42
+ return join(getConfigDir(), `codex-history-backup-${legacyCodexHistoryBackupId(stateDbPath)}.json`);
43
+ }
44
+
45
+ /**
46
+ * The manifest that actually shadows one state database. Canonical name first; when
47
+ * only a pre-#4442 extended-length name exists, that manifest still shadows the
48
+ * database rather than reading as absent. When BOTH names exist the canonical
49
+ * manifest wins and the legacy file is left untouched — a conflict is resolved by
50
+ * keeping both, never by silently replacing one.
51
+ */
52
+ export function resolveExistingHistoryBackupPath(
53
+ stateDbPath: string,
54
+ exists: (path: string) => boolean = existsSync,
55
+ ): string {
56
+ const canonical = historyBackupPathFor(stateDbPath);
57
+ if (exists(canonical)) return canonical;
58
+ const legacy = legacyHistoryBackupPathFor(stateDbPath);
59
+ return legacy !== canonical && exists(legacy) ? legacy : canonical;
60
+ }
61
+
35
62
  /**
36
63
  * Open the live `state_5.sqlite` the way the Codex app expects a *secondary* writer to behave:
37
64
  * wait on the WAL/file lock instead of failing instantly, so we never race the app's own
@@ -318,7 +345,7 @@ export function preflightCodexHistoryInjection(
318
345
  // A partially restored row may already be native while its manifest still
319
346
  // owns work. Match the restore worker's target set before removing routing.
320
347
  const restoreEntries = providerTableMode ? []
321
- : Object.values(readBackup(historyBackupPathFor(resolvedPath), resolvedPath).manifest.entries);
348
+ : Object.values(readBackup(resolveExistingHistoryBackupPath(resolvedPath), resolvedPath).manifest.entries);
322
349
  if (!existsSync(resolvedPath)) {
323
350
  return restoreEntries.length > 0 ? "history_state_database_missing" : null;
324
351
  }
@@ -1388,7 +1415,7 @@ function openaiRestoreIsNoop(stateDbPath: string, backupPath: string): boolean {
1388
1415
  export function syncCodexHistoryProvider(
1389
1416
  provider: CodexHistoryProvider,
1390
1417
  stateDbPath = resolveCodexStateDbPath(),
1391
- backupPath = historyBackupPathFor(stateDbPath),
1418
+ backupPath = resolveExistingHistoryBackupPath(stateDbPath),
1392
1419
  opts: { skipWhenProvablyNoop?: boolean } = {},
1393
1420
  ): CodexHistorySyncResult {
1394
1421
  // Opt-in steady-state gate (Design B loopback callers only): default semantics of
@@ -1714,7 +1741,7 @@ export function restoreLegacyOpenaiHistory(stateDbPath = resolveCodexStateDbPath
1714
1741
  */
1715
1742
  export function migrateHistoryToOpenai(
1716
1743
  stateDbPath = resolveCodexStateDbPath(),
1717
- backupPath = historyBackupPathFor(stateDbPath),
1744
+ backupPath = resolveExistingHistoryBackupPath(stateDbPath),
1718
1745
  opts: { attempts?: number; delayMs?: number; sleepFn?: (ms: number) => void } = {},
1719
1746
  ): CodexHistorySyncResult {
1720
1747
  // Steady-state gate: this migration is Design-B-specific (inject + guardian callers),
@@ -1747,7 +1774,7 @@ export function snapshotCodexHistoryNoop(
1747
1774
  const stateDbPresent = existsSync(stateDbPath);
1748
1775
  const backupPresent = existsSync(backupPath);
1749
1776
  const base = { canonicalStateDbPath, stateDbPresent, canonicalBackupPath, backupPresent };
1750
- if (!sameCodexHistoryPath(backupPath, historyBackupPathFor(stateDbPath))) {
1777
+ if (!sameCodexHistoryPath(backupPath, resolveExistingHistoryBackupPath(stateDbPath))) {
1751
1778
  return { kind: "unknown", pendingRows: null, backupEntries: null, ...base, reason: "backup-path" };
1752
1779
  }
1753
1780
  const backup = inspectBackupForNoop(backupPath, stateDbPath);
@@ -1831,7 +1858,7 @@ export interface PendingHistoryCount {
1831
1858
  */
1832
1859
  export function countPendingOpencodexHistory(
1833
1860
  stateDbPath = resolveCodexStateDbPath(),
1834
- backupPath = historyBackupPathFor(stateDbPath),
1861
+ backupPath = resolveExistingHistoryBackupPath(stateDbPath),
1835
1862
  opts: { validateRestoreTargets?: boolean } = {},
1836
1863
  ): PendingHistoryCount {
1837
1864
  const backup = readBackupStrict(backupPath, stateDbPath);
@@ -895,6 +895,8 @@ export function chooseCatalogPathForInjection(
895
895
  export interface CodexInjectResult {
896
896
  success: boolean;
897
897
  message: string;
898
+ /** Structured read-only history preflight refusal; never parsed from display text. */
899
+ historyPreflightFailureReason?: string;
898
900
  status?: "skipped";
899
901
  /** `hub-gated` is the hub-role gate (#4236), distinct from the user's own OFF switch. */
900
902
  skippedReason?: "desired_disabled" | "desired_enabled" | "hub-gated";
@@ -1192,6 +1194,7 @@ async function injectCodexConfigImpl(
1192
1194
  if (historyPreflightError) {
1193
1195
  return {
1194
1196
  success: false,
1197
+ historyPreflightFailureReason: historyPreflightError,
1195
1198
  message: `Codex config injection refused: ${historyPreflightError}. `
1196
1199
  + "Existing provider definitions and conversation files were preserved. "
1197
1200
  + "Paginated history requires native-writer coordination; do not run legacy recovery or retry this transition blindly.",
@@ -1949,6 +1952,12 @@ function restoreCodexConfigInlineImpl(kind: string): CodexRestoreConfigResult {
1949
1952
  const restored = journal.configRestored
1950
1953
  ? { success: true, message: "Codex config restored from opencodex journal." }
1951
1954
  : removeCodexConfig({ preserveProfile: journal.profileRestored || journal.profileChanged });
1955
+ if (restored.success) {
1956
+ // A successful journal/fallback write can race native history migration too.
1957
+ // Refuse here while preimage compensation and the remove transaction can roll back.
1958
+ const finalHistoryError = preflightCodexHistoryInjection(false, false);
1959
+ if (finalHistoryError) return { state: "failed", changed: false, action: "failed", message: `Codex configuration and journal preserved: ${finalHistoryError}.` };
1960
+ }
1952
1961
  return restored.success
1953
1962
  ? {
1954
1963
  state: "ok",
@@ -21,6 +21,7 @@ import { clearAccountNeedsReauth } from "./account-runtime-state";
21
21
  import { advanceCodexCredentialMutationEpoch } from "./credential-mutation-epoch";
22
22
  import { withNativeMainExclusiveClaim } from "./native-main-claim";
23
23
  import { resolveNativeProfileContext } from "./native-profile-store";
24
+ import { isNativeMainTrafficBlocked } from "./native-profile-startup";
24
25
 
25
26
  export { MAIN_CODEX_ACCOUNT_ID } from "./account-id";
26
27
 
@@ -218,6 +219,118 @@ export function setMainAuthJsonBeforeRenameHookForTests(hook: (() => void) | nul
218
219
  beforeMainAuthJsonRenameForTests = hook;
219
220
  }
220
221
 
222
+ /** Complete token set a native device reauth commits into the main slot (#3898). */
223
+ export interface NativeMainReauthTokens {
224
+ accessToken: string;
225
+ refreshToken: string;
226
+ idToken: string;
227
+ chatgptAccountId: string;
228
+ }
229
+
230
+ export class NativeMainReauthUnavailableError extends Error {
231
+ constructor(message = "Native main credential cannot be reauthenticated in this state") {
232
+ super(message);
233
+ this.name = "NativeMainReauthUnavailableError";
234
+ }
235
+ }
236
+
237
+ export class NativeMainReauthIdentityMismatchError extends Error {
238
+ constructor() {
239
+ super("Device login completed for a different ChatGPT account than the native main identity");
240
+ this.name = "NativeMainReauthIdentityMismatchError";
241
+ }
242
+ }
243
+
244
+ /**
245
+ * The reauth twin of persistRefreshedMainAuthJson (#3898). That function
246
+ * spreads expected.tokens and never writes id_token, which would keep the
247
+ * OLD identity token beside the new grant; this sibling sets all four
248
+ * credential fields together and overwrites any prior id_token. Everything
249
+ * else — allowed root metadata, the pre-rename snapshot guards, the
250
+ * mutation epoch — follows the refresh path exactly.
251
+ */
252
+ function persistNativeMainReauthTokens(
253
+ expected: MainAuthJsonCredential,
254
+ tokens: NativeMainReauthTokens,
255
+ ): void {
256
+ assertNotRealCodexHomeUnderTest(resolveCodexHomeDir());
257
+ const nextTokens = {
258
+ ...expected.tokens,
259
+ access_token: tokens.accessToken,
260
+ refresh_token: tokens.refreshToken,
261
+ id_token: tokens.idToken,
262
+ account_id: tokens.chatgptAccountId,
263
+ };
264
+ atomicWriteFile(
265
+ expected.path,
266
+ JSON.stringify({ ...expected.root, tokens: nextTokens }, null, 2) + "\n",
267
+ undefined,
268
+ {
269
+ beforeRename: () => {
270
+ assertMainAuthJsonSnapshotUnchanged(expected);
271
+ const hook = beforeMainAuthJsonRenameForTests;
272
+ beforeMainAuthJsonRenameForTests = null;
273
+ hook?.();
274
+ },
275
+ validateBeforeRename: () => assertMainAuthJsonSnapshotUnchanged(expected),
276
+ },
277
+ );
278
+ advanceCodexCredentialMutationEpoch();
279
+ }
280
+
281
+ /**
282
+ * Prepare a same-identity reauth of the native __main__ slot (#3898).
283
+ *
284
+ * The existing credential snapshot is captured NOW and held only inside the
285
+ * closure — callers (the device-reauth service) never see the expected
286
+ * account id, so a flow cannot be steered toward a different identity. No
287
+ * claim is held while the human completes the device page. The returned
288
+ * commit, called once the device grant exists:
289
+ *
290
+ * 1. requires the SAME chatgpt account identity as the snapshot;
291
+ * 2. acquires the owner-independent exclusive claim (native-main-claim) —
292
+ * deliberately NOT assertNativeMainOwner, which a headless hub cannot
293
+ * satisfy;
294
+ * 3. re-verifies the snapshot (path + hash + dev/ino) inside the claim;
295
+ * 4. writes access/refresh/id token + account_id atomically and clears the
296
+ * main account's reauth quarantine for the new credential generation.
297
+ */
298
+ export function beginNativeMainReauth(): {
299
+ commit: (tokens: NativeMainReauthTokens) => Promise<{ chatgptAccountId: string }>;
300
+ } {
301
+ const expected = readMainAuthJsonCredential();
302
+ if (!expected || !expected.chatgptAccountId) {
303
+ throw new NativeMainReauthUnavailableError(
304
+ "No native main credential exists to reauthenticate; enrollment is the native profile workflow",
305
+ );
306
+ }
307
+ return {
308
+ async commit(tokens: NativeMainReauthTokens): Promise<{ chatgptAccountId: string }> {
309
+ if (!tokens.accessToken || !tokens.refreshToken || !tokens.idToken) {
310
+ throw new NativeMainReauthUnavailableError("Device grant did not produce a complete token set");
311
+ }
312
+ if (tokens.chatgptAccountId !== expected.chatgptAccountId) {
313
+ throw new NativeMainReauthIdentityMismatchError();
314
+ }
315
+ return withNativeMainExclusiveClaim(resolveNativeProfileContext(), async () => {
316
+ // Recovery/admission recheck (080): a recovery-blocked or not-ready
317
+ // home fails native_main_unavailable rather than rewriting auth.json
318
+ // underneath the gate. The claim waits bounded like the refresh path
319
+ // (30s) so a busy claim is not an instant refusal.
320
+ if (isNativeMainTrafficBlocked()) {
321
+ throw new NativeMainReauthUnavailableError(
322
+ "Native main traffic is blocked by startup or recovery state",
323
+ );
324
+ }
325
+ assertMainAuthJsonSnapshotUnchanged(expected);
326
+ persistNativeMainReauthTokens(expected, tokens);
327
+ clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);
328
+ return { chatgptAccountId: tokens.chatgptAccountId };
329
+ }, { waitMs: 30_000 });
330
+ },
331
+ };
332
+ }
333
+
221
334
  async function resolveMainAccountToken(
222
335
  dependencies: NativeMainRefreshDependencies = {},
223
336
  rejectedAccessToken?: string,
@@ -0,0 +1,89 @@
1
+ import type { OcxConfig } from "../types";
2
+ import { jsonResponse } from "../server/auth-cors";
3
+ import {
4
+ cancelMainDeviceReauth,
5
+ getMainDeviceReauthStatus,
6
+ MainDeviceReauthFlowBusyError,
7
+ startMainDeviceReauth,
8
+ } from "./main-device-reauth";
9
+ import { NativeMainReauthUnavailableError } from "./main-account";
10
+
11
+ /**
12
+ * Dedicated native-main device reauth route (#3898).
13
+ *
14
+ * `/api/codex-auth/login` stays pool-only and keeps rejecting __main__;
15
+ * this namespace is the only device-reauth surface for the native main slot.
16
+ * DTOs carry flowId/status/verificationUrl/deviceCode and safe failure codes
17
+ * — never tokens, emails, or raw account ids. The route is registered in
18
+ * management-api ahead of the generic /api/codex-auth/* dispatch, so the
19
+ * existing management origin/auth/session controls wrap it unchanged.
20
+ */
21
+
22
+ const ROUTE = "/api/codex-auth/main/reauth-device";
23
+
24
+ function errorResponse(
25
+ req: Request,
26
+ config: OcxConfig,
27
+ message: string,
28
+ code: string,
29
+ status: number,
30
+ ): Response {
31
+ return jsonResponse({ error: message, code }, status, req, config);
32
+ }
33
+
34
+ function flowIdFromQuery(url: URL): string | null {
35
+ for (const key of url.searchParams.keys()) {
36
+ if (key !== "flowId") return null;
37
+ }
38
+ const flowId = url.searchParams.get("flowId");
39
+ return flowId && flowId.trim() ? flowId : null;
40
+ }
41
+
42
+ export async function handleMainDeviceReauthAPI(
43
+ req: Request,
44
+ url: URL,
45
+ config: OcxConfig,
46
+ ): Promise<Response | null> {
47
+ if (url.pathname !== ROUTE) return null;
48
+
49
+ if (req.method === "POST") {
50
+ // Strict body: no request keys exist for start; anything supplied is an error.
51
+ const text = await req.text();
52
+ if (text.trim()) {
53
+ return errorResponse(req, config, "The reauth-device start takes no request body", "invalid_request", 400);
54
+ }
55
+ try {
56
+ return jsonResponse(startMainDeviceReauth(), 200, req, config);
57
+ } catch (error) {
58
+ if (error instanceof MainDeviceReauthFlowBusyError) {
59
+ return errorResponse(req, config, error.message, error.code, 409);
60
+ }
61
+ if (error instanceof NativeMainReauthUnavailableError) {
62
+ return errorResponse(req, config, error.message, "native_main_unavailable", 503);
63
+ }
64
+ throw error;
65
+ }
66
+ }
67
+
68
+ if (req.method === "GET") {
69
+ const flowId = flowIdFromQuery(url);
70
+ if (!flowId) {
71
+ return errorResponse(req, config, "An exact flowId query is required", "invalid_request", 400);
72
+ }
73
+ const status = getMainDeviceReauthStatus(flowId);
74
+ if (!status) return errorResponse(req, config, "Unknown or expired reauth flow", "unknown_flow", 404);
75
+ return jsonResponse(status, 200, req, config);
76
+ }
77
+
78
+ if (req.method === "DELETE") {
79
+ const flowId = flowIdFromQuery(url);
80
+ if (!flowId) {
81
+ return errorResponse(req, config, "An exact flowId query is required", "invalid_request", 400);
82
+ }
83
+ const status = cancelMainDeviceReauth(flowId);
84
+ if (!status) return errorResponse(req, config, "Unknown or expired reauth flow", "unknown_flow", 404);
85
+ return jsonResponse(status, 200, req, config);
86
+ }
87
+
88
+ return errorResponse(req, config, "Method not allowed", "method_not_allowed", 405);
89
+ }
@@ -0,0 +1,217 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { loginChatGPTNativeDevice, type NativeDeviceLogin } from "../oauth/chatgpt-device";
3
+ import type { OAuthController } from "../oauth/types";
4
+ import {
5
+ beginNativeMainReauth,
6
+ MainAuthJsonChangedDuringRefreshError,
7
+ NativeMainReauthIdentityMismatchError,
8
+ NativeMainReauthUnavailableError,
9
+ type NativeMainReauthTokens,
10
+ } from "./main-account";
11
+
12
+ /**
13
+ * Process-owned native-main device reauth flow (#3898).
14
+ *
15
+ * Exactly one active flow per process, started from the management API or the
16
+ * CLI. The human-facing DTO carries only flowId, status, the verification
17
+ * URL and the device code: tokens, emails, and raw account ids never leave
18
+ * the device/grant layer, and the opaque device_auth_id never leaves
19
+ * chatgpt-device.ts at all. The grant runs on this flow's own
20
+ * AbortController — deliberately NOT through startLoginFlow("chatgpt"),
21
+ * which would overwrite the pool scratch slot and collide with pool logins.
22
+ */
23
+
24
+ export type MainDeviceReauthStatus =
25
+ | { flowId: string; status: "pending"; verificationUrl: string; deviceCode: string }
26
+ | { flowId: string; status: "committing" }
27
+ | { flowId: string; status: "succeeded"; credentialUpdated: true }
28
+ | { flowId: string; status: "cancelled" }
29
+ | {
30
+ flowId: string;
31
+ status: "failed";
32
+ credentialUpdated?: true;
33
+ code:
34
+ | "identity_mismatch"
35
+ | "credential_changed"
36
+ | "native_main_unavailable"
37
+ | "device_authorization_failed"
38
+ | "publication_failed"
39
+ | "reconciliation_failed";
40
+ };
41
+
42
+ export class MainDeviceReauthFlowBusyError extends Error {
43
+ readonly code = "flow_in_progress";
44
+ constructor() {
45
+ super("A native main device reauth is already in progress");
46
+ this.name = "MainDeviceReauthFlowBusyError";
47
+ }
48
+ }
49
+
50
+ interface ActiveFlow {
51
+ flowId: string;
52
+ controller: AbortController;
53
+ status: MainDeviceReauthStatus;
54
+ /** Set once auth.json has been replaced; cancellation can no longer win. */
55
+ published: boolean;
56
+ /** Snapshot-holding commit prepared at start; closure-private identity. */
57
+ prepared?: { commit: (tokens: NativeMainReauthTokens) => Promise<{ chatgptAccountId: string }> };
58
+ }
59
+
60
+ /** Bounded terminal retention so status/cancel stay answerable after completion. */
61
+ const TERMINAL_RETENTION_MS = 300_000;
62
+
63
+ let activeFlow: ActiveFlow | null = null;
64
+ const terminalFlows = new Map<string, { status: MainDeviceReauthStatus; expiresAt: number }>();
65
+
66
+ export interface MainDeviceReauthDeps {
67
+ login?: (ctrl: OAuthController) => Promise<NativeDeviceLogin>;
68
+ beginCommit?: () => { commit: (tokens: NativeMainReauthTokens) => Promise<{ chatgptAccountId: string }> };
69
+ flowId?: () => string;
70
+ now?: () => number;
71
+ }
72
+
73
+ function isTerminal(status: MainDeviceReauthStatus): boolean {
74
+ return status.status === "succeeded" || status.status === "cancelled" || status.status === "failed";
75
+ }
76
+
77
+ function sweepTerminal(now: number): void {
78
+ for (const [flowId, row] of terminalFlows) {
79
+ if (row.expiresAt <= now) terminalFlows.delete(flowId);
80
+ }
81
+ }
82
+
83
+ function finish(flow: ActiveFlow, status: MainDeviceReauthStatus, now: number): void {
84
+ // Publication beats a racing cancellation: once auth.json was replaced the
85
+ // honest terminal is succeeded, never cancelled (080). Every other terminal
86
+ // is first-write-wins so a superseded or cancelled completion cannot
87
+ // publish a later result.
88
+ if (isTerminal(flow.status)) {
89
+ if (!(flow.published && status.status === "succeeded")) return;
90
+ }
91
+ if (status.status === "succeeded") flow.published = true;
92
+ flow.status = status;
93
+ terminalFlows.set(flow.flowId, { status, expiresAt: now + TERMINAL_RETENTION_MS });
94
+ }
95
+
96
+ function mapFailure(flowId: string, error: unknown): MainDeviceReauthStatus {
97
+ if (error instanceof NativeMainReauthIdentityMismatchError) {
98
+ return { flowId, status: "failed", code: "identity_mismatch" };
99
+ }
100
+ if (error instanceof MainAuthJsonChangedDuringRefreshError) {
101
+ return { flowId, status: "failed", code: "credential_changed" };
102
+ }
103
+ if (error instanceof NativeMainReauthUnavailableError) {
104
+ return { flowId, status: "failed", code: "native_main_unavailable" };
105
+ }
106
+ const code = (error as { code?: unknown } | null)?.code;
107
+ if (code === "NATIVE_MAIN_CLAIM_UNAVAILABLE" || code === "NATIVE_MAIN_OWNER_UNAVAILABLE") {
108
+ return { flowId, status: "failed", code: "native_main_unavailable" };
109
+ }
110
+ const name = (error as { name?: unknown } | null)?.name;
111
+ if (name === "TimeoutError" || name === "AbortError") {
112
+ return { flowId, status: "failed", code: "device_authorization_failed" };
113
+ }
114
+ if (error instanceof Error && /device authorization/.test(error.message)) {
115
+ return { flowId, status: "failed", code: "device_authorization_failed" };
116
+ }
117
+ return { flowId, status: "failed", code: "publication_failed" };
118
+ }
119
+
120
+ /**
121
+ * Start the one active flow. Returns the pending status; the URL/code arrive
122
+ * with the usercode response and are visible through the status endpoint.
123
+ */
124
+ export function startMainDeviceReauth(deps: MainDeviceReauthDeps = {}): MainDeviceReauthStatus {
125
+ const now = (deps.now ?? Date.now)();
126
+ sweepTerminal(now);
127
+ if (activeFlow && !isTerminal(activeFlow.status)) throw new MainDeviceReauthFlowBusyError();
128
+ const flowId = (deps.flowId ?? randomUUID)();
129
+ const flow: ActiveFlow = {
130
+ flowId,
131
+ controller: new AbortController(),
132
+ status: { flowId, status: "pending", verificationUrl: "", deviceCode: "" },
133
+ published: false,
134
+ };
135
+ // Claim the singleflight slot BEFORE preparing: two overlapping starts must
136
+ // not both snapshot and then have one throw flow_in_progress after the
137
+ // other already began polling.
138
+ activeFlow = flow;
139
+ try {
140
+ // Prepare NOW: the existing credential snapshot is captured at start (080),
141
+ // so a hub with no reauthenticatable main credential fails fast with
142
+ // native_main_unavailable instead of after the human completes the page.
143
+ flow.prepared = (deps.beginCommit ?? beginNativeMainReauth)();
144
+ } catch (error) {
145
+ activeFlow = null;
146
+ throw error;
147
+ }
148
+ const login = deps.login ?? loginChatGPTNativeDevice;
149
+ const clock = deps.now ?? Date.now;
150
+ void (async () => {
151
+ try {
152
+ const grant = await login({
153
+ signal: flow.controller.signal,
154
+ onAuth: info => {
155
+ // A superseded or cancelled flow may not publish its URL/code.
156
+ if (activeFlow !== flow || isTerminal(flow.status)) return;
157
+ flow.status = {
158
+ flowId,
159
+ status: "pending",
160
+ verificationUrl: info.url,
161
+ deviceCode: info.deviceCode ?? "",
162
+ };
163
+ },
164
+ });
165
+ if (flow.controller.signal.aborted) return;
166
+ if (!isTerminal(flow.status)) flow.status = { flowId, status: "committing" };
167
+ // Recheck immediately before the write: a cancel that landed while the
168
+ // grant was resolving must not reach auth.json. Once the commit DOES
169
+ // publish, the terminal is succeeded even if a cancel raced it (080).
170
+ if (activeFlow !== flow || flow.controller.signal.aborted || isTerminal(flow.status)) return;
171
+ await flow.prepared!.commit({
172
+ accessToken: grant.credential.access,
173
+ refreshToken: grant.credential.refresh,
174
+ idToken: grant.idToken,
175
+ chatgptAccountId: grant.credential.accountId!,
176
+ });
177
+ flow.published = true;
178
+ finish(flow, { flowId, status: "succeeded", credentialUpdated: true }, clock());
179
+ } catch (error) {
180
+ if (flow.controller.signal.aborted && !flow.published) return;
181
+ finish(flow, mapFailure(flowId, error), clock());
182
+ }
183
+ })();
184
+ return flow.status;
185
+ }
186
+
187
+ export function getMainDeviceReauthStatus(flowId: string, deps: MainDeviceReauthDeps = {}): MainDeviceReauthStatus | null {
188
+ const now = (deps.now ?? Date.now)();
189
+ sweepTerminal(now);
190
+ if (activeFlow?.flowId === flowId) return activeFlow.status;
191
+ return terminalFlows.get(flowId)?.status ?? null;
192
+ }
193
+
194
+ /**
195
+ * Cancel the flow. Cancellation after publication returns the published
196
+ * terminal (succeeded), never cancelled; a pending/committing flow aborts its
197
+ * grant and settles cancelled.
198
+ */
199
+ export function cancelMainDeviceReauth(flowId: string, deps: MainDeviceReauthDeps = {}): MainDeviceReauthStatus | null {
200
+ const now = (deps.now ?? Date.now)();
201
+ sweepTerminal(now);
202
+ if (activeFlow?.flowId === flowId && !isTerminal(activeFlow.status)) {
203
+ activeFlow.controller.abort();
204
+ finish(activeFlow, { flowId, status: "cancelled" }, now);
205
+ return activeFlow.status;
206
+ }
207
+ return terminalFlows.get(flowId)?.status ?? null;
208
+ }
209
+
210
+ /** Test hook: drop all in-memory flow state. Production never calls this. */
211
+ export function resetMainDeviceReauthForTests(): void {
212
+ // Abort first: a reset that only clears the maps leaves a pending grant
213
+ // polling against real timers for up to the 15-minute device TTL.
214
+ activeFlow?.controller.abort();
215
+ activeFlow = null;
216
+ terminalFlows.clear();
217
+ }
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  closeSync,
3
+ existsSync,
3
4
  fstatSync,
4
5
  lstatSync,
5
6
  openSync,
@@ -16,7 +17,7 @@ import { Database } from "bun:sqlite";
16
17
 
17
18
  import { getConfigDir } from "../config";
18
19
  import { catalogHasRoutedEntries, parseCatalogJson } from "./catalog/parsing";
19
- import { codexHistoryBackupId, validateCodexHistoryBackupManifest } from "./history-manifest";
20
+ import { codexHistoryBackupId, legacyCodexHistoryBackupId, validateCodexHistoryBackupManifest } from "./history-manifest";
20
21
  import {
21
22
  hasInjectedCodexRouting,
22
23
  OCX_SECTION_MARKER,
@@ -590,7 +591,13 @@ function classifyHistoryDatabase(path: string): NativeRoutedResidueResult {
590
591
  }
591
592
 
592
593
  function historyBackupPath(stateDatabasePath: string): string {
593
- return join(getConfigDir(), `codex-history-backup-${codexHistoryBackupId(stateDatabasePath)}.json`);
594
+ const canonical = join(getConfigDir(), `codex-history-backup-${codexHistoryBackupId(stateDatabasePath)}.json`);
595
+ if (existsSync(canonical)) return canonical;
596
+ // A database path spelled with the Win32 extended-length prefix hashed to a different
597
+ // manifest name before #4442; that manifest still shadows the database (and a canonical
598
+ // file always wins over it, with the legacy one left in place).
599
+ const legacy = join(getConfigDir(), `codex-history-backup-${legacyCodexHistoryBackupId(stateDatabasePath)}.json`);
600
+ return legacy !== canonical && existsSync(legacy) ? legacy : canonical;
594
601
  }
595
602
 
596
603
  function classifyHistoryBackup(path: string, stateDatabasePath: string): NativeRoutedResidueResult {
@@ -8,7 +8,7 @@ import { isSelectableCodexPoolAccount } from "./account-id";
8
8
  import { reconcileMainCodexAccountRuntimeState } from "./account-lifecycle";
9
9
  import { isCodexAccountPaused } from "./account-pause";
10
10
  import { isAccountNeedsReauth, markAccountNeedsReauth } from "./account-runtime-state";
11
- import { getValidCodexToken, isCodexAccountGenerationLive, readCodexAccountRecord } from "./account-store";
11
+ import { capturePoolQuotaWriter, getValidCodexToken, isCodexAccountGenerationLive, readCodexAccountRecord } from "./account-store";
12
12
  import { codexAccountLogLabel } from "./account-label";
13
13
  import { getMainAccountToken, getValidMainAccountToken, MAIN_CODEX_ACCOUNT_ID } from "./main-account";
14
14
  import { isMainAccountHardLocked } from "./main-account-hard-lock";
@@ -170,10 +170,11 @@ async function warmAccount(config: OcxConfig, accountId: string): Promise<void |
170
170
  if (!record?.credential || record.deletedAt != null || record.codexValidationPending
171
171
  || record.generation !== token.generation) return false;
172
172
  if (isCodexAccountPaused(config, accountId) || isAccountNeedsReauth(accountId)) return false;
173
+ const poolWriter = capturePoolQuotaWriter(accountId, token);
173
174
  try {
174
175
  await warmCodexAccount({ ...token, onCompleted: headers => {
175
176
  if (isCodexAccountGenerationLive(accountId, token.generation)) {
176
- applyAccountQuotaFromUpstreamHeaders(accountId, headers, writerGeneration);
177
+ applyAccountQuotaFromUpstreamHeaders(accountId, headers, writerGeneration, undefined, { poolWriter });
177
178
  }
178
179
  } });
179
180
  } catch (error) {