@bitkyc08/opencodex 2.7.43-preview.20260728 → 2.8.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 (173) hide show
  1. package/README.md +8 -1
  2. package/bin/ocx.mjs +47 -22
  3. package/gui/dist/assets/index-BDjpkcRN.js +67 -0
  4. package/gui/dist/assets/index-BHsKRFh9.css +1 -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/discovery.ts +4 -1
  10. package/src/adapters/cursor/effort-map.ts +3 -0
  11. package/src/adapters/cursor/native-exec-shell.ts +18 -6
  12. package/src/adapters/cursor/protobuf-events.ts +24 -2
  13. package/src/adapters/cursor/protobuf-request.ts +1 -2
  14. package/src/adapters/cursor/tool-definitions.ts +68 -29
  15. package/src/adapters/google-wire-compiler.ts +4 -0
  16. package/src/adapters/google.ts +128 -2
  17. package/src/adapters/identity.ts +12 -2
  18. package/src/adapters/kiro.ts +64 -7
  19. package/src/adapters/mimo-free.ts +2 -0
  20. package/src/adapters/openai-responses.ts +246 -59
  21. package/src/claude/agents-inject.ts +5 -0
  22. package/src/claude/alias.ts +94 -14
  23. package/src/claude/inbound.ts +26 -9
  24. package/src/claude/outbound.ts +6 -3
  25. package/src/cli/account-auth.ts +1 -1
  26. package/src/cli/agent-driven.ts +37 -0
  27. package/src/cli/catalog-prewarm.ts +24 -0
  28. package/src/cli/claude.ts +35 -10
  29. package/src/cli/doctor.ts +71 -19
  30. package/src/cli/help.ts +42 -6
  31. package/src/cli/index.ts +93 -19
  32. package/src/cli/interactive-confirm.ts +133 -0
  33. package/src/cli/opencode.ts +701 -0
  34. package/src/cli/provider-runtime.ts +3 -0
  35. package/src/cli/provider.ts +31 -10
  36. package/src/cli/star-prompt.ts +79 -18
  37. package/src/cli/status.ts +47 -13
  38. package/src/cli/v2.ts +10 -1
  39. package/src/codex/account-id.ts +34 -0
  40. package/src/codex/account-lifecycle.ts +4 -1
  41. package/src/codex/account-namespace-match.ts +63 -0
  42. package/src/codex/account-namespaces.ts +149 -0
  43. package/src/codex/account-pause.ts +20 -0
  44. package/src/codex/account-store.ts +2 -0
  45. package/src/codex/account-usability.ts +6 -1
  46. package/src/codex/app-server-processes.ts +511 -0
  47. package/src/codex/auth-api.ts +293 -34
  48. package/src/codex/auth-collision.ts +2 -1
  49. package/src/codex/auth-context.ts +60 -17
  50. package/src/codex/catalog/bundled.ts +9 -2
  51. package/src/codex/catalog/parsing.ts +42 -2
  52. package/src/codex/catalog/provider-fetch.ts +264 -70
  53. package/src/codex/catalog/sync.ts +45 -8
  54. package/src/codex/catalog.ts +2 -2
  55. package/src/codex/features.ts +524 -5
  56. package/src/codex/history-provider.ts +145 -1
  57. package/src/codex/inject.ts +114 -14
  58. package/src/codex/main-account.ts +2 -8
  59. package/src/codex/pool-rotation.ts +186 -0
  60. package/src/codex/quota.ts +92 -2
  61. package/src/codex/routing.ts +695 -106
  62. package/src/codex/runtime.ts +10 -1
  63. package/src/codex/shim.ts +4 -1
  64. package/src/codex/subagent-defaults.ts +550 -0
  65. package/src/codex/subagent-model-fallback.ts +2 -0
  66. package/src/codex/sync.ts +3 -0
  67. package/src/config.ts +574 -25
  68. package/src/generated/jawcode-model-metadata.ts +12 -12
  69. package/src/github/star-state.ts +191 -0
  70. package/src/images/artifacts.ts +516 -0
  71. package/src/images/fulfill-video.ts +163 -0
  72. package/src/images/fulfill.ts +111 -0
  73. package/src/images/index.ts +4 -0
  74. package/src/images/loop.ts +789 -0
  75. package/src/images/plan.ts +133 -0
  76. package/src/images/synthetic-tool.ts +133 -0
  77. package/src/images/types.ts +41 -0
  78. package/src/images/xai-client.ts +141 -0
  79. package/src/images/xai-video-client.ts +163 -0
  80. package/src/lib/admin-secrets.ts +25 -0
  81. package/src/lib/bun-binary-validator.d.mts +3 -0
  82. package/src/lib/bun-binary-validator.mjs +18 -0
  83. package/src/lib/bun-runtime.ts +6 -20
  84. package/src/lib/config-ownership.ts +327 -0
  85. package/src/lib/crash-guard.ts +2 -0
  86. package/src/lib/destination-policy.ts +132 -7
  87. package/src/lib/pinned-http.ts +151 -0
  88. package/src/lib/process-control.ts +2 -2
  89. package/src/lib/provider-outbound.ts +167 -0
  90. package/src/lib/provider-url.ts +14 -0
  91. package/src/lib/proxy-env.ts +18 -0
  92. package/src/lib/shadow-call.ts +30 -0
  93. package/src/lib/test-home-guard.ts +90 -0
  94. package/src/lib/win-exec.ts +12 -2
  95. package/src/lib/windows-elevation.ts +81 -3
  96. package/src/lib/windows-secret-acl.ts +189 -12
  97. package/src/lib/winsw.ts +2 -0
  98. package/src/oauth/anthropic-routing.ts +570 -0
  99. package/src/oauth/health.ts +6 -0
  100. package/src/oauth/index.ts +310 -75
  101. package/src/oauth/key-providers.ts +38 -8
  102. package/src/oauth/kimi.ts +2 -0
  103. package/src/oauth/kiro-credentials.ts +373 -12
  104. package/src/oauth/kiro.ts +424 -43
  105. package/src/oauth/login-cli.ts +33 -6
  106. package/src/oauth/store.ts +56 -4
  107. package/src/oauth/types.ts +11 -0
  108. package/src/providers/alibaba-region-migration.ts +16 -3
  109. package/src/providers/antigravity-models.ts +3 -0
  110. package/src/providers/api-keys.ts +13 -6
  111. package/src/providers/derive.ts +8 -2
  112. package/src/providers/key-failover.ts +24 -4
  113. package/src/providers/model-discovery.ts +356 -0
  114. package/src/providers/quota.ts +233 -29
  115. package/src/providers/registry.ts +125 -3
  116. package/src/responses/parser.ts +11 -0
  117. package/src/responses/state.ts +22 -8
  118. package/src/responses/tool-groups.ts +19 -0
  119. package/src/router.ts +19 -7
  120. package/src/server/auth-cors.ts +114 -24
  121. package/src/server/claude-messages.ts +8 -1
  122. package/src/server/gui-static.ts +30 -6
  123. package/src/server/images.ts +303 -9
  124. package/src/server/index.ts +77 -9
  125. package/src/server/lifecycle.ts +25 -1
  126. package/src/server/live.ts +75 -25
  127. package/src/server/management/agent-settings-routes.ts +106 -8
  128. package/src/server/management/combo-routes.ts +7 -0
  129. package/src/server/management/config-routes.ts +22 -7
  130. package/src/server/management/context.ts +11 -1
  131. package/src/server/management/logs-usage-routes.ts +167 -3
  132. package/src/server/management/model-routes.ts +46 -13
  133. package/src/server/management/oauth-account-routes.ts +163 -17
  134. package/src/server/management/provider-routes.ts +73 -10
  135. package/src/server/management/shared.ts +2 -2
  136. package/src/server/management/sidebar-routes.ts +39 -0
  137. package/src/server/management/system-restart.ts +172 -0
  138. package/src/server/management/system-routes.ts +33 -10
  139. package/src/server/management-api.ts +5 -3
  140. package/src/server/management-auth.ts +216 -0
  141. package/src/server/proxy-liveness.ts +14 -3
  142. package/src/server/responses/compact.ts +21 -13
  143. package/src/server/responses/core.ts +614 -172
  144. package/src/server/responses/upstream-error.ts +48 -0
  145. package/src/server/responses-image-gen-repair.ts +118 -0
  146. package/src/server/responses-item-id-repair.ts +10 -85
  147. package/src/server/sse-payload-rewrite.ts +116 -0
  148. package/src/server/startup-action-control.ts +30 -14
  149. package/src/server/system-env.ts +28 -10
  150. package/src/service.ts +284 -19
  151. package/src/storage/cleanup-job.ts +57 -0
  152. package/src/storage/cleanup.ts +1504 -28
  153. package/src/storage/policy-job.ts +387 -0
  154. package/src/storage/policy-scheduler.ts +40 -0
  155. package/src/storage/policy-worker.ts +53 -0
  156. package/src/storage/policy.ts +522 -0
  157. package/src/storage/restore-job.ts +253 -0
  158. package/src/storage/restore-worker.ts +52 -0
  159. package/src/storage/storage-mutation-coordinator.ts +109 -0
  160. package/src/storage/worker-lifecycle.ts +81 -0
  161. package/src/tray/windows.ts +34 -4
  162. package/src/types.ts +107 -1
  163. package/src/update/badge.ts +72 -0
  164. package/src/update/index.ts +36 -18
  165. package/src/update/job.ts +111 -16
  166. package/src/update/npm-invocation.d.mts +23 -0
  167. package/src/update/npm-invocation.mjs +94 -0
  168. package/src/usage/debug.ts +2 -0
  169. package/src/usage/expected-prices.ts +6 -5
  170. package/src/usage/log.ts +12 -0
  171. package/src/web-search/loop.ts +57 -16
  172. package/gui/dist/assets/index-CjKFJHSC.js +0 -65
  173. 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,74 @@ 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`, or " +
293
+ "`%LOCALAPPDATA%\\Kiro-Cli\\data.sqlite3` on Windows), " +
294
+ "unset KIROCLI_DB_PATH / KIRO_CLI_DB_FILE if set for import-only overrides, then retry.",
295
+ );
296
+ }
297
+ const previousSession = inspected.snapshot;
298
+ await bindMatchingLegacyIdentitylessKiroAccounts(runner, ctrl.signal);
299
+ if (previousSession) persistKiroCliSessionRecovery(previousSession);
300
+ try {
301
+ const logout = await runner(["logout"], ctrl.signal);
302
+ throwIfKiroLoginCancelled(ctrl.signal);
303
+ if (logout.exitCode !== 0) throw new Error("Kiro CLI could not prepare a fresh login.");
304
+ const login = await runner(["login"], ctrl.signal);
305
+ throwIfKiroLoginCancelled(ctrl.signal);
306
+ if (login.exitCode !== 0) throw new Error("Kiro CLI login did not complete successfully.");
307
+ const fresh = readKiroCliSqliteCredential();
308
+ if (!fresh) throw new Error("Kiro CLI login completed but no credential could be imported.");
309
+ const credential = await oauthCredentialFromImported(fresh, runner, ctrl.signal);
310
+ throwIfKiroLoginCancelled(ctrl.signal);
311
+ if (!credential.accountId && !credential.email) {
312
+ throw new Error("Kiro login completed but OCX could not determine a stable account identity.");
313
+ }
314
+ if (previousSession) pendingKiroLoginTransactions.set(credential, previousSession);
315
+ else pendingKiroEmptyPriorSessions.add(credential);
316
+ return credential;
317
+ } catch (error) {
318
+ restoreKiroLoginOrThrow(previousSession, !previousSession, error);
319
+ }
320
+ }
321
+
58
322
  const imported = readImportedKiroCredential();
59
323
  if (imported) {
60
324
  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
- };
325
+ return oauthCredentialFromImported(imported, runner, ctrl.signal);
67
326
  }
68
327
 
69
328
  const envToken = process.env.KIRO_ACCESS_TOKEN;
70
329
  if (envToken) {
71
330
  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" };
331
+ const routing = environmentKiroRoutingMetadata();
332
+ return {
333
+ access: envToken,
334
+ refresh: process.env.KIRO_REFRESH_TOKEN ?? "",
335
+ expires: Date.now() + 3600_000,
336
+ source: "environment",
337
+ ...(routing ? { kiro: routing } : {}),
338
+ };
73
339
  }
74
340
 
75
341
  if (ctrl.onManualCodeInput) {
@@ -85,7 +351,16 @@ export async function loginKiro(ctrl: OAuthController): Promise<OAuthCredentials
85
351
  });
86
352
  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
353
  const raw = (await ctrl.onManualCodeInput()).trim();
88
- if (raw) return { access: raw, refresh: "", expires: Date.now() + 3600_000, source: "manual" };
354
+ if (raw) {
355
+ const routing = environmentKiroRoutingMetadata();
356
+ return {
357
+ access: raw,
358
+ refresh: "",
359
+ expires: Date.now() + 3600_000,
360
+ source: "manual",
361
+ ...(routing ? { kiro: routing } : {}),
362
+ };
363
+ }
89
364
  }
90
365
 
91
366
  throw new Error(
@@ -95,16 +370,25 @@ export async function loginKiro(ctrl: OAuthController): Promise<OAuthCredentials
95
370
  );
96
371
  }
97
372
 
98
- /** Auth/SSO region precedence: KIRO_REGION → imported SSO region → default us-east-1. */
99
- export function resolveKiroRegion(): string {
373
+ /** Account metadata is authoritative; legacy accountless calls use KIRO_REGION → local import → default. */
374
+ export function resolveKiroRegion(account?: KiroOAuthMetadata): string {
375
+ if (account !== undefined) return normalizeKiroRegion(account.ssoRegion) || DEFAULT_REGION;
100
376
  if (process.env.KIRO_REGION !== undefined) return requireKiroRegion(process.env.KIRO_REGION);
101
377
  return normalizeKiroRegion(readImportedKiroCredential()?.ssoRegion) || DEFAULT_REGION;
102
378
  }
103
379
 
104
- /** Runtime API region precedence: KIRO_API_REGION imported API/profile region auth region. */
105
- export function resolveKiroApiRegion(): string {
106
- const imported = readImportedKiroCredential();
380
+ /** Account metadata is authoritative; legacy accountless calls may use KIRO_API_REGION/local import. */
381
+ export function resolveKiroApiRegion(account?: Pick<KiroOAuthMetadata, "apiRegion" | "profileArn" | "ssoRegion">): string {
382
+ if (account !== undefined) {
383
+ return (
384
+ normalizeKiroRegion(account.apiRegion) ||
385
+ inferRegionFromProfileArn(account.profileArn) ||
386
+ normalizeKiroRegion(account.ssoRegion) ||
387
+ DEFAULT_REGION
388
+ );
389
+ }
107
390
  if (process.env.KIRO_API_REGION !== undefined) return requireKiroRegion(process.env.KIRO_API_REGION);
391
+ const imported = readImportedKiroCredential();
108
392
  return (
109
393
  normalizeKiroRegion(imported?.apiRegion) ||
110
394
  inferRegionFromProfileArn(imported?.profileArn) ||
@@ -116,15 +400,29 @@ export function resolveKiroApiRegion(): string {
116
400
 
117
401
  /**
118
402
  * 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).
403
+ * Account metadata is authoritative. Legacy accountless calls use KIRO_PROFILE_ARN → local import.
404
+ * Returns undefined if absent (the adapter decides whether that is fatal).
121
405
  */
122
- export function resolveKiroProfileArn(): string | undefined {
406
+ export function resolveKiroProfileArn(account?: Pick<KiroOAuthMetadata, "profileArn">): string | undefined {
407
+ if (account !== undefined) return account.profileArn;
123
408
  const env = process.env.KIRO_PROFILE_ARN;
124
409
  if (env) return env;
125
410
  return readImportedKiroCredential()?.profileArn;
126
411
  }
127
412
 
413
+ async function kiroTokenRefreshError(response: Response): Promise<KiroTokenRefreshError> {
414
+ let oauthError: string | undefined;
415
+ try {
416
+ const payload = await response.json() as { error?: unknown };
417
+ if (typeof payload.error === "string" && KIRO_TERMINAL_REFRESH_ERRORS.has(payload.error)) {
418
+ oauthError = payload.error;
419
+ }
420
+ } catch {
421
+ // Error bodies are untrusted and intentionally excluded from the surfaced message.
422
+ }
423
+ return new KiroTokenRefreshError(response.status, oauthError);
424
+ }
425
+
128
426
  async function readTokenResponse(res: Response, oldRefresh: string): Promise<OAuthCredentials> {
129
427
  const data = (await res.json()) as { accessToken?: string; refreshToken?: string; expiresIn?: number };
130
428
  if (!data.accessToken) throw new Error("Kiro refresh returned no accessToken");
@@ -135,43 +433,126 @@ async function readTokenResponse(res: Response, oldRefresh: string): Promise<OAu
135
433
  };
136
434
  }
137
435
 
138
- async function refreshKiroDesktopToken(refresh: string, signal?: AbortSignal): Promise<OAuthCredentials> {
139
- const region = resolveKiroRegion();
436
+ function kiroRefreshSignal(signal?: AbortSignal): AbortSignal {
437
+ const timeout = AbortSignal.timeout(30_000);
438
+ return signal ? AbortSignal.any([signal, timeout]) : timeout;
439
+ }
440
+
441
+ async function refreshKiroDesktopToken(refresh: string, signal?: AbortSignal, metadata?: KiroOAuthMetadata): Promise<OAuthCredentials> {
442
+ const region = resolveKiroRegion(metadata);
140
443
  const res = await fetch(REFRESH_URL.replace("{region}", region), {
141
444
  method: "POST",
142
445
  headers: { "Content-Type": "application/json" },
143
446
  body: JSON.stringify({ refreshToken: refresh }),
144
- signal: signal ?? AbortSignal.timeout(30_000),
447
+ signal: kiroRefreshSignal(signal),
145
448
  });
146
- if (!res.ok) throw new Error(`Kiro token refresh failed: ${res.status}`);
449
+ if (!res.ok) throw await kiroTokenRefreshError(res);
147
450
  return readTokenResponse(res, refresh);
148
451
  }
149
452
 
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();
453
+ async function refreshAwsSsoOidcToken(
454
+ refresh: string,
455
+ signal?: AbortSignal,
456
+ credential?: OAuthCredentials,
457
+ ): Promise<OAuthCredentials> {
458
+ let metadata = credential?.kiro;
459
+ if (!metadata) {
460
+ const local = readImportedKiroCredential();
461
+ // Only a local store holding this exact refresh token describes this account. Anything else
462
+ // belongs to whichever account kiro-cli is currently signed into.
463
+ if (local?.refresh === refresh) metadata = metadataFromImported(local);
464
+ }
465
+ // A stored OCX account with no usable `kiro` metadata must still refresh account-scoped: falling
466
+ // through to `resolveKiroRegion(undefined)` would read KIRO_REGION or the local CLI import and
467
+ // borrow an unrelated account's region after a switch. Environment/manual credentials may still
468
+ // honor explicit KIRO_* routing; other stored accounts pin an empty marker (default region).
469
+ // Only a truly accountless refresh (no stored credential) keeps the legacy env/local fallback.
470
+ if (!metadata && credential) {
471
+ metadata = credential.source === "environment" || credential.source === "manual"
472
+ ? environmentKiroRoutingMetadata() ?? {}
473
+ : {};
474
+ }
475
+ const clientId = metadata?.clientId;
476
+ const clientSecret = metadata?.clientSecret;
477
+ if (!clientId || !clientSecret) return refreshKiroDesktopToken(refresh, signal, metadata);
478
+ const region = resolveKiroRegion(metadata);
154
479
  const run = async (refreshToken: string): Promise<Response> => fetch(OIDC_URL.replace("{region}", region), {
155
480
  method: "POST",
156
481
  headers: { "Content-Type": "application/json" },
157
482
  body: JSON.stringify({
158
483
  grantType: "refresh_token",
159
- clientId: imported.clientId,
160
- clientSecret: imported.clientSecret,
484
+ clientId,
485
+ clientSecret,
161
486
  refreshToken,
162
487
  }),
163
- signal: signal ?? AbortSignal.timeout(30_000),
488
+ signal: kiroRefreshSignal(signal),
164
489
  });
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}`);
490
+ const res = await run(refresh);
491
+ if (!res.ok) throw await kiroTokenRefreshError(res);
171
492
  return readTokenResponse(res, refresh);
