@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
@@ -1,19 +1,20 @@
1
- import type { OAuthController, OAuthCredentials } from "./types";
1
+ import type { KiroOAuthMetadata, OAuthController, OAuthCredentials } from "./types";
2
2
  import { parseCallbackInput } from "./callback-server";
3
3
  import type { OcxConfig, OcxProviderConfig, RefreshPolicy } from "../types";
4
4
  import { loadConfig, resolveEnvValue, saveConfig } from "../config";
5
5
  import { maskEmail } from "../lib/privacy";
6
- import { getAccountCredential, getAccountSet, saveAccountCredential, saveCredential, markAccountNeedsReauth, getCredential, credentialGeneration, createOAuthRefreshIntentLock, mergeAccountCredential, markAccountNeedsReauthIfGeneration, readOAuthRefreshIntent, writeOAuthRefreshIntent, clearOAuthRefreshIntent } from "./store";
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
8
  import { loginXai, refreshXaiToken, XAI_LOCAL_CLI_DETACH_WARNING, XaiTokenRequestError } from "./xai";
8
9
  import { ANTHROPIC_OAUTH_BETA, AnthropicTokenError, loginAnthropic, refreshAnthropicToken } from "./anthropic";
9
10
  import { loginKimi, refreshKimiToken } from "./kimi";
10
- import { loginKiro, readKiroCliSqlite, refreshKiroToken } from "./kiro";
11
11
  import { loginChatGPT, refreshChatGPTToken } from "./chatgpt";
12
12
  import { loginAntigravity, refreshAntigravityToken } from "./google-antigravity";
13
13
  import { loginCursor, refreshCursorToken } from "./cursor";
14
14
  import { loginGithubCopilot, refreshGithubCopilotToken, validateCopilotApiBaseUrl } from "./github-copilot";
15
15
  import { deriveOAuthDefaultModel, deriveOAuthProviderConfig } from "../providers/derive";
16
- import { effectiveGoogleMode } from "../providers/registry";
16
+ import { apiKeyPoolEntryId, sanitizeApiKeyValue } from "../providers/api-keys";
17
+ import { effectiveGoogleMode, getProviderRegistryEntry } from "../providers/registry";
17
18
  import { resolveProviderTransport } from "../providers/xai-transport";
18
19
  import { detectClaudeCodeToken, detectGrokCliToken, hasComparableGrokIdentity, isSameGrokIdentity, shouldAdoptGrokGeneration } from "./local-token-detect";
19
20
  import { logOAuthEvent } from "./log";
@@ -37,6 +38,7 @@ export {
37
38
  type OAuthHealthLabel,
38
39
  } from "./health";
39
40
  export { OAUTH_REFRESH_LOCK_WAIT_MS, peekAuthStore, peekOAuthRefreshIntent } from "./store";
41
+ import { codexAccountNamespaceProviderCollisionError } from "../codex/account-namespace-match";
40
42
 
41
43
  const REFRESH_SKEW_MS = 60_000;
