@bitkyc08/opencodex 2.77.0 → 2.78.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 (222) hide show
  1. package/bin/ocx.mjs +11 -10
  2. package/gui/dist/assets/App-BtfG5id-.js +54 -0
  3. package/gui/dist/assets/App-lCIslZ1L.css +1 -0
  4. package/gui/dist/assets/{Tray-gloIYNaW.js → Tray-DiJpSub0.js} +1 -1
  5. package/gui/dist/assets/index-Btc3Ogw4.js +94 -0
  6. package/gui/dist/assets/index-DmQB8D_R.css +1 -0
  7. package/gui/dist/assets/quota-summary-B_a96nXj.js +1 -0
  8. package/gui/dist/index.html +2 -2
  9. package/package.json +1 -1
  10. package/src/adapters/anthropic/beta-allowlist.ts +19 -2
  11. package/src/adapters/anthropic/client-identity.ts +13 -4
  12. package/src/adapters/anthropic/native-client-preamble.ts +31 -0
  13. package/src/adapters/anthropic/passthrough.ts +103 -25
  14. package/src/adapters/devin/cloud-direct/chat.ts +7 -2
  15. package/src/adapters/devin/trajectory.ts +49 -0
  16. package/src/adapters/devin.ts +13 -0
  17. package/src/adapters/ollama-native.ts +35 -19
  18. package/src/claude/intercept/client-hello.ts +103 -0
  19. package/src/claude/intercept/model-bindings.ts +3 -2
  20. package/src/claude/intercept/picker-listener.ts +279 -66
  21. package/src/cli/access-audio-input.ts +84 -0
  22. package/src/cli/access-audio-live.ts +99 -0
  23. package/src/cli/access-audio.ts +123 -0
  24. package/src/cli/access-data-client.ts +127 -0
  25. package/src/cli/access-data-plane.ts +104 -0
  26. package/src/cli/access-data-response.ts +76 -0
  27. package/src/cli/access.ts +65 -2
  28. package/src/cli/account-api.ts +30 -6
  29. package/src/cli/account-auth.ts +111 -42
  30. package/src/cli/account-key-quota.ts +118 -0
  31. package/src/cli/account-main.ts +11 -4
  32. package/src/cli/account-next-actions.ts +63 -0
  33. package/src/cli/account-policy-dto.ts +68 -0
  34. package/src/cli/account-policy.ts +167 -0
  35. package/src/cli/account-target.ts +8 -0
  36. package/src/cli/account.ts +34 -8
  37. package/src/cli/agent-runtime-settings.ts +130 -0
  38. package/src/cli/agent-settings.ts +101 -0
  39. package/src/cli/agent.ts +19 -2
  40. package/src/cli/alias.ts +7 -2
  41. package/src/cli/aside-profiles.ts +2 -2
  42. package/src/cli/capabilities-access-remote.ts +147 -0
  43. package/src/cli/capabilities-accounts.ts +195 -0
  44. package/src/cli/capabilities-agents-routing.ts +414 -0
  45. package/src/cli/capabilities-base.ts +783 -0
  46. package/src/cli/capabilities-command.ts +1 -0
  47. package/src/cli/capabilities-integrations.ts +264 -0
  48. package/src/cli/capabilities-lab.ts +214 -0
  49. package/src/cli/capabilities-observe-system.ts +662 -0
  50. package/src/cli/capabilities-provider-models.ts +302 -0
  51. package/src/cli/capabilities.ts +30 -1199
  52. package/src/cli/capability-types.ts +54 -0
  53. package/src/cli/catalog-command-result.ts +57 -0
  54. package/src/cli/chatgpt-command.ts +25 -2
  55. package/src/cli/claude-agent-startup-sync.ts +17 -0
  56. package/src/cli/claude-desktop-profile.ts +100 -0
  57. package/src/cli/claude-desktop.ts +39 -6
  58. package/src/cli/codex-shim-autorestore.ts +2 -1
  59. package/src/cli/codex-shim-readiness.ts +23 -3
  60. package/src/cli/combo-input.ts +88 -0
  61. package/src/cli/combo-result.ts +52 -0
  62. package/src/cli/combo-stats.ts +123 -0
  63. package/src/cli/combo.ts +37 -14
  64. package/src/cli/companion-timeline.ts +142 -0
  65. package/src/cli/companion-usage.ts +146 -0
  66. package/src/cli/companion.ts +12 -3
  67. package/src/cli/config-command.ts +24 -8
  68. package/src/cli/dispatch.ts +52 -79
  69. package/src/cli/doctor.ts +22 -4
  70. package/src/cli/help-catalog.ts +31 -14
  71. package/src/cli/help-recovery.ts +2 -2
  72. package/src/cli/help.ts +25 -11
  73. package/src/cli/index.ts +38 -27
  74. package/src/cli/injection-follow.ts +35 -0
  75. package/src/cli/inspect.ts +5 -0
  76. package/src/cli/integration-aside-sync.ts +69 -0
  77. package/src/cli/integration-cursor.ts +106 -0
  78. package/src/cli/integration-input.ts +60 -0
  79. package/src/cli/integration-journal.ts +49 -0
  80. package/src/cli/integration-plan-dto.ts +156 -0
  81. package/src/cli/integration-preview.ts +215 -0
  82. package/src/cli/integrations.ts +24 -18
  83. package/src/cli/json-input.ts +134 -0
  84. package/src/cli/link.ts +32 -4
  85. package/src/cli/local-sync-result.ts +41 -0
  86. package/src/cli/log-follow.ts +84 -0
  87. package/src/cli/log-view-filter.ts +146 -0
  88. package/src/cli/logout-command.ts +44 -0
  89. package/src/cli/model-picker-ordering.ts +137 -0
  90. package/src/cli/models-custom-input.ts +107 -0
  91. package/src/cli/models-custom-runtime.ts +112 -0
  92. package/src/cli/models-order.ts +194 -0
  93. package/src/cli/models-runtime-subcommands.ts +2 -0
  94. package/src/cli/models-runtime.ts +7 -0
  95. package/src/cli/models.ts +66 -81
  96. package/src/cli/observe-stream.ts +142 -0
  97. package/src/cli/observe.ts +75 -34
  98. package/src/cli/provider-batch.ts +77 -0
  99. package/src/cli/provider-lifecycle-runtime.ts +170 -0
  100. package/src/cli/provider-result.ts +124 -0
  101. package/src/cli/provider-runtime.ts +15 -12
  102. package/src/cli/provider-settings.ts +143 -0
  103. package/src/cli/provider.ts +112 -34
  104. package/src/cli/registry.ts +62 -20
  105. package/src/cli/remote-workspace-hub.ts +83 -0
  106. package/src/cli/remote-workspace.ts +6 -1
  107. package/src/cli/restart-failure.ts +27 -0
  108. package/src/cli/root.ts +12 -0
  109. package/src/cli/route-policy-write.ts +137 -0
  110. package/src/cli/route-policy.ts +8 -0
  111. package/src/cli/runtime-api.ts +56 -79
  112. package/src/cli/secret-args.ts +149 -0
  113. package/src/cli/settings-result.ts +30 -0
  114. package/src/cli/storage.ts +77 -7
  115. package/src/cli/system-command.ts +60 -2
  116. package/src/cli/system-restart-client.ts +20 -3
  117. package/src/cli/system-settings-parity.ts +83 -0
  118. package/src/cli/update-restart-candidate.ts +21 -0
  119. package/src/cli/update-restart-child.ts +81 -0
  120. package/src/cli/update-restart-home.ts +38 -0
  121. package/src/cli/update-restart-transport.ts +193 -0
  122. package/src/cli/update-restart.ts +260 -0
  123. package/src/cli/usage-model-search.ts +58 -0
  124. package/src/cli/usage-report.ts +13 -5
  125. package/src/cli/v2-input.ts +66 -0
  126. package/src/cli/v2-local-output.ts +37 -0
  127. package/src/cli/v2-runtime.ts +90 -0
  128. package/src/cli/v2.ts +102 -144
  129. package/src/clients/config-export/dsh.ts +21 -0
  130. package/src/clients/config-export.ts +7 -1
  131. package/src/codex/catalog/build-entries.ts +1 -1
  132. package/src/codex/catalog/bundled.ts +9 -2
  133. package/src/codex/catalog/heal-observation.ts +66 -0
  134. package/src/codex/catalog/publication-observer.ts +19 -0
  135. package/src/codex/catalog/remote.ts +13 -15
  136. package/src/codex/catalog/restore.ts +19 -6
  137. package/src/codex/catalog/retained-sync.ts +58 -10
  138. package/src/codex/catalog/routed-removal.ts +116 -0
  139. package/src/codex/catalog/write-audit-contract.ts +21 -0
  140. package/src/codex/catalog/write-audit.ts +171 -0
  141. package/src/codex/catalog-self-heal.ts +284 -0
  142. package/src/codex/catalog-write-serialization.ts +71 -7
  143. package/src/codex/codex-home-owner.ts +124 -0
  144. package/src/codex/convergence.ts +55 -15
  145. package/src/codex/features.ts +8 -1
  146. package/src/codex/inject/restore.ts +46 -4
  147. package/src/codex/inject.ts +11 -1
  148. package/src/codex/internal/catalog-writer.ts +120 -13
  149. package/src/codex/journal.ts +49 -13
  150. package/src/codex/management-convergence.ts +3 -0
  151. package/src/codex/quota-types.ts +3 -0
  152. package/src/codex/quota.ts +13 -3
  153. package/src/codex/refresh.ts +20 -9
  154. package/src/codex/runtime.ts +7 -1
  155. package/src/codex/shim-diagnostics.ts +80 -0
  156. package/src/codex/shim-fingerprint.ts +7 -2
  157. package/src/codex/shim-migration.ts +153 -0
  158. package/src/codex/shim-overlay.ts +509 -0
  159. package/src/codex/shim-path-resolution.ts +14 -2
  160. package/src/codex/shim-probe.ts +4 -3
  161. package/src/codex/shim-state-file.ts +93 -7
  162. package/src/codex/shim.ts +15 -68
  163. package/src/codex/sync.ts +24 -4
  164. package/src/combos/failover.ts +19 -0
  165. package/src/combos/index.ts +1 -0
  166. package/src/combos/resolve.ts +12 -0
  167. package/src/config/atomic-write.ts +13 -8
  168. package/src/config/diagnostics.ts +19 -1
  169. package/src/config/load-degrade.ts +3 -0
  170. package/src/config/schema/chatgpt-desktop.ts +22 -0
  171. package/src/config/schema/config-schema.ts +32 -14
  172. package/src/config/schema/leaf-validators.ts +3 -2
  173. package/src/generated/compatibility-version.json +445 -129
  174. package/src/integrations/merge.ts +13 -5
  175. package/src/integrations/mutation-plan.ts +46 -3
  176. package/src/integrations/omp-yaml-source.ts +181 -1
  177. package/src/integrations/ownership-policy.ts +66 -0
  178. package/src/integrations/registry.ts +50 -1
  179. package/src/integrations/state.ts +46 -21
  180. package/src/integrations/target.ts +25 -2
  181. package/src/integrations/writer.ts +89 -31
  182. package/src/lib/package-tree-integrity.ts +42 -1
  183. package/src/lib/proxy-env.ts +3 -0
  184. package/src/lib/test-home-guard.ts +2 -1
  185. package/src/oauth/anthropic-account-refusal.ts +19 -8
  186. package/src/oauth/anthropic-routing.ts +17 -12
  187. package/src/oauth/anthropic.ts +27 -2
  188. package/src/oauth/index.ts +6 -8
  189. package/src/oauth/pool-settings-capability.ts +9 -2
  190. package/src/protocols/plan-snapshot.ts +1 -1
  191. package/src/protocols/settings.ts +28 -4
  192. package/src/server/caller-session-identity.ts +32 -0
  193. package/src/server/claude-messages.ts +11 -4
  194. package/src/server/index/serve-options.ts +12 -7
  195. package/src/server/index.ts +1 -1
  196. package/src/server/management/agent-settings-routes.ts +6 -5
  197. package/src/server/management/context.ts +8 -1
  198. package/src/server/management/cursor-integration-routes.ts +3 -3
  199. package/src/server/management/integration-routes.ts +5 -5
  200. package/src/server/management/model-routes.ts +3 -3
  201. package/src/server/management/native-integration-routes.ts +2 -2
  202. package/src/server/management/oauth-account-routes.ts +96 -30
  203. package/src/server/management/route-registry.ts +25 -24
  204. package/src/server/messages-native-eligibility.ts +3 -6
  205. package/src/server/messages-native-oauth.ts +82 -35
  206. package/src/server/messages-native.ts +78 -16
  207. package/src/server/responses/adapter-dispatch.ts +53 -28
  208. package/src/server/responses/core-auth.ts +10 -4
  209. package/src/server/responses/core-combo.ts +46 -6
  210. package/src/service/cli.ts +6 -1
  211. package/src/service/ownership-mutation-lease.d.mts +26 -0
  212. package/src/service/ownership-mutation-lease.mjs +76 -1
  213. package/src/types/config.ts +2 -0
  214. package/src/types/provider.ts +10 -10
  215. package/src/update/index.ts +2 -1
  216. package/src/update/update-failure-guidance.d.mts +6 -0
  217. package/src/update/update-failure-guidance.mjs +30 -5
  218. package/gui/dist/assets/App-COdEeqSj.css +0 -1
  219. package/gui/dist/assets/App-DEpCJEP_.js +0 -54
  220. package/gui/dist/assets/index-Cd7ittMv.css +0 -1
  221. package/gui/dist/assets/index-DwJKFJm5.js +0 -94
  222. package/gui/dist/assets/quota-summary-DGcEJU3h.js +0 -1
