@bitkyc08/opencodex 2.7.42 → 2.7.43

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 (143) hide show
  1. package/README.md +8 -1
  2. package/bin/ocx.mjs +13 -14
  3. package/gui/dist/assets/index-Czw-jpTU.css +1 -0
  4. package/gui/dist/assets/index-cmds12BG.js +67 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +1 -1
  7. package/src/AGENTS.md +28 -0
  8. package/src/adapters/anthropic.ts +15 -6
  9. package/src/adapters/cursor/native-exec-shell.ts +18 -6
  10. package/src/adapters/cursor/protobuf-events.ts +24 -2
  11. package/src/adapters/cursor/protobuf-request.ts +1 -2
  12. package/src/adapters/cursor/tool-definitions.ts +68 -29
  13. package/src/adapters/google-wire-compiler.ts +4 -0
  14. package/src/adapters/google.ts +128 -2
  15. package/src/adapters/identity.ts +12 -2
  16. package/src/adapters/kiro.ts +49 -6
  17. package/src/adapters/mimo-free.ts +2 -0
  18. package/src/adapters/openai-responses.ts +246 -59
  19. package/src/claude/agents-inject.ts +5 -0
  20. package/src/claude/inbound.ts +26 -9
  21. package/src/cli/account-auth.ts +1 -1
  22. package/src/cli/agent-driven.ts +37 -0
  23. package/src/cli/claude.ts +3 -3
  24. package/src/cli/doctor.ts +23 -18
  25. package/src/cli/help.ts +42 -6
  26. package/src/cli/index.ts +88 -19
  27. package/src/cli/interactive-confirm.ts +129 -0
  28. package/src/cli/opencode.ts +701 -0
  29. package/src/cli/provider-runtime.ts +3 -0
  30. package/src/cli/provider.ts +31 -10
  31. package/src/cli/star-prompt.ts +55 -16
  32. package/src/cli/status.ts +47 -13
  33. package/src/codex/account-id.ts +34 -0
  34. package/src/codex/account-lifecycle.ts +4 -1
  35. package/src/codex/account-namespace-match.ts +63 -0
  36. package/src/codex/account-namespaces.ts +149 -0
  37. package/src/codex/account-pause.ts +20 -0
  38. package/src/codex/account-usability.ts +6 -1
  39. package/src/codex/app-server-processes.ts +511 -0
  40. package/src/codex/auth-api.ts +293 -34
  41. package/src/codex/auth-collision.ts +2 -1
  42. package/src/codex/auth-context.ts +60 -17
  43. package/src/codex/catalog/parsing.ts +16 -1
  44. package/src/codex/catalog/provider-fetch.ts +48 -12
  45. package/src/codex/catalog/sync.ts +18 -3
  46. package/src/codex/catalog.ts +1 -1
  47. package/src/codex/history-provider.ts +145 -1
  48. package/src/codex/inject.ts +114 -14
  49. package/src/codex/main-account.ts +2 -8
  50. package/src/codex/pool-rotation.ts +186 -0
  51. package/src/codex/quota.ts +15 -0
  52. package/src/codex/routing.ts +695 -106
  53. package/src/codex/shim.ts +4 -1
  54. package/src/codex/subagent-defaults.ts +550 -0
  55. package/src/codex/subagent-model-fallback.ts +2 -0
  56. package/src/codex/sync.ts +3 -0
  57. package/src/config.ts +566 -25
  58. package/src/images/artifacts.ts +516 -0
  59. package/src/images/fulfill-video.ts +163 -0
  60. package/src/images/fulfill.ts +111 -0
  61. package/src/images/index.ts +4 -0
  62. package/src/images/loop.ts +789 -0
  63. package/src/images/plan.ts +133 -0
  64. package/src/images/synthetic-tool.ts +133 -0
  65. package/src/images/types.ts +41 -0
  66. package/src/images/xai-client.ts +141 -0
  67. package/src/images/xai-video-client.ts +163 -0
  68. package/src/lib/admin-secrets.ts +25 -0
  69. package/src/lib/config-ownership.ts +327 -0
  70. package/src/lib/crash-guard.ts +2 -0
  71. package/src/lib/destination-policy.ts +122 -4
  72. package/src/lib/pinned-http.ts +151 -0
  73. package/src/lib/process-control.ts +2 -2
  74. package/src/lib/provider-outbound.ts +164 -0
  75. package/src/lib/provider-url.ts +14 -0
  76. package/src/lib/proxy-env.ts +18 -0
  77. package/src/lib/windows-elevation.ts +81 -3
  78. package/src/lib/windows-secret-acl.ts +189 -12
  79. package/src/lib/winsw.ts +2 -0
  80. package/src/oauth/anthropic-routing.ts +570 -0
  81. package/src/oauth/health.ts +6 -0
  82. package/src/oauth/index.ts +283 -72
  83. package/src/oauth/key-providers.ts +17 -6
  84. package/src/oauth/kimi.ts +2 -0
  85. package/src/oauth/kiro-credentials.ts +322 -10
  86. package/src/oauth/kiro.ts +423 -43
  87. package/src/oauth/login-cli.ts +32 -5
  88. package/src/oauth/store.ts +54 -4
  89. package/src/oauth/types.ts +11 -0
  90. package/src/providers/alibaba-region-migration.ts +16 -3
  91. package/src/providers/antigravity-models.ts +3 -0
  92. package/src/providers/api-keys.ts +13 -6
  93. package/src/providers/derive.ts +6 -0
  94. package/src/providers/key-failover.ts +24 -4
  95. package/src/providers/quota.ts +233 -29
  96. package/src/providers/registry.ts +11 -3
  97. package/src/responses/parser.ts +11 -0
  98. package/src/responses/state.ts +22 -8
  99. package/src/responses/tool-groups.ts +19 -0
  100. package/src/router.ts +14 -4
  101. package/src/server/auth-cors.ts +110 -22
  102. package/src/server/claude-messages.ts +8 -1
  103. package/src/server/gui-static.ts +30 -6
  104. package/src/server/images.ts +303 -9
  105. package/src/server/index.ts +77 -9
  106. package/src/server/lifecycle.ts +25 -1
  107. package/src/server/management/agent-settings-routes.ts +28 -4
  108. package/src/server/management/combo-routes.ts +7 -0
  109. package/src/server/management/config-routes.ts +5 -2
  110. package/src/server/management/logs-usage-routes.ts +167 -3
  111. package/src/server/management/oauth-account-routes.ts +163 -17
  112. package/src/server/management/provider-routes.ts +35 -7
  113. package/src/server/management/system-restart.ts +172 -0
  114. package/src/server/management/system-routes.ts +33 -10
  115. package/src/server/management-api.ts +2 -2
  116. package/src/server/management-auth.ts +216 -0
  117. package/src/server/proxy-liveness.ts +14 -3
  118. package/src/server/responses/compact.ts +21 -13
  119. package/src/server/responses/core.ts +583 -152
  120. package/src/server/responses-image-gen-repair.ts +118 -0
  121. package/src/server/responses-item-id-repair.ts +10 -85
  122. package/src/server/sse-payload-rewrite.ts +116 -0
  123. package/src/server/system-env.ts +28 -10
  124. package/src/service.ts +49 -2
  125. package/src/storage/cleanup-job.ts +57 -0
  126. package/src/storage/cleanup.ts +1504 -28
  127. package/src/storage/policy-job.ts +366 -0
  128. package/src/storage/policy-scheduler.ts +40 -0
  129. package/src/storage/policy-worker.ts +53 -0
  130. package/src/storage/policy.ts +522 -0
  131. package/src/storage/restore-job.ts +242 -0
  132. package/src/storage/restore-worker.ts +52 -0
  133. package/src/storage/storage-mutation-coordinator.ts +109 -0
  134. package/src/tray/windows.ts +2 -0
  135. package/src/types.ts +96 -1
  136. package/src/update/index.ts +36 -18
  137. package/src/update/job.ts +103 -12
  138. package/src/update/npm-invocation.d.mts +23 -0
  139. package/src/update/npm-invocation.mjs +94 -0
  140. package/src/usage/debug.ts +2 -0
  141. package/src/usage/log.ts +4 -0
  142. package/gui/dist/assets/index-Bl_VBGoI.js +0 -65
  143. package/gui/dist/assets/index-DfVGuN88.css +0 -1