42
44
  export interface OAuthAccessSnapshot {
@@ -44,6 +46,8 @@ export interface OAuthAccessSnapshot {
44
46
  accountId: string;
45
47
  generation: string;
46
48
  accessToken: string;
49
+ /** Safe request-routing subset; refresh-only Kiro client secrets never leave the credential store. */
50
+ kiro?: Pick<KiroOAuthMetadata, "profileArn" | "apiRegion" | "ssoRegion">;
47
51
  }
48
52
 
49
53
  const tokenRefreshes = new Map<string, Promise<OAuthAccessSnapshot>>();
@@ -57,9 +61,18 @@ function cached(p:string,a:string,c:OAuthCredentials,now:()=>number){const k=ver
57
61
 
58
62
  export interface LoginOpts { forceLogin?: boolean; /** When set, persist into this account slot and require matching identity. */ reauthAccountId?: string }
59
63
 
64
+ export interface LoginFlowLifecycle {
65
+ /** Runs after background credential/config persistence settles, before status becomes done. */
66
+ onSettled?: () => void | Promise<void>;
67
+ }
68
+
60
69
  interface OAuthProviderDef {
61
70
  login(ctrl: OAuthController, opts?: LoginOpts): Promise<OAuthCredentials>;
62
- refresh(refreshToken: string, signal?: AbortSignal): Promise<OAuthCredentials>;
71
+ refresh(
72
+ refreshToken: string,
73
+ signal?: AbortSignal,
74
+ credential?: OAuthCredentials,
75
+ ): Promise<OAuthCredentials>;
63
76
  /** provider entry written into config.json on first login. */
64
77
  providerConfig: OcxProviderConfig;
65
78
  defaultModel: string;
@@ -107,8 +120,8 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderDef> = {
107
120
  defaultModel: oauthDefaultModel("kimi"),
108
121
  },
109
122
  kiro: {
110
- login: (ctrl) => loginKiro(ctrl),
111
- refresh: (rt, signal) => refreshKiroToken(rt, signal),
123
+ login: (ctrl, opts) => loginKiro(ctrl, { forceLogin: opts?.forceLogin }),
124
+ refresh: (rt, signal, credential) => refreshKiroToken(rt, signal, credential),
112
125
  providerConfig: oauthConfig("kiro"),
113
126
  defaultModel: oauthDefaultModel("kiro"),
114
127
  },
@@ -194,11 +207,25 @@ export class OAuthLoginRequiredError extends Error {
194
207
  }
195
208
 
196
209
  function accessSnapshot(provider: string, accountId: string, cred: OAuthCredentials): OAuthAccessSnapshot {
210
+ const storedKiroRouting = {
211
+ ...(cred.kiro?.profileArn ? { profileArn: cred.kiro.profileArn } : {}),
212
+ ...(cred.kiro?.apiRegion ? { apiRegion: cred.kiro.apiRegion } : {}),
213
+ ...(cred.kiro?.ssoRegion ? { ssoRegion: cred.kiro.ssoRegion } : {}),
214
+ };
197
215
  return {
198
216
  provider,
199
217
  accountId,
200
218
  generation: credentialGeneration(cred),
201
219
  accessToken: cred.access,
220
+ // Stored account metadata remains authoritative. Metadata-less legacy/environment credentials
221
+ // may use explicit environment routing, but never borrow the currently signed-in local CLI account.
222
+ ...(provider === "kiro"
223
+ ? {
224
+ kiro: Object.keys(storedKiroRouting).length > 0
225
+ ? storedKiroRouting
226
+ : environmentKiroRoutingMetadata() ?? {},
227
+ }
228
+ : {}),
202
229
  };
203
230
  }
204
231
 
@@ -267,12 +294,6 @@ export async function getValidAccessTokenForAccount(provider: string, accountId:
267
294
  return (await resolveAccessSnapshotForAccount(provider, accountId)).accessToken;
268
295
  }
269
296
 
270
- function readFreshKiroCliCredential(): OAuthCredentials | undefined {
271
- const imported = readKiroCliSqlite();
272
- if (!imported || imported.expires <= Date.now() + REFRESH_SKEW_MS) return undefined;
273
- return { access: imported.access, refresh: imported.refresh, expires: imported.expires, source: "local-cli" };
274
- }
275
-
276
297
  /** Terminal refresh failures (revoked/rotated-away grants) — retrying cannot succeed. */
277
298
  function isTerminalRefreshError(err: unknown): boolean {
278
299
  const msg = (err instanceof Error ? err.message : String(err)).toLowerCase();
@@ -286,6 +307,7 @@ function isTerminalRefreshError(err: unknown): boolean {
286
307
  function terminal(error:unknown):boolean{
287
308
  if(error instanceof XaiTokenRequestError)return ["invalid_grant","refresh_token_reused","revoked_token"].includes(error.oauthError??"");
288
309
  if(error instanceof AnthropicTokenError)return (error.httpStatus===400||error.httpStatus===401)&&["invalid_grant","refresh_token_reused","revoked","revoked_token","refresh_token_revoked"].includes(error.oauthError??"");
310
+ if(error instanceof KiroTokenRefreshError)return (error.httpStatus===400||error.httpStatus===401)&&error.oauthError!==undefined;
289
311
  return isTerminalRefreshError(error);
290
312
  }
291
313
  function authoritative(stored:OAuthCredentials,active:boolean,now:()=>number):OAuthCredentials{if(stored.source!=="local-cli")return stored;const disk=detectGrokCliToken();if(!disk)return stored;const allowed=isSameGrokIdentity(stored,disk)||(active&&!hasComparableGrokIdentity(stored,disk));return allowed&&shouldAdoptGrokGeneration(stored,disk,now(),REFRESH_SKEW_MS)?disk:stored;}
@@ -297,6 +319,7 @@ function merged(fresh: OAuthCredentials, previous: OAuthCredentials): OAuthCrede
297
319
  ...(fresh.apiBaseUrl === undefined && previous.apiBaseUrl ? { apiBaseUrl: previous.apiBaseUrl } : {}),
298
320
  ...(fresh.email === undefined && previous.email ? { email: previous.email } : {}),
299
321
  ...(fresh.accountId === undefined && previous.accountId ? { accountId: previous.accountId } : {}),
322
+ ...(fresh.kiro === undefined && previous.kiro ? { kiro: previous.kiro } : {}),
300
323
  };
301
324
  }
302
325
  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();}}
@@ -395,7 +418,7 @@ export async function refreshGenericAccountWithLock(
395
418
  }
396
419
  const generation = credentialGeneration(stored);
397
420
  try {
398
- const fresh = merged(await def.refresh(stored.refresh), stored);
421
+ const fresh = merged(await def.refresh(stored.refresh, undefined, stored), stored);
399
422
  const outcome = await mergeAccountCredential(provider, accountId, fresh, {
400
423
  expectedGeneration: generation,
401
424
  afterPrePersistRead: deps.afterPrePersistRead,
@@ -407,7 +430,7 @@ export async function refreshGenericAccountWithLock(
407
430
  logOAuthEvent("OAuth credentials rotated and persisted", { provider, accountId });
408
431
  return fresh.access;
409
432
  } catch (error) {
410
- if (!isTerminalRefreshError(error)) throw error;
433
+ if (!terminal(error)) throw error;
411
434
  await markAccountNeedsReauthIfGeneration(provider, accountId, generation);
412
435
  throw new OAuthLoginRequiredError(provider);
413
436
  }
@@ -422,30 +445,9 @@ async function refreshAndPersistAccessToken(
422
445
  def: OAuthProviderDef,
423
446
  cred: OAuthCredentials,
424
447
  ): Promise<string> {
425
- // Local-CLI import fallback only for the ACTIVE account: importing another identity's
426
- // token under a background account id would silently contaminate that account.
427
- const isActive = getAccountSet(provider)?.activeAccountId === accountId;
428
- if (provider === "kiro" && isActive) {
429
- const imported = readFreshKiroCliCredential();
430
- if (imported) {
431
- await saveCredential(provider, imported);
432
- return imported.access;
433
- }
434
- }
435
448
  if (provider === "xai") return refreshXaiAccountWithLock(provider, accountId, def, cred);
436
449
  if (provider === "anthropic") return refreshAnthropicAccountWithLock(provider, accountId, def, cred);
437
- try {
438
- return await refreshGenericAccountWithLock(provider, accountId, def, cred);
439
- } catch (err) {
440
- if (provider === "kiro" && isActive) {
441
- const imported = readFreshKiroCliCredential();
442
- if (imported) {
443
- await saveCredential(provider, imported);
444
- return imported.access;
445
- }
446
- }
447
- throw err;
448
- }
450
+ return refreshGenericAccountWithLock(provider, accountId, def, cred);
449
451
  }
450
452
 
451
453
  /**
@@ -468,8 +470,9 @@ export async function resolveModelsAuthToken(name: string, prov: OcxProviderConf
468
470
  /**
469
471
  * Provider-correct `GET /models` request (URL + headers), so both model-listing paths fetch the
470
472
  * LIVE catalog correctly per adapter. Anthropic is the special case: its endpoint is `/v1/models`
471
- * (not `/models`), it needs `anthropic-version`, and it authenticates with `x-api-key` (key) or
472
- * `Authorization: Bearer` + the OAuth beta (oauth) not a bare Bearer. Google (ai-studio mode)
473
+ * (not `/models`), it needs `anthropic-version`, and it authenticates with `x-api-key` by default
474
+ * (or `Authorization: Bearer` when `apiKeyTransport = "bearer"`), plus the OAuth beta for oauth
475
+ * mode — not a bare Bearer. Google (ai-studio mode)
473
476
  * is the other special case: `x-goog-api-key` + `/v1beta/models`, returning `{ models: [...] }`.
474
477
  * The catalog authority gate intentionally degrades that non-OpenAI shape to stale/static data.
475
478
  * Everyone else uses the OpenAI-style `/models` + Bearer with a `{ data: [{ id, owned_by? }] }`
@@ -492,14 +495,16 @@ export function buildModelsRequest(prov: OcxProviderConfig, apiKey: string | und
492
495
  return { url: `${effectiveProvider.baseUrl}/v1beta/models?pageSize=1000`, headers };
493
496
  }
494
497
  if (effectiveProvider.adapter === "anthropic") {
498
+ const base = effectiveProvider.baseUrl.replace(/\/v1\/?$/, "");
495
499
  headers["anthropic-version"] = "2023-06-01";
496
500
  if (effectiveProvider.authMode === "oauth") {
497
501
  headers["anthropic-beta"] = ANTHROPIC_OAUTH_BETA;
498
502
  if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
499
503
  } else if (apiKey) {
500
- headers["x-api-key"] = apiKey;
504
+ if (effectiveProvider.apiKeyTransport === "bearer") headers["Authorization"] = `Bearer ${apiKey}`;
505
+ else headers["x-api-key"] = apiKey;
501
506
  }
502
- return { url: `${effectiveProvider.baseUrl}/v1/models?limit=1000`, headers };
507
+ return { url: `${base}/v1/models?limit=1000`, headers };
503
508
  }
504
509
  if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
505
510
  return { url: `${effectiveProvider.baseUrl}/models`, headers };
@@ -566,39 +571,219 @@ export function reconcileOAuthProviders(config: OcxConfig): boolean {
566
571
  return changed;
567
572
  }
568
573
 
569
- /** Add/refresh an OAuth provider's config entry on a config object (does not persist). */
574
+ /** Runtime guards: provider config is intentionally passthrough, so persisted fields may be malformed. */
575
+ function preservableApiKeyPool(value: unknown): NonNullable<OcxProviderConfig["apiKeyPool"]> | undefined {
576
+ if (!Array.isArray(value)) return undefined;
577
+ const pool: NonNullable<OcxProviderConfig["apiKeyPool"]> = [];
578
+ const ids = new Set<string>();
579
+ const keys = new Set<string>();
580
+ for (const entry of value as unknown[]) {
581
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue;
582
+ const candidate = entry as Record<string, unknown>;
583
+ const id = typeof candidate.id === "string" ? candidate.id.trim() : "";
584
+ const key = sanitizeApiKeyValue(candidate.key);
585
+ if (!id || !key || ids.has(id) || keys.has(key)) continue;
586
+ const label = typeof candidate.label === "string" ? candidate.label : undefined;
587
+ const addedAt = typeof candidate.addedAt === "number" && Number.isFinite(candidate.addedAt)
588
+ ? candidate.addedAt
589
+ : undefined;
590
+ ids.add(id);
591
+ keys.add(key);
592
+ pool.push({
593
+ id,
594
+ key,
595
+ ...(label !== undefined ? { label } : {}),
596
+ ...(addedAt !== undefined ? { addedAt } : {}),
597
+ });
598
+ }
599
+ // `apiKey` remains the routing source of truth. Keep valid alternate slots even when a
600
+ // hand-edited config left the pool out of sync, rather than deleting usable credentials.
601
+ return pool.length > 0 ? pool : undefined;
602
+ }
603
+
604
+ /**
605
+ * Add/refresh an OAuth provider's config entry on a config object (does not persist).
606
+ *
607
+ * Providers whose registry entry sets `allowKeyAuthOverride` (xai, github-copilot) can be
608
+ * billed through a stored API key instead of the OAuth login (router.ts honors
609
+ * `authMode: "key"` for them). A blind preset overwrite here deletes `apiKey`/`apiKeyPool`
610
+ * on every OAuth login, silently destroying the stored key and forcing a re-paste — and it
611
+ * flips billing back to the subscription without the user asking. Carry the key fields over
612
+ * and keep key billing while usable key material remains and the user was not explicitly on
613
+ * oauth. If the final key was removed and only the old key mode remains, let the OAuth
614
+ * preset restore `authMode: "oauth"` so the newly saved OAuth credential can be used.
615
+ *
616
+ * After preservation, `apiKey` always has exactly one matching pool entry (inserting via the
617
+ * same content-derived id as the API-key manager when the active key was missing from the
618
+ * pool). Key mode reflects stored user intent (explicit `"key"` or omitted mode with safe
619
+ * key material) — never whether the login CLI process can resolve an env reference. Env-backed
620
+ * availability is decided at proxy routing time in `router.ts`.
621
+ */
570
622
  export function upsertOAuthProvider(config: OcxConfig, provider: string): void {
571
623
  if (provider === "chatgpt") return;
572
624
  const def = OAUTH_PROVIDERS[provider];
573
625
  if (!def) return;
574
- config.providers[provider] = { ...def.providerConfig };
626
+ const namespaceCollision = codexAccountNamespaceProviderCollisionError(config.codexAccountNamespaces, provider);
627
+ if (namespaceCollision) throw new Error(namespaceCollision);
628
+ const existing = config.providers[provider];
629
+ const next: OcxProviderConfig = { ...def.providerConfig };
630
+ if (existing && getProviderRegistryEntry(provider)?.allowKeyAuthOverride === true) {
631
+ // Shared sanitizeApiKeyValue trim / no-CRLF checks from api-key pool writes.
632
+ let storedApiKey = sanitizeApiKeyValue(existing.apiKey);
633
+ const storedApiKeyPool = preservableApiKeyPool(existing.apiKeyPool);
634
+ // Unsafe/blank active key with a usable pool: promote the first safe pool entry so
635
+ // key billing keeps working instead of falling back to oauth while pool keys remain.
636
+ if (storedApiKey === undefined && storedApiKeyPool && storedApiKeyPool.length > 0) {
637
+ storedApiKey = storedApiKeyPool[0]!.key;
638
+ }
639
+ if (storedApiKey !== undefined) {
640
+ const pool = storedApiKeyPool ? [...storedApiKeyPool] : [];
641
+ // Keep routing and listProviderApiKeys in sync: never leave a hidden active key that
642
+ // is absent from the pool (listing would fall back to pool[0] as "active").
643
+ if (!pool.some(entry => entry.key === storedApiKey)) {
644
+ pool.push({ id: apiKeyPoolEntryId(storedApiKey), key: storedApiKey });
645
+ }
646
+ next.apiKey = storedApiKey;
647
+ next.apiKeyPool = pool;
648
+ const previousModeAllowsKey = existing.authMode === "key" || existing.authMode === undefined;
649
+ if (previousModeAllowsKey) next.authMode = "key";
650
+ }
651
+ }
652
+ config.providers[provider] = next;
653
+ }
654
+
655
+ interface RunLoginDeps {
656
+ saveCredential?: typeof saveCredential;
657
+ saveAccountCredential?: typeof saveAccountCredential;
658
+ loadConfig?: typeof loadConfig;
659
+ saveConfig?: typeof saveConfig;
660
+ settleKiroLoginTransaction?: typeof settleKiroLoginTransaction;
661
+ removeAccount?: typeof removeAccount;
662
+ setActiveAccount?: typeof setActiveAccount;
663
+ }
664
+
665
+ /** Roll back only accounts created by this forced login, preserving concurrent refreshes of others. */
666
+ async function rollbackForcedKiroAccountWrite(
667
+ provider: string,
668
+ previousActiveId: string | undefined,
669
+ previousAccountIds: ReadonlySet<string>,
670
+ deps: Pick<RunLoginDeps, "removeAccount" | "setActiveAccount">,
671
+ ): Promise<void> {
672
+ const set = getAccountSet(provider);
673
+ if (!set) return;
674
+ for (const account of [...set.accounts]) {
675
+ if (previousAccountIds.has(account.id)) continue;
676
+ await (deps.removeAccount ?? removeAccount)(provider, account.id);
677
+ }
678
+ if (previousActiveId && getAccountCredential(provider, previousActiveId)) {
679
+ await (deps.setActiveAccount ?? setActiveAccount)(provider, previousActiveId);
680
+ }
575
681
  }
576
682
 
577
683
  /** Run the login flow, persist the credential + upsert the provider entry to disk, return cred. */
578
- export async function runLogin(provider: string, ctrl: OAuthController, opts?: LoginOpts): Promise<OAuthCredentials> {
684
+ export async function runLogin(
685
+ provider: string,
686
+ ctrl: OAuthController,
687
+ opts?: LoginOpts,
688
+ deps: RunLoginDeps = {},
689
+ ): Promise<OAuthCredentials> {
579
690
  const def = OAUTH_PROVIDERS[provider];
580
691
  if (!def) throw new UnsupportedOAuthProviderError(provider);
692
+ const loadLatestConfig = deps.loadConfig ?? loadConfig;
693
+ const saveLatestConfig = deps.saveConfig ?? saveConfig;
694
+ if (provider !== "chatgpt") {
695
+ const preflightConfig = loadLatestConfig();
696
+ const namespaceCollision = codexAccountNamespaceProviderCollisionError(
697
+ preflightConfig.codexAccountNamespaces,
698
+ provider,
699
+ );
700
+ if (namespaceCollision) throw new Error(namespaceCollision);
701
+ }
702
+ // loginKiro keys its pending CLI-session transaction by object identity. Keep this exact object
703
+ // for settlement even when source normalization below creates a derived credential object.
704
+ const shouldRollbackKiroAccounts = provider === "kiro" && opts?.forceLogin === true;
705
+ const previousKiroAccounts = shouldRollbackKiroAccounts ? getAccountSet(provider) : undefined;
706
+ const previousKiroActiveId = previousKiroAccounts?.activeAccountId;
707
+ const previousKiroAccountIds = new Set(previousKiroAccounts?.accounts.map(account => account.id) ?? []);
581
708
  const rawCred = await def.login(ctrl, opts);
582
709
  const cred: OAuthCredentials = rawCred.source ? rawCred : { ...rawCred, source: "oauth" };
583
- if (opts?.reauthAccountId) {
584
- const existing = getAccountCredential(provider, opts.reauthAccountId);
585
- if (!existing) throw new Error(`Unknown account for reauth: ${opts.reauthAccountId}`);
586
- const expected = existing.accountId ?? existing.email;
587
- const got = cred.accountId ?? cred.email;
588
- if (!expected) {
589
- throw new Error("Could not verify signed-in account identity for reauth.");
710
+ const settleKiroTransaction = deps.settleKiroLoginTransaction ?? settleKiroLoginTransaction;
711
+ try {
712
+ // Validate the provider row before credential persistence. A namespace claimed during the
713
+ // credential write is handled again below before the latest row is re-upserted.
714
+ if (provider !== "chatgpt") {
715
+ const preCommitConfig = loadLatestConfig();
716
+ upsertOAuthProvider(preCommitConfig, provider);
590
717
  }
591
- if (!got || expected !== got) {
592
- throw new Error("Signed-in account does not match the selected account. Sign in with the same account.");
718
+ if (opts?.reauthAccountId) {
719
+ const existing = getAccountCredential(provider, opts.reauthAccountId);
720
+ if (!existing) throw new Error(`Unknown account for reauth: ${opts.reauthAccountId}`);
721
+ if (!existing.accountId && !existing.email) {
722
+ throw new Error("Could not verify signed-in account identity for reauth.");
723
+ }
724
+ const identityMatches = existing.accountId && cred.accountId
725
+ ? existing.accountId === cred.accountId
726
+ : existing.email && cred.email
727
+ ? existing.email.toLowerCase() === cred.email.toLowerCase()
728
+ : false;
729
+ if (!identityMatches) {
730
+ throw new Error("Signed-in account does not match the selected account. Sign in with the same account.");
731
+ }
732
+ await (deps.saveAccountCredential ?? saveAccountCredential)(provider, opts.reauthAccountId, cred);
733
+ } else {
734
+ await (deps.saveCredential ?? saveCredential)(provider, cred, {
735
+ preserveIdentityless: provider === "kiro" && opts?.forceLogin === true,
736
+ });
737
+ }
738
+ if (provider !== "chatgpt") {
739
+ // Re-run against post-credential state so same-provider API-key additions, removals,
740
+ // and active-key switches survive. A late namespace claim wins over provider creation.
741
+ const latestConfig = loadLatestConfig();
742
+ const lateCollision = codexAccountNamespaceProviderCollisionError(
743
+ latestConfig.codexAccountNamespaces,
744
+ provider,
745
+ );
746
+ if (lateCollision) {
747
+ throw new Error(
748
+ `${lateCollision}. The credential for "${provider}" was saved, but the provider entry was not written. `
749
+ + "Rename the account selector, then re-run the login.",
750
+ );
751
+ }
752
+ upsertOAuthProvider(latestConfig, provider);
753
+ saveLatestConfig(latestConfig);
754
+ }
755
+ } catch (error) {
756
+ const errors: unknown[] = [error];
757
+ if (shouldRollbackKiroAccounts) {
758
+ try {
759
+ await rollbackForcedKiroAccountWrite(provider, previousKiroActiveId, previousKiroAccountIds, deps);
760
+ } catch (rollbackError) {
761
+ errors.push(rollbackError);
762
+ }
763
+ }
764
+ try {
765
+ settleKiroTransaction(rawCred, false);
766
+ } catch (restoreError) {
767
+ errors.push(restoreError);
768
+ }
769
+ if (errors.length > 1) {
770
+ throw new AggregateError(
771
+ errors,
772
+ "Kiro login persistence failed and the previous Kiro CLI session could not be restored.",
773
+ );
774
+ }
775
+ throw error;
776
+ }
777
+ settleKiroTransaction(rawCred, true);
778
+ if (provider !== "chatgpt") {
779
+ try {
780
+ const { clearAccountQuotaCache, clearProviderQuotaCache } = await import("../providers/quota");
781
+ clearProviderQuotaCache();
782
+ clearAccountQuotaCache(provider);
783
+ } catch {
784
+ // Quota module may be unavailable in tightly scoped unit tests.
593
785
  }
594
- await saveAccountCredential(provider, opts.reauthAccountId, cred);
595
- } else {
596
- await saveCredential(provider, cred);
597
786
  }
598
- if (provider === "chatgpt") return cred;
599
- const config = loadConfig();
600
- upsertOAuthProvider(config, provider);
601
- saveConfig(config);
602
787
  return cred;
603
788
  }
604
789
 
@@ -745,7 +930,11 @@ export function cancelLoginFlow(provider: string): boolean {
745
930
  return true;
746
931
  }
747
932
 
748
- export async function startLoginFlow(provider: string, opts?: LoginOpts): Promise<{ url: string; instructions?: string; deviceCode?: string }> {
933
+ export async function startLoginFlow(
934
+ provider: string,
935
+ opts?: LoginOpts,
936
+ lifecycle?: LoginFlowLifecycle,
937
+ ): Promise<{ url: string; instructions?: string; deviceCode?: string }> {
749
938
  const def = OAUTH_PROVIDERS[provider];
750
939
  if (!def) throw new UnsupportedOAuthProviderError(provider);
751
940
  const existing = loginState.get(provider);
@@ -768,22 +957,44 @@ export async function startLoginFlow(provider: string, opts?: LoginOpts): Promis
768
957
  onManualCodeInput: (expectedState?: string) => waitForManualLoginCode(provider, abort.signal, expectedState),
769
958
  signal: abort.signal,
770
959
  };
771
- // Background: runLogin persists the credential + upserts the provider entry to disk config.
772
- runLogin(provider, ctrl, opts)
773
- .then(() => {
960
+ const settle = async (error?: unknown): Promise<void> => {
961
+ let finalError = error;
962
+ try {
963
+ await lifecycle?.onSettled?.();
964
+ } catch (settleError) {
965
+ // A successful credential/config commit is not fully live until its owner reconciles the
966
+ // runtime config. For an already-failed login, keep the original recovery error.
967
+ if (finalError === undefined) finalError = settleError;
968
+ }
969
+ if (finalError === undefined) {
774
970
  loginAbort.delete(provider);
775
971
  clearManualCodeSlot(provider);
776
972
  loginState.set(provider, { done: true });
777
973
  // Local-token import (grok-cli / Claude Code keychain) completes WITHOUT firing onAuth —
778
974
  // resolve so the GUI call returns instead of hanging.
779
975
  if (!urlResolved) resolve({ url: "", instructions: "Logged in via an existing local CLI/keychain token — no browser needed." });
780
- })
781
- .catch((e: unknown) => {
782
- loginAbort.delete(provider);
783
- clearManualCodeSlot(provider);
784
- const msg = e instanceof Error ? e.message : String(e);
785
- loginState.set(provider, { done: true, error: msg });
786
- if (!urlResolved) reject(e);
787
- });
976
+ return;
977
+ }
978
+
979
+ const e = finalError;
980
+ loginAbort.delete(provider);
981
+ clearManualCodeSlot(provider);
982
+ const msg = e instanceof Error ? e.message : String(e);
983
+ loginState.set(provider, { done: true, error: msg });
984
+ if (!urlResolved) reject(e);
985
+ };
986
+ // Background: runLogin persists the credential + provider entry to disk. The lifecycle hook
987
+ // lets a long-lived server config adopt that settled state before clients observe done=true.
988
+ void runLogin(provider, ctrl, opts).then(
989
+ () => settle(),
990
+ (e: unknown) => settle(e),
991
+ ).catch((e: unknown) => {
992
+ // settle catches lifecycle failures, so this is only a defensive promise-boundary guard.
993
+ loginAbort.delete(provider);
994
+ clearManualCodeSlot(provider);
995
+ const msg = e instanceof Error ? e.message : String(e);
996
+ loginState.set(provider, { done: true, error: msg });
997
+ if (!urlResolved) reject(e);
998
+ });
788
999
  });
789
1000
  }
@@ -5,7 +5,8 @@ import { deriveKeyLoginMap, enrichProviderFromRegistry, type DerivedKeyLoginProv
5
5
  * API-key "login" providers: not OAuth — the flow opens the provider's dashboard so the user can
6
6
  * create/copy a key, then validates + stores it as the provider's `apiKey` (authMode "key").
7
7
  * Most use the OpenAI-compatible chat API (`openai-chat` adapter, `Authorization: Bearer <key>`); a
8
- * few expose only an Anthropic-compatible endpoint and set `adapter: "anthropic"` (`x-api-key`).
8
+ * few expose only an Anthropic-compatible endpoint and set `adapter: "anthropic"` (default
9
+ * `x-api-key`, optional bearer via `apiKeyTransport`).
9
10
  */
10
11
  export interface KeyLoginProvider extends DerivedKeyLoginProvider {}
11
12
 
@@ -29,6 +30,20 @@ export function listKeyLoginProviders(): Array<{ id: string } & KeyLoginProvider
29
30
  return Object.entries(KEY_LOGIN_PROVIDERS).map(([id, p]) => ({ id, ...p }));
30
31
  }
31
32
 
33
+ function anthropicKeyValidationHeaders(provider: Pick<KeyLoginProvider, "apiKeyTransport">, key: string): HeadersInit {
34
+ return provider.apiKeyTransport === "bearer"
35
+ ? {
36
+ "Content-Type": "application/json",
37
+ "anthropic-version": "2023-06-01",
38
+ "Authorization": `Bearer ${key}`,
39
+ }
40
+ : {
41
+ "Content-Type": "application/json",
42
+ "anthropic-version": "2023-06-01",
43
+ "x-api-key": key,
44
+ };
45
+ }
46
+
32
47
  /** Best-effort key validation. Returns true/false/unknown; never persists the key itself. */
33
48
  export async function validateApiKey(provider: KeyLoginProvider, key: string): Promise<boolean | "unknown"> {
34
49
  try {
@@ -36,11 +51,7 @@ export async function validateApiKey(provider: KeyLoginProvider, key: string): P
36
51
  const base = provider.baseUrl.replace(/\/v1\/?$/, "");
37
52
  const res = await fetch(`${base}/v1/messages`, {
38
53
  method: "POST",
39
- headers: {
40
- "Content-Type": "application/json",
41
- "anthropic-version": "2023-06-01",
42
- "x-api-key": key,
43
- },
54
+ headers: anthropicKeyValidationHeaders(provider, key),
44
55
  body: JSON.stringify({
45
56
  model: provider.defaultModel ?? "claude-haiku-4-5",
46
57
  max_tokens: 1,
package/src/oauth/kimi.ts CHANGED
@@ -3,6 +3,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import * as os from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { randomUUID } from "node:crypto";
6
+ import { recordOwnedConfigPath } from "../lib/config-ownership";
6
7
  import { getConfigDir } from "../config";
7
8
  import type { OAuthController, OAuthCredentials } from "./types";
8
9
 
@@ -106,6 +107,7 @@ function getDeviceId(): string {
106
107
  if ((e as { code?: string })?.code !== "ENOENT") throw e;
107
108
  }
108
109
  const id = randomUUID().replace(/-/g, "");
110
+ recordOwnedConfigPath(getConfigDir(), p);
109
111
  if (!existsSync(getConfigDir())) mkdirSync(getConfigDir(), { recursive: true });
110
112
  writeFileSync(p, id + "\n", { mode: 0o600 });
111
113
  deviceIdCache = id;