@@ -36,6 +36,12 @@ export interface IntegrationTarget {
36
36
  readonly format: ConfigFormat;
37
37
  /** The contribution shape that file's reader understands. */
38
38
  readonly buildContribution: BuildContribution;
39
+ /**
40
+ * The one YAML leaf patched in place in that file, or null when the writer
41
+ * re-renders the document. A client's config file and its store have
42
+ * different shapes, so this belongs to the target rather than the client.
43
+ */
44
+ readonly sourcePreservingYaml: { readonly path: readonly string[] } | null;
39
45
  /**
40
46
  * Set when a write to the file above would not reach the client.
41
47
  *
@@ -60,10 +66,20 @@ export interface IneffectiveWrite {
60
66
  * with the store itself as the target: a client that bumps its schema after
61
67
  * we wrote the store leaves our block there, removable, and the file no
62
68
  * longer one we may merge into.
69
+ *
70
+ * `missing-store` — the store does not exist, but the client already manages it
71
+ * (DSH: a Desktop profile manifest is present). DSH renames `settings.yaml` to
72
+ * `settings.yaml.imported` and imports it on startup, so a config-file write would
73
+ * orphan our ownership record. `remedy` says how to bring the store back, and
74
+ * `emptyDocument` is what to create it with.
63
75
  */
64
- readonly why: "owned-config-file" | "unestablished-schema";
76
+ readonly why: IneffectiveWriteReason;
77
+ readonly remedy?: string;
78
+ readonly emptyDocument?: string;
65
79
  }