package/src/oauth/kiro.ts CHANGED
@@ -1,29 +1,44 @@
1
1
  /**
2
2
  * Kiro (AWS CodeWhisperer) OAuth — import-first.
3
3
  *
4
- * Unlike browser/PKCE providers, kiro reuses the locally installed kiro-cli login:
5
- * it reads the kiro-cli SQLite token store, falls back to KIRO_ACCESS_TOKEN env, then to a
6
- * manual access-token paste (CLI only). Refresh hits the Kiro desktop refresh endpoint.
4
+ * Normal login imports the locally installed kiro-cli session. Account-add login deliberately
5
+ * asks kiro-cli to switch identities in its supported browser flow, then imports that fresh session.
7
6
  *
8
7
  * Ported from jawcode packages/ai/src/providers/kiro.ts (readKiroCliSqlite, refreshKiroDesktopToken).
9
- * profileArn/region are NOT stored in the credential — the kiro ADAPTER resolves them at request
10
- * time (SQLite profile_arn / KIRO_PROFILE_ARN, KIRO_REGION) since getValidAccessToken surfaces
11
- * only the access token.
8
+ * profileArn/region/client registration are persisted per OCX account so switching the account pool
9
+ * never combines one account's access token with another account's local Kiro profile metadata.
12
10
  */
13
- import type { OAuthController, OAuthCredentials } from "./types";
11
+ import type { KiroOAuthMetadata, OAuthController, OAuthCredentials } from "./types";
14
12
  import {
13
+ discardKiroCliSessionRecovery,
15
14
  inferRegionFromProfileArn,
16
15
  inspectKiroCliSqliteSources,
16
+ inspectKiroCliSessionSnapshot,
17
17
  normalizeKiroRegion,
18
+ persistKiroCliSessionRecovery,
18
19
  readImportedKiroCredential,
19
20
  readKiroCliSqliteCredential,
21
+ restoreKiroCliSession,
22
+ restoreStaleKiroCliSessionRecovery,
20
23
  requireKiroRegion,
24
+ type ImportedKiroCredential,
25
+ type KiroCliSessionSnapshot,
21
26
  type KiroImportDiagnostic,
22
27
  } from "./kiro-credentials";
28
+ import { getAccountSet, saveAccountCredential } from "./store";
23
29
 
24
30
  const DEFAULT_REGION = "us-east-1";
25
31
  const REFRESH_URL = "https://prod.{region}.auth.desktop.kiro.dev/refreshToken";
26
32
  const OIDC_URL = "https://oidc.{region}.amazonaws.com/token";