172
493
  }
173
494
 
174
- export async function refreshKiroToken(refresh: string, signal?: AbortSignal): Promise<OAuthCredentials> {
495
+ function matchingRotatedKiroCliCredential(
496
+ refresh: string,
497
+ credential?: OAuthCredentials,
498
+ ): ImportedKiroCredential | undefined {
499
+ const storedIdentities = [credential?.kiro?.profileArn, credential?.accountId]
500
+ .filter((value): value is string => Boolean(value));
501
+ if (storedIdentities.length === 0 || new Set(storedIdentities).size !== 1) return undefined;
502
+ try {
503
+ const local = readKiroCliSqliteCredential();
504
+ if (!local?.profileArn || storedIdentities.some(identity => identity !== local.profileArn)) return undefined;
505
+ if (!local.refresh || local.refresh === refresh) return undefined;
506
+ return local;
507
+ } catch {
508
+ // An unrelated or malformed local store must not block the stored OCX account.
509
+ return undefined;
510
+ }
511
+ }
512
+
513
+ function metadataForRotatedKiroCliCredential(
514
+ credential: OAuthCredentials,
515
+ local: ImportedKiroCredential,
516
+ ): KiroOAuthMetadata {
517
+ const {
518
+ clientId: _storedClientId,
519
+ clientSecret: _storedClientSecret,
520
+ ...storedRouting
521
+ } = credential.kiro ?? {};
522
+ const localMetadata = metadataFromImported(local) ?? {};
523
+ const {
524
+ clientId: _localClientId,
525
+ clientSecret: _localClientSecret,
526
+ ...localRouting
527
+ } = localMetadata;
528
+ return {
529
+ ...storedRouting,
530
+ ...localRouting,
531
+ ...(local.authType === "aws_sso_oidc" && local.clientId && local.clientSecret
532
+ ? { clientId: local.clientId, clientSecret: local.clientSecret }
533
+ : {}),
534
+ };
535
+ }
536
+
537
+ export async function refreshKiroToken(
538
+ refresh: string,
539
+ signal?: AbortSignal,
540
+ credential?: OAuthCredentials,
541
+ ): Promise<OAuthCredentials> {
175
542
  if (!refresh) throw new Error("Kiro: no refresh token available (re-run `kiro-cli login`).");
176
- return refreshAwsSsoOidcToken(refresh, signal);
543
+ try {
544
+ return await refreshAwsSsoOidcToken(refresh, signal, credential);
545
+ } catch (error) {
546
+ if (!(error instanceof KiroTokenRefreshError) || error.httpStatus !== 400) throw error;
547
+ if (!credential) throw error;
548
+ const local = matchingRotatedKiroCliCredential(refresh, credential);
549
+ if (!local) throw error;
550
+ const retryMetadata = metadataForRotatedKiroCliCredential(credential, local);
551
+ const fresh = await refreshAwsSsoOidcToken(local.refresh, signal, {
552
+ ...credential,
553
+ refresh: local.refresh,
554
+ kiro: retryMetadata,
555
+ });
556
+ return { ...fresh, kiro: retryMetadata };
557
+ }
177
558
  }
@@ -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 });
@@ -113,7 +135,7 @@ async function handleKeyLogin(name: string): Promise<void> {
113
135
  process.exit(1);
114
136
  }
115
137
  process.stdout.write(" validating… ");
116
- const valid = await validateApiKey({ ...def, baseUrl }, key);
138
+ const valid = await validateApiKey(name, { ...def, baseUrl }, key);
117
139
  console.log(valid === true ? "valid ✅" : valid === false ? "INVALID ❌" : "couldn't validate (may still work)");
118
140
  if (valid === false) {
119
141
  console.error("Provider rejected the key. Not saved.");
@@ -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);