@bitkyc08/opencodex 2.63.0-preview.20260923 → 2.64.0-preview.20260923

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 (262) hide show
  1. package/AGENTS_INSTALL.md +4 -3
  2. package/README.md +46 -33
  3. package/assets/download-linux.svg +10 -0
  4. package/assets/download-macos.svg +10 -0
  5. package/assets/download-windows.svg +10 -0
  6. package/bin/ocx.mjs +12 -4
  7. package/gui/dist/assets/App-D5eN1TID.js +50 -0
  8. package/gui/dist/assets/App-I5AnaSLh.css +1 -0
  9. package/gui/dist/assets/{Tray-B03pW-Uf.js → Tray-Bh_ErQDh.js} +1 -1
  10. package/gui/dist/assets/index-BEq4OCOz.js +86 -0
  11. package/gui/dist/assets/index-DiBRuK-d.css +1 -0
  12. package/gui/dist/assets/usage-companion-chart-DBG_37kQ.js +1 -0
  13. package/gui/dist/index.html +2 -2
  14. package/native/remote-workspace-helper/src/protocol.rs +5 -0
  15. package/package.json +4 -1
  16. package/src/adapters/anthropic.ts +66 -7
  17. package/src/adapters/codebuddy/adapter.ts +112 -16
  18. package/src/adapters/codebuddy/mcp-server.ts +180 -0
  19. package/src/adapters/codebuddy/scaffold-guard.ts +25 -8
  20. package/src/adapters/codebuddy/tool-bridge.ts +597 -0
  21. package/src/adapters/coding-agent/protocol.ts +123 -10
  22. package/src/adapters/coding-agent/turn.ts +324 -2
  23. package/src/adapters/command-code-restored-schema.ts +112 -0
  24. package/src/adapters/command-code-tool-text.ts +589 -0
  25. package/src/adapters/command-code.ts +80 -6
  26. package/src/adapters/cursor/catalog.ts +12 -0
  27. package/src/adapters/cursor/current-request.ts +46 -0
  28. package/src/adapters/cursor/discovery.ts +1 -1
  29. package/src/adapters/cursor/effort-map.ts +4 -0
  30. package/src/adapters/cursor/native-exec.ts +15 -0
  31. package/src/adapters/cursor/protobuf-events.ts +40 -12
  32. package/src/adapters/cursor/protobuf-request.ts +83 -18
  33. package/src/adapters/cursor/request-builder.ts +5 -4
  34. package/src/adapters/cursor/tool-guidance.ts +1 -1
  35. package/src/adapters/devin/live-models.ts +7 -0
  36. package/src/adapters/inline-think-tags.ts +251 -0
  37. package/src/adapters/kiro/adapter.ts +1 -0
  38. package/src/adapters/kiro/stream.ts +5 -3
  39. package/src/adapters/kiro/usage.ts +4 -3
  40. package/src/adapters/mimo-free.ts +37 -18
  41. package/src/adapters/openai-chat/messages.ts +5 -5
  42. package/src/adapters/openai-chat/tool-schema.ts +124 -2
  43. package/src/adapters/openai-chat.ts +13 -3
  44. package/src/adapters/openai-responses/passthrough.ts +15 -2
  45. package/src/adapters/openai-responses/reasoning.ts +14 -1
  46. package/src/adapters/openai-responses/request-strips.ts +29 -3
  47. package/src/adapters/openai-responses/tool-output-recovery.ts +9 -3
  48. package/src/adapters/qoder/adapter.ts +15 -12
  49. package/src/adapters/qoder/scaffold-guard.ts +45 -13
  50. package/src/bridge/internal.ts +20 -0
  51. package/src/bridge/sse.ts +14 -5
  52. package/src/claude/agents-inject.ts +2 -1
  53. package/src/claude/desktop-applied-marker.ts +44 -0
  54. package/src/claude/desktop-profile.ts +22 -12
  55. package/src/claude/inbound.ts +8 -3
  56. package/src/cli/access.ts +22 -5
  57. package/src/cli/account-api.ts +6 -0
  58. package/src/cli/account-auth.ts +5 -2
  59. package/src/cli/account.ts +5 -6
  60. package/src/cli/aside-profiles.ts +61 -1
  61. package/src/cli/claude-desktop.ts +3 -2
  62. package/src/cli/claude.ts +3 -1
  63. package/src/cli/codex-cli-update.ts +2 -2
  64. package/src/cli/codex-shim-autorestore.ts +3 -0
  65. package/src/cli/dispatch.ts +12 -4
  66. package/src/cli/index.ts +98 -10
  67. package/src/cli/registry.ts +1 -1
  68. package/src/cli/resolve.ts +113 -1
  69. package/src/cli/root.ts +5 -0
  70. package/src/cli/stop-approval.ts +186 -0
  71. package/src/cli/stop-report.ts +1 -1
  72. package/src/cli/system-restart-client.ts +19 -4
  73. package/src/client/hub-client.ts +25 -18
  74. package/src/clients/config-export.ts +12 -4
  75. package/src/codex/account-auto-switch.ts +59 -0
  76. package/src/codex/account-lifecycle.ts +7 -0
  77. package/src/codex/account-priority.ts +10 -0
  78. package/src/codex/account-usability.ts +9 -0
  79. package/src/codex/auth-api/account-list.ts +5 -0
  80. package/src/codex/auth-api/login-flow.ts +15 -5
  81. package/src/codex/auth-api/login-state.ts +5 -16
  82. package/src/codex/auth-api/pool-mode-gate.ts +30 -7
  83. package/src/codex/auth-api/routes.ts +39 -3
  84. package/src/codex/auth-context.ts +145 -17
  85. package/src/codex/catalog/build-entries.ts +2 -1
  86. package/src/codex/catalog/effort.ts +8 -2
  87. package/src/codex/catalog/metadata.ts +33 -7
  88. package/src/codex/catalog/native-models.ts +102 -5
  89. package/src/codex/catalog/parsing.ts +2 -0
  90. package/src/codex/catalog/provider-models.ts +19 -6
  91. package/src/codex/cli-installation-targets.ts +44 -40
  92. package/src/codex/codex-write-lock.ts +32 -2
  93. package/src/codex/inject/multi-agent-v2.ts +90 -0
  94. package/src/codex/inject/plan.ts +365 -0
  95. package/src/codex/inject.ts +237 -362
  96. package/src/codex/log-guard/maintenance.ts +30 -13
  97. package/src/codex/project-config-warnings.ts +47 -7
  98. package/src/codex/prompt-layers/encoding.ts +41 -0
  99. package/src/codex/prompt-layers/toml-read.ts +1 -1
  100. package/src/codex/prompt-layers.ts +17 -13
  101. package/src/codex/prompt-text-probe.ts +20 -10
  102. package/src/codex/quota-observation-freshness.ts +34 -0
  103. package/src/codex/quota-rejection.ts +8 -4
  104. package/src/codex/quota.ts +4 -2
  105. package/src/codex/routing/selection.ts +32 -9
  106. package/src/codex/routing.ts +53 -11
  107. package/src/codex/subagent-model-fallback.ts +4 -3
  108. package/src/codex/sync.ts +11 -0
  109. package/src/codex/windows-installation-files.ts +33 -11
  110. package/src/codex/write-coordination.ts +4 -0
  111. package/src/combos/request.ts +6 -2
  112. package/src/companion/settings.ts +6 -1
  113. package/src/config/atomic-write.ts +12 -1
  114. package/src/config/derived-registries.ts +29 -0
  115. package/src/config/diagnostics.ts +17 -0
  116. package/src/config/live-reconcile.ts +11 -2
  117. package/src/config/load-degrade.ts +9 -2
  118. package/src/config/persist-unlocked.ts +4 -3
  119. package/src/config/persisted-mutation.ts +94 -0
  120. package/src/config/provider-validation.ts +1 -1
  121. package/src/config/proxy-env.ts +62 -12
  122. package/src/config/rebase-provenance.ts +80 -1
  123. package/src/config/schema/config-schema.ts +5 -0
  124. package/src/config/schema/leaf-validators.ts +50 -1
  125. package/src/config/subagent-models.ts +41 -15
  126. package/src/config.ts +18 -95
  127. package/src/generated/compatibility-version.json +330 -218
  128. package/src/generated/model-metadata.ts +8 -5
  129. package/src/github/star-state.ts +46 -6
  130. package/src/grok/inject.ts +74 -40
  131. package/src/grok/status.ts +11 -4
  132. package/src/integrations/cursor-effort-table.ts +50 -13
  133. package/src/integrations/raycast-detect.ts +19 -4
  134. package/src/integrations/serialize.ts +11 -1
  135. package/src/lib/bounded-body.ts +30 -0
  136. package/src/lib/crash-guard.ts +52 -4
  137. package/src/lib/local-aside-sync-contract.ts +41 -0
  138. package/src/lib/package-tree-integrity.ts +181 -9
  139. package/src/lib/proxy-env.ts +18 -1
  140. package/src/lib/request-failure-attribution.ts +1 -0
  141. package/src/lib/request-failure-model.ts +3 -0
  142. package/src/lib/service-secrets.ts +99 -2
  143. package/src/lib/socks5-fetch.ts +43 -14
  144. package/src/lib/token-estimate.ts +17 -2
  145. package/src/oauth/command-code.ts +3 -2
  146. package/src/oauth/generic-account-failover.ts +29 -1
  147. package/src/oauth/index.ts +24 -16
  148. package/src/oauth/login-flow-state.ts +5 -5
  149. package/src/oauth/meta-muse-device.ts +49 -7
  150. package/src/oauth/store.ts +74 -9
  151. package/src/providers/alibaba-region-backup.ts +16 -1
  152. package/src/providers/anthropic-fast.ts +89 -0
  153. package/src/providers/anthropic-reset-grant-ledger.ts +288 -0
  154. package/src/providers/anthropic-reset-grants.ts +329 -0
  155. package/src/providers/claude-cli-identity.ts +12 -0
  156. package/src/providers/command-code-efforts.ts +184 -102
  157. package/src/providers/derive.ts +11 -3
  158. package/src/providers/fastwire.ts +16 -3
  159. package/src/providers/label.ts +8 -3
  160. package/src/providers/model-rename-fields.ts +1 -0
  161. package/src/providers/openai-virtual-models.ts +1 -0
  162. package/src/providers/quota/vendor-probes-oauth.ts +2 -1
  163. package/src/providers/reasoning-metadata.ts +38 -15
  164. package/src/providers/registry/entries-core.ts +95 -7
  165. package/src/providers/registry/entries-extended.ts +21 -10
  166. package/src/providers/registry/model-ids.ts +1 -0
  167. package/src/providers/registry/model-seeds.ts +31 -3
  168. package/src/providers/registry/types.ts +3 -1
  169. package/src/providers/resolved-model-policy-merge.ts +38 -8
  170. package/src/providers/resolved-model-policy.ts +4 -2
  171. package/src/providers/service-tier.ts +3 -1
  172. package/src/reasoning-effort.ts +6 -5
  173. package/src/responses/code-mode-helper-compat.ts +14 -4
  174. package/src/responses/code-mode-shell-input.ts +54 -0
  175. package/src/responses/custom-tool-compat.ts +173 -5
  176. package/src/responses/parser.ts +38 -2
  177. package/src/responses/reasoning-replay-cache.ts +26 -0
  178. package/src/router.ts +11 -1
  179. package/src/routing/history/indexer.ts +7 -2
  180. package/src/routing/history/schema.ts +3 -1
  181. package/src/server/adapter-resolve.ts +2 -2
  182. package/src/server/auth-cors.ts +3 -0
  183. package/src/server/chat-completions.ts +14 -1
  184. package/src/server/chat-native.ts +17 -0
  185. package/src/server/claude-messages.ts +4 -3
  186. package/src/server/direct-local-http.ts +45 -19
  187. package/src/server/gui-session.ts +6 -15
  188. package/src/server/index/package-tree-guard.ts +53 -0
  189. package/src/server/index/serve-options.ts +17 -3
  190. package/src/server/index/startup-warnings.ts +17 -0
  191. package/src/server/index.ts +15 -17
  192. package/src/server/lifecycle.ts +8 -0
  193. package/src/server/live.ts +54 -4
  194. package/src/server/management/agent-settings-routes.ts +44 -8
  195. package/src/server/management/anthropic-reset-grant-routes.ts +252 -0
  196. package/src/server/management/codex-prompt-routes.ts +5 -1
  197. package/src/server/management/config-routes.ts +14 -4
  198. package/src/server/management/oauth-account-routes.ts +17 -1
  199. package/src/server/management/provider-overwrite-carry.ts +164 -0
  200. package/src/server/management/provider-routes.ts +36 -6
  201. package/src/server/management/remote-workspace-routes.ts +2 -2
  202. package/src/server/management/route-registry.ts +2 -0
  203. package/src/server/management/shadow-call-validation.ts +39 -0
  204. package/src/server/management/system-restart.ts +72 -6
  205. package/src/server/management-api.ts +16 -2
  206. package/src/server/management-auth.ts +52 -8
  207. package/src/server/proxy-liveness.ts +110 -4
  208. package/src/server/request-log.ts +26 -9
  209. package/src/server/request-metrics.ts +5 -0
  210. package/src/server/responses/adapter-continuation.ts +6 -3
  211. package/src/server/responses/adapter-dispatch.ts +65 -1
  212. package/src/server/responses/compact.ts +31 -1
  213. package/src/server/responses/compaction-routing.ts +28 -3
  214. package/src/server/responses/core-codex-account.ts +85 -26
  215. package/src/server/responses/core-combo-failure.ts +16 -15
  216. package/src/server/responses/core-normalize.ts +5 -1
  217. package/src/server/responses/core-opaque-recovery.ts +50 -2
  218. package/src/server/responses/core-options.ts +2 -0
  219. package/src/server/responses/core-replay.ts +7 -0
  220. package/src/server/responses/core.ts +1 -1
  221. package/src/server/responses/encrypted-payload.ts +23 -9
  222. package/src/server/responses/passthrough-delivery.ts +55 -35
  223. package/src/server/responses/passthrough-dispatch.ts +12 -13
  224. package/src/server/responses/policy-fallback.ts +12 -1
  225. package/src/server/responses/request-prepare.ts +46 -7
  226. package/src/server/responses/request-spend.ts +31 -15
  227. package/src/server/responses/run-turn-execution.ts +6 -5
  228. package/src/server/responses/shadow-target-availability.ts +61 -0
  229. package/src/server/responses-custom-tool-repair.ts +2 -0
  230. package/src/service/claim.ts +185 -0
  231. package/src/service/cli.ts +10 -1
  232. package/src/service/guarded-manager-target.ts +151 -0
  233. package/src/service/guards.ts +47 -48
  234. package/src/service/managing-cli.ts +170 -0
  235. package/src/service/orchestration.ts +3 -1
  236. package/src/service/state.ts +4 -0
  237. package/src/service/systemd.ts +16 -1
  238. package/src/service.ts +1 -1
  239. package/src/types/config.ts +8 -0
  240. package/src/types/provider.ts +16 -0
  241. package/src/types/request.ts +10 -0
  242. package/src/types/tools.ts +9 -1
  243. package/src/types/wire.ts +68 -4
  244. package/src/types.ts +1 -0
  245. package/src/update/job.ts +10 -16
  246. package/src/update/npm-invocation.mjs +17 -16
  247. package/src/update/transactional-install.d.mts +22 -1
  248. package/src/update/transactional-install.mjs +176 -19
  249. package/src/update/update-failure-guidance.d.mts +8 -0
  250. package/src/update/update-failure-guidance.mjs +47 -0
  251. package/src/usage/expected-prices.ts +54 -12
  252. package/src/usage/log.ts +117 -3
  253. package/src/usage/telemetry-contract.ts +1 -0
  254. package/src/usage/timeline.ts +34 -6
  255. package/src/usage/user-cost-overlay-reconciler.ts +3 -3
  256. package/src/vision/reasoning.ts +2 -4
  257. package/src/web-search/xai-executor.ts +14 -4
  258. package/gui/dist/assets/App-EJxiUFMq.js +0 -50
  259. package/gui/dist/assets/index-BmJwNBHL.js +0 -86
  260. package/gui/dist/assets/index-DdDunwDb.css +0 -1
  261. package/gui/dist/assets/usage-companion-chart-IftE60UK.js +0 -1
  262. package/src/adapters/kiro-thinking.ts +0 -112
@@ -7,6 +7,8 @@ const SOCKS5_CONNECT_TIMEOUT_MS = 30_000;
7
7
  const SOCKS5_RESPONSE_TIMEOUT_MS = 200_000;
8
8
  const MAX_RESPONSE_HEADER_BYTES = 64 * 1024;
9
9
  const MAX_BODY_SLICE_BYTES = 64 * 1024;
10
+ const MAX_DECODED_BODY_BYTES = 32 * 1024 * 1024;
11
+ const MAX_STREAM_DECODE_EXPANSION_RATIO = 128;
10
12
  const SOCKS5_VERSION = 0x05;
11
13
  const SOCKS5_NO_AUTH = 0x00;
12
14
  const SOCKS5_USER_PASS = 0x02;
@@ -526,9 +528,9 @@ function bodylessResponse(method: string, status: number): boolean {
526
528
  * response means an upstream ignored that; gzip and deflate are undone here, and any other
527
529
  * coding fails closed rather than surfacing bytes no caller can parse.
528
530
  *
529
- * No decompressed-size ceiling is imposed. The identity path has no total-size bound either —
530
- * it cannot, because a long-lived SSE stream is legitimately unbounded — and a ceiling on only
531
- * the coded path would fail responses that succeed uncompressed.
531
+ * Buffered decoded bodies have an absolute cap. Event streams instead have an expansion bound,
532
+ * so a long stream can continue without allowing a tiny coded response to inflate unchecked.
533
+ * Identity bodies retain their existing streaming behavior; providers are asked to use that path.
532
534
  */
533
535
  function contentCodingFormat(headers: Headers): "gzip" | "deflate" | undefined {
534
536
  const coding = classifyContentCoding(headers);
@@ -537,6 +539,37 @@ function contentCodingFormat(headers: Headers): "gzip" | "deflate" | undefined {
537
539
  throw new Socks5FetchError("SOCKS5 upstream returned an unsupported content-encoding: " + coding.coding);
538
540
  }
539
541
 
542
+ /** Bound buffered bodies absolutely and event streams relative to consumed coded bytes. */
543
+ function decodedBody(
544
+ body: ReadableStream<Uint8Array>,
545
+ format: "gzip" | "deflate",
546
+ eventStream: boolean,
547
+ ): ReadableStream<Uint8Array> {
548
+ const decompressor = new DecompressionStream(format) as unknown as ReadableWritablePair<Uint8Array, Uint8Array>;
549
+ let codedBytes = 0;
550
+ let decodedBytes = 0;
551
+ const countedBody = eventStream ? body.pipeThrough(new TransformStream<Uint8Array, Uint8Array>({
552
+ transform(chunk, controller) {
553
+ codedBytes += chunk.byteLength;
554
+ controller.enqueue(chunk);
555
+ },
556
+ })) : body;
557
+ return countedBody.pipeThrough(decompressor).pipeThrough(new TransformStream<Uint8Array, Uint8Array>({
558
+ transform(chunk, controller) {
559
+ decodedBytes += chunk.byteLength;
560
+ const limit = eventStream
561
+ ? Math.max(MAX_DECODED_BODY_BYTES, codedBytes * MAX_STREAM_DECODE_EXPANSION_RATIO)
562
+ : MAX_DECODED_BODY_BYTES;
563
+ if (decodedBytes > limit) {
564
+ throw new Socks5FetchError(eventStream
565
+ ? "SOCKS5 decoded event stream exceeds expansion limit"
566
+ : `SOCKS5 decoded response exceeds ${MAX_DECODED_BODY_BYTES} byte cap`);
567
+ }
568
+ controller.enqueue(chunk);
569
+ },
570
+ }));
571
+ }
572
+
540
573
  /** Read response heads until the final one, consuming the interim informational answers. */
541
574
  async function finalResponseHead(
542
575
  reader: SocketReader,
@@ -708,19 +741,15 @@ export async function socks5Fetch(
708
741
  // The declared length describes the coded bytes, not what the caller now reads.
709
742
  responseHeaders.delete("content-length");
710
743
  }
711
- // `DecompressionStream` declares its writable side as `WritableStream<BufferSource>`, and
712
- // TypeScript measures `WritableStream` as invariant in its chunk type, so the pair is not
713
- // assignable to `ReadableWritablePair<Uint8Array, Uint8Array>` even though every chunk this
714
- // body produces is a valid `BufferSource`. The conversion states that relationship and
715
- // nothing else; it does not widen what is actually written.
716
- const decompressor = codingFormat === undefined
717
- ? undefined
718
- : new DecompressionStream(codingFormat) as unknown as ReadableWritablePair<Uint8Array, Uint8Array>;
719
- const decodedBody = body !== null && decompressor !== undefined
720
- ? body.pipeThrough(decompressor)
744
+ const responseBodyStream = body !== null && codingFormat !== undefined
745
+ ? decodedBody(
746
+ body,
747
+ codingFormat,
748
+ responseHead.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() === "text/event-stream",
749
+ )
721
750
  : body;
722
751
  request.signal.removeEventListener("abort", onAbort);
723
- return new Response(decodedBody, {
752
+ return new Response(responseBodyStream, {
724
753
  status: responseHead.status,
725
754
  statusText: responseHead.statusText,
726
755
  headers: responseHeaders,
@@ -142,12 +142,27 @@ export function estimateTokens(text: string, modelId?: string, contextWindow?: n
142
142
  if (!text) return 0;
143
143
  const len = text.length;
144
144
  if (len === 0) return 0;
145
- const latinRatio = charsPerToken(modelId);
146
145
  const cjk = countCjk(text);
146
+ return estimateTokensFromCharacterCounts(len - cjk, cjk, modelId, contextWindow);
147
+ }
148
+
149
+ /**
150
+ * Estimate tokens from already-counted script buckets without materializing replacement text.
151
+ * `latin` and `cjk` are non-negative integer character counts, as produced from a string.
152
+ */
153
+ export function estimateTokensFromCharacterCounts(
154
+ latin: number,
155
+ cjk: number,
156
+ modelId?: string,
157
+ contextWindow?: number,
158
+ ): number {
159
+ const len = latin + cjk;
160
+ if (len === 0) return 0;
161
+ const latinRatio = charsPerToken(modelId);
147
162
  // Continuous in the CJK share: no threshold, so one added Korean character moves the estimate
148
163
  // by a fraction of a token instead of switching the whole blob to a different divisor.
149
164
  const estimate = cjk === 0
150
165
  ? Math.ceil(len / latinRatio)
151
- : Math.ceil((len - cjk) / latinRatio + cjk / CJK_CHARS_PER_TOKEN);
166
+ : Math.ceil(latin / latinRatio + cjk / CJK_CHARS_PER_TOKEN);
152
167
  return capEstimateAtContextWindow(Math.max(1, estimate), contextWindow);
153
168
  }
@@ -172,8 +172,9 @@ function parsePastedCommandCodeInput(input: string, expectedState: string): Comm
172
172
  if (!apiKey) return undefined;
173
173
  // A URL/query-shaped paste is an authorization response and must carry a matching state,
174
174
  // mirroring the shared OAuth callback flow; a stale or attacker-supplied URL from another
175
- // session must not be accepted. Raw in-session keys are exempt (no state to compare).
176
- if (parsed.kind !== "raw" && parsed.state !== expectedState) return undefined;
175
+ // session must not be accepted. A raw paste with an explicit #state suffix is state-bearing
176
+ // too, as in the shared submit gate; only a bare in-session key has no state to compare.
177
+ if ((parsed.kind !== "raw" || parsed.state !== undefined) && parsed.state !== expectedState) return undefined;
177
178
  return { apiKey, state: expectedState, userId: "", userName: "", keyName: "manual" };
178
179
  }
179
180
 
@@ -179,13 +179,41 @@ function isProactivePreferenceEnabled(config: OcxConfig, providerName: string, n
179
179
 
180
180
  /** Accounts that may serve traffic right now: not cooled, not flagged for reauth. */
181
181
  export function eligibleFailoverAccounts(providerName: string, now = Date.now(), family?: QuotaModelFamily): string[] {
182
- const set = getAccountSet(providerName);
182
+ return eligibleIdsIn(getAccountSet(providerName), providerName, now, family);
183
+ }
184
+
185
+ function eligibleIdsIn(
186
+ set: ReturnType<typeof getAccountSet>,
187
+ providerName: string,
188
+ now: number,
189
+ family?: QuotaModelFamily,
190
+ ): string[] {
183
191
  if (!set) return [];
184
192
  return set.accounts
185
193
  .filter(account => account.needsReauth !== true && !isCooled(providerName, account.id, now, family))
186
194
  .map(account => account.id);
187
195
  }
188
196
 
197
+ /**
198
+ * Whether reactive rotation has an alternate account it could select right now.
199
+ *
200
+ * Answers from the same live roster read and the same guards `rotateGenericOAuthAccountOn429`
201
+ * applies: a roster of fewer than two accounts has nowhere to go, even when a cached quorum
202
+ * count or a stale failed id would suggest otherwise. It applies no cooldown and advances no
203
+ * rotation state; like every eligibility read, it may prune an already-expired cooldown entry.
204
+ */
205
+ export function hasEligibleGenericOAuthFailoverTarget(
206
+ providerName: string,
207
+ failedAccountId: string,
208
+ now = Date.now(),
209
+ requestedModelId?: string | null,
210
+ ): boolean {
211
+ const set = getAccountSet(providerName);
212
+ if (!set || set.accounts.length < 2) return false;
213
+ const family = classifyModelFamilyForQuota(providerName, requestedModelId);
214
+ return eligibleIdsIn(set, providerName, now, family).some(id => id !== failedAccountId);
215
+ }
216
+
189
217
  /** Generic pool strategies the kernel can actually run. `quota` IS the pre-kernel path. */
190
218
  type ActiveGenericStrategy = "round-robin" | "fill-first";
191
219
 
@@ -38,6 +38,7 @@ import { loginChatGPT, refreshChatGPTToken, type ChatGPTLoginFlow } from "./chat
38
38
  import { loginAntigravity, refreshAntigravityToken } from "./google-antigravity";
39
39
  import { loginCursor, refreshCursorToken } from "./cursor";
40
40
  import { loginDevin, refreshDevinToken } from "./devin";
41
+ import { validateDevinApiBaseUrl } from "./devin/api-base";
41
42
  import { loginGithubCopilot, refreshGithubCopilotToken, validateCopilotApiBaseUrl } from "./github-copilot";
42
43
  import { loginCommandCode, refreshCommandCodeToken } from "./command-code";
43
44
  import { loginMetaMuse, refreshMetaMuseToken } from "./meta-muse";
@@ -89,11 +90,12 @@ export interface OAuthAccessSnapshot {
89
90
  /** Safe request-routing subset; refresh-only Kiro client secrets never leave the credential store. */
90
91
  kiro?: Pick<KiroOAuthMetadata, "profileArn" | "apiRegion" | "ssoRegion" | "authType">;
91
92
  /**
92
- * Allowlisted GitHub Copilot API origin belonging to THIS account.
93
+ * Allowlisted API origin belonging to THIS account.
93
94
  *
94
- * Copilot pins its bearer to an account-scoped regional host. Initial routing, 401 refresh, and
95
- * account failover must resolve transport from this same snapshot; rereading the active account
96
- * can pair account A's token with account B's origin during a concurrent switch (#2568d).
95
+ * Copilot and Devin pin credentials to account-scoped regional or tenant hosts. Initial routing,
96
+ * discovery, refresh, and account failover must resolve transport from this same snapshot;
97
+ * rereading the active account can pair account A's token with account B's origin during a
98
+ * concurrent switch (#2568d).
97
99
  */
98
100
  apiBaseUrl?: string;
99
101
  }
@@ -177,6 +179,7 @@ export interface LoginOpts {
177
179
  }
178
180
 
179
181
  export interface LoginFlowLifecycle {
182
+ flowId?: string;
180
183
  /** Runs after background credential/config persistence settles, before status becomes done. */
181
184
  onSettled?: () => void | Promise<void>;
182
185
  }
@@ -480,16 +483,18 @@ function accessSnapshot(provider: string, accountId: string, cred: OAuthCredenti
480
483
  // Validated here, not at the call site: an unvalidated origin from a legacy or crafted
481
484
  // credential must never travel with a bearer, and dropping it makes the transport fall back to
482
485
  // the canonical host rather than to whatever the previous account was using.
483
- const copilotApiBaseUrl = provider === "github-copilot"
486
+ const accountApiBaseUrl = provider === "github-copilot"
484
487
  ? validateCopilotApiBaseUrl(cred.apiBaseUrl)
485
- : undefined;
488
+ : provider === "devin" || provider === "devin-cli"
489
+ ? validateDevinApiBaseUrl(cred.apiBaseUrl)
490
+ : undefined;
486
491
  return {
487
492
  provider,
488
493
  accountId,
489
494
  generation: credentialGeneration(cred),
490
495
  accessToken: cred.access,
491
496
  ...(cred.projectId ? { projectId: cred.projectId } : {}),
492
- ...(copilotApiBaseUrl ? { apiBaseUrl: copilotApiBaseUrl } : {}),
497
+ ...(accountApiBaseUrl ? { apiBaseUrl: accountApiBaseUrl } : {}),
493
498
  // Stored account metadata remains authoritative. Metadata-less legacy/environment credentials
494
499
  // may use explicit environment routing, but never borrow the currently signed-in local CLI account.
495
500
  ...(provider === "kiro"
@@ -527,7 +532,9 @@ export function observeActiveOAuthAccessToken(
527
532
  if (account.credential.expires <= now) return { kind: "expired" };
528
533
  if (account.credential.expires <= now + REFRESH_SKEW_MS) return { kind: "near-expiry" };
529
534
 
530
- const apiBaseUrl = validateCopilotApiBaseUrl(account.credential.apiBaseUrl);
535
+ const apiBaseUrl = provider === "github-copilot"
536
+ ? validateCopilotApiBaseUrl(account.credential.apiBaseUrl)
537
+ : undefined;
531
538
  return {
532
539
  kind: "available",
533
540
  snapshot: {
@@ -1813,17 +1820,18 @@ export function oauthLoginSummary(maskEmails = true): Array<{ provider: string;
1813
1820
  }
1814
1821
 
1815
1822
  export function clearLoginState(provider: string): void {
1816
- loginAbort.get(provider)?.abort("cleared");
1823
+ loginAbort.get(provider)?.controller.abort("cleared");
1817
1824
  loginAbort.delete(provider);
1818
1825
  clearManualCodeSlot(provider);
1819
1826
  loginState.delete(provider);
1820
1827
  }
1821
1828
 
1822
- export function cancelLoginFlow(provider: string): boolean {
1823
- const ctrl = loginAbort.get(provider);
1829
+ export function cancelLoginFlow(provider: string, flowId?: string): boolean {
1830
+ const active = loginAbort.get(provider);
1824
1831
  const existing = loginState.get(provider);
1825
- if (!ctrl && (!existing || existing.done)) return false;
1826
- ctrl?.abort("cancelled");
1832
+ if (flowId !== undefined && active?.flowId !== flowId) return false;
1833
+ if (!active && (!existing || existing.done)) return false;
1834
+ active?.controller.abort("cancelled");
1827
1835
  loginAbort.delete(provider);
1828
1836
  clearManualCodeSlot(provider);
1829
1837
  loginState.set(provider, { done: true, error: "Login cancelled" });
@@ -1844,7 +1852,7 @@ export async function startLoginFlow(
1844
1852
  clearManualCodeSlot(provider);
1845
1853
  loginState.set(provider, { done: false });
1846
1854
  const abort = new AbortController();
1847
- loginAbort.set(provider, abort);
1855
+ loginAbort.set(provider, { controller: abort, flowId: lifecycle?.flowId });
1848
1856
  if (provider === "kiro") kiroLoginSettling.add(provider);
1849
1857
  return new Promise((resolve, reject) => {
1850
1858
  let urlResolved = false;
@@ -1859,7 +1867,7 @@ export async function startLoginFlow(
1859
1867
  signal: abort.signal,
1860
1868
  };
1861
1869
  const abandonIfNotOwner = (error?: unknown): boolean => {
1862
- if (loginAbort.get(provider) === abort) return false;
1870
+ if (loginAbort.get(provider)?.controller === abort) return false;
1863
1871
  if (!urlResolved) reject(error ?? new Error("OAuth login was superseded"));
1864
1872
  return true;
1865
1873
  };
@@ -1897,7 +1905,7 @@ export async function startLoginFlow(
1897
1905
  // Background: runLogin persists the credential + provider entry to disk. The lifecycle hook
1898
1906
  // lets a long-lived server config adopt that settled state before clients observe done=true.
1899
1907
  const assertCurrentOwner = (): void => {
1900
- if (loginAbort.get(provider) !== abort) throw new OAuthLoginSupersededError();
1908
+ if (loginAbort.get(provider)?.controller !== abort) throw new OAuthLoginSupersededError();
1901
1909
  };
1902
1910
  void runLogin(provider, ctrl, opts, { assertCurrentOwner }).then(
1903
1911
  () => settle(),
@@ -11,7 +11,7 @@ import type { GenerationContext } from "../lib/state-store-sweeper";
11
11
  * credential. `index.ts` re-exports the two public names, so existing importers are unaffected.
12
12
  */
13
13
  export const loginState = new Map<string, { error?: string; done: boolean }>();
14
- export const loginAbort = new Map<string, AbortController>();
14
+ export const loginAbort = new Map<string, { controller: AbortController; flowId?: string }>();
15
15
  export const kiroLoginSettling = new Set<string>();
16
16
 
17
17
  /** Pending paste for a login in progress: either a waiter or a stashed early submission. */
@@ -97,13 +97,13 @@ export function submitManualLoginCode(provider: string, input: string): { ok: tr
97
97
  // stashed and re-validated by the callback loop.
98
98
  const parsed = parseCallbackInput(trimmed);
99
99
  // Command Code's manual fallback accepts a pasted JSON callback payload
100
- // (`{ apiKey, state, ... }`) which has no `code` param. Let it through the
101
- // shared gate so the provider-specific parser can validate it.
102
- const isCommandCodeJson = provider === "command-code" && trimmed.startsWith("{") && !parsed.code;
100
+ // (`{ apiKey, state, ... }`). Keep that opaque to the generic raw parser so
101
+ // hashes in JSON strings do not become a fake state suffix; its provider parser validates state.
102
+ const isCommandCodeJson = provider === "command-code" && trimmed.startsWith("{");
103
103
  if (!parsed.code && !isCommandCodeJson) return { ok: false, error: "no authorization code found in input" };
104
104
  // A raw paste carrying an explicit code#state suffix is state-bearing too: it
105
105
  // must match the expected state rather than bypass validation.
106
- const stateBearing = parsed.kind !== "raw" || parsed.state !== undefined;
106
+ const stateBearing = !isCommandCodeJson && (parsed.kind !== "raw" || parsed.state !== undefined);
107
107
  if (stateBearing && slot.expectedState !== undefined) {
108
108
  if (parsed.state === undefined) return { ok: false, error: "redirect URL is missing the state parameter" };
109
109
  if (parsed.state !== slot.expectedState) {
@@ -23,6 +23,7 @@
23
23
  */
24
24
  import type { OAuthController, OAuthCredentials } from "./types";
25
25
  import { sanitizeApiKeyValue } from "../providers/api-keys";
26
+ import { BOUNDED_BODY_MAX_BYTES, readBoundedResponseBytes } from "../lib/bounded-body";
26
27
 
27
28
  /** Meta's own Muse Code client. Public in its device-approval URL; not a secret. */
28
29
  const CLIENT_ID = "1031625952748946";
@@ -173,12 +174,45 @@ function requestSignal(signal: AbortSignal | undefined): AbortSignal {
173
174
  return signal ? AbortSignal.any([signal, timeout]) : timeout;
174
175
  }
175
176
 
177
+ async function readMuseJson(
178
+ response: Response,
179
+ signal: AbortSignal,
180
+ kind: "device-authorization" | "device-token" | "mint-invalid",
181
+ ): Promise<Record<string, unknown> | undefined> {
182
+ const declaredLength = response.headers.get("content-length");
183
+ if (declaredLength && /^\d+$/.test(declaredLength) && Number(declaredLength) > BOUNDED_BODY_MAX_BYTES) {
184
+ void response.body?.cancel().catch(() => undefined);
185
+ throw new MuseDeviceLoginError(
186
+ kind,
187
+ `Muse Code response exceeded the ${BOUNDED_BODY_MAX_BYTES}-byte limit`,
188
+ { status: response.status },
189
+ );
190
+ }
191
+ const { bytes, oversized } = await readBoundedResponseBytes(response, {
192
+ maxBytes: BOUNDED_BODY_MAX_BYTES,
193
+ signal,
194
+ });
195
+ if (oversized) {
196
+ throw new MuseDeviceLoginError(
197
+ kind,
198
+ `Muse Code response exceeded the ${BOUNDED_BODY_MAX_BYTES}-byte limit`,
199
+ { status: response.status },
200
+ );
201
+ }
202
+ try {
203
+ return record(JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)));
204
+ } catch {
205
+ return undefined;
206
+ }
207
+ }
208
+
176
209
  /** Step 1: ask Meta for a user code. */
177
210
  export async function requestMuseDeviceAuthorization(
178
211
  deps: MuseDeviceDeps = {},
179
212
  signal?: AbortSignal,
180
213
  ): Promise<MuseDeviceAuthorization> {
181
214
  const now = deps.now ?? Date.now;
215
+ const request = requestSignal(signal);
182
216
  const response = await (deps.fetchImpl ?? fetch)(DEVICE_AUTHORIZATION_URL, {
183
217
  method: "POST",
184
218
  headers: {
@@ -188,16 +222,19 @@ export async function requestMuseDeviceAuthorization(
188
222
  },
189
223
  body: new URLSearchParams({ client_id: CLIENT_ID }).toString(),
190
224
  redirect: "error",
191
- signal: requestSignal(signal),
225
+ signal: request,
192
226
  });
193
227
  if (!response.ok) {
228
+ // The error body is never parsed, but it still has to be released: an unread
229
+ // response body keeps the underlying connection occupied.
230
+ void response.body?.cancel().catch(() => undefined);
194
231
  throw new MuseDeviceLoginError(
195
232
  "device-authorization",
196
233
  `Muse Code device authorization request failed: HTTP ${response.status}`,
197
234
  { status: response.status },
198
235
  );
199
236
  }
200
- const payload = record(await response.json().catch(() => undefined));
237
+ const payload = await readMuseJson(response, request, "device-authorization");
201
238
  const deviceCode = text(payload?.device_code);
202
239
  const userCode = text(payload?.user_code);
203
240
  if (!deviceCode || !userCode) {
@@ -241,6 +278,7 @@ export async function pollMuseDeviceToken(
241
278
  // shape checked the deadline at the top, so a sleep ending exactly at the deadline
242
279
  // skipped the final poll and discarded an approval the user had already completed
243
280
  // inside that window.
281
+ const request = requestSignal(signal);
244
282
  const response = await (deps.fetchImpl ?? fetch)(DEVICE_TOKEN_URL, {
245
283
  method: "POST",
246
284
  headers: {
@@ -254,9 +292,9 @@ export async function pollMuseDeviceToken(
254
292
  grant_type: DEVICE_GRANT_TYPE,
255
293
  }).toString(),
256
294
  redirect: "error",
257
- signal: requestSignal(signal),
295
+ signal: request,
258
296
  });
259
- const payload = record(await response.json().catch(() => undefined));
297
+ const payload = await readMuseJson(response, request, "device-token");
260
298
  if (response.ok) {
261
299
  // [W3] No deadline re-check here. If Meta answered 200 with a token, Meta accepted
262
300
  // the device code; its clock is authoritative and ours is not. Discarding an issued
@@ -322,6 +360,7 @@ export async function mintMuseApiKey(
322
360
  signal?: AbortSignal,
323
361
  ): Promise<MuseKeyPayload> {
324
362
  const now = deps.now ?? Date.now;
363
+ const request = requestSignal(signal);
325
364
  const response = await (deps.fetchImpl ?? fetch)(MUSE_KEY_URL, {
326
365
  method: "POST",
327
366
  headers: {
@@ -332,9 +371,10 @@ export async function mintMuseApiKey(
332
371
  },
333
372
  body: JSON.stringify(options.onboard ? { onboard: true } : {}),
334
373
  redirect: "error",
335
- signal: requestSignal(signal),
374
+ signal: request,
336
375
  });
337
376
  if (response.status === 429) {
377
+ void response.body?.cancel().catch(() => undefined);
338
378
  const wait = retryAfterMs(response.headers.get("retry-after"), now());
339
379
  throw new MuseDeviceLoginError(
340
380
  "mint-rate-limited",
@@ -345,14 +385,16 @@ export async function mintMuseApiKey(
345
385
  );
346
386
  }
347
387
  if (!response.ok) {
348
- // Status only. The body of this endpoint can carry the key itself.
388
+ // Status only. The body of this endpoint can carry the key itself, so it is
389
+ // never parsed — but it is still cancelled so the connection is released.
390
+ void response.body?.cancel().catch(() => undefined);
349
391
  throw new MuseDeviceLoginError(
350
392
  "mint-http",
351
393
  `Muse Code key exchange failed: HTTP ${response.status}`,
352
394
  { status: response.status },
353
395
  );
354
396
  }
355
- const payload = record(await response.json().catch(() => undefined));
397
+ const payload = await readMuseJson(response, request, "mint-invalid");
356
398
  if (!payload) {
357
399
  throw new MuseDeviceLoginError("mint-invalid", "Muse Code key exchange returned an unreadable response", {
358
400
  status: response.status,
@@ -6,6 +6,9 @@
6
6
  * Legacy single-credential values (`{ access, refresh, expires, ... }`) normalize on load,
7
7
  * and the first new-shape persist writes a one-time `auth.json.pre-multiauth` backup so a
8
8
  * downgraded loader (which silently drops unknown shapes) cannot destroy refresh tokens.
9
+ * Destructive mutations (logout, account deletion, provider deletion) remove the affected
10
+ * provider's entry from that backup, and the file once nothing is left, so deleted
11
+ * credentials are not retained while other providers keep their downgrade recovery.
9
12
  *
10
13
  * Exceptions:
11
14
  * - `chatgpt` stays single-slot (always replaced): codex-auth-api uses it as a scratch slot
@@ -17,9 +20,10 @@
17
20
  * both append distinct identified accounts under multiauth.
18
21
  */
19
22
  import { createHash, randomUUID } from "node:crypto";
20
- import { chmodSync, closeSync, copyFileSync, existsSync, fstatSync, mkdirSync, openSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
23
+ import { chmodSync, closeSync, constants as fsConstants, copyFileSync, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
21
24
  import { join } from "node:path";
22
25
  import { getConfigDir, atomicWriteFile, backupInvalidConfig, hardenConfigDir, hardenExistingSecret, withConfigMutationLockSync } from "../config";
26
+ import { atomicWriteFileNoFollowUnclaimed } from "../config/atomic-write";
23
27
  import { assertNotRealHomeUnderTest } from "../lib/test-home-guard";
24
28
  import { recordOwnedConfigPath } from "../lib/config-ownership";
25
29
  import { MAX_PENDING_OAUTH_MUTATIONS } from "../lib/translator-budget";
@@ -433,9 +437,11 @@ export function createOAuthRefreshIntentLock(provider:string,accountId:string,ov
433
437
  function backupLegacyOnce(): void {
434
438
  const path = getAuthStorePath();
435
439
  const backup = `${path}.pre-multiauth`;
436
- if (!existsSync(path) || existsSync(backup)) return;
440
+ // Any existing entry, including a dangling symlink existsSync would call absent, is left
441
+ // alone, and the copy is exclusive, so credentials are never written through a link.
442
+ if (!existsSync(path) || directoryEntryExists(backup)) return;
437
443
  try {
438
- copyFileSync(path, backup);
444
+ copyFileSync(path, backup, fsConstants.COPYFILE_EXCL);
439
445
  try { chmodSync(backup, 0o600); } catch { /* best-effort */ }
440
446
  try {
441
447
  // Register only the copy we just created. An unowned home still needs downgrade recovery.
@@ -448,6 +454,53 @@ function backupLegacyOnce(): void {
448
454
  } catch { /* best-effort */ }
449
455
  }
450
456
 
457
+ /**
458
+ * Destructive mutations (logout, account deletion, provider deletion) remove the affected
459
+ * providers from the downgrade backup: it holds a copy of the very credentials the user
460
+ * removed. Entries for other providers stay, so their downgrade recovery survives; the file
461
+ * goes once nothing is left. Account deletion drops the provider's whole legacy entry, since
462
+ * a refreshed token cannot be matched to the account it came from. A backup entry that is
463
+ * not a regular file is never followed or rewritten; a symlink is removed, while a directory
464
+ * is left in place with a warning. A regular backup is replaced atomically when providers
465
+ * remain and removed when none do. Best-effort like the create path: this runs after
466
+ * persist, so a failure must not report a failed logout for an account that is already
467
+ * gone; it warns instead. A stale uninstall-manifest entry is harmless:
468
+ * removeOwnedConfigState skips paths that no longer exist.
469
+ */
470
+ function scrubLegacyBackup(providers: readonly string[]): void {
471
+ const backup = `${getAuthStorePath()}.pre-multiauth`;
472
+ try {
473
+ const remaining = readLegacyBackupEntries(backup);
474
+ for (const provider of providers) delete remaining[provider];
475
+ if (Object.keys(remaining).length > 0) atomicWriteFileNoFollowUnclaimed(backup, `${JSON.stringify(remaining, null, 2)}\n`);
476
+ else unlinkSync(backup);
477
+ } catch (error) {
478
+ if (errorCode(error) !== "ENOENT") {
479
+ console.warn(`[oauth] could not remove deleted credentials from the legacy credential backup: ${error instanceof Error ? error.message : String(error)}`);
480
+ }
481
+ }
482
+ }
483
+
484
+ /** Provider entries of the backup; empty (so the file is removed) when unreadable or not a regular file. */
485
+ function readLegacyBackupEntries(backup: string): Record<string, unknown> {
486
+ if (!lstatSync(backup).isFile()) return {};
487
+ try {
488
+ const parsed: unknown = JSON.parse(readFileSync(backup, "utf-8"));
489
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? { ...(parsed as Record<string, unknown>) } : {};
490
+ } catch {
491
+ return {};
492
+ }
493
+ }
494
+
495
+ function directoryEntryExists(path: string): boolean {
496
+ try {
497
+ lstatSync(path);
498
+ return true;
499
+ } catch (error) {
500
+ return errorCode(error) !== "ENOENT";
501
+ }
502
+ }
503
+
451
504
  function isCredentialSource(value: unknown): value is OAuthCredentialSource {
452
505
  return value === "oauth" || value === "local-cli" || value === "credential-file" || value === "environment" || value === "manual";
453
506
  }
@@ -712,7 +765,7 @@ function serializeMutation<T>(work: () => Promise<T>, retainedValues: readonly u
712
765
  drainOAuthMutations();
713
766
  return result;
714
767
  }
715
- export function mutateStore<T>(fn:(store:AuthStore)=>T|Promise<T>, retainedValues: readonly unknown[] = [], options?: { waitMs?: number; assertBeforePersist?: () => void }):Promise<T>{return serializeMutation(async()=>{const guard=await createOAuthFileLock({path:getAuthStoreLockPath(),staleAfterMs:30000}).acquire();try{
768
+ export function mutateStore<T>(fn:(store:AuthStore)=>T|Promise<T>, retainedValues: readonly unknown[] = [], options?: { waitMs?: number; assertBeforePersist?: () => void; scrubLegacyBackup?: (result: T) => readonly string[] }):Promise<T>{return serializeMutation(async()=>{const guard=await createOAuthFileLock({path:getAuthStoreLockPath(),staleAfterMs:30000}).acquire();try{
716
769
  const { store, hadLegacy } = loadAuthStoreInternal();
717
770
  if (hadLegacy) backupLegacyOnce();
718
771
  const selections = new Map(Object.entries(store).map(([provider, set]) => [provider, {
@@ -722,6 +775,9 @@ export function mutateStore<T>(fn:(store:AuthStore)=>T|Promise<T>, retainedValue
722
775
  accountIds: set.accounts.map(account => account.id),
723
776
  }]));
724
777
  const result = await fn(store);
778
+ // Only providers whose credentials this mutation actually removed leave the downgrade
779
+ // backup; a no-op removal names none. The result decides, so it is read here.
780
+ const scrubbedProviders = options?.scrubLegacyBackup?.(result) ?? [];
725
781
  options?.assertBeforePersist?.();
726
782
  const changedProviders: string[] = [];
727
783
  for (const provider of new Set([...selections.keys(), ...Object.keys(store)])) {
@@ -743,6 +799,7 @@ export function mutateStore<T>(fn:(store:AuthStore)=>T|Promise<T>, retainedValue
743
799
  }
744
800
  }
745
801
  persist(store);
802
+ if (scrubbedProviders.length > 0) scrubLegacyBackup(scrubbedProviders);
746
803
  for (const provider of changedProviders) publishAccountSelection(provider, "oauth");
747
804
  return result;
748
805
  }finally{guard.release();}}, retainedValues, options?.waitMs);
@@ -886,7 +943,7 @@ export async function removeCredential(provider: string): Promise<"removed" | "n
886
943
  }
887
944
  set.activeAccountId = set.accounts[0]!.id;
888
945
  return "removed" as const;
889
- }, [provider]);
946
+ }, [provider], { scrubLegacyBackup: result => result === "removed" ? [provider] : [] });
890
947
  }
891
948
 
892
949
  // ---------------------------------------------------------------------------
@@ -1029,19 +1086,26 @@ export async function removeAccount(provider: string, accountId: string): Promis
1029
1086
  }
1030
1087
  if (set.activeAccountId === accountId) set.activeAccountId = set.accounts[0]!.id;
1031
1088
  return true;
1032
- }, [provider, accountId]);
1089
+ }, [provider, accountId], { scrubLegacyBackup: removed => removed ? [provider] : [] });
1033
1090
  return removed;
1034
1091
  }
1035
1092
 
1036
- /** Replace or clear a provider account set (used for transactional Kiro add-account rollback). */
1093
+ /**
1094
+ * Replace or clear a provider account set (provider deletion, transactional Kiro add-account
1095
+ * rollback). Clearing a provider that had credentials is a destructive mutation, so it also removes
1096
+ * the provider from the legacy downgrade backup, like logout and account deletion. A non-empty
1097
+ * replacement leaves the backup alone: a future caller that removes accounts through a
1098
+ * replacement needs its own decision here.
1099
+ */
1037
1100
  export async function replaceProviderAccountSet(
1038
1101
  provider: string,
1039
1102
  set: ProviderAccountSet | null,
1040
1103
  ): Promise<void> {
1041
1104
  await mutateStore(store => {
1042
1105
  if (!set || set.accounts.length === 0) {
1106
+ const cleared = store[provider] !== undefined;
1043
1107
  delete store[provider];
1044
- return;
1108
+ return cleared;
1045
1109
  }
1046
1110
  store[provider] = {
1047
1111
  activeAccountId: set.activeAccountId,
@@ -1053,7 +1117,8 @@ export async function replaceProviderAccountSet(
1053
1117
  ...(account.addedAt !== undefined ? { addedAt: account.addedAt } : {}),
1054
1118
  })),
1055
1119
  };
1056
- }, [provider, set]);
1120
+ return false;
1121
+ }, [provider, set], { scrubLegacyBackup: cleared => cleared ? [provider] : [] });
1057
1122
  }
1058
1123
 
1059
1124
  export type ProviderCredentialRekeyOutcome = "moved" | "absent" | "conflict";
@@ -1,10 +1,12 @@
1
- import { copyFileSync, existsSync, linkSync, readFileSync, rmSync } from "node:fs";
1
+ import { chmodSync, copyFileSync, existsSync, linkSync, readFileSync, rmSync } from "node:fs";
2
2
  import { getConfigPath } from "../config";
3
+ import { hardenSecretPath } from "../lib/windows-secret-acl";
3
4
 
4
5
  export interface AlibabaBackupIO {
5
6
  exists: (path: string) => boolean;
6
7
  read: (path: string) => Buffer;
7
8
  copy: (source: string, destination: string) => void;
9
+ harden: (path: string) => void;
8
10
  /** Publish with no-replace semantics: fails with EEXIST if the destination exists. */
9
11
  publishNoReplace: (temp: string, destination: string) => void;
10
12
  remove: (path: string) => void;
@@ -14,6 +16,16 @@ const DEFAULT_IO: AlibabaBackupIO = {
14
16
  exists: existsSync,
15
17
  read: path => readFileSync(path),
16
18
  copy: (source, destination) => copyFileSync(source, destination),
19
+ harden: path => {
20
+ // POSIX: a failed chmod must not publish a credential-bearing backup with weak permissions.
21
+ // Windows keeps its own control via hardenSecretPath below, which is the required check.
22
+ if (process.platform === "win32") {
23
+ try { chmodSync(path, 0o600); } catch { /* Windows may not support POSIX chmod */ }
24
+ } else {
25
+ chmodSync(path, 0o600);
26
+ }
27
+ if (process.platform === "win32") hardenSecretPath(path, { required: true });
28
+ },
17
29
  publishNoReplace: linkSync,
18
30
  remove: path => rmSync(path, { force: true }),
19
31
  };
@@ -57,6 +69,9 @@ export function backupConfigBeforeAlibabaRegionMigration(
57
69
  const temp = `${backup}.${process.pid}.tmp`;
58
70
  try {
59
71
  io.copy(configPath, temp);
72
+ // The snapshot contains credentials. Harden it before publication so the
73
+ // stable backup path is never exposed with inherited permissions or ACLs.
74
+ io.harden(temp);
60
75
  // Verify before publishing: a short copy must never become the snapshot.
61
76
  if (!io.read(temp).equals(source)) {
62
77
  throw new AlibabaBackupIntegrityError(`failed to write a complete backup to ${temp}`);