66
80
 
81
+ export type IneffectiveWriteReason = "owned-config-file" | "unestablished-schema" | "missing-store";
82
+
67
83
  function configFileTarget(
68
84
  clientId: IntegrationClientId,
69
85
  configPath: string,
@@ -74,6 +90,7 @@ function configFileTarget(
74
90
  configPath,
75
91
  format: exportSpec.format,
76
92
  buildContribution: exportSpec.buildContribution,
93
+ sourcePreservingYaml: INTEGRATION_CLIENTS[clientId].sourcePreservingYaml ?? null,
77
94
  ineffective,
78
95
  };
79
96
  }
@@ -87,6 +104,7 @@ function storeTarget(
87
104
  configPath,
88
105
  format: declared.format,
89
106
  buildContribution: declared.buildContribution,
107
+ sourcePreservingYaml: declared.sourcePreservingYaml ?? null,
90
108
  ineffective,
91
109
  };
92
110
  }
@@ -141,7 +159,12 @@ export function resolveIntegrationTarget(args: {
141
159
  if (!declared) return configFileTarget(clientId, configPath, null);
142
160
  const storePath = declared.path(args.env, args.home);
143
161
  const kind = io.statKind(storePath);
144
- if (kind === "missing") return configFileTarget(clientId, configPath, null);
162
+ if (kind === "missing") {
163
+ const missing = declared.missingStore;
164
+ return missing?.readsStore(storePath, path => io.statKind(path)) === true
165
+ ? configFileTarget(clientId, configPath, { store: storePath, why: "missing-store", remedy: missing.remedy, emptyDocument: missing.emptyDocument })
166
+ : configFileTarget(clientId, configPath, null);
167
+ }
145
168
  // Only proven absence permits a legacy write. Unreadable or non-file stores
146
169
  // cannot establish what the client reads; preserve the recorded removal target.
147
170
  if (kind !== "file") {
@@ -33,7 +33,7 @@ import {
33
33
  refreshablePathsOf,
34
34
  semanticProtectedContributionFingerprint,
35
35
  } from "./ownership-policy";
36
- import { AmbiguousSelectorError, createdContainerPaths, mergeContribution, removeFragments } from "./merge";
36
+ import { AmbiguousSelectorError, createdContainerPaths, mergeContribution, parseSegment, removeFragments } from "./merge";
37
37
  import {
38
38
  INTEGRATION_CLIENTS,
39
39
  assertDroidPathsUnambiguous,
@@ -48,7 +48,7 @@ import type { IntegrationState } from "./state";
48
48
  import { serializeDocument, UnserializableValueError } from "./serialize";
49
49
  import { ClientPathError } from "../clients/config-export";
50
50
  import { matchesOperationResult, newOpId, type JournalEntry } from "./journal";
51
- import { observeIntegration, type IntegrationWriteInput, type RefusalReason } from "./mutation-plan";
51
+ import { observeIntegration, restoreStoreDirectoryRefusal, type IntegrationWriteInput, type RefusalReason } from "./mutation-plan";
52
52
  import { createIntegrationStateStore, type IntegrationStateStore } from "./store";
53
53
  import {
54
54
  patchYamlFragmentSource,
@@ -300,6 +300,8 @@ function applyOrRefreshIntegration(
300
300
  return refuse(clientId, "superseded_store", classified.state,
301
301
  target.ineffective.why === "owned-config-file"
302
302
  ? `${clientId} now reads its providers from ${readsFrom}, and opencodex still has a block in ${configPath}, which it no longer reads. Disable the ${clientId} integration to remove that block, then enable it again to write ${readsFrom}.`
303
+ : target.ineffective.why === "missing-store"
304
+ ? `${clientId} reads its providers from ${readsFrom}, which does not exist. ${clientId} renames ${configPath} away and imports it on its next start, so opencodex will not write there. ${target.ineffective.remedy ?? fallback}`
303
305
  : readsFrom === configPath
304
306
  ? `opencodex does not recognise the schema of ${readsFrom}, the file ${clientId} reads its providers from, so it will not merge into it. ${fallback}`
305
307
  : `${clientId} now reads its providers from ${readsFrom}, whose schema opencodex does not recognise, so writing ${configPath} would change nothing it loads. ${fallback}`);
@@ -383,8 +385,15 @@ function applyOrRefreshIntegration(
383
385
  let created: string[];
384
386
  let text: string;
385
387
  try {
388
+ // The source patcher updates this selected row in place. Keep it in the
389
+ // semantic merge too, even when we originally created it, and retain that
390
+ // provenance so disable can still remove the row when it becomes empty.
391
+ const sourceRoot = target.sourcePreservingYaml?.path[0];
392
+ const retainedRow = sourceRoot && parseSegment(sourceRoot).kind === "select" ? sourceRoot : null;
393
+ const priorCreated = record?.createdContainers ?? [];
394
+ const prunable = new Set(priorCreated.filter(path => path !== retainedRow));
386
395
  const base = classified.state === "stale" && record
387
- ? removeFragments(parsed, record.fragmentPaths, new Set(record.createdContainers ?? [])).doc
396
+ ? removeFragments(parsed, record.fragmentPaths, prunable).doc
388
397
  : classified.state === "conflict" && record
389
398
  /*
390
399
  * A forced overwrite of a `foreign-edit` conflict drops what the previous
@@ -399,26 +408,27 @@ function applyOrRefreshIntegration(
399
408
  * them, so a later disable removes our leaves and leaves their structure
400
409
  * standing.
401
410
  */
402
- ? removeFragments(parsed, record.fragmentPaths, new Set(record.createdContainers ?? [])).doc
411
+ ? removeFragments(parsed, record.fragmentPaths, prunable).doc
403
412
  : parsed;
404
413
  // Computed against the document as it stands BEFORE the merge: afterwards
405
414
  // every container exists and "did we create this?" is unanswerable.
406
415
  created = createdContainerPaths(base, contribution);
416
+ if (retainedRow && priorCreated.includes(retainedRow)) created = [...new Set([retainedRow, ...created])];
407
417
  const nextDocument = mergeContribution(base, contribution);
408
418
  if (clientId === "cline") preserveClineSelection(parsed, nextDocument);
409
- if (spec.sourcePreservingYaml && before !== null) {
410
- const value = sourcePreservingFragmentValue(contribution, spec.sourcePreservingYaml.path);
419
+ if (target.sourcePreservingYaml && before !== null) {
420
+ const value = sourcePreservingFragmentValue(contribution, target.sourcePreservingYaml.path);
411
421
  const patched = value === undefined
412
422
  ? null
413
423
  : patchYamlFragmentSource(
414
424
  before,
415
- spec.sourcePreservingYaml.path,
425
+ target.sourcePreservingYaml.path,
416
426
  { kind: "upsert", value },
417
427
  nextDocument,
418
428
  );
419
429
  if (patched === null) {
420
430
  return refuse(clientId, "unsafe", "unsafe",
421
- yamlRefusalReason(before, spec.sourcePreservingYaml.path, configPath, "it was left alone"));
431
+ yamlRefusalReason(before, target.sourcePreservingYaml.path, configPath, "it was left alone"));
422
432
  }
423
433
  text = patched;
424
434
  } else {
@@ -565,12 +575,12 @@ export function disableIntegration(input: IntegrationWriteInput): WriteOutcome {
565
575
  * comment makes its ancestor user-owned without protecting our leaf.
566
576
  */
567
577
  const recordedCreated = record!.createdContainers ?? [];
568
- const prunableCreated = spec.sourcePreservingYaml && before !== null
569
- ? sourcePrunableYamlContainers(before, spec.sourcePreservingYaml.path, recordedCreated)
578
+ const prunableCreated = target.sourcePreservingYaml && before !== null
579
+ ? sourcePrunableYamlContainers(before, target.sourcePreservingYaml.path, recordedCreated)
570
580
  : recordedCreated;
571
581
  if (prunableCreated === null) {
572
582
  return refuse(clientId, "unsafe", "unsafe",
573
- yamlRefusalReason(before ?? "", spec.sourcePreservingYaml!.path, configPath, "nothing was removed"));
583
+ yamlRefusalReason(before ?? "", target.sourcePreservingYaml!.path, configPath, "nothing was removed"));
574
584
  }
575
585
  let doc: unknown;
576
586
  let removed: boolean;
@@ -586,14 +596,14 @@ export function disableIntegration(input: IntegrationWriteInput): WriteOutcome {
586
596
  }
587
597
  let text: string;
588
598
  try {
589
- if (spec.sourcePreservingYaml && before !== null) {
590
- const patched = patchYamlFragmentSource(before, spec.sourcePreservingYaml.path, {
599
+ if (target.sourcePreservingYaml && before !== null) {
600
+ const patched = patchYamlFragmentSource(before, target.sourcePreservingYaml.path, {
591
601
  kind: "remove",
592
602
  createdContainers: prunableCreated,
593
603
  }, doc);
594
604
  if (patched === null) {
595
605
  return refuse(clientId, "unsafe", "unsafe",
596
- yamlRefusalReason(before, spec.sourcePreservingYaml.path, configPath, "nothing was removed"));
606
+ yamlRefusalReason(before, target.sourcePreservingYaml.path, configPath, "nothing was removed"));
597
607
  }
598
608
  text = patched;
599
609
  } else {
@@ -651,6 +661,10 @@ export function restoreIntegration(input: IntegrationRestoreInput): WriteOutcome
651
661
  return refuse(clientId, "conflict", "conflict",
652
662
  `that operation was recorded for ${configPath}, which this client no longer writes; it now resolves to ${resolvedPath}`);
653
663
  }
664
+ const directoryRefusal = restoreStoreDirectoryRefusal(input, configPath, io);
665
+ if (directoryRefusal !== null) {
666
+ return refuse(clientId, "unsafe", "unsafe", directoryRefusal);
667
+ }
654
668
  /*
655
669
  * Preview refuses this in observeRestore. Refusing here too is what keeps an
656
670
  * undo of an older candidate from replacing the record a newer candidate owns.
@@ -862,6 +876,57 @@ function tryFreezeIntegrationInput(input: IntegrationWriteInput):
862
876
  }
863
877
  }
864
878
 
879
+ /**
880
+ * The store lock to hold beside the config file's, or null.
881
+ *
882
+ * Only for a store whose directory exists: a lock is never what creates a
883
+ * client's profile. A store path that cannot be resolved names nothing to lock;
884
+ * target resolution reports it on its own.
885
+ */
886
+ function storeLockFile(frozen: FrozenIntegrationInput): string | null {
887
+ const declared = INTEGRATION_CLIENTS[frozen.clientId].currentStore;
888
+ if (!declared?.lockFile) return null;
889
+ let storePath: string;
890
+ try {
891
+ storePath = declared.path(frozen.env, frozen.home);
892
+ } catch (error) {
893
+ if (error instanceof ClientPathError) return null;
894
+ throw error;
895
+ }
896
+ return frozen.io.statKind(dirname(storePath)) === "dir" ? declared.lockFile(storePath) : null;
897
+ }
898
+
899
+ /**
900
+ * Hold the config file's sibling lock, then the store's. Every operation takes
901
+ * them in that order, so two of ours cannot deadlock, and the client's own
902
+ * writer only ever holds one of them.
903
+ */
904
+ function withClientLocks(
905
+ frozen: FrozenIntegrationInput,
906
+ suffix: ".lock",
907
+ operation: () => WriteOutcome,
908
+ options?: CoordinatedIntegrationOptions,
909
+ ): Promise<WriteOutcome> {
910
+ const run = async (heldStoreLock: string | null): Promise<WriteOutcome> => {
911
+ const refused = await options?.revalidate?.(frozen);
912
+ if (refused) return refused;
913
+ // A profile can appear while revalidation awaits. Acquire its lock and
914
+ // repeat the validation before observing or mutating its document.
915
+ const required = storeLockFile(frozen);
916
+ if (required !== null && required !== heldStoreLock) {
917
+ return withIntegrationWriterLock(required, () => run(required), options?.lockSeams, ".lock");
918
+ }
919
+ // No await between the last store probe and the synchronous transaction.
920
+ return operation();
921
+ };
922
+ return withIntegrationWriterLock(frozen.resolvedPaths.configPath, () => {
923
+ // Decide only after the outer lock has actually been acquired.
924
+ const storeLock = storeLockFile(frozen);
925
+ return storeLock === null ? run(null)
926
+ : withIntegrationWriterLock(storeLock, () => run(storeLock), options?.lockSeams, ".lock");
927
+ }, options?.lockSeams, suffix);
928
+ }
929
+
865
930
  async function coordinatedWrite(
866
931
  input: IntegrationWriteInput,
867
932
  operation: (frozen: IntegrationWriteInput) => WriteOutcome,
@@ -879,16 +944,14 @@ async function coordinatedWrite(
879
944
  // An absent client home is not created merely to acquire a sibling lock.
880
945
  if (frozen.io.statKind(frozen.resolvedPaths.detectDir) !== "dir") {
881
946
  const refused = await options?.revalidate?.(frozen);
882
- return refused ?? operation(frozen);
947
+ if (refused) return refused;
948
+ if (frozen.io.statKind(frozen.resolvedPaths.detectDir) !== "dir") return operation(frozen);
883
949
  }
884
- return withIntegrationWriterLock(
885
- frozen.resolvedPaths.configPath,
886
- async () => {
887
- const refused = await options?.revalidate?.(frozen);
888
- return refused ?? operation(frozen);
889
- },
890
- options?.lockSeams,
950
+ return withClientLocks(
951
+ frozen,
891
952
  spec.writerLock.suffix,
953
+ () => operation(frozen),
954
+ options,
892
955
  );
893
956
  }
894
957
 
@@ -941,15 +1004,10 @@ export async function restoreIntegrationCoordinated(
941
1004
  `${frozen.resolvedPaths.detectDir} is missing; restore will not create the client home`,
942
1005
  );
943
1006
  }
944
- return withIntegrationWriterLock(
945
- frozen.resolvedPaths.configPath,
946
- async () => {
947
- // An undo is bound like any other confirmation, and this is the only place where that check
948
- // happens with the lock held and before the snapshot, the write and the journal row.
949
- const refused = await options?.revalidate?.(frozen);
950
- return refused ?? run();
951
- },
952
- options?.lockSeams,
1007
+ return withClientLocks(
1008
+ frozen,
953
1009
  spec.writerLock.suffix,
1010
+ run,
1011
+ options,
954
1012
  );
955
1013
  }
@@ -1,6 +1,7 @@
1
1
  import { readFileSync, statSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
+ import { isRealBunBinary } from "./bun-binary-validator.mjs";
4
5
  import { isStandaloneBinary } from "./standalone";
5
6
 
6
7
  export interface PackageTreeObservation {
@@ -56,6 +57,14 @@ export interface PackageTreeIntegrityOptions {
56
57
  schedule?: (callback: () => void, delayMs: number) => (() => void) | void;
57
58
  /** Test seam for `installedVersion()`; production reads the package manifest. */
58
59
  readInstalledVersion?: () => string | undefined;
60
+ /**
61
+ * Whether the runtime a restart would spawn is complete. An in-place npm install writes
62
+ * package.json and extracts the `bun` package's small placeholder before its postinstall
63
+ * swaps the real binary in, and the replacement spawns `process.execPath`, that same path.
64
+ * While this answers false the replacement is treated like an unreadable tree: no restart,
65
+ * then a fresh full debounce once the runtime is ready.
66
+ */
67
+ runtimeReady?: () => boolean;
59
68
  }
60
69
 
61
70
  export type ObservePackageTree = () => PackageTreeObservation | null;
@@ -142,6 +151,12 @@ export function sameObservation(left: PackageTreeObservation, right: PackageTree
142
151
  */
143
152
  const PACKAGE_TREE_RECHECK_MS = 1_000;
144
153
 
154
+ /**
155
+ * Fences a live process whose package manifest was replaced, and once the replacement is readable,
156
+ * stable for the debounce interval and any configured `runtimeReady` check passes, hands it to
157
+ * `onReplaced`. If the callback throws, the guard rechecks the tree and may call it again after
158
+ * another debounce interval while the replacement persists.
159
+ */
145
160
  export function createPackageTreeIntegrityGuard(
146
161
  observe: ObservePackageTree = observePackageManifest,
147
162
  now: () => number = Date.now,
@@ -154,6 +169,8 @@ export function createPackageTreeIntegrityGuard(
154
169
  let timerScheduled = false;
155
170
  let cancelScheduled: (() => void) | null = null;
156
171
  let waitingForReadableTree = false;
172
+ /** Logged once per wait, so a postinstall that never runs is visible rather than silent. */
173
+ let waitingForRuntime = false;
157
174
  let replacementCandidate: PackageTreeObservation | null = null;
158
175
  /** The replacement identity that survived a full stability interval (see installedVersion). */
159
176
  let settledReplacement: PackageTreeObservation | null = null;
@@ -192,9 +209,24 @@ export function createPackageTreeIntegrityGuard(
192
209
  if (sameObservation(boot, current)) {
193
210
  resetRestartTimer();
194
211
  waitingForReadableTree = false;
212
+ waitingForRuntime = false;
195
213
  replacementCandidate = null;
196
214
  return;
197
215
  }
216
+ if (options.runtimeReady && !options.runtimeReady()) {
217
+ if (!waitingForRuntime) {
218
+ waitingForRuntime = true;
219
+ console.warn("Package tree replaced; waiting for its Bun runtime to finish installing before restarting");
220
+ }
221
+ // A replacement settled before a failed admission is no longer restart-ready: withdraw
222
+ // it so installedVersion() stays silent until the fresh debounce settles it again.
223
+ settledReplacement = null;
224
+ resetRestartTimer();
225
+ waitingForReadableTree = true;
226
+ armRestartTimer(PACKAGE_TREE_RECHECK_MS);
227
+ return;
228
+ }
229
+ waitingForRuntime = false;
198
230
  if (waitingForReadableTree) {
199
231
  waitingForReadableTree = false;
200
232
  replacementCandidate = current;
@@ -239,6 +271,7 @@ export function createPackageTreeIntegrityGuard(
239
271
  return {
240
272
  installedVersion: () => {
241
273
  if (settledReplacement === null) return undefined;
274
+ if (options.runtimeReady && !options.runtimeReady()) return undefined;
242
275
  const current = observe();
243
276
  if (current === null || !sameObservation(settledReplacement, current)) return undefined;
244
277
  return readInstalledVersion();
@@ -271,6 +304,7 @@ export function createPackageTreeIntegrityGuard(
271
304
  lastOkAt = at;
272
305
  resetRestartTimer();
273
306
  waitingForReadableTree = false;
307
+ waitingForRuntime = false;
274
308
  replacementCandidate = null;
275
309
  return { ok: true };
276
310
  },
@@ -292,5 +326,12 @@ export function createRuntimePackageTreeIntegrityGuard(
292
326
  if (installer === "source" || isStandaloneBinary()) {
293
327
  return { status: () => ({ ok: true }), dispose: () => {} };
294
328
  }
295
- return createPackageTreeIntegrityGuard(observe, now, options);
329
+ // The restart replacement spawns process.execPath, the bundled Bun in an npm install. Read it
330
+ // now: Linux Bun resolves it lazily, and after npm swaps the package directory a first read
331
+ // reports the retired binary as "(deleted)"; reading it once pins the boot path.
332
+ const runtimePath = process.execPath;
333
+ return createPackageTreeIntegrityGuard(observe, now, {
334
+ ...options,
335
+ runtimeReady: options.runtimeReady ?? (() => isRealBunBinary(runtimePath)),
336
+ });
296
337
  }
@@ -96,6 +96,9 @@ export function outboundProxyConfigured(
96
96
  return OUTBOUND_PROXY_ENV_KEYS.some(key => proxyEnvPresent(key, env));
97
97
  }
98
98
 
99
+ // Captured before config/proxy-env applies runtime changes; Bun retains startup proxy env.
100
+ export const startupOutboundProxyConfigured = outboundProxyConfigured();
101
+
99
102
  /**
100
103
  * The value when `raw` is a proxy URL Bun fetch can actually use, else null.
101
104
  * Bun rejects unparseable values and non-http(s) schemes (UnsupportedProxyProtocol),
@@ -150,7 +150,8 @@ export function assertNotRealCodexHomeUnderTest(dir: string): void {
150
150
  if (canonicalize(dir) !== PROTECTED_CODEX_HOME) return;
151
151
  throw new Error(
152
152
  `refusing to write the real Codex home (${PROTECTED_CODEX_HOME}) from a test process. `
153
- + "Point CODEX_HOME at a temp directory for this test before writing native auth.json.",
153
+ + "Point CODEX_HOME at a temp directory for this test before writing Codex files "
154
+ + "(auth.json, the catalog, models_cache.json, the journal or config.toml).",
154
155
  );
155
156
  }
156
157
 
@@ -9,13 +9,13 @@ import type { OcxConfig } from "../types";
9
9
  import type { AnthropicRouteDecision } from "./anthropic-model-routes";
10
10
  import { recordAnthropicAccountRefusal, rotateAnthropicAccountOnRefusal, hasAnthropicFailoverQuorum, isAnthropicAccountPoolEnabled, pickAlternateAnthropicAccount } from "./anthropic-routing";
11
11
 
12
- const responseCredentials = new WeakMap<Response, Pick<OAuthAccessSnapshot, "accountId" | "generation">>();
12
+ const responseCredentials = new WeakMap<Response, Pick<OAuthAccessSnapshot, "accountId" | "generation"> & { providerAccountUuid?: string; checkProviderUuid: boolean }>();
13
13
  const retryStates = new WeakMap<object, { firstAccountId: string; sameAccount: boolean; detour: boolean }>();
14
14
  const verdicts = new WeakMap<Response, Promise<boolean>>();
15
15
 
16
16
  /** Called only when the outgoing headers prove ownership of the selected stored bearer. */
17
- export function bindAnthropicRefusalCredential(response: Response, snapshot: OAuthAccessSnapshot): void {
18
- responseCredentials.set(response, { accountId: snapshot.accountId, generation: snapshot.generation });
17
+ export function bindAnthropicRefusalCredential(response: Response, snapshot: OAuthAccessSnapshot, providerAccountUuid?: string): void {
18
+ responseCredentials.set(response, { accountId: snapshot.accountId, generation: snapshot.generation, providerAccountUuid, checkProviderUuid: arguments.length >= 3 });
19
19
  }
20
20
 
21
21
  async function isAccountRefusal(response: Response, signal?: AbortSignal): Promise<boolean> {
@@ -55,6 +55,10 @@ export async function rotateAnthropicAccountOnResponse(
55
55
  requestKey?: object;
56
56
  allow429Recovery?: boolean;
57
57
  allowAccountRefusal?: boolean;
58
+ /** Applies to alternate ranking only; the shared same-account throttle remains allowed. */
59
+ excludedAccountIds?: ReadonlySet<string>;
60
+ /** Native dispatch re-reads operator policy after asynchronous classification/waits. */
61
+ currentDecision?: () => AnthropicRouteDecision | null;
58
62
  },
59
63
  ): Promise<string | null> {
60
64
  if (options.signal?.aborted || isNonReplayableResponse(response)) return null;
@@ -62,11 +66,13 @@ export async function rotateAnthropicAccountOnResponse(
62
66
  const sent = responseCredentials.get(response);
63
67
  const current = sent && getAccountCredentialWithStatus("anthropic", sent.accountId);
64
68
  if (!sent || sent.accountId !== options.accountId || !current || current.needsReauth
65
- || credentialGeneration(current.credential) !== sent.generation) return null;
69
+ || credentialGeneration(current.credential) !== sent.generation
70
+ || sent.checkProviderUuid && current.credential.accountId !== sent.providerAccountUuid) return null;
71
+ options.currentDecision?.();
66
72
  const kind = classifyAnthropic429(response.headers);
67
73
  if (kind === "family-quota") {
68
74
  if (anthropicModelFamily(options.model) !== "Fable" || !options.canRetry || options.allow429Recovery === false) return null;
69
- return pickAlternateAnthropicAccount(options.config, sent.accountId, Date.now(), options.decision ?? null, options.model);
75
+ return pickAlternateAnthropicAccount(options.config, sent.accountId, Date.now(), (options.currentDecision ? options.currentDecision() : options.decision ?? null), options.model, options.excludedAccountIds);
70
76
  }
71
77
  if (kind !== "shared-quota") {
72
78
  if (!isAnthropicAccountPoolEnabled(options.config) && !hasAnthropicFailoverQuorum() && !current.paused) return null;
@@ -87,16 +93,19 @@ export async function rotateAnthropicAccountOnResponse(
87
93
  const live = getAccountCredentialWithStatus("anthropic", sent.accountId);
88
94
  if (!live || live.paused || live.needsReauth || options.signal?.aborted
89
95
  || credentialGeneration(live.credential) !== sent.generation
96
+ || sent.checkProviderUuid && live.credential.accountId !== sent.providerAccountUuid
90
97
  || anthropicRatePauseUntil(sent.accountId, Math.max(Date.now(), retryAt))) return null;
98
+ options.currentDecision?.();
91
99
  return sent.accountId;
92
100
  }
93
101
  if (kind === "transient-rate" && !state.detour) {
94
102
  state.detour = true;
95
- return pickAlternateAnthropicAccount(options.config, sent.accountId, Date.now(), options.decision ?? null, options.model);
103
+ return pickAlternateAnthropicAccount(options.config, sent.accountId, Date.now(), (options.currentDecision ? options.currentDecision() : options.decision ?? null), options.model, options.excludedAccountIds);
96
104
  }
97
105
  return null;
98
106
  }
99
107
  if (options.allow429Recovery === false) {
108
+ options.currentDecision?.();
100
109
  recordAnthropicAccountRefusal(options.config, options.accountId, 429, response.headers.get("retry-after"), Date.now(), response.headers);
101
110
  return null;
102
111
  }
@@ -112,8 +121,10 @@ export async function rotateAnthropicAccountOnResponse(
112
121
  if (!await verdict || options.signal?.aborted) return null;
113
122
  // A late refusal must not cool a new credential stored while its body was being read.
114
123
  const current = getAccountCredentialWithStatus("anthropic", sent.accountId);
115
- if (!current || current.paused || current.needsReauth || credentialGeneration(current.credential) !== sent.generation) return null;
124
+ if (!current || current.paused || current.needsReauth || credentialGeneration(current.credential) !== sent.generation
125
+ || sent.checkProviderUuid && current.credential.accountId !== sent.providerAccountUuid) return null;
116
126
  }
127
+ const decision = options.currentDecision ? options.currentDecision() : options.decision;
117
128
  const status = response.status === 429 ? 429 : 403;
118
129
  if (!options.canRetry) {
119
130
  recordAnthropicAccountRefusal(options.config, options.accountId, status,
@@ -121,5 +132,5 @@ export async function rotateAnthropicAccountOnResponse(
121
132
  return null;
122
133
  }
123
134
  return rotateAnthropicAccountOnRefusal(options.config, options.accountId, status,
124
- response.headers.get("retry-after"), options.sessionKey, Date.now(), response.headers, options.decision, options.model);
135
+ response.headers.get("retry-after"), options.sessionKey, Date.now(), response.headers, decision, options.model, options.excludedAccountIds);
125
136
  }
@@ -111,6 +111,9 @@ type OAuthAccountSelection = NonNullable<ReturnType<typeof captureOAuthAccountSe
111
111
  // Undefined means this runtime has not admitted a selection yet; null means consumed.
112
112
  // The startup baseline comes from the authoritative store, never a second persisted pin.
113
113
  let manualPreference: OAuthAccountSelection | null | undefined;
114
+ let manualSelectionGeneration = 0;
115
+ /** Automatic pointer moves preserve affinity; manual selection revokes older bindings. */
116
+ export function captureAnthropicManualSelectionGeneration(): number { return manualSelectionGeneration; }
114
117
 
115
118
  function normalizeAffinityComponent(value: string | null | undefined): string {
116
119
  const normalized = value?.trim() ?? "";
@@ -285,6 +288,7 @@ export function clearAnthropicAccountPoolState(): void {
285
288
  clearAnthropicCooldownGenerations();
286
289
  sessionAffinity.clear();
287
290
  manualPreference = undefined;
291
+ manualSelectionGeneration++;
288
292
  quorumCache = null;
289
293
  }
290
294
 
@@ -508,9 +512,9 @@ function compareScoredAccounts(a: ScoredAccount, b: ScoredAccount): number {
508
512
  return a.score - b.score || a.fiveHourTieBreak - b.fiveHourTieBreak;
509
513
  }
510
514
 
511
- function pickLowestUsage(config: OcxConfig, excludeId: string | undefined, now: number, decision: AnthropicRouteDecision | null = null, model?: string): string | null {
515
+ function pickLowestUsage(config: OcxConfig, excludeId: string | undefined, now: number, decision: AnthropicRouteDecision | null = null, model?: string, excludedAccountIds?: ReadonlySet<string>): string | null {
512
516
  const window = anthropicQuotaWindow(anthropicAccountPoolConfig(config));
513
- const unfiltered = routeCandidates(getEligibleAnthropicAccounts(now, model), decision).filter(id => id !== excludeId);
517
+ const unfiltered = routeCandidates(getEligibleAnthropicAccounts(now, model), decision).filter(id => id !== excludeId && !excludedAccountIds?.has(id));
514
518
  const available = window === "weekly" ? unfiltered.filter(id => !exhausted5h(id)
515
519
  || isAnthropicAccountPoolEnabled(config) && anthropicAccountAutoSwitchThreshold(config, id) === 0) : unfiltered;
516
520
  const modelAvailable = model ? available.filter(id => !anthropicModelExhausted(id, model)) : available;
@@ -589,16 +593,17 @@ export function pickAlternateAnthropicAccount(
589
593
  now: number,
590
594
  decision: AnthropicRouteDecision | null,
591
595
  model?: string,
596
+ excludedAccountIds?: ReadonlySet<string>,
592
597
  ): string | null {
593
598
  const strategy = anthropicPoolStrategy(config);
594
- const eligible = routeCandidates(getEligibleAnthropicAccounts(now, model), decision).filter(id => id !== excludeId);
599
+ const eligible = routeCandidates(getEligibleAnthropicAccounts(now, model), decision).filter(id => id !== excludeId && !excludedAccountIds?.has(id));
595
600
  if (strategy === "round-robin") {
596
601
  return peekRoundRobinAccount(POOL_KEY_ANTHROPIC, eligible, stickyLimitForPool(config));
597
602
  }
598
603
  if (strategy === "fill-first") {
599
604
  return pickNextFillFirstAnthropicAccount(config, excludeId, eligible, decision, model);
600
605
  }
601
- return pickLowestUsage(config, excludeId, now, decision, model);
606
+ return pickLowestUsage(config, excludeId, now, decision, model, excludedAccountIds);
602
607
  }
603
608
 
604
609
  function pruneExpiredAffinity(now: number): void {
@@ -918,6 +923,7 @@ export function rotateAnthropicAccountOnRefusal(
918
923
  rateLimitHeaders?: AnthropicRateLimitHeaders | null,
919
924
  decision: AnthropicRouteDecision | null = null,
920
925
  model?: string,
926
+ excludedAccountIds?: ReadonlySet<string>,
921
927
  ): string | null {
922
928
  if (!recordAnthropicAccountRefusal(config, failedAccountId, status, retryAfterHeader, now, rateLimitHeaders)) return null;
923
929
 
@@ -926,8 +932,8 @@ export function rotateAnthropicAccountOnRefusal(
926
932
  // because those dormant values remain in config. The quota picker is the neutral
927
933
  // recovery policy already used by the default strategy.
928
934
  const next = isAnthropicAccountPoolEnabled(config)
929
- ? pickAlternateAnthropicAccount(config, failedAccountId, now, decision, model)
930
- : pickLowestUsage(config, failedAccountId, now, null, model);
935
+ ? pickAlternateAnthropicAccount(config, failedAccountId, now, decision, model, excludedAccountIds)
936
+ : pickLowestUsage(config, failedAccountId, now, null, model, excludedAccountIds);
931
937
  if (!next) {
932
938
  console.warn(`[anthropic-pool] ${decision ? `route:#${decision.position} ` : ""}no eligible replacement; returning ${status}`);
933
939
  return null;
@@ -1070,6 +1076,7 @@ export function commitAnthropicSelectionRouting(
1070
1076
  */
1071
1077
  export function resetAnthropicRoutingForManualSelection(accountId: string): void {
1072
1078
  sessionAffinity.clear();
1079
+ manualSelectionGeneration++;
1073
1080
  manualPreference = captureOAuthAccountSelection(PROVIDER);
1074
1081
  seedPoolRotationAccount(POOL_KEY_ANTHROPIC, accountId);
1075
1082
  // A manual account selection is an operator statement about the roster; do not answer the
@@ -1172,12 +1179,10 @@ export function anthropicSessionKeyFromParts(input: {
1172
1179
  /** When true, prompt_cache_key is a shared Desktop cohort — ignore it for affinity. */
1173
1180
  promptCacheKeyIsSharedCohort?: boolean;
1174
1181
  }): string | null {
1175
- const preferred = (
1176
- input.clientThreadId
1177
- ?? input.sessionIdHeader
1178
- ?? input.threadIdHeader
1179
- ?? ""
1180
- ).trim();
1182
+ const preferred = input.clientThreadId?.trim()
1183
+ || input.sessionIdHeader?.trim()
1184
+ || input.threadIdHeader?.trim()
1185
+ || "";
1181
1186
  if (preferred) {
1182
1187
  return preferred.length <= 128 ? preferred : createHash("sha256").update(preferred).digest("hex");
1183
1188
  }
@@ -3,6 +3,7 @@ import { OAuthCallbackFlow } from "./callback-server";
3
3
  import { bindAnthropicIdentity, resolveAnthropicAccountIdentity } from "./anthropic-identity";
4
4
  import { generatePKCE } from "./pkce";
5
5
  import type { LocalTokenImportMode, OAuthController, OAuthCredentials } from "./types";
6
+ import { outboundProxyConfigured, startupOutboundProxyConfigured } from "../lib/proxy-env";
6
7
 
7
8
  const CLIENT_ID = atob("OWQxYzI1MGEtZTYxYi00NGQ5LTg4ZWQtNTk0NGQxOTYyZjVl");
8
9
  const AUTHORIZE_URL = "https://claude.ai/oauth/authorize";
@@ -40,6 +41,7 @@ export class AnthropicTokenError extends Error {
40
41
  message: string,
41
42
  readonly httpStatus: number | undefined,
42
43
  readonly oauthError: string | undefined,
44
+ readonly requestNotSent = false,
43
45
  ) {
44
46
  super(message);
45
47
  this.name = "AnthropicTokenError";
@@ -47,12 +49,35 @@ export class AnthropicTokenError extends Error {
47
49
  }
48
50
 
49
51
  async function postJson(url: string, body: Record<string, string | number>): Promise<string> {
50
- const response = await fetch(url, {
52
+ const direct = !startupOutboundProxyConfigured && !outboundProxyConfigured();
53
+ const init: RequestInit & { protocol: "http1.1" } = {
51
54
  method: "POST",
52
55
  headers: { "Content-Type": "application/json", Accept: "application/json" },
53
56
  body: JSON.stringify(body),
54
57
  signal: AbortSignal.timeout(30_000),
55
- });
58
+ // Pinned Bun 1.4.0: one HTTP/1.1 attempt, no keep-alive reuse or redirect following.
59
+ // Only without an outbound proxy can token-host DNS failure prove no request was sent.
60
+ redirect: "manual",
61
+ keepalive: false,
62
+ protocol: "http1.1",
63
+ };
64
+ let response: Response;
65
+ try {
66
+ response = await fetch(url, init);
67
+ } catch (error) {
68
+ if (direct && typeof error === "object" && error !== null
69
+ && "code" in error && error.code === "ENOTFOUND"
70
+ && "syscall" in error && error.syscall === "getaddrinfo"
71
+ && "hostname" in error && error.hostname === new URL(url).hostname) {
72
+ throw new AnthropicTokenError("Anthropic OAuth DNS lookup failed before connection", undefined, undefined, true);
73
+ }
74
+ throw error;
75
+ }
76
+ if (response.status >= 300 && response.status < 400) {
77
+ // A redirect can follow a completed POST and token rotation; it is not a rejection.
78
+ await response.body?.cancel().catch(() => {});
79
+ throw new AnthropicTokenError(`Anthropic OAuth redirect HTTP ${response.status}: outcome unknown`, undefined, undefined);
80
+ }
56
81
  const responseBody = await response.text();
57
82
  if (!response.ok) {
58
83
  let oauthError: string | undefined;