33
+ const KIRO_TERMINAL_REFRESH_ERRORS = new Set([
34
+ "invalid_grant",
35
+ "refresh_token_reused",
36
+ "revoked",
37
+ "revoked_token",
38
+ "refresh_token_revoked",
39
+ "access_denied",
40
+ "expired_token",
41
+ ]);
27
42
 
28
43
  interface ImportedKiroToken {
29
44
  access: string;
@@ -31,6 +46,205 @@ interface ImportedKiroToken {
31
46
  expires: number;
32
47
  }
33
48
 
49
+ export interface KiroCliCommandResult {
50
+ exitCode: number;
51
+ stdout: string;
52
+ }
53
+
54
+ export class KiroTokenRefreshError extends Error {
55
+ constructor(
56
+ readonly httpStatus: number,
57
+ readonly oauthError?: string,
58
+ ) {
59
+ super(`Kiro token refresh failed: ${httpStatus}${oauthError ? ` (${oauthError})` : ""}`);
60
+ this.name = "KiroTokenRefreshError";
61
+ }
62
+ }
63
+
64
+ export type KiroCliRunner = (args: string[], signal?: AbortSignal) => Promise<KiroCliCommandResult>;
65
+
66
+ export interface KiroLoginOptions {
67
+ forceLogin?: boolean;
68
+ cliRunner?: KiroCliRunner;
69
+ }
70
+
71
+ const pendingKiroLoginTransactions = new WeakMap<OAuthCredentials, KiroCliSessionSnapshot>();
72
+ /** Forced logins that started with no native CLI DB must logout on persistence failure. */
73
+ const pendingKiroEmptyPriorSessions = new WeakSet<OAuthCredentials>();
74
+
75
+ function logoutKiroCliBestEffort(): void {
76
+ try {
77
+ Bun.spawnSync(["kiro-cli", "logout"], {
78
+ stdin: "ignore",
79
+ stdout: "ignore",
80
+ stderr: "ignore",
81
+ windowsHide: true,
82
+ });
83
+ } catch {
84
+ // Best-effort rollback when the prior CLI state was empty.
85
+ }
86
+ }
87
+
88
+ /** Settle the external CLI side of a forced login after OCX credential persistence resolves. */
89
+ export function settleKiroLoginTransaction(credential: OAuthCredentials, persisted: boolean): void {
90
+ const snapshot = pendingKiroLoginTransactions.get(credential);
91
+ const emptyPrior = pendingKiroEmptyPriorSessions.has(credential);
92
+ pendingKiroLoginTransactions.delete(credential);
93
+ pendingKiroEmptyPriorSessions.delete(credential);
94
+ if (!persisted) {
95
+ if (snapshot) {
96
+ restoreKiroCliSession(snapshot);
97
+ discardKiroCliSessionRecovery(snapshot);
98
+ return;
99
+ }
100
+ if (emptyPrior) logoutKiroCliBestEffort();
101
+ return;
102
+ }
103
+ if (snapshot) discardKiroCliSessionRecovery(snapshot);
104
+ }
105
+
106
+ function restoreKiroLoginOrThrow(snapshot: KiroCliSessionSnapshot | null, emptyPrior: boolean, cause: unknown): never {
107
+ if (snapshot) {
108
+ try {
109
+ restoreKiroCliSession(snapshot);
110
+ discardKiroCliSessionRecovery(snapshot);
111
+ } catch (restoreError) {
112
+ throw new AggregateError(
113
+ [cause, restoreError],
114
+ "Kiro login failed and the previous Kiro CLI session could not be restored.",
115
+ );
116
+ }
117
+ } else if (emptyPrior) {
118
+ logoutKiroCliBestEffort();
119
+ }
120
+ throw cause;
121
+ }
122
+
123
+ function throwIfKiroLoginCancelled(signal?: AbortSignal): void {
124
+ if (signal?.aborted) throw new Error("Kiro login cancelled.");
125
+ }
126
+
127
+ async function defaultKiroCliRunner(args: string[], signal?: AbortSignal): Promise<KiroCliCommandResult> {
128
+ throwIfKiroLoginCancelled(signal);
129
+ let child: ReturnType<typeof Bun.spawn>;
130
+ try {
131
+ child = Bun.spawn(["kiro-cli", ...args], {
132
+ stdin: "ignore",
133
+ stdout: "pipe",
134
+ stderr: "ignore",
135
+ windowsHide: true,
136
+ });
137
+ } catch {
138
+ throw new Error("Kiro CLI is not installed or could not be started.");
139
+ }
140
+ const abort = () => child.kill();
141
+ signal?.addEventListener("abort", abort, { once: true });
142
+ // AbortSignal does not replay an abort that lands between the pre-check and listener registration.
143
+ if (signal?.aborted) abort();
144
+ try {
145
+ const [exitCode, stdout] = await Promise.all([
146
+ child.exited,
147
+ child.stdout instanceof ReadableStream ? new Response(child.stdout).text() : Promise.resolve(""),
148
+ ]);
149
+ throwIfKiroLoginCancelled(signal);
150
+ return { exitCode, stdout };
151
+ } finally {
152
+ signal?.removeEventListener("abort", abort);
153
+ }
154
+ }
155
+
156
+ async function readKiroCliIdentity(runner: KiroCliRunner, signal?: AbortSignal): Promise<{ email?: string }> {
157
+ try {
158
+ const result = await runner(["whoami", "--format", "json"], signal);
159
+ if (result.exitCode !== 0) return {};
160
+ const parsed = JSON.parse(result.stdout) as { email?: unknown };
161
+ const email = typeof parsed.email === "string" ? parsed.email.trim().toLowerCase() : "";
162
+ return email && email.length <= 320 ? { email } : {};
163
+ } catch {
164
+ return {};
165
+ }
166
+ }
167
+
168
+ function metadataFromImported(imported: ImportedKiroCredential): KiroOAuthMetadata | undefined {
169
+ const metadata: KiroOAuthMetadata = {
170
+ ...(imported.profileArn ? { profileArn: imported.profileArn } : {}),
171
+ ...(imported.ssoRegion ? { ssoRegion: imported.ssoRegion } : {}),
172
+ ...(imported.apiRegion ? { apiRegion: imported.apiRegion } : {}),
173
+ ...(imported.clientId ? { clientId: imported.clientId } : {}),
174
+ ...(imported.clientSecret ? { clientSecret: imported.clientSecret } : {}),
175
+ };
176
+ return Object.keys(metadata).length > 0 ? metadata : undefined;
177
+ }
178
+
179
+ /** Persistable routing subset from explicit KIRO_* environment overrides (never local CLI state). */
180
+ export function environmentKiroRoutingMetadata(): Pick<KiroOAuthMetadata, "profileArn" | "apiRegion" | "ssoRegion"> | undefined {
181
+ const profileArn = process.env.KIRO_PROFILE_ARN?.trim() || undefined;
182
+ const apiRegion = process.env.KIRO_API_REGION !== undefined
183
+ ? requireKiroRegion(process.env.KIRO_API_REGION)
184
+ : undefined;
185
+ const ssoRegion = process.env.KIRO_REGION !== undefined
186
+ ? requireKiroRegion(process.env.KIRO_REGION)
187
+ : undefined;
188
+ if (!profileArn && !apiRegion && !ssoRegion) return undefined;
189
+ return {
190
+ ...(profileArn ? { profileArn } : {}),
191
+ ...(apiRegion ? { apiRegion } : {}),
192
+ ...(ssoRegion ? { ssoRegion } : {}),
193
+ };
194
+ }
195
+
196
+ /**
197
+ * Before Add-account switches the external CLI identity, bind any matching legacy identity-less
198
+ * OCX row to the current CLI session. Unmatched identity-less rows are left alone here so a
199
+ * cancelled browser login cannot destroy the only stored credential; after a successful add they
200
+ * remain selectable but cannot borrow the new CLI identity (and reauth refuses identity-less slots).
201
+ */
202
+ async function bindMatchingLegacyIdentitylessKiroAccounts(
203
+ runner: KiroCliRunner,
204
+ signal?: AbortSignal,
205
+ ): Promise<void> {
206
+ const set = getAccountSet("kiro");
207
+ if (!set) return;
208
+ const legacyAccounts = set.accounts.filter(
209
+ account => account.credential.accountId === undefined && account.credential.email === undefined,
210
+ );
211
+ if (legacyAccounts.length === 0) return;
212
+
213
+ let imported: ImportedKiroCredential | null = null;
214
+ try {
215
+ imported = readKiroCliSqliteCredential();
216
+ } catch {
217
+ imported = null;
218
+ }
219
+ if (!imported?.refresh) return;
220
+
221
+ for (const account of legacyAccounts) {
222
+ const refresh = account.credential.refresh;
223
+ if (!refresh || imported.refresh !== refresh) continue;
224
+ const bound = await oauthCredentialFromImported(imported, runner, signal);
225
+ if (!bound.accountId && !bound.email) continue;
226
+ await saveAccountCredential("kiro", account.id, bound);
227
+ }
228
+ }
229
+
230
+ async function oauthCredentialFromImported(
231
+ imported: ImportedKiroCredential,
232
+ runner: KiroCliRunner,
233
+ signal?: AbortSignal,
234
+ ): Promise<OAuthCredentials> {
235
+ const identity = imported.source === "sqlite" ? await readKiroCliIdentity(runner, signal) : {};
236
+ const metadata = metadataFromImported(imported);
237
+ return {
238
+ access: imported.access,
239
+ refresh: imported.refresh,
240
+ expires: imported.expires,
241
+ source: imported.source === "json" ? "credential-file" : "local-cli",
242
+ ...(imported.profileArn ? { accountId: imported.profileArn } : {}),
243
+ ...(identity.email ? { email: identity.email } : {}),
244
+ ...(metadata ? { kiro: metadata } : {}),
245
+ };
246
+ }
247
+
34
248
  export type KiroCliImportDiagnosticStatus = KiroImportDiagnostic["status"];
35
249
  export type KiroCliImportDiagnostic = KiroImportDiagnostic;
36
250
 
@@ -54,22 +268,73 @@ export function readKiroCliSqlite(): ImportedKiroToken | null {
54
268
  * so the GUI renders the paste-input field, then blocks on onManualCodeInput for the token.
55
269
  * If neither onAuth nor onManualCodeInput is available, throws a clear error.
56
270
  */
57
- export async function loginKiro(ctrl: OAuthController): Promise<OAuthCredentials> {
271
+ export async function loginKiro(ctrl: OAuthController, options: KiroLoginOptions = {}): Promise<OAuthCredentials> {
272
+ const runner = options.cliRunner ?? defaultKiroCliRunner;
273
+ // A prior process may have exited after switching the external CLI account but before
274
+ // settlement. Recover that durable transaction before either importing or switching again.
275
+ restoreStaleKiroCliSessionRecovery();
276
+ if (options.forceLogin) {
277
+ throwIfKiroLoginCancelled(ctrl.signal);
278
+ ctrl.onAuth?.({
279
+ url: "",
280
+ instructions: "Kiro CLI is opening a fresh browser login. This also switches the account used by kiro-cli.",
281
+ });
282
+ ctrl.onProgress?.("Opening a fresh Kiro CLI browser login.");
283
+ // Never log out of a session we could not capture: the recovery contract promises an exact
284
+ // restore, so an uncapturable store (unreadable / schema-mismatched / ambiguous token) must
285
+ // abort here rather than let a later failure destroy it permanently.
286
+ const inspected = inspectKiroCliSessionSnapshot();
287
+ if (inspected.blocked) {
288
+ throw new Error(
289
+ "Kiro CLI session could not be backed up, so OCX will not sign it out. " +
290
+ "Repair or remove the unreadable kiro-cli credential database " +
291
+ "(usually `~/.local/share/kiro-cli/data.sqlite3` or " +
292
+ "`~/Library/Application Support/kiro-cli/data.sqlite3`), " +
293
+ "unset KIROCLI_DB_PATH / KIRO_CLI_DB_FILE if set for import-only overrides, then retry.",
294
+ );
295
+ }
296
+ const previousSession = inspected.snapshot;
297
+ await bindMatchingLegacyIdentitylessKiroAccounts(runner, ctrl.signal);
298
+ if (previousSession) persistKiroCliSessionRecovery(previousSession);
299
+ try {
300
+ const logout = await runner(["logout"], ctrl.signal);
301
+ throwIfKiroLoginCancelled(ctrl.signal);
302
+ if (logout.exitCode !== 0) throw new Error("Kiro CLI could not prepare a fresh login.");
303
+ const login = await runner(["login"], ctrl.signal);
304
+ throwIfKiroLoginCancelled(ctrl.signal);
305
+ if (login.exitCode !== 0) throw new Error("Kiro CLI login did not complete successfully.");
306
+ const fresh = readKiroCliSqliteCredential();
307
+ if (!fresh) throw new Error("Kiro CLI login completed but no credential could be imported.");
308
+ const credential = await oauthCredentialFromImported(fresh, runner, ctrl.signal);
309
+ throwIfKiroLoginCancelled(ctrl.signal);
310
+ if (!credential.accountId && !credential.email) {
311
+ throw new Error("Kiro login completed but OCX could not determine a stable account identity.");
312
+ }
313
+ if (previousSession) pendingKiroLoginTransactions.set(credential, previousSession);
314
+ else pendingKiroEmptyPriorSessions.add(credential);
315
+ return credential;
316
+ } catch (error) {
317
+ restoreKiroLoginOrThrow(previousSession, !previousSession, error);
318
+ }
319
+ }
320
+
58
321
  const imported = readImportedKiroCredential();
59
322
  if (imported) {
60
323
  ctrl.onProgress?.(imported.source === "json" ? "Imported token from Kiro credentials file." : "Imported token from installed kiro-cli login.");
61
- return {
62
- access: imported.access,
63
- refresh: imported.refresh,
64
- expires: imported.expires,
65
- source: imported.source === "json" ? "credential-file" : "local-cli",
66
- };
324
+ return oauthCredentialFromImported(imported, runner, ctrl.signal);
67
325
  }
68
326
 
69
327
  const envToken = process.env.KIRO_ACCESS_TOKEN;
70
328
  if (envToken) {
71
329
  ctrl.onProgress?.("Using KIRO_ACCESS_TOKEN from environment.");
72
- return { access: envToken, refresh: process.env.KIRO_REFRESH_TOKEN ?? "", expires: Date.now() + 3600_000, source: "environment" };
330
+ const routing = environmentKiroRoutingMetadata();
331
+ return {
332
+ access: envToken,
333
+ refresh: process.env.KIRO_REFRESH_TOKEN ?? "",
334
+ expires: Date.now() + 3600_000,
335
+ source: "environment",
336
+ ...(routing ? { kiro: routing } : {}),
337
+ };
73
338
  }
74
339
 
75
340
  if (ctrl.onManualCodeInput) {
@@ -85,7 +350,16 @@ export async function loginKiro(ctrl: OAuthController): Promise<OAuthCredentials
85
350
  });
86
351
  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.");
87
352
  const raw = (await ctrl.onManualCodeInput()).trim();
88
- if (raw) return { access: raw, refresh: "", expires: Date.now() + 3600_000, source: "manual" };
353
+ if (raw) {
354
+ const routing = environmentKiroRoutingMetadata();
355
+ return {
356
+ access: raw,
357
+ refresh: "",
358
+ expires: Date.now() + 3600_000,
359
+ source: "manual",
360
+ ...(routing ? { kiro: routing } : {}),
361
+ };
362
+ }
89
363
  }
90
364
 
91
365
  throw new Error(
@@ -95,16 +369,25 @@ export async function loginKiro(ctrl: OAuthController): Promise<OAuthCredentials
95
369
  );
96
370
  }
97
371
 
98
- /** Auth/SSO region precedence: KIRO_REGION → imported SSO region → default us-east-1. */
99
- export function resolveKiroRegion(): string {
372
+ /** Account metadata is authoritative; legacy accountless calls use KIRO_REGION → local import → default. */
373
+ export function resolveKiroRegion(account?: KiroOAuthMetadata): string {
374
+ if (account !== undefined) return normalizeKiroRegion(account.ssoRegion) || DEFAULT_REGION;
100
375
  if (process.env.KIRO_REGION !== undefined) return requireKiroRegion(process.env.KIRO_REGION);
101
376
  return normalizeKiroRegion(readImportedKiroCredential()?.ssoRegion) || DEFAULT_REGION;
102
377
  }
103
378
 
104
- /** Runtime API region precedence: KIRO_API_REGION → imported API/profile region → auth region. */
105
- export function resolveKiroApiRegion(): string {
106
- const imported = readImportedKiroCredential();
379
+ /** Account metadata is authoritative; legacy accountless calls may use KIRO_API_REGION/local import. */
380
+ export function resolveKiroApiRegion(account?: Pick<KiroOAuthMetadata, "apiRegion" | "profileArn" | "ssoRegion">): string {
381
+ if (account !== undefined) {
382
+ return (
383
+ normalizeKiroRegion(account.apiRegion) ||
384
+ inferRegionFromProfileArn(account.profileArn) ||
385
+ normalizeKiroRegion(account.ssoRegion) ||
386
+ DEFAULT_REGION
387
+ );
388
+ }
107
389
  if (process.env.KIRO_API_REGION !== undefined) return requireKiroRegion(process.env.KIRO_API_REGION);
390
+ const imported = readImportedKiroCredential();
108
391
  return (
109
392
  normalizeKiroRegion(imported?.apiRegion) ||
110
393
  inferRegionFromProfileArn(imported?.profileArn) ||
@@ -116,15 +399,29 @@ export function resolveKiroApiRegion(): string {
116
399
 
117
400
  /**
118
401
  * Resolve the CodeWhisperer profileArn for request-time use by the adapter.
119
- * KIRO_PROFILE_ARN env → kiro-cli SQLite `profile_arn`. Returns undefined if absent
120
- * (the adapter decides whether that is fatal).
402
+ * Account metadata is authoritative. Legacy accountless calls use KIRO_PROFILE_ARN → local import.
403
+ * Returns undefined if absent (the adapter decides whether that is fatal).
121
404
  */
122
- export function resolveKiroProfileArn(): string | undefined {
405
+ export function resolveKiroProfileArn(account?: Pick<KiroOAuthMetadata, "profileArn">): string | undefined {
406
+ if (account !== undefined) return account.profileArn;
123
407
  const env = process.env.KIRO_PROFILE_ARN;
124
408
  if (env) return env;
125
409
  return readImportedKiroCredential()?.profileArn;
126
410
  }
127
411
 
412
+ async function kiroTokenRefreshError(response: Response): Promise<KiroTokenRefreshError> {
413
+ let oauthError: string | undefined;
414
+ try {
415
+ const payload = await response.json() as { error?: unknown };
416
+ if (typeof payload.error === "string" && KIRO_TERMINAL_REFRESH_ERRORS.has(payload.error)) {
417
+ oauthError = payload.error;
418
+ }
419
+ } catch {
420
+ // Error bodies are untrusted and intentionally excluded from the surfaced message.
421
+ }
422
+ return new KiroTokenRefreshError(response.status, oauthError);
423
+ }
424
+
128
425
  async function readTokenResponse(res: Response, oldRefresh: string): Promise<OAuthCredentials> {
129
426
  const data = (await res.json()) as { accessToken?: string; refreshToken?: string; expiresIn?: number };
130
427
  if (!data.accessToken) throw new Error("Kiro refresh returned no accessToken");
@@ -135,43 +432,126 @@ async function readTokenResponse(res: Response, oldRefresh: string): Promise<OAu
135
432
  };
136
433
  }
137
434
 
138
- async function refreshKiroDesktopToken(refresh: string, signal?: AbortSignal): Promise<OAuthCredentials> {
139
- const region = resolveKiroRegion();
435
+ function kiroRefreshSignal(signal?: AbortSignal): AbortSignal {
436
+ const timeout = AbortSignal.timeout(30_000);
437
+ return signal ? AbortSignal.any([signal, timeout]) : timeout;
438
+ }
439
+
440
+ async function refreshKiroDesktopToken(refresh: string, signal?: AbortSignal, metadata?: KiroOAuthMetadata): Promise<OAuthCredentials> {
441
+ const region = resolveKiroRegion(metadata);
140
442
  const res = await fetch(REFRESH_URL.replace("{region}", region), {
141
443
  method: "POST",
142
444
  headers: { "Content-Type": "application/json" },
143
445
  body: JSON.stringify({ refreshToken: refresh }),
144
- signal: signal ?? AbortSignal.timeout(30_000),
446
+ signal: kiroRefreshSignal(signal),
145
447
  });
146
- if (!res.ok) throw new Error(`Kiro token refresh failed: ${res.status}`);
448
+ if (!res.ok) throw await kiroTokenRefreshError(res);
147
449
  return readTokenResponse(res, refresh);
148
450
  }
149
451
 
150
- async function refreshAwsSsoOidcToken(refresh: string, signal?: AbortSignal): Promise<OAuthCredentials> {
151
- const imported = readImportedKiroCredential();
152
- if (!imported?.clientId || !imported.clientSecret) return refreshKiroDesktopToken(refresh, signal);
153
- const region = resolveKiroRegion();
452
+ async function refreshAwsSsoOidcToken(
453
+ refresh: string,
454
+ signal?: AbortSignal,
455
+ credential?: OAuthCredentials,
456
+ ): Promise<OAuthCredentials> {
457
+ let metadata = credential?.kiro;
458
+ if (!metadata) {
459
+ const local = readImportedKiroCredential();
460
+ // Only a local store holding this exact refresh token describes this account. Anything else
461
+ // belongs to whichever account kiro-cli is currently signed into.
462
+ if (local?.refresh === refresh) metadata = metadataFromImported(local);
463
+ }
464
+ // A stored OCX account with no usable `kiro` metadata must still refresh account-scoped: falling
465
+ // through to `resolveKiroRegion(undefined)` would read KIRO_REGION or the local CLI import and
466
+ // borrow an unrelated account's region after a switch. Environment/manual credentials may still
467
+ // honor explicit KIRO_* routing; other stored accounts pin an empty marker (default region).
468
+ // Only a truly accountless refresh (no stored credential) keeps the legacy env/local fallback.
469
+ if (!metadata && credential) {
470
+ metadata = credential.source === "environment" || credential.source === "manual"
471
+ ? environmentKiroRoutingMetadata() ?? {}
472
+ : {};
473
+ }
474
+ const clientId = metadata?.clientId;
475
+ const clientSecret = metadata?.clientSecret;
476
+ if (!clientId || !clientSecret) return refreshKiroDesktopToken(refresh, signal, metadata);
477
+ const region = resolveKiroRegion(metadata);
154
478
  const run = async (refreshToken: string): Promise<Response> => fetch(OIDC_URL.replace("{region}", region), {
155
479
  method: "POST",
156
480
  headers: { "Content-Type": "application/json" },
157
481
  body: JSON.stringify({
158
482
  grantType: "refresh_token",
159
- clientId: imported.clientId,
160
- clientSecret: imported.clientSecret,
483
+ clientId,
484
+ clientSecret,
161
485
  refreshToken,
162
486
  }),
163
- signal: signal ?? AbortSignal.timeout(30_000),
487
+ signal: kiroRefreshSignal(signal),
164
488
  });
165
- let res = await run(refresh);
166
- if (!res.ok && res.status === 400 && imported.source === "sqlite") {
167
- const reloaded = readImportedKiroCredential();
168
- if (reloaded?.refresh && reloaded.refresh !== refresh) res = await run(reloaded.refresh);
169
- }
170
- if (!res.ok) throw new Error(`Kiro AWS SSO OIDC refresh failed: ${res.status}`);
489
+ const res = await run(refresh);
490
+ if (!res.ok) throw await kiroTokenRefreshError(res);
171
491
  return readTokenResponse(res, refresh);
172
492
  }
173
493
 
174
- export async function refreshKiroToken(refresh: string, signal?: AbortSignal): Promise<OAuthCredentials> {
494
+ function matchingRotatedKiroCliCredential(
495
+ refresh: string,
496
+ credential?: OAuthCredentials,
497
+ ): ImportedKiroCredential | undefined {
498
+ const storedIdentities = [credential?.kiro?.profileArn, credential?.accountId]
499
+ .filter((value): value is string => Boolean(value));
500
+ if (storedIdentities.length === 0 || new Set(storedIdentities).size !== 1) return undefined;
501
+ try {
502
+ const local = readKiroCliSqliteCredential();
503
+ if (!local?.profileArn || storedIdentities.some(identity => identity !== local.profileArn)) return undefined;
504
+ if (!local.refresh || local.refresh === refresh) return undefined;
505
+ return local;
506
+ } catch {
507
+ // An unrelated or malformed local store must not block the stored OCX account.
508
+ return undefined;
509
+ }
510
+ }
511
+
512
+ function metadataForRotatedKiroCliCredential(
513
+ credential: OAuthCredentials,
514
+ local: ImportedKiroCredential,
515
+ ): KiroOAuthMetadata {
516
+ const {
517
+ clientId: _storedClientId,
518
+ clientSecret: _storedClientSecret,
519
+ ...storedRouting
520
+ } = credential.kiro ?? {};
521
+ const localMetadata = metadataFromImported(local) ?? {};
522
+ const {
523
+ clientId: _localClientId,
524
+ clientSecret: _localClientSecret,
525
+ ...localRouting
526
+ } = localMetadata;
527
+ return {
528
+ ...storedRouting,
529
+ ...localRouting,
530
+ ...(local.authType === "aws_sso_oidc" && local.clientId && local.clientSecret
531
+ ? { clientId: local.clientId, clientSecret: local.clientSecret }
532
+ : {}),
533
+ };
534
+ }
535
+
536
+ export async function refreshKiroToken(
537
+ refresh: string,
538
+ signal?: AbortSignal,
539
+ credential?: OAuthCredentials,
540
+ ): Promise<OAuthCredentials> {
175
541
  if (!refresh) throw new Error("Kiro: no refresh token available (re-run `kiro-cli login`).");
176
- return refreshAwsSsoOidcToken(refresh, signal);
542
+ try {
543
+ return await refreshAwsSsoOidcToken(refresh, signal, credential);
544
+ } catch (error) {
545
+ if (!(error instanceof KiroTokenRefreshError) || error.httpStatus !== 400) throw error;
546
+ if (!credential) throw error;
547
+ const local = matchingRotatedKiroCliCredential(refresh, credential);
548
+ if (!local) throw error;
549
+ const retryMetadata = metadataForRotatedKiroCliCredential(credential, local);
550
+ const fresh = await refreshAwsSsoOidcToken(local.refresh, signal, {
551
+ ...credential,
552
+ refresh: local.refresh,
553
+ kiro: retryMetadata,
554
+ });
555
+ return { ...fresh, kiro: retryMetadata };
556
+ }
177
557
  }
@@ -2,19 +2,21 @@ import * as readline from "node:readline";
2
2
  import { openUrl } from "../lib/open-url";
3
3
  import { loadConfig, saveConfig } from "../config";
4
4
  import { findLiveProxy, probeHostname } from "../server/proxy-liveness";
5
- import { isPublicOAuthProvider, listOAuthProviders, OAUTH_PROVIDERS, runLogin } from "./index";
5
+ import { isPublicOAuthProvider, listOAuthProviders, runLogin } from "./index";
6
6
  import { KEY_LOGIN_PROVIDERS, isKeyLoginProvider, validateApiKey, type KeyLoginProvider } from "./key-providers";
7
7
  import type { OcxProviderConfig } from "../types";
8
+ import { configuredAdminToken } from "../lib/admin-secrets";
9
+ import { codexAccountNamespaceProviderCollisionError } from "../codex/account-namespace-match";
8
10
 
9
11
  export function runningProxyUpdateHeaders(): Headers {
10
12
  const headers = new Headers({ "Content-Type": "application/json" });
11
- const apiToken = process.env.OPENCODEX_API_AUTH_TOKEN?.trim();
12
- if (apiToken) headers.set("X-OpenCodex-API-Key", apiToken);
13
+ const adminToken = configuredAdminToken();
14
+ if (adminToken) headers.set("X-OpenCodex-API-Key", adminToken);
13
15
  return headers;
14
16
  }
15
17
 
16
18
  /** Push the new provider into a running proxy's live config so it routes without a restart. */
17
- async function notifyRunningProxy(name: string, provider: unknown): Promise<void> {
19
+ export async function notifyRunningProxy(name: string, provider: unknown): Promise<void> {
18
20
  // Identity-checked runtime-port lookup: reaches a fallback-port proxy and avoids
19
21
  // posting credentials-adjacent config to whatever else answers on config.port.
20
22
  const live = await findLiveProxy();
@@ -30,6 +32,19 @@ async function notifyRunningProxy(name: string, provider: unknown): Promise<void
30
32
  }
31
33
  }
32
34
 
35
+ /**
36
+ * After `runLogin()` has persisted the merged provider (including preserved apiKey /
37
+ * apiKeyPool / authMode), push that on-disk entry into a running proxy.
38
+ *
39
+ * Must not send `OAUTH_PROVIDERS[name].providerConfig`: POST /api/providers replaces the
40
+ * live entry and saves it, which would drop the preserved key billing state.
41
+ */
42
+ export async function notifyRunningProxyAfterOAuthLogin(name: string): Promise<void> {
43
+ const provider = loadConfig().providers[name];
44
+ if (!provider) return;
45
+ await notifyRunningProxy(name, provider);
46
+ }
47
+
33
48
  export async function handleLogin(provider?: string): Promise<void> {
34
49
  const name = (provider ?? "").trim().toLowerCase();
35
50
  if (isPublicOAuthProvider(name)) return handleOAuthLogin(name);
@@ -58,7 +73,7 @@ async function handleOAuthLogin(name: string): Promise<void> {
58
73
  } finally {
59
74
  rl.close();
60
75
  }
61
- await notifyRunningProxy(name, OAUTH_PROVIDERS[name].providerConfig);
76
+ await notifyRunningProxyAfterOAuthLogin(name);
62
77
  console.log(`\nāœ… Logged in to ${name}. Try: ocx sync`);
63
78
  }
64
79
 
@@ -67,6 +82,7 @@ export function providerConfigFromKeyLoginProvider(def: KeyLoginProvider, key: s
67
82
  adapter: def.adapter,
68
83
  baseUrl: baseUrlOverride ?? def.baseUrl,
69
84
  apiKey: key,
85
+ ...(def.apiKeyTransport !== undefined ? { apiKeyTransport: def.apiKeyTransport } : {}),
70
86
  ...(def.defaultModel ? { defaultModel: def.defaultModel } : {}),
71
87
  ...(def.models ? { models: [...def.models] } : {}),
72
88
  ...(def.contextWindow !== undefined ? { contextWindow: def.contextWindow } : {}),
@@ -92,6 +108,12 @@ export function providerConfigFromKeyLoginProvider(def: KeyLoginProvider, key: s
92
108
 
93
109
  async function handleKeyLogin(name: string): Promise<void> {
94
110
  const def = KEY_LOGIN_PROVIDERS[name];
111
+ const preflightConfig = loadConfig();
112
+ const namespaceCollision = codexAccountNamespaceProviderCollisionError(preflightConfig.codexAccountNamespaces, name);
113
+ if (namespaceCollision) {
114
+ console.error(`Error: ${namespaceCollision}.`);
115
+ process.exit(1);
116
+ }
95
117
  console.log(`\nšŸ”‘ ${def.label} — opening ${def.dashboardUrl} so you can create/copy an API key...`);
96
118
  openUrl(def.dashboardUrl);
97
119
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
@@ -121,6 +143,11 @@ async function handleKeyLogin(name: string): Promise<void> {
121
143
  }
122
144
  const provider = providerConfigFromKeyLoginProvider(def, key, baseUrl);
123
145
  const config = loadConfig();
146
+ const commitCollision = codexAccountNamespaceProviderCollisionError(config.codexAccountNamespaces, name);
147
+ if (commitCollision) {
148
+ console.error(`Error: ${commitCollision}.`);
149
+ process.exit(1);
150
+ }
124
151
  config.providers[name] = provider;
125
152
  saveConfig(config);
126
153
  await notifyRunningProxy(name, provider);