@bitkyc08/opencodex 2.8.0 → 2.9.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 (175) hide show
  1. package/README.md +24 -0
  2. package/bin/ocx.mjs +32 -4
  3. package/gui/dist/assets/index-CHwf3tTD.css +1 -0
  4. package/gui/dist/assets/index-u5eFOv2y.js +67 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +1 -1
  7. package/src/adapters/anthropic-image-normalize.ts +114 -20
  8. package/src/adapters/anthropic.ts +126 -10
  9. package/src/adapters/azure.ts +3 -3
  10. package/src/adapters/base.ts +7 -3
  11. package/src/adapters/cursor/discovery.ts +14 -4
  12. package/src/adapters/cursor/effort-map.ts +18 -6
  13. package/src/adapters/cursor/framing.ts +102 -27
  14. package/src/adapters/cursor/kv-store.ts +30 -3
  15. package/src/adapters/cursor/live-models.ts +22 -2
  16. package/src/adapters/cursor/live-transport.ts +245 -49
  17. package/src/adapters/cursor/mcp-manager.ts +105 -8
  18. package/src/adapters/cursor/native-exec-mcp.ts +5 -3
  19. package/src/adapters/cursor/native-exec-shell.ts +296 -14
  20. package/src/adapters/cursor/native-exec.ts +381 -33
  21. package/src/adapters/cursor/protobuf-events.ts +28 -1
  22. package/src/adapters/cursor/protobuf-request.ts +71 -39
  23. package/src/adapters/cursor/request-builder.ts +2 -2
  24. package/src/adapters/cursor/transport.ts +2 -0
  25. package/src/adapters/cursor.ts +13 -2
  26. package/src/adapters/google-antigravity-replay.ts +184 -17
  27. package/src/adapters/google.ts +58 -8
  28. package/src/adapters/kiro-thinking.ts +23 -9
  29. package/src/adapters/kiro-tools.ts +49 -18
  30. package/src/adapters/kiro.ts +377 -133
  31. package/src/adapters/mimo-free.ts +36 -4
  32. package/src/adapters/openai-chat.ts +143 -17
  33. package/src/adapters/openai-responses.ts +130 -14
  34. package/src/adapters/run-turn-queue.ts +7 -1
  35. package/src/bridge.ts +466 -69
  36. package/src/chat/outbound.ts +144 -38
  37. package/src/claude/inbound-debug.ts +53 -8
  38. package/src/claude/outbound.ts +224 -38
  39. package/src/cli/agent-driven.ts +34 -1
  40. package/src/cli/catalog-prewarm.ts +5 -2
  41. package/src/cli/claude-desktop.ts +2 -2
  42. package/src/cli/doctor.ts +12 -0
  43. package/src/cli/export-command.ts +187 -0
  44. package/src/cli/help.ts +11 -0
  45. package/src/cli/index.ts +13 -3
  46. package/src/cli/init.ts +129 -102
  47. package/src/cli/opencode.ts +36 -151
  48. package/src/cli/star-prompt.ts +13 -4
  49. package/src/cli/status-oauth.ts +12 -2
  50. package/src/clients/config-export.ts +377 -0
  51. package/src/codex/account-runtime-state.ts +19 -1
  52. package/src/codex/account-store.ts +162 -82
  53. package/src/codex/auth-api.ts +467 -159
  54. package/src/codex/auth-context.ts +15 -2
  55. package/src/codex/catalog/aggregation.ts +15 -0
  56. package/src/codex/catalog/effort.ts +16 -6
  57. package/src/codex/catalog/metadata.ts +6 -0
  58. package/src/codex/catalog/parsing.ts +3 -1
  59. package/src/codex/catalog/provider-fetch.ts +29 -0
  60. package/src/codex/catalog/sync.ts +64 -7
  61. package/src/codex/catalog.ts +2 -2
  62. package/src/codex/inject.ts +5 -5
  63. package/src/codex/main-account-cache.ts +8 -1
  64. package/src/codex/model-cache.ts +81 -2
  65. package/src/codex/pool-rotation.ts +39 -0
  66. package/src/codex/project-config-warnings.ts +12 -1
  67. package/src/codex/quota.ts +35 -3
  68. package/src/codex/routing.ts +46 -1
  69. package/src/codex/shim.ts +10 -4
  70. package/src/codex/subagent-model-fallback.ts +12 -0
  71. package/src/codex/websocket-registry.ts +27 -0
  72. package/src/combos/failover.ts +31 -1
  73. package/src/combos/request.ts +9 -0
  74. package/src/combos/resolve.ts +60 -4
  75. package/src/combos/types.ts +12 -0
  76. package/src/config.ts +510 -55
  77. package/src/github/star-state.ts +13 -1
  78. package/src/images/fulfill.ts +39 -1
  79. package/src/images/loop.ts +52 -12
  80. package/src/lib/admission.ts +83 -0
  81. package/src/lib/app-owned-memory-stores.ts +173 -0
  82. package/src/lib/app-owned-memory.ts +265 -0
  83. package/src/lib/bun-stream-caps.ts +31 -7
  84. package/src/lib/config-ownership.ts +33 -0
  85. package/src/lib/crash-guard.ts +65 -5
  86. package/src/lib/debug-log-buffer.ts +47 -6
  87. package/src/lib/destination-policy.ts +12 -1
  88. package/src/lib/errors.ts +3 -0
  89. package/src/lib/gcp-adc.ts +40 -2
  90. package/src/lib/injection-debug-log.ts +26 -2
  91. package/src/lib/provider-outbound.ts +3 -0
  92. package/src/lib/sidecar-tracker.ts +5 -2
  93. package/src/lib/sse-decoder.ts +257 -37
  94. package/src/lib/state-store-registrations.ts +109 -0
  95. package/src/lib/state-store-sweeper.ts +184 -0
  96. package/src/lib/translator-budget.ts +356 -0
  97. package/src/lib/windows-secret-acl.ts +33 -12
  98. package/src/lib/winsw.ts +14 -1
  99. package/src/oauth/anthropic-routing.ts +31 -7
  100. package/src/oauth/google-antigravity.ts +2 -1
  101. package/src/oauth/health.ts +30 -12
  102. package/src/oauth/index.ts +127 -23
  103. package/src/oauth/kiro-credentials.ts +72 -1
  104. package/src/oauth/kiro.ts +23 -4
  105. package/src/oauth/store.ts +165 -18
  106. package/src/oauth/token-guardian.ts +43 -4
  107. package/src/oauth/types.ts +2 -1
  108. package/src/providers/base-url-choices.ts +10 -0
  109. package/src/providers/derive.ts +12 -0
  110. package/src/providers/free-directory.ts +4 -1
  111. package/src/providers/key-failover.ts +12 -0
  112. package/src/providers/openai-sidecar.ts +4 -1
  113. package/src/providers/quota.ts +68 -7
  114. package/src/providers/registry.ts +279 -3
  115. package/src/responses/parser.ts +5 -1
  116. package/src/responses/spill-store.ts +394 -0
  117. package/src/responses/state.ts +520 -102
  118. package/src/router.ts +18 -1
  119. package/src/server/adapter-resolve.ts +20 -3
  120. package/src/server/auth-cors.ts +121 -28
  121. package/src/server/chat-completions.ts +57 -12
  122. package/src/server/claude-messages.ts +85 -13
  123. package/src/server/index.ts +242 -100
  124. package/src/server/lifecycle.ts +155 -25
  125. package/src/server/management/agent-settings-routes.ts +79 -36
  126. package/src/server/management/api-key-usage.ts +167 -0
  127. package/src/server/management/body.ts +35 -0
  128. package/src/server/management/combo-routes.ts +5 -1
  129. package/src/server/management/config-routes.ts +42 -12
  130. package/src/server/management/logs-usage-routes.ts +41 -21
  131. package/src/server/management/model-routes.ts +188 -54
  132. package/src/server/management/oauth-account-routes.ts +115 -26
  133. package/src/server/management/provider-routes.ts +56 -6
  134. package/src/server/management/shared.ts +16 -3
  135. package/src/server/management/sidebar-routes.ts +50 -1
  136. package/src/server/management/system-restart.ts +13 -6
  137. package/src/server/management/system-routes.ts +15 -3
  138. package/src/server/management/usage-summary-cache.ts +86 -0
  139. package/src/server/management-api.ts +39 -5
  140. package/src/server/management-auth.ts +65 -14
  141. package/src/server/port-reclaim.ts +58 -12
  142. package/src/server/ports.ts +2 -0
  143. package/src/server/proxy-liveness.ts +60 -14
  144. package/src/server/relay-eager.ts +20 -4
  145. package/src/server/relay.ts +548 -154
  146. package/src/server/request-decompress.ts +51 -4
  147. package/src/server/request-log.ts +134 -15
  148. package/src/server/responses/collaboration.ts +15 -4
  149. package/src/server/responses/compact.ts +3 -0
  150. package/src/server/responses/core.ts +241 -66
  151. package/src/server/responses-image-gen-repair.ts +19 -5
  152. package/src/server/responses-item-id-repair.ts +23 -5
  153. package/src/server/sse-payload-rewrite.ts +71 -12
  154. package/src/server/startup-health-cache.ts +14 -1
  155. package/src/server/system-env.ts +8 -1
  156. package/src/server/windows-tcp-drop.ts +15 -5
  157. package/src/server/ws-bridge.ts +25 -0
  158. package/src/service.ts +179 -13
  159. package/src/storage/policy-job.ts +93 -23
  160. package/src/storage/policy-worker.ts +6 -0
  161. package/src/storage/restore-job.ts +62 -16
  162. package/src/storage/restore-worker.ts +6 -0
  163. package/src/storage/storage-mutation-coordinator.ts +36 -6
  164. package/src/storage/worker-lifecycle.ts +181 -47
  165. package/src/tray/windows.ts +97 -25
  166. package/src/types.ts +39 -6
  167. package/src/update/index.ts +24 -5
  168. package/src/update/job.ts +598 -73
  169. package/src/usage/log.ts +115 -17
  170. package/src/usage/summary.ts +67 -2
  171. package/src/vision/index.ts +112 -22
  172. package/src/web-search/loop.ts +38 -6
  173. package/src/web-search/progress-stream.ts +14 -3
  174. package/gui/dist/assets/index-BDjpkcRN.js +0 -67
  175. package/gui/dist/assets/index-BHsKRFh9.css +0 -1
@@ -28,12 +28,15 @@ import {
28
28
  seedPoolRotationAccount,
29
29
  } from "../codex/pool-rotation";
30
30
  import type { OcxAccountPoolRotationStrategy, OcxConfig } from "../types";
31
+ import { sweepExpiredOnWrite } from "../lib/state-store-sweeper";
32
+ import { retainedUtf8Bytes } from "../lib/admission";
31
33
 
32
34
  const PROVIDER = "anthropic";
33
35
  const DEFAULT_COOLDOWN_MS = 60_000;
34
36
  const MAX_COOLDOWN_MS = 15 * 60_000;
35
37
  const AFFINITY_IDLE_TTL_MS = 24 * 60 * 60_000;
36
38
  const MAX_AFFINITY_ENTRIES = 2_000;
39
+ const MAX_AFFINITY_COMPONENT_BYTES = 512;
37
40
  const UNKNOWN_USAGE_SCORE = 100;
38
41
  const DEFAULT_AUTO_SWITCH_THRESHOLD = 80;
39
42
  /** Cap same-request 429 rotations so short Retry-After cannot infinite-loop. */
@@ -62,6 +65,11 @@ interface AffinityEntry {
62
65
  const upstreamHealth = new Map<string, AccountHealth>();
63
66
  const sessionAffinity = new Map<string, AffinityEntry>();
64
67
 
68
+ function normalizeAffinityComponent(value: string | null | undefined): string {
69
+ const normalized = value?.trim() ?? "";
70
+ return normalized && retainedUtf8Bytes(normalized) <= MAX_AFFINITY_COMPONENT_BYTES ? normalized : "";
71
+ }
72
+
65
73
  export function anthropicAccountPoolConfig(config: OcxConfig): AnthropicAccountPoolConfig {
66
74
  const raw = config.anthropicAccountPool;
67
75
  if (!raw || typeof raw !== "object") return {};
@@ -110,12 +118,26 @@ export function clearAnthropicAccountCooldown(accountId: string): boolean {
110
118
  return upstreamHealth.delete(accountId);
111
119
  }
112
120
 
121
+ export function sweepExpiredAnthropicRoutingHealth(now = Date.now()): number {
122
+ let removed = 0;
123
+ for (const [accountId, health] of upstreamHealth) {
124
+ if (health.cooldownUntil > now) continue;
125
+ upstreamHealth.delete(accountId);
126
+ removed += 1;
127
+ }
128
+ return removed;
129
+ }
130
+
113
131
  /** Test / logout helper. */
114
132
  export function clearAnthropicAccountPoolState(): void {
115
133
  upstreamHealth.clear();
116
134
  sessionAffinity.clear();
117
135
  }
118
136
 
137
+ export function anthropicSessionAffinitySizeForTests(): number {
138
+ return sessionAffinity.size;
139
+ }
140
+
119
141
  function isCooled(accountId: string, now: number): boolean {
120
142
  return getAnthropicAccountHealthSnapshot(accountId, now) !== null;
121
143
  }
@@ -344,7 +366,7 @@ export function resolveAnthropicAccountForSession(
344
366
  return { accountId: set.activeAccountId, reason: "pool-disabled" };
345
367
  }
346
368
 
347
- const key = sessionKey?.trim() || "";
369
+ const key = normalizeAffinityComponent(sessionKey);
348
370
  if (key) {
349
371
  const affined = sessionAffinity.get(key);
350
372
  if (affined && now - affined.lastUsedAt <= AFFINITY_IDLE_TTL_MS) {
@@ -374,7 +396,7 @@ export function resolveAnthropicAccountForSession(
374
396
  if (strategyPick) {
375
397
  // Do not promote active here — token validation may still fail. Callers
376
398
  // (responses/core) promote after getAnthropicPoolAccessToken succeeds.
377
- if (key) {
399
+ if (key && normalizeAffinityComponent(strategyPick.accountId)) {
378
400
  sessionAffinity.set(key, { accountId: strategyPick.accountId, lastUsedAt: now });
379
401
  pruneExpiredAffinity(now);
380
402
  }
@@ -420,7 +442,7 @@ export function resolveAnthropicAccountForSession(
420
442
  return { accountId: null, reason: anyCooled ? "all-cooled" : "none" };
421
443
  }
422
444
 
423
- if (key) {
445
+ if (key && normalizeAffinityComponent(accountId)) {
424
446
  sessionAffinity.set(key, { accountId, lastUsedAt: now });
425
447
  pruneExpiredAffinity(now);
426
448
  }
@@ -432,8 +454,8 @@ export function bindAnthropicSessionAffinity(
432
454
  accountId: string,
433
455
  now = Date.now(),
434
456
  ): void {
435
- const key = sessionKey?.trim();
436
- if (!key) return;
457
+ const key = normalizeAffinityComponent(sessionKey);
458
+ if (!key || !normalizeAffinityComponent(accountId)) return;
437
459
  sessionAffinity.set(key, { accountId, lastUsedAt: now });
438
460
  pruneExpiredAffinity(now);
439
461
  }
@@ -464,6 +486,7 @@ export function rotateAnthropicAccountOn429(
464
486
  cooldownUntil: now + cooldownMs,
465
487
  cooldownSource: parsedRetry ? "retry-after" : "default",
466
488
  });
489
+ sweepExpiredOnWrite(now);
467
490
  clearAnthropicSessionAffinityForAccount(failedAccountId);
468
491
  notePoolRotationFailure(POOL_KEY_ANTHROPIC, failedAccountId);
469
492
 
@@ -473,8 +496,9 @@ export function rotateAnthropicAccountOn429(
473
496
  return null;
474
497
  }
475
498
 
476
- if (sessionKey?.trim()) {
477
- sessionAffinity.set(sessionKey.trim(), { accountId: next, lastUsedAt: now });
499
+ const affinityKey = normalizeAffinityComponent(sessionKey);
500
+ if (affinityKey && normalizeAffinityComponent(next)) {
501
+ sessionAffinity.set(affinityKey, { accountId: next, lastUsedAt: now });
478
502
  pruneExpiredAffinity(now);
479
503
  }
480
504
  console.warn(
@@ -32,7 +32,8 @@ const SCOPES = [
32
32
  ];
33
33
  const CALLBACK_PORT = 51121;
34
34
  const CALLBACK_PATH = "/callback";
35
- const REFRESH_SKEW_MS = 50 * 60 * 1000; // refresh proactively ~50min before nominal 1h expiry
35
+ // Keep provider-side margins small: the shared OAuth freshness gate applies an additional minute.
36
+ const REFRESH_SKEW_MS = 5 * 60 * 1000;
36
37
  const REQUEST_TIMEOUT_MS = 30_000;
37
38
  const ONBOARD_ATTEMPTS = 5;
38
39
  const ONBOARD_POLL_MS = 2_000;
@@ -3,8 +3,8 @@ import { getAnthropicAccountHealthSnapshot } from "./anthropic-routing";
3
3
  import { isAccountNeedsReauth } from "../codex/account-runtime-state";
4
4
  import { getCodexAccountCredential, listCodexAccountIds } from "../codex/account-store";
5
5
  import { MAIN_CODEX_ACCOUNT_ID } from "../codex/main-account";
6
+ import { configuredAdminToken } from "../lib/admin-secrets";
6
7
  import { maskAccountId } from "../lib/privacy";
7
- import { loadServiceTokenFromFile } from "../lib/service-secrets";
8
8
  import { findLiveProxy, probeHostname } from "../server/proxy-liveness";
9
9
  import { loadAuthStore, peekAuthStore, peekOAuthRefreshIntent, readOAuthRefreshIntent } from "./store";
10
10
  import type { ProviderAccount } from "./types";
@@ -320,13 +320,20 @@ function coerceRemoteAccountHealth(
320
320
  return projectOAuthAccountHealth({ needsReauth: account.needsReauth === true });
321
321
  }
322
322
 
323
+ type LiveProxyCodexHealthResult = {
324
+ source: CodexHealthSource;
325
+ entries: OAuthHealthEntry[] | null;
326
+ };
327
+
323
328
  async function fetchCodexHealthFromLiveProxy(
324
329
  fetchImpl: typeof fetch = fetch,
325
330
  findLiveProxyImpl: typeof findLiveProxy = findLiveProxy,
326
- ): Promise<OAuthHealthEntry[] | null> {
331
+ ): Promise<LiveProxyCodexHealthResult> {
327
332
  const live = await findLiveProxyImpl();
328
- if (!live) return null;
329
- const token = process.env.OPENCODEX_API_AUTH_TOKEN ?? loadServiceTokenFromFile(process.env);
333
+ if (!live) return { source: "unavailable", entries: null };
334
+ // This is a management-plane endpoint. A data-plane service token is intentionally not
335
+ // interchangeable with the admin credential even on loopback.
336
+ const token = configuredAdminToken();
330
337
  const headers: Record<string, string> = {};
331
338
  if (token) headers.Authorization = `Bearer ${token}`;
332
339
  try {
@@ -334,22 +341,29 @@ async function fetchCodexHealthFromLiveProxy(
334
341
  `http://${probeHostname(live.hostname)}:${live.port}/api/codex-auth/accounts`,
335
342
  { headers, signal: AbortSignal.timeout(4000) },
336
343
  );
337
- if (!res.ok) return null;
344
+ if (res.status === 401 || res.status === 403) {
345
+ return { source: "management-auth-failed", entries: null };
346
+ }
347
+ if (!res.ok) return { source: "management-api-unavailable", entries: null };
338
348
  const json = await res.json() as { accounts?: ProxyCodexAccountHealth[] };
339
- if (!Array.isArray(json.accounts)) return null;
349
+ if (!Array.isArray(json.accounts)) return { source: "management-api-unavailable", entries: null };
340
350
  const entries: OAuthHealthEntry[] = [];
341
351
  for (const account of json.accounts) {
342
352
  if (!account?.id || typeof account.id !== "string") continue;
343
353
  pushEntry(entries, "codex", account.id, coerceRemoteAccountHealth(account));
344
354
  }
345
- return entries;
355
+ return { source: "management-api", entries };
346
356
  } catch {
347
- return null;
357
+ return { source: "management-api-unavailable", entries: null };
348
358
  }
349
359
  }
350
360
 
351
361
  /** How CLI/doctor obtained Codex cooldown/reauth (proxy memory only lives in the proxy). */
352
- export type CodexHealthSource = "management-api" | "unavailable";
362
+ export type CodexHealthSource =
363
+ | "management-api"
364
+ | "unavailable"
365
+ | "management-auth-failed"
366
+ | "management-api-unavailable";
353
367
 
354
368
  export type OAuthCliHealthReport = {
355
369
  entries: OAuthHealthEntry[];
@@ -359,6 +373,10 @@ export type OAuthCliHealthReport = {
359
373
  /** Shown by `ocx status` / `ocx doctor` when the proxy management API is unreachable. */
360
374
  export const CODEX_HEALTH_UNAVAILABLE_NOTE =
361
375
  "Codex health: unavailable (proxy not running; live cooldown/reauth requires the management API)";
376
+ export const CODEX_HEALTH_AUTH_FAILED_NOTE =
377
+ "Codex health: unavailable (proxy running; management authentication failed)";
378
+ export const CODEX_HEALTH_MANAGEMENT_API_UNAVAILABLE_NOTE =
379
+ "Codex health: unavailable (proxy running; management API did not return account health)";
362
380
 
363
381
  /**
364
382
  * CLI/doctor collector: observe-only OAuth store reads, and Codex health only from the
@@ -373,9 +391,9 @@ export async function collectOAuthHealthEntriesForCli(
373
391
  ): Promise<OAuthCliHealthReport> {
374
392
  const entries = collectOAuthHealthEntries(now, { observeOnly: true, includeLocalCodex: false });
375
393
  const remote = await fetchCodexHealthFromLiveProxy(deps.fetchImpl, deps.findLiveProxyImpl);
376
- if (remote) {
377
- for (const entry of remote) entries.push(entry);
394
+ if (remote.entries) {
395
+ for (const entry of remote.entries) entries.push(entry);
378
396
  return { entries, codexHealthSource: "management-api" };
379
397
  }
380
- return { entries, codexHealthSource: "unavailable" };
398
+ return { entries, codexHealthSource: remote.source };
381
399
  }
@@ -4,7 +4,7 @@ import type { OcxConfig, OcxProviderConfig, RefreshPolicy } from "../types";
4
4
  import { loadConfig, resolveEnvValue, saveConfig } from "../config";
5
5
  import { maskEmail } from "../lib/privacy";
6
6
  import { KiroTokenRefreshError, environmentKiroRoutingMetadata, loginKiro, refreshKiroToken, settleKiroLoginTransaction } from "./kiro";
7
- import { getAccountCredential, getAccountSet, removeAccount, saveAccountCredential, saveCredential, setActiveAccount, getCredential, credentialGeneration, createOAuthRefreshIntentLock, mergeAccountCredential, markAccountNeedsReauthIfGeneration, readOAuthRefreshIntent, writeOAuthRefreshIntent, clearOAuthRefreshIntent } from "./store";
7
+ import { getAccountCredential, getAccountSet, removeAccount, saveAccountCredential, saveCredential, setActiveAccount, getCredential, credentialGeneration, createOAuthRefreshIntentLock, mergeAccountCredential, markAccountNeedsReauthIfGeneration, readOAuthRefreshIntent, writeOAuthRefreshIntent, markOAuthRefreshIntentStaleOwner, clearOAuthRefreshIntent, OAuthMutationBusyError } from "./store";
8
8
  import { loginXai, refreshXaiToken, XAI_LOCAL_CLI_DETACH_WARNING, XaiTokenRequestError } from "./xai";
9
9
  import { ANTHROPIC_OAUTH_BETA, AnthropicTokenError, loginAnthropic, refreshAnthropicToken } from "./anthropic";
10
10
  import { loginKimi, refreshKimiToken } from "./kimi";
@@ -19,7 +19,12 @@ import { resolveProviderModelDiscoveryUrl } from "../providers/model-discovery";
19
19
  import { resolveProviderTransport } from "../providers/xai-transport";
20
20
  import { detectClaudeCodeToken, detectGrokCliToken, hasComparableGrokIdentity, isSameGrokIdentity, shouldAdoptGrokGeneration } from "./local-token-detect";
21
21
  import { logOAuthEvent } from "./log";
22
+ import { captureConfigGeneration, sweepExpiredOnWrite, type GenerationContext } from "../lib/state-store-sweeper";
23
+ import { retainedUtf8Bytes } from "../lib/admission";
24
+ import { randomUUID } from "node:crypto";
22
25
  export {
26
+ CODEX_HEALTH_AUTH_FAILED_NOTE,
27
+ CODEX_HEALTH_MANAGEMENT_API_UNAVAILABLE_NOTE,
23
28
  CODEX_HEALTH_UNAVAILABLE_NOTE,
24
29
  MASKED_ACCOUNT_FALLBACK,
25
30
  collectOAuthHealthEntries,
@@ -51,14 +56,57 @@ export interface OAuthAccessSnapshot {
51
56
  kiro?: Pick<KiroOAuthMetadata, "profileArn" | "apiRegion" | "ssoRegion">;
52
57
  }
53
58
 
54
- const tokenRefreshes = new Map<string, Promise<OAuthAccessSnapshot>>();
59
+ const MAX_OAUTH_TOKEN_REFRESH_FLIGHTS = 32;
60
+ const OAUTH_TOKEN_REFRESH_FLIGHT_STALE_MS = 120_000;
61
+ interface OAuthRefreshFlightEvidence { flightId: string; dispatched: boolean }
62
+ interface OAuthTokenRefreshFlight extends OAuthRefreshFlightEvidence { promise: Promise<OAuthAccessSnapshot>; startedAt: number; abort: AbortController }
63
+ const tokenRefreshes = new Map<string, OAuthTokenRefreshFlight>();
64
+ export class OAuthTokenRefreshBusyError extends Error {
65
+ readonly code = "OAUTH_TOKEN_REFRESH_BUSY";
66
+ readonly retryable = true;
67
+ constructor() { super("OAuth token refresh capacity reached"); this.name = "OAuthTokenRefreshBusyError"; }
68
+ }
69
+ export class OAuthTokenRefreshStaleError extends Error {
70
+ readonly code = "OAUTH_TOKEN_REFRESH_STALE";
71
+ readonly retryable = true;
72
+ constructor() { super("OAuth token refresh owner became stale"); this.name = "OAuthTokenRefreshStaleError"; }
73
+ }
74
+
75
+ /** Focused owner-identity tests only. Synthetic owners retain no account data. */
76
+ export function seedOAuthTokenRefreshFlightsForTests(rows: Array<{ key: string; startedAt?: number; flightId?: string; dispatched?: boolean }>): {
77
+ promises: Promise<OAuthAccessSnapshot>[];
78
+ cleanup: () => void;
79
+ } {
80
+ const inserted: OAuthTokenRefreshFlight[] = [];
81
+ const promises = rows.map(({ key, startedAt, flightId, dispatched }) => {
82
+ const abort = new AbortController();
83
+ const promise = new Promise<OAuthAccessSnapshot>((_resolve, reject) => {
84
+ abort.signal.addEventListener("abort", () => reject(abort.signal.reason), { once: true });
85
+ });
86
+ const flight = { promise, startedAt: startedAt ?? Date.now(), abort, flightId: flightId ?? randomUUID(), dispatched: dispatched ?? false };
87
+ tokenRefreshes.set(key, flight);
88
+ inserted.push(flight);
89
+ return promise;
90
+ });
91
+ return {
92
+ promises,
93
+ cleanup() {
94
+ for (const [key, flight] of tokenRefreshes) {
95
+ if (!inserted.includes(flight)) continue;
96
+ tokenRefreshes.delete(key);
97
+ flight.abort.abort(new Error("test cleanup"));
98
+ }
99
+ },
100
+ };
101
+ }
55
102
  const XAI_PERMANENT_FAILURE_TTL_MS=30_000;
56
103
  const permanentRefreshFailures=new Map<string,number>();
57
- interface XaiRefreshDeps { intentLock?:ReturnType<typeof createOAuthRefreshIntentLock>; now?:()=>number; afterPrePersistRead?:()=>void|Promise<void> }
58
- interface AnthropicRefreshDeps { intentLock?:ReturnType<typeof createOAuthRefreshIntentLock>; now?:()=>number; afterPrePersistRead?:()=>void|Promise<void> }
59
- interface GenericRefreshDeps { intentLock?:ReturnType<typeof createOAuthRefreshIntentLock>; afterPrePersistRead?:()=>void|Promise<void> }
104
+ interface XaiRefreshDeps { intentLock?:ReturnType<typeof createOAuthRefreshIntentLock>; now?:()=>number; afterPrePersistRead?:()=>void|Promise<void>; signal?: AbortSignal }
105
+ interface AnthropicRefreshDeps { intentLock?:ReturnType<typeof createOAuthRefreshIntentLock>; now?:()=>number; afterPrePersistRead?:()=>void|Promise<void>; signal?: AbortSignal; flight?: OAuthRefreshFlightEvidence; replacedStaleFlight?: OAuthRefreshFlightEvidence }
106
+ interface GenericRefreshDeps { intentLock?:ReturnType<typeof createOAuthRefreshIntentLock>; afterPrePersistRead?:()=>void|Promise<void>; signal?: AbortSignal }
60
107
  function verdictKey(p:string,a:string,c:OAuthCredentials){return `${p}\0${a}\0${credentialGeneration(c)}`;}
61
108
  function cached(p:string,a:string,c:OAuthCredentials,now:()=>number){const k=verdictKey(p,a,c),u=permanentRefreshFailures.get(k);if(u===undefined)return false;if(u<=now()){permanentRefreshFailures.delete(k);return false;}return true;}
109
+ export function sweepExpiredXaiPermanentFailureVerdicts(now=Date.now()):number{let removed=0;for(const[key,until]of permanentRefreshFailures){if(until>now)continue;permanentRefreshFailures.delete(key);removed+=1;}return removed;}
62
110
 
63
111
  export interface LoginOpts { forceLogin?: boolean; /** When set, persist into this account slot and require matching identity. */ reauthAccountId?: string }
64
112
 
@@ -244,24 +292,44 @@ async function resolveAccessSnapshotForAccount(
244
292
  if (rejectedGeneration === undefined && cred.expires > Date.now() + REFRESH_SKEW_MS) return current;
245
293
 
246
294
  const key = `${provider}\u0000${accountId}`;
247
- const existing = tokenRefreshes.get(key);
248
- if (existing) {
295
+ let existing = tokenRefreshes.get(key);
296
+ let replacedStaleFlight: OAuthRefreshFlightEvidence | undefined;
297
+ if (existing && Date.now() - existing.startedAt <= OAUTH_TOKEN_REFRESH_FLIGHT_STALE_MS) {
249
298
  logOAuthEvent("OAuth refresh joined existing operation", { provider, accountId });
250
- return existing;
299
+ return existing.promise;
300
+ }
301
+ if (existing) {
302
+ replacedStaleFlight = { flightId: existing.flightId, dispatched: existing.dispatched };
303
+ existing.abort.abort(new OAuthTokenRefreshStaleError());
304
+ if (tokenRefreshes.get(key) === existing) tokenRefreshes.delete(key);
305
+ existing = undefined;
251
306
  }
307
+ if (tokenRefreshes.size >= MAX_OAUTH_TOKEN_REFRESH_FLIGHTS) throw new OAuthTokenRefreshBusyError();
252
308
 
309
+ const abort = new AbortController();
310
+ const flight: OAuthTokenRefreshFlight = {
311
+ promise: undefined as unknown as Promise<OAuthAccessSnapshot>,
312
+ startedAt: Date.now(),
313
+ abort,
314
+ flightId: randomUUID(),
315
+ dispatched: false,
316
+ };
253
317
  const refresh = (async (): Promise<OAuthAccessSnapshot> => {
254
- const accessToken = await refreshAndPersistAccessToken(provider, accountId, def, cred);
318
+ const accessToken = await refreshAndPersistAccessToken(provider, accountId, def, cred, abort.signal, flight, replacedStaleFlight);
255
319
  const persisted = getAccountCredential(provider, accountId);
256
320
  if (!persisted) throw new OAuthLoginRequiredError(provider);
257
321
  if (persisted.access !== accessToken) {
258
322
  throw new Error(`OAuth refresh persisted an unexpected access token for ${provider}`);
259
323
  }
260
324
  return accessSnapshot(provider, accountId, persisted);
261
- })().finally(() => {
262
- if (tokenRefreshes.get(key) === refresh) tokenRefreshes.delete(key);
325
+ })().catch(error => {
326
+ if (abort.signal.reason instanceof OAuthTokenRefreshStaleError) throw abort.signal.reason;
327
+ throw error;
328
+ }).finally(() => {
329
+ if (tokenRefreshes.get(key) === flight) tokenRefreshes.delete(key);
263
330
  });
264
- tokenRefreshes.set(key, refresh);
331
+ flight.promise = refresh;
332
+ tokenRefreshes.set(key, flight);
265
333
  return refresh;
266
334
  }
267
335
 
@@ -323,7 +391,7 @@ function merged(fresh: OAuthCredentials, previous: OAuthCredentials): OAuthCrede
323
391
  ...(fresh.kiro === undefined && previous.kiro ? { kiro: previous.kiro } : {}),
324
392
  };
325
393
  }
326
- export async function refreshXaiAccountWithLock(provider:string,accountId:string,def:OAuthProviderDef,callerCredential:OAuthCredentials,deps:XaiRefreshDeps={}):Promise<string>{const now=deps.now??Date.now;const guard=await(deps.intentLock??createOAuthRefreshIntentLock(provider,accountId)).acquire();try{const stored=getAccountCredential(provider,accountId);if(!stored)throw new OAuthLoginRequiredError(provider);const active=getAccountSet(provider)?.activeAccountId===accountId,candidate=authoritative(stored,active,now);if(credentialGeneration(candidate)!==credentialGeneration(callerCredential)&&candidate.expires>now()+REFRESH_SKEW_MS){if(credentialGeneration(candidate)!==credentialGeneration(stored)){const o=await mergeAccountCredential(provider,accountId,candidate,{expectedGeneration:credentialGeneration(stored),afterPrePersistRead:deps.afterPrePersistRead});if(o.superseded){if(o.stored.expires>now()+REFRESH_SKEW_MS)return o.stored.access;throw new OAuthLoginRequiredError(provider);}}return candidate.access;}if(cached(provider,accountId,candidate,now))throw new OAuthLoginRequiredError(provider);const generation=credentialGeneration(candidate);try{const fresh=merged(await def.refresh(candidate.refresh),candidate);const o=await mergeAccountCredential(provider,accountId,fresh,{expectedGeneration:generation,afterPrePersistRead:deps.afterPrePersistRead});if(o.superseded){if(o.stored.expires>now()+REFRESH_SKEW_MS)return o.stored.access;throw new OAuthLoginRequiredError(provider);}permanentRefreshFailures.delete(verdictKey(provider,accountId,candidate));if(candidate.source==="local-cli")console.warn(XAI_LOCAL_CLI_DETACH_WARNING);return fresh.access;}catch(error){if(!terminal(error))throw error;permanentRefreshFailures.set(verdictKey(provider,accountId,candidate),now()+XAI_PERMANENT_FAILURE_TTL_MS);await markAccountNeedsReauthIfGeneration(provider,accountId,generation);throw new OAuthLoginRequiredError(provider);}}finally{guard.release();}}
394
+ export async function refreshXaiAccountWithLock(provider:string,accountId:string,def:OAuthProviderDef,callerCredential:OAuthCredentials,deps:XaiRefreshDeps={}):Promise<string>{const writerGeneration=captureConfigGeneration();const now=deps.now??Date.now;const guard=await(deps.intentLock??createOAuthRefreshIntentLock(provider,accountId)).acquire();try{const stored=getAccountCredential(provider,accountId);if(!stored)throw new OAuthLoginRequiredError(provider);const active=getAccountSet(provider)?.activeAccountId===accountId,candidate=authoritative(stored,active,now);if(credentialGeneration(candidate)!==credentialGeneration(callerCredential)&&candidate.expires>now()+REFRESH_SKEW_MS){if(credentialGeneration(candidate)!==credentialGeneration(stored)){const o=await mergeAccountCredential(provider,accountId,candidate,{expectedGeneration:credentialGeneration(stored),afterPrePersistRead:deps.afterPrePersistRead});if(o.superseded){if(o.stored.expires>now()+REFRESH_SKEW_MS)return o.stored.access;throw new OAuthLoginRequiredError(provider);}}return candidate.access;}if(cached(provider,accountId,candidate,now))throw new OAuthLoginRequiredError(provider);const generation=credentialGeneration(candidate);try{const fresh=merged(await def.refresh(candidate.refresh,deps.signal),candidate);const o=await mergeAccountCredential(provider,accountId,fresh,{expectedGeneration:generation,afterPrePersistRead:deps.afterPrePersistRead});if(o.superseded){if(o.stored.expires>now()+REFRESH_SKEW_MS)return o.stored.access;throw new OAuthLoginRequiredError(provider);}permanentRefreshFailures.delete(verdictKey(provider,accountId,candidate));if(candidate.source==="local-cli")console.warn(XAI_LOCAL_CLI_DETACH_WARNING);return fresh.access;}catch(error){if(error instanceof OAuthMutationBusyError){permanentRefreshFailures.delete(verdictKey(provider,accountId,candidate));throw error;}if(!terminal(error))throw error;const failedAt=now();permanentRefreshFailures.set(verdictKey(provider,accountId,candidate),failedAt+XAI_PERMANENT_FAILURE_TTL_MS);sweepExpiredOnWrite(failedAt);await markAccountNeedsReauthIfGeneration(provider,accountId,generation,writerGeneration);throw new OAuthLoginRequiredError(provider);}}finally{guard.release();}}
327
395
 
328
396
  function newerClaudeCredential(stored: OAuthCredentials, now: number): OAuthCredentials | undefined {
329
397
  if (stored.source !== "local-cli") return undefined;
@@ -339,6 +407,7 @@ export async function refreshAnthropicAccountWithLock(
339
407
  callerCredential: OAuthCredentials,
340
408
  deps: AnthropicRefreshDeps = {},
341
409
  ): Promise<string> {
410
+ const writerGeneration = captureConfigGeneration();
342
411
  const now = deps.now ?? Date.now;
343
412
  const guard = await (deps.intentLock ?? createOAuthRefreshIntentLock(provider, accountId)).acquire();
344
413
  try {
@@ -346,7 +415,7 @@ export async function refreshAnthropicAccountWithLock(
346
415
  if (!stored) throw new OAuthLoginRequiredError(provider);
347
416
  const account = getAccountSet(provider)?.accounts.find(candidate => candidate.id === accountId);
348
417
  const generation = credentialGeneration(stored);
349
- const pendingIntent = readOAuthRefreshIntent(provider, accountId);
418
+ let pendingIntent = readOAuthRefreshIntent(provider, accountId);
350
419
  const disk = newerClaudeCredential(stored, now());
351
420
  if (disk) {
352
421
  const outcome = await mergeAccountCredential(provider, accountId, disk, {
@@ -361,8 +430,19 @@ export async function refreshAnthropicAccountWithLock(
361
430
  if (pendingIntent) clearOAuthRefreshIntent(provider, accountId, pendingIntent.generation);
362
431
  return disk.access;
363
432
  }
433
+ if (!pendingIntent?.uncertain && pendingIntent?.generation === generation) {
434
+ if (pendingIntent.staleOwner) throw new OAuthTokenRefreshStaleError();
435
+ if (deps.replacedStaleFlight && pendingIntent.flightId === deps.replacedStaleFlight.flightId) {
436
+ if (deps.replacedStaleFlight.dispatched) {
437
+ markOAuthRefreshIntentStaleOwner(provider, accountId, generation, deps.replacedStaleFlight.flightId);
438
+ throw new OAuthTokenRefreshStaleError();
439
+ }
440
+ clearOAuthRefreshIntent(provider, accountId, generation);
441
+ pendingIntent = undefined;
442
+ }
443
+ }
364
444
  if (pendingIntent?.uncertain || pendingIntent?.generation === generation) {
365
- await markAccountNeedsReauthIfGeneration(provider, accountId, generation);
445
+ await markAccountNeedsReauthIfGeneration(provider, accountId, generation, writerGeneration);
366
446
  throw new OAuthLoginRequiredError(provider);
367
447
  }
368
448
  if (pendingIntent) clearOAuthRefreshIntent(provider, accountId, pendingIntent.generation);
@@ -374,8 +454,10 @@ export async function refreshAnthropicAccountWithLock(
374
454
  }
375
455
 
376
456
  try {
377
- writeOAuthRefreshIntent(provider, accountId, generation, now());
378
- const fresh = merged(await def.refresh(stored.refresh), stored);
457
+ writeOAuthRefreshIntent(provider, accountId, generation, now(), deps.flight?.flightId);
458
+ if (deps.signal?.aborted) throw deps.signal.reason;
459
+ if (deps.flight) deps.flight.dispatched = true;
460
+ const fresh = merged(await def.refresh(stored.refresh, deps.signal), stored);
379
461
  const outcome = await mergeAccountCredential(provider, accountId, fresh, {
380
462
  expectedGeneration: generation,
381
463
  afterPrePersistRead: deps.afterPrePersistRead,
@@ -388,8 +470,9 @@ export async function refreshAnthropicAccountWithLock(
388
470
  clearOAuthRefreshIntent(provider, accountId, generation);
389
471
  return fresh.access;
390
472
  } catch (error) {
473
+ if (error instanceof OAuthMutationBusyError) throw error;
391
474
  if (!terminal(error)) throw error;
392
- await markAccountNeedsReauthIfGeneration(provider, accountId, generation);
475
+ await markAccountNeedsReauthIfGeneration(provider, accountId, generation, writerGeneration);
393
476
  clearOAuthRefreshIntent(provider, accountId, generation);
394
477
  throw new OAuthLoginRequiredError(provider);
395
478
  }
@@ -405,6 +488,7 @@ export async function refreshGenericAccountWithLock(
405
488
  callerCredential: OAuthCredentials,
406
489
  deps: GenericRefreshDeps = {},
407
490
  ): Promise<string> {
491
+ const writerGeneration = captureConfigGeneration();
408
492
  logOAuthEvent("OAuth refresh started", { provider, accountId });
409
493
  const guard = await (deps.intentLock ?? createOAuthRefreshIntentLock(provider, accountId)).acquire();
410
494
  try {
@@ -419,7 +503,7 @@ export async function refreshGenericAccountWithLock(
419
503
  }
420
504
  const generation = credentialGeneration(stored);
421
505
  try {
422
- const fresh = merged(await def.refresh(stored.refresh, undefined, stored), stored);
506
+ const fresh = merged(await def.refresh(stored.refresh, deps.signal, stored), stored);
423
507
  const outcome = await mergeAccountCredential(provider, accountId, fresh, {
424
508
  expectedGeneration: generation,
425
509
  afterPrePersistRead: deps.afterPrePersistRead,
@@ -431,8 +515,9 @@ export async function refreshGenericAccountWithLock(
431
515
  logOAuthEvent("OAuth credentials rotated and persisted", { provider, accountId });
432
516
  return fresh.access;
433
517
  } catch (error) {
518
+ if (error instanceof OAuthMutationBusyError) throw error;
434
519
  if (!terminal(error)) throw error;
435
- await markAccountNeedsReauthIfGeneration(provider, accountId, generation);
520
+ await markAccountNeedsReauthIfGeneration(provider, accountId, generation, writerGeneration);
436
521
  throw new OAuthLoginRequiredError(provider);
437
522
  }
438
523
  } finally {
@@ -445,10 +530,13 @@ async function refreshAndPersistAccessToken(
445
530
  accountId: string,
446
531
  def: OAuthProviderDef,
447
532
  cred: OAuthCredentials,
533
+ signal?: AbortSignal,
534
+ flight?: OAuthRefreshFlightEvidence,
535
+ replacedStaleFlight?: OAuthRefreshFlightEvidence,
448
536
  ): Promise<string> {
449
- if (provider === "xai") return refreshXaiAccountWithLock(provider, accountId, def, cred);
450
- if (provider === "anthropic") return refreshAnthropicAccountWithLock(provider, accountId, def, cred);
451
- return refreshGenericAccountWithLock(provider, accountId, def, cred);
537
+ if (provider === "xai") return refreshXaiAccountWithLock(provider, accountId, def, cred, { signal });
538
+ if (provider === "anthropic") return refreshAnthropicAccountWithLock(provider, accountId, def, cred, { signal, flight, replacedStaleFlight });
539
+ return refreshGenericAccountWithLock(provider, accountId, def, cred, { signal });
452
540
  }
453
541
 
454
542
  /**
@@ -831,6 +919,21 @@ interface ManualCodeSlot {
831
919
  expectedState?: string;
832
920
  }
833
921
  const loginManual = new Map<string, ManualCodeSlot>();
922
+ const OAUTH_PENDING_CODE_MAX_BYTES = 4 * 1024;
923
+ let lastOAuthFlowReconciledGeneration = 0;
924
+
925
+ export function reconcileOAuthFlowState(context: GenerationContext): number {
926
+ if (context.generation <= lastOAuthFlowReconciledGeneration) return 0;
927
+ let removed = 0;
928
+ for (const [provider, state] of loginState) {
929
+ if (context.providerNames.has(provider) || !state.done || loginAbort.has(provider)) continue;
930
+ if (loginState.delete(provider)) removed += 1;
931
+ if (loginManual.delete(provider)) removed += 1;
932
+ if (loginAbort.delete(provider)) removed += 1;
933
+ }
934
+ lastOAuthFlowReconciledGeneration = context.generation;
935
+ return removed;
936
+ }
834
937
 
835
938
  function clearManualCodeSlot(provider: string): void {
836
939
  loginManual.delete(provider);
@@ -879,6 +982,7 @@ function waitForManualLoginCode(provider: string, signal: AbortSignal, expectedS
879
982
  export function submitManualLoginCode(provider: string, input: string): { ok: true } | { ok: false; error: string } {
880
983
  const trimmed = input.trim();
881
984
  if (!trimmed) return { ok: false, error: "empty code" };
985
+ if (retainedUtf8Bytes(trimmed) > OAUTH_PENDING_CODE_MAX_BYTES) return { ok: false, error: "code too large" };
882
986
  const st = loginState.get(provider);
883
987
  if (!st || st.done) return { ok: false, error: "no login in progress" };
884
988
  const slot = ensureManualCodeSlot(provider);
@@ -1,5 +1,5 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { chmodSync, closeSync, existsSync, fsyncSync, linkSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
+ import { chmodSync, closeSync, existsSync, fsyncSync, linkSync, openSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import { isAbsolute, join, posix, win32 } from "node:path";
5
5
  import { Database } from "bun:sqlite";
@@ -169,6 +169,77 @@ export function resolveKiroCliNativeSessionEntries(
169
169
  return [{ location: "kiro-cli-linux-data", path: posix.join(home, ".local", "share", "kiro-cli", "data.sqlite3") }];
170
170
  }
171
171
 
172
+ /**
173
+ * Resolve the absolute kiro-cli executable for spawn/login helpers.
174
+ *
175
+ * Pure + parameterized like `resolveKiroCliNativeSessionEntries` so Windows install layouts can be
176
+ * covered from any host. PATH remains the first choice; only when bare `kiro-cli` is missing do we
177
+ * fall back to the platform-native install directories next to the session database.
178
+ *
179
+ * Windows: official MSI installs to `C:\Program Files\Kiro-Cli\kiro-cli.exe`, while some local
180
+ * installs keep the binary next to `%LOCALAPPDATA%\Kiro-Cli\data.sqlite3`.
181
+ * macOS/Linux: prefer PATH, then the usual user-local bin directories.
182
+ */
183
+ export function resolveKiroCliExecutable(
184
+ inputs: KiroCliNativeInputs & {
185
+ pathEntries?: string[];
186
+ exists?: (path: string) => boolean;
187
+ isFile?: (path: string) => boolean;
188
+ },
189
+ ): string {
190
+ const exists = inputs.exists ?? existsSync;
191
+ // A directory named `kiro-cli` on PATH satisfies existsSync and would then be handed to
192
+ // spawn(), which fails with EACCES at login instead of falling through to the next candidate.
193
+ // When a caller injects `exists` it owns the whole filesystem view, so the real stat would
194
+ // reject every synthetic path; such callers inject `isFile` too when they care about it.
195
+ const isFile = inputs.isFile ?? (inputs.exists ? () => true : ((path: string) => {
196
+ try {
197
+ return statSync(path).isFile();
198
+ } catch {
199
+ return false;
200
+ }
201
+ }));
202
+ const pathEntries = inputs.pathEntries
203
+ ?? (inputs.env.PATH ?? inputs.env.Path ?? "").split(inputs.platform === "win32" ? ";" : ":")
204
+ .map(entry => entry.trim())
205
+ .filter(Boolean);
206
+
207
+ const pathCandidates = inputs.platform === "win32"
208
+ ? pathEntries.flatMap(entry => [
209
+ win32.join(entry, "kiro-cli.exe"),
210
+ win32.join(entry, "kiro-cli"),
211
+ ])
212
+ : pathEntries.map(entry => posix.join(entry, "kiro-cli"));
213
+
214
+ const installCandidates: string[] = [];
215
+ if (inputs.platform === "win32") {
216
+ const localBase = inputs.env.LOCALAPPDATA?.trim()
217
+ || (inputs.env.USERPROFILE?.trim() ? win32.join(inputs.env.USERPROFILE.trim(), "AppData", "Local") : "")
218
+ || win32.join(inputs.home, "AppData", "Local");
219
+ const programFiles = inputs.env["ProgramFiles"]?.trim() || "C:\\Program Files";
220
+ installCandidates.push(
221
+ win32.join(localBase, "Kiro-Cli", "kiro-cli.exe"),
222
+ win32.join(programFiles, "Kiro-Cli", "kiro-cli.exe"),
223
+ );
224
+ } else if (inputs.platform === "darwin") {
225
+ installCandidates.push(
226
+ posix.join(inputs.home, ".local", "bin", "kiro-cli"),
227
+ "/usr/local/bin/kiro-cli",
228
+ "/opt/homebrew/bin/kiro-cli",
229
+ );
230
+ } else {
231
+ installCandidates.push(
232
+ posix.join(inputs.home, ".local", "bin", "kiro-cli"),
233
+ "/usr/local/bin/kiro-cli",
234
+ );
235
+ }
236
+
237
+ for (const candidate of [...pathCandidates, ...installCandidates]) {
238
+ if (exists(candidate) && isFile(candidate)) return candidate;
239
+ }
240
+ return inputs.platform === "win32" ? "kiro-cli.exe" : "kiro-cli";
241
+ }
242
+
172
243
  function nativeKiroCliSessionEntries(): Array<{ location: KiroCliNativeLocation; path: string }> {
173
244
  // Only the stores that `kiro-cli logout` / `kiro-cli login` themselves mutate. Import fallbacks
174
245
  // (Amazon Q / SSO cache) and KIROCLI_DB_PATH selectors must not be snapshotted for rollback.
package/src/oauth/kiro.ts CHANGED
@@ -18,6 +18,7 @@ import {
18
18
  persistKiroCliSessionRecovery,
19
19
  readImportedKiroCredential,
20
20
  readKiroCliSqliteCredential,
21
+ resolveKiroCliExecutable,
21
22
  restoreKiroCliSession,
22
23
  restoreStaleKiroCliSessionRecovery,
23
24
  requireKiroRegion,
@@ -25,11 +26,14 @@ import {
25
26
  type KiroCliSessionSnapshot,
26
27
  type KiroImportDiagnostic,
27
28
  } from "./kiro-credentials";
29
+ import { homedir } from "node:os";
28
30
  import { getAccountSet, saveAccountCredential } from "./store";
29
31
 
30
32
  const DEFAULT_REGION = "us-east-1";
31
33
  const REFRESH_URL = "https://prod.{region}.auth.desktop.kiro.dev/refreshToken";
32
34
  const OIDC_URL = "https://oidc.{region}.amazonaws.com/token";
35
+ const KIRO_CLI_UNIX_INSTALL_COMMAND = "curl -fsSL https://cli.kiro.dev/install | bash";
36
+ const KIRO_CLI_WINDOWS_INSTALL_COMMAND = "irm 'https://cli.kiro.dev/install.ps1' | iex";
33
37
  const KIRO_TERMINAL_REFRESH_ERRORS = new Set([
34
38
  "invalid_grant",
35
39
  "refresh_token_reused",
@@ -68,13 +72,28 @@ export interface KiroLoginOptions {
68
72
  cliRunner?: KiroCliRunner;
69
73
  }
70
74
 
75
+ export function kiroCliInstallGuidance(platform = process.platform): string {
76
+ return platform === "win32"
77
+ ? `install the Kiro CLI in PowerShell (\`${KIRO_CLI_WINDOWS_INSTALL_COMMAND}\`)`
78
+ : `install the Kiro CLI (\`${KIRO_CLI_UNIX_INSTALL_COMMAND}\`)`;
79
+ }
80
+
71
81
  const pendingKiroLoginTransactions = new WeakMap<OAuthCredentials, KiroCliSessionSnapshot>();
72
82
  /** Forced logins that started with no native CLI DB must logout on persistence failure. */
73
83
  const pendingKiroEmptyPriorSessions = new WeakSet<OAuthCredentials>();
74
84
 
85
+
86
+ function resolveRuntimeKiroCliExecutable(): string {
87
+ return resolveKiroCliExecutable({
88
+ env: process.env,
89
+ platform: process.platform,
90
+ home: process.platform === "win32" ? homedir() : (process.env.HOME || homedir()),
91
+ });
92
+ }
93
+
75
94
  function logoutKiroCliBestEffort(): void {
76
95
  try {
77
- Bun.spawnSync(["kiro-cli", "logout"], {
96
+ Bun.spawnSync([resolveRuntimeKiroCliExecutable(), "logout"], {
78
97
  stdin: "ignore",
79
98
  stdout: "ignore",
80
99
  stderr: "ignore",
@@ -128,7 +147,7 @@ async function defaultKiroCliRunner(args: string[], signal?: AbortSignal): Promi
128
147
  throwIfKiroLoginCancelled(signal);
129
148
  let child: ReturnType<typeof Bun.spawn>;
130
149
  try {
131
- child = Bun.spawn(["kiro-cli", ...args], {
150
+ child = Bun.spawn([resolveRuntimeKiroCliExecutable(), ...args], {
132
151
  stdin: "ignore",
133
152
  stdout: "pipe",
134
153
  stderr: "ignore",
@@ -346,7 +365,7 @@ export async function loginKiro(ctrl: OAuthController, options: KiroLoginOptions
346
365
  url: "",
347
366
  instructions:
348
367
  "No kiro-cli token found. Paste a Kiro access token below (starts with 'aoa'). " +
349
- "Otherwise install the Kiro CLI (`curl -fsSL https://cli.kiro.dev/install | bash`), " +
368
+ `Otherwise ${kiroCliInstallGuidance()}, ` +
350
369
  "run `kiro-cli login`, and retry — or set KIRO_ACCESS_TOKEN.",
351
370
  });
352
371
  ctrl.onProgress?.("No kiro-cli token found. Paste a Kiro access token (starts with 'aoa'), or install the Kiro CLI and run `kiro-cli login` first.");
@@ -364,7 +383,7 @@ export async function loginKiro(ctrl: OAuthController, options: KiroLoginOptions
364
383
  }
365
384
 
366
385
  throw new Error(
367
- "Kiro: no token found. Install the Kiro CLI (`curl -fsSL https://cli.kiro.dev/install | bash`) " +
386
+ `Kiro: no token found. ${kiroCliInstallGuidance()} ` +
368
387
  "and run `kiro-cli login` to import its session, or set KIRO_ACCESS_TOKEN. " +
369
388
  "Browser login is not supported for Kiro.",
370
389
  );