@bitkyc08/opencodex 2.6.27-preview.20260705 → 2.6.30-preview.20260707

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 (121) hide show
  1. package/README.md +1 -0
  2. package/bin/ocx.mjs +4 -4
  3. package/gui/dist/assets/index-ByGC8-Bm.css +1 -0
  4. package/gui/dist/assets/index-D6YMHYXe.js +15 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +4 -4
  7. package/src/adapters/anthropic-image-guard.ts +195 -0
  8. package/src/adapters/anthropic.ts +85 -14
  9. package/src/adapters/cursor/cursor-errors.ts +2 -2
  10. package/src/adapters/cursor/live-transport.ts +1 -1
  11. package/src/adapters/cursor/transport-retry.ts +2 -2
  12. package/src/adapters/google-errors.ts +1 -1
  13. package/src/adapters/google-http.ts +1 -1
  14. package/src/adapters/google-truncation.ts +1 -1
  15. package/src/adapters/google.ts +1 -1
  16. package/src/adapters/kiro-errors.ts +1 -1
  17. package/src/adapters/kiro-retry.ts +1 -1
  18. package/src/adapters/kiro-truncation.ts +1 -1
  19. package/src/adapters/kiro.ts +1 -1
  20. package/src/adapters/openai-chat.ts +12 -2
  21. package/src/adapters/openai-responses.ts +126 -3
  22. package/src/bridge.ts +164 -7
  23. package/src/{doctor.ts → cli/doctor.ts} +109 -4
  24. package/src/{cli-help.ts → cli/help.ts} +1 -1
  25. package/src/cli/index.ts +584 -0
  26. package/src/{init.ts → cli/init.ts} +6 -6
  27. package/src/{cli-models.ts → cli/models.ts} +2 -2
  28. package/src/{cli-provider.ts → cli/provider.ts} +12 -8
  29. package/src/{star-prompt.ts → cli/star-prompt.ts} +1 -1
  30. package/src/{cli-status.ts → cli/status.ts} +7 -7
  31. package/src/cli.ts +9 -575
  32. package/src/{codex-account-label.ts → codex/account-label.ts} +1 -1
  33. package/src/{codex-account-lifecycle.ts → codex/account-lifecycle.ts} +6 -6
  34. package/src/{codex-account-store.ts → codex/account-store.ts} +2 -2
  35. package/src/{codex-account-usability.ts → codex/account-usability.ts} +4 -4
  36. package/src/{codex-auth-api.ts → codex/auth-api.ts} +18 -18
  37. package/src/{codex-auth-collision.ts → codex/auth-collision.ts} +4 -4
  38. package/src/{codex-auth-context.ts → codex/auth-context.ts} +9 -9
  39. package/src/{codex-catalog.ts → codex/catalog.ts} +49 -20
  40. package/src/codex/history-migration-guardian.ts +102 -0
  41. package/src/{codex-history-provider.ts → codex/history-provider.ts} +111 -7
  42. package/src/{codex-home.ts → codex/home.ts} +69 -14
  43. package/src/{codex-inject.ts → codex/inject.ts} +204 -26
  44. package/src/{codex-journal.ts → codex/journal.ts} +2 -2
  45. package/src/{codex-main-account.ts → codex/main-account.ts} +2 -2
  46. package/src/{model-cache.ts → codex/model-cache.ts} +1 -1
  47. package/src/{codex-paths.ts → codex/paths.ts} +2 -2
  48. package/src/{codex-plugins-doctor.ts → codex/plugins-doctor.ts} +2 -2
  49. package/src/{codex-refresh.ts → codex/refresh.ts} +4 -4
  50. package/src/{codex-routing.ts → codex/routing.ts} +8 -8
  51. package/src/{codex-shim.ts → codex/shim.ts} +80 -15
  52. package/src/{codex-sync.ts → codex/sync.ts} +4 -4
  53. package/src/{codex-websocket-registry.ts → codex/websocket-registry.ts} +1 -1
  54. package/src/config.ts +2 -0
  55. package/src/generated/jawcode-model-metadata.ts +2 -0
  56. package/src/{bun-runtime.ts → lib/bun-runtime.ts} +1 -1
  57. package/src/{crash-guard.ts → lib/crash-guard.ts} +1 -1
  58. package/src/lib/gcp-adc.ts +13 -2
  59. package/src/{process-control.ts → lib/process-control.ts} +1 -1
  60. package/src/{service-secrets.ts → lib/service-secrets.ts} +1 -1
  61. package/src/oauth/anthropic.ts +2 -2
  62. package/src/oauth/callback-server.ts +1 -1
  63. package/src/oauth/google-antigravity.ts +7 -4
  64. package/src/oauth/index.ts +67 -16
  65. package/src/oauth/local-token-detect.ts +36 -5
  66. package/src/oauth/login-cli.ts +2 -2
  67. package/src/oauth/store.ts +236 -20
  68. package/src/oauth/token-guardian.ts +24 -20
  69. package/src/oauth/types.ts +16 -0
  70. package/src/providers/api-keys.ts +121 -0
  71. package/src/{provider-context-cap.ts → providers/context-cap.ts} +1 -1
  72. package/src/providers/derive.ts +2 -0
  73. package/src/providers/key-failover.ts +145 -0
  74. package/src/{provider-label.ts → providers/label.ts} +1 -1
  75. package/src/{provider-quota.ts → providers/quota.ts} +12 -7
  76. package/src/providers/registry.ts +66 -4
  77. package/src/responses/compaction.ts +117 -0
  78. package/src/responses/parser.ts +89 -13
  79. package/src/responses/reasoning-envelope.ts +52 -0
  80. package/src/responses/schema.ts +15 -3
  81. package/src/responses/state.ts +117 -2
  82. package/src/router.ts +2 -0
  83. package/src/server/auth-cors.ts +231 -0
  84. package/src/server/index.ts +523 -0
  85. package/src/server/lifecycle.ts +73 -0
  86. package/src/server/management-api.ts +628 -0
  87. package/src/{proxy-liveness.ts → server/proxy-liveness.ts} +1 -1
  88. package/src/server/relay.ts +534 -0
  89. package/src/server/request-decompress.ts +46 -0
  90. package/src/server/request-log.ts +310 -0
  91. package/src/server/responses.ts +775 -0
  92. package/src/{ws-bridge.ts → server/ws-bridge.ts} +44 -13
  93. package/src/service.ts +23 -11
  94. package/src/types.ts +27 -0
  95. package/src/{update.ts → update/index.ts} +5 -5
  96. package/src/{update-job.ts → update/job.ts} +7 -6
  97. package/src/{update-notify.ts → update/notify.ts} +3 -3
  98. package/src/{usage-debug.ts → usage/debug.ts} +3 -3
  99. package/src/{usage-log.ts → usage/log.ts} +3 -3
  100. package/src/{usage-summary.ts → usage/summary.ts} +3 -3
  101. package/src/{usage-totals.ts → usage/totals.ts} +1 -1
  102. package/src/vision/describe.ts +3 -3
  103. package/src/vision/index.ts +21 -1
  104. package/src/web-search/executor.ts +4 -4
  105. package/src/web-search/index.ts +1 -1
  106. package/src/web-search/loop.ts +84 -24
  107. package/gui/dist/assets/index-BcHhxo1I.css +0 -1
  108. package/gui/dist/assets/index-z8MZ_KPP.js +0 -15
  109. package/src/server.ts +0 -2501
  110. /package/src/{codex-account-runtime-state.ts → codex/account-runtime-state.ts} +0 -0
  111. /package/src/{codex-quota.ts → codex/quota.ts} +0 -0
  112. /package/src/{abort.ts → lib/abort.ts} +0 -0
  113. /package/src/{debug.ts → lib/debug.ts} +0 -0
  114. /package/src/{errors.ts → lib/errors.ts} +0 -0
  115. /package/src/{open-url.ts → lib/open-url.ts} +0 -0
  116. /package/src/{privacy.ts → lib/privacy.ts} +0 -0
  117. /package/src/{redact.ts → lib/redact.ts} +0 -0
  118. /package/src/{sidecar-tracker.ts → lib/sidecar-tracker.ts} +0 -0
  119. /package/src/{upstream-retry.ts → lib/upstream-retry.ts} +0 -0
  120. /package/src/{win-paths.ts → lib/win-paths.ts} +0 -0
  121. /package/src/{ports.ts → server/ports.ts} +0 -0
@@ -1,5 +1,5 @@
1
1
  import { join } from "node:path";
2
- import { getConfigDir } from "./config";
2
+ import { getConfigDir } from "../config";
3
3
 
4
4
  export function serviceApiTokenFilePath(): string {
5
5
  return join(getConfigDir(), "service-api-token");
@@ -137,8 +137,8 @@ export async function loginAnthropic(
137
137
  } else if (importLocal === "only") {
138
138
  throw new Error(
139
139
  process.platform === "darwin"
140
- ? "No Claude Code token found in the keychain. Run 'ocx login anthropic' for browser OAuth."
141
- : "Claude Code auto-import is macOS-only. Run 'ocx login anthropic' for browser OAuth.",
140
+ ? "No Claude Code token found (Keychain or ~/.claude/.credentials.json). Run 'ocx login anthropic' for browser OAuth."
141
+ : "No Claude Code token found (~/.claude/.credentials.json). Run 'ocx login anthropic' for browser OAuth.",
142
142
  );
143
143
  }
144
144
  }
@@ -8,7 +8,7 @@
8
8
  * Handles: port allocation (preferred → random fallback), callback server, CSRF state,
9
9
  * manual-input race, 300s timeout. Providers implement generateAuthUrl() + exchangeToken().
10
10
  */
11
- import { isAddrInUse } from "../ports";
11
+ import { isAddrInUse } from "../server/ports";
12
12
  import type { OAuthController, OAuthCredentials } from "./types";
13
13
 
14
14
  const DEFAULT_TIMEOUT = 300_000;
@@ -154,8 +154,9 @@ function credentialsFromPayload(payload: GoogleTokenPayload, refreshFallback = "
154
154
 
155
155
  class AntigravityOAuthFlow extends OAuthCallbackFlow {
156
156
  #verifier = "";
157
+ #forceAccountSelect: boolean;
157
158
 
158
- constructor(ctrl: OAuthController) {
159
+ constructor(ctrl: OAuthController, opts?: { forceAccountSelect?: boolean }) {
159
160
  super(ctrl, {
160
161
  preferredPort: CALLBACK_PORT,
161
162
  callbackPath: CALLBACK_PATH,
@@ -163,6 +164,7 @@ class AntigravityOAuthFlow extends OAuthCallbackFlow {
163
164
  callbackBindHostname: "127.0.0.1",
164
165
  redirectUri: `http://127.0.0.1:${CALLBACK_PORT}${CALLBACK_PATH}`,
165
166
  } satisfies OAuthCallbackFlowOptions);
167
+ this.#forceAccountSelect = opts?.forceAccountSelect === true;
166
168
  }
167
169
 
168
170
  async generateAuthUrl(state: string, redirectUri: string): Promise<{ url: string; instructions?: string }> {
@@ -176,7 +178,8 @@ class AntigravityOAuthFlow extends OAuthCallbackFlow {
176
178
  code_challenge: pkce.challenge,
177
179
  code_challenge_method: "S256",
178
180
  access_type: "offline",
179
- prompt: "consent",
181
+ // select_account lets the user pick a DIFFERENT Google account when adding a second one.
182
+ prompt: this.#forceAccountSelect ? "consent select_account" : "consent",
180
183
  state,
181
184
  });
182
185
  return {
@@ -207,8 +210,8 @@ class AntigravityOAuthFlow extends OAuthCallbackFlow {
207
210
  }
208
211
  }
209
212
 
210
- export async function loginAntigravity(ctrl: OAuthController): Promise<OAuthCredentials> {
211
- return new AntigravityOAuthFlow(ctrl).login();
213
+ export async function loginAntigravity(ctrl: OAuthController, opts?: { forceAccountSelect?: boolean }): Promise<OAuthCredentials> {
214
+ return new AntigravityOAuthFlow(ctrl, opts).login();
212
215
  }
213
216
 
214
217
  export async function refreshAntigravityToken(refreshToken: string, signal?: AbortSignal): Promise<OAuthCredentials> {
@@ -1,8 +1,8 @@
1
1
  import type { OAuthController, OAuthCredentials } from "./types";
2
2
  import type { OcxConfig, OcxProviderConfig, RefreshPolicy } from "../types";
3
3
  import { loadConfig, resolveEnvValue, saveConfig } from "../config";
4
- import { maskEmail } from "../privacy";
5
- import { getCredential, saveCredential } from "./store";
4
+ import { maskEmail } from "../lib/privacy";
5
+ import { getAccountCredential, getAccountSet, saveAccountCredential, saveCredential, markAccountNeedsReauth, getCredential } from "./store";
6
6
  import { loginXai, refreshXaiToken } from "./xai";
7
7
  import { ANTHROPIC_OAUTH_BETA, loginAnthropic, refreshAnthropicToken } from "./anthropic";
8
8
  import { loginKimi, refreshKimiToken } from "./kimi";
@@ -46,13 +46,14 @@ function oauthDefaultModel(id: string): string {
46
46
 
47
47
  export const OAUTH_PROVIDERS: Record<string, OAuthProviderDef> = {
48
48
  xai: {
49
- login: (ctrl) => loginXai(ctrl, { importLocal: "fallback" }),
49
+ // forceLogin skips the local grok-cli import so a SECOND account can be chosen in the browser.
50
+ login: (ctrl, opts) => loginXai(ctrl, { importLocal: opts?.forceLogin ? "off" : "fallback" }),
50
51
  refresh: refreshXaiToken,
51
52
  providerConfig: oauthConfig("xai"),
52
53
  defaultModel: oauthDefaultModel("xai"),
53
54
  },
54
55
  anthropic: {
55
- login: (ctrl) => loginAnthropic(ctrl, { importLocal: "fallback" }),
56
+ login: (ctrl, opts) => loginAnthropic(ctrl, { importLocal: opts?.forceLogin ? "off" : "fallback" }),
56
57
  refresh: refreshAnthropicToken,
57
58
  providerConfig: oauthConfig("anthropic"),
58
59
  defaultModel: oauthDefaultModel("anthropic"),
@@ -73,7 +74,7 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderDef> = {
73
74
  defaultModel: oauthDefaultModel("kiro"),
74
75
  },
75
76
  "google-antigravity": {
76
- login: (ctrl) => loginAntigravity(ctrl),
77
+ login: (ctrl, opts) => loginAntigravity(ctrl, { forceAccountSelect: opts?.forceLogin === true }),
77
78
  refresh: refreshAntigravityToken,
78
79
  providerConfig: oauthConfig("google-antigravity"),
79
80
  defaultModel: oauthDefaultModel("google-antigravity"),
@@ -136,19 +137,33 @@ export class OAuthLoginRequiredError extends Error {
136
137
  }
137
138
  }
138
139
 
139
- /** Return a valid access token, refreshing + persisting if expired. Throws if not logged in. */
140
+ /** Return a valid access token for the ACTIVE account, refreshing + persisting if expired. */
140
141
  export async function getValidAccessToken(provider: string): Promise<string> {
141
142
  const def = OAUTH_PROVIDERS[provider];
142
143
  if (!def) throw new UnsupportedOAuthProviderError(provider);
143
- const cred = getCredential(provider);
144
+ const set = getAccountSet(provider);
145
+ if (!set) throw new OAuthLoginRequiredError(provider);
146
+ return getValidAccessTokenForAccount(provider, set.activeAccountId);
147
+ }
148
+
149
+ /**
150
+ * Account-scoped token resolver (multiauth): refresh is single-flighted per
151
+ * (provider, account), and the rotated credential is persisted for THAT account only —
152
+ * a guardian refresh of a background account never switches the active account.
153
+ */
154
+ export async function getValidAccessTokenForAccount(provider: string, accountId: string): Promise<string> {
155
+ const def = OAUTH_PROVIDERS[provider];
156
+ if (!def) throw new UnsupportedOAuthProviderError(provider);
157
+ const cred = getAccountCredential(provider, accountId);
144
158
  if (!cred) throw new OAuthLoginRequiredError(provider);
145
159
  if (cred.expires > Date.now() + REFRESH_SKEW_MS) return cred.access;
146
- const existing = tokenRefreshes.get(provider);
160
+ const key = `${provider}\u0000${accountId}`;
161
+ const existing = tokenRefreshes.get(key);
147
162
  if (existing) return existing;
148
- const refresh = refreshAndPersistAccessToken(provider, def, cred).finally(() => {
149
- if (tokenRefreshes.get(provider) === refresh) tokenRefreshes.delete(provider);
163
+ const refresh = refreshAndPersistAccessToken(provider, accountId, def, cred).finally(() => {
164
+ if (tokenRefreshes.get(key) === refresh) tokenRefreshes.delete(key);
150
165
  });
151
- tokenRefreshes.set(provider, refresh);
166
+ tokenRefreshes.set(key, refresh);
152
167
  return refresh;
153
168
  }
154
169
 
@@ -158,12 +173,22 @@ function readFreshKiroCliCredential(): OAuthCredentials | undefined {
158
173
  return { access: imported.access, refresh: imported.refresh, expires: imported.expires, source: "local-cli" };
159
174
  }
160
175
 
176
+ /** Terminal refresh failures (revoked/rotated-away grants) — retrying cannot succeed. */
177
+ function isTerminalRefreshError(err: unknown): boolean {
178
+ const msg = (err instanceof Error ? err.message : String(err)).toLowerCase();
179
+ return msg.includes("invalid_grant") || msg.includes("refresh_token_reused") || msg.includes("revoked");
180
+ }
181
+
161
182
  async function refreshAndPersistAccessToken(
162
183
  provider: string,
184
+ accountId: string,
163
185
  def: OAuthProviderDef,
164
186
  cred: OAuthCredentials,
165
187
  ): Promise<string> {
166
- if (provider === "kiro") {
188
+ // Local-CLI import fallback only for the ACTIVE account: importing another identity's
189
+ // token under a background account id would silently contaminate that account.
190
+ const isActive = getAccountSet(provider)?.activeAccountId === accountId;
191
+ if (provider === "kiro" && isActive) {
167
192
  const imported = readFreshKiroCliCredential();
168
193
  if (imported) {
169
194
  saveCredential(provider, imported);
@@ -172,22 +197,31 @@ async function refreshAndPersistAccessToken(
172
197
  }
173
198
  try {
174
199
  const fresh = await def.refresh(cred.refresh);
175
- saveCredential(provider, {
200
+ // Persist to THIS account (rotation-safe: new refresh token hits disk before use) without
201
+ // touching activeAccountId.
202
+ saveAccountCredential(provider, accountId, {
176
203
  ...fresh,
177
204
  source: fresh.source ?? cred.source ?? "oauth",
178
205
  // Preserve a previously-discovered project id when a refresh-time re-discovery comes back empty
179
206
  // (e.g. a transient network blip), so Antigravity does not lose its CCA project across refresh.
180
207
  ...(fresh.projectId === undefined && cred.projectId ? { projectId: cred.projectId } : {}),
208
+ // Preserve identity fields the refresh response may omit, so identity matching stays stable.
209
+ ...(fresh.email === undefined && cred.email ? { email: cred.email } : {}),
210
+ ...(fresh.accountId === undefined && cred.accountId ? { accountId: cred.accountId } : {}),
181
211
  });
182
212
  return fresh.access;
183
213
  } catch (err) {
184
- if (provider === "kiro") {
214
+ if (provider === "kiro" && isActive) {
185
215
  const imported = readFreshKiroCliCredential();
186
216
  if (imported) {
187
217
  saveCredential(provider, imported);
188
218
  return imported.access;
189
219
  }
190
220
  }
221
+ if (isTerminalRefreshError(err)) {
222
+ markAccountNeedsReauth(provider, accountId, true);
223
+ throw new OAuthLoginRequiredError(provider);
224
+ }
191
225
  throw err;
192
226
  }
193
227
  }
@@ -329,10 +363,27 @@ export async function runLogin(provider: string, ctrl: OAuthController, opts?: L
329
363
  const loginState = new Map<string, { error?: string; done: boolean }>();
330
364
  const loginAbort = new Map<string, AbortController>();
331
365
 
332
- export function getLoginStatus(provider: string): { loggedIn: boolean; email?: string; source?: OAuthCredentials["source"]; error?: string; done: boolean } {
366
+ export interface OAuthAccountSummary { id: string; email?: string; active: boolean; needsReauth?: boolean; expiresAt?: number }
367
+
368
+ export function getLoginStatus(provider: string): { loggedIn: boolean; email?: string; source?: OAuthCredentials["source"]; error?: string; done: boolean; activeAccountId?: string; accounts?: OAuthAccountSummary[] } {
333
369
  const cred = getCredential(provider);
334
370
  const st = loginState.get(provider);
335
- return { loggedIn: !!cred, email: maskEmail(cred?.email) ?? undefined, source: cred?.source, error: st?.error, done: st?.done ?? false };
371
+ const set = getAccountSet(provider);
372
+ const accounts: OAuthAccountSummary[] | undefined = set?.accounts.map(a => ({
373
+ id: a.id,
374
+ email: maskEmail(a.credential.email) ?? undefined,
375
+ active: a.id === set.activeAccountId,
376
+ ...(a.needsReauth ? { needsReauth: true } : {}),
377
+ expiresAt: a.credential.expires,
378
+ }));
379
+ return {
380
+ loggedIn: !!cred,
381
+ email: maskEmail(cred?.email) ?? undefined,
382
+ source: cred?.source,
383
+ error: st?.error,
384
+ done: st?.done ?? false,
385
+ ...(set ? { activeAccountId: set.activeAccountId, accounts } : {}),
386
+ };
336
387
  }
337
388
 
338
389
  /** Token-safe per-provider login state for the CLI `ocx status` logins section (no tokens, masked email). */
@@ -39,8 +39,14 @@ export function detectGrokCliToken(): OAuthCredentials | null {
39
39
  }
40
40
  }
41
41
 
42
- /** Read the Claude Code OAuth credential from macOS Keychain. Windows/Linux: use `ocx login`. */
43
- function readClaudeSecureStorage(): string | null {
42
+ /** Claude Code config dir: `CLAUDE_CONFIG_DIR` override, else `~/.claude`. */
43
+ function claudeConfigDir(): string {
44
+ const explicit = process.env.CLAUDE_CONFIG_DIR?.trim();
45
+ return explicit ? explicit : join(homedir(), ".claude");
46
+ }
47
+
48
+ /** Read the Claude Code OAuth credential from the macOS Keychain (darwin only). */
49
+ function readClaudeKeychain(): string | null {
44
50
  if (process.platform !== "darwin") return null;
45
51
  try {
46
52
  return execSync(`security find-generic-password -s "${CLAUDE_KEYCHAIN_SERVICE}" -w`, {
@@ -51,9 +57,28 @@ function readClaudeSecureStorage(): string | null {
51
57
  }
52
58
  }
53
59
 
54
- export function detectClaudeCodeToken(): OAuthCredentials | null {
55
- const raw = readClaudeSecureStorage();
56
- if (!raw) return null;
60
+ /**
61
+ * Read the Claude Code credential file (`<config-dir>/.credentials.json`).
62
+ * Claude Code writes this on Linux/Windows (and on macOS when the Keychain is
63
+ * unavailable); it carries the same `claudeAiOauth` payload as the Keychain item.
64
+ * Exported for tests.
65
+ */
66
+ export function readClaudeCredentialsFile(): string | null {
67
+ const path = join(claudeConfigDir(), ".credentials.json");
68
+ try {
69
+ if (!existsSync(path)) return null;
70
+ return readFileSync(path, "utf8").trim();
71
+ } catch {
72
+ return null;
73
+ }
74
+ }
75
+
76
+ /** Keychain first on macOS, then the cross-platform credentials file. */
77
+ function readClaudeSecureStorage(): string | null {
78
+ return readClaudeKeychain() ?? readClaudeCredentialsFile();
79
+ }
80
+
81
+ export function parseClaudeOauthPayload(raw: string): OAuthCredentials | null {
57
82
  try {
58
83
  const data = JSON.parse(raw) as { claudeAiOauth?: { accessToken?: string; refreshToken?: string; expiresAt?: number } };
59
84
  const o = data.claudeAiOauth;
@@ -63,3 +88,9 @@ export function detectClaudeCodeToken(): OAuthCredentials | null {
63
88
  return null;
64
89
  }
65
90
  }
91
+
92
+ export function detectClaudeCodeToken(): OAuthCredentials | null {
93
+ const raw = readClaudeSecureStorage();
94
+ if (!raw) return null;
95
+ return parseClaudeOauthPayload(raw);
96
+ }
@@ -1,7 +1,7 @@
1
1
  import * as readline from "node:readline";
2
- import { openUrl } from "../open-url";
2
+ import { openUrl } from "../lib/open-url";
3
3
  import { loadConfig, saveConfig } from "../config";
4
- import { findLiveProxy, probeHostname } from "../proxy-liveness";
4
+ import { findLiveProxy, probeHostname } from "../server/proxy-liveness";
5
5
  import { OAUTH_PROVIDERS, runLogin } from "./index";
6
6
  import { KEY_LOGIN_PROVIDERS, isKeyLoginProvider, validateApiKey, type KeyLoginProvider } from "./key-providers";
7
7
  import type { OcxProviderConfig } from "../types";
@@ -1,28 +1,51 @@
1
- /** OAuth token store at ~/.opencodex/auth.json, keyed by provider name. */
2
- import { existsSync, mkdirSync, readFileSync, chmodSync } from "node:fs";
1
+ /**
2
+ * OAuth token store at ~/.opencodex/auth.json, keyed by provider name.
3
+ *
4
+ * Multiauth shape (260706): each provider value is a ProviderAccountSet
5
+ * `{ activeAccountId, accounts: [{ id, credential, needsReauth?, addedAt? }] }`.
6
+ * Legacy single-credential values (`{ access, refresh, expires, ... }`) normalize on load,
7
+ * and the first new-shape persist writes a one-time `auth.json.pre-multiauth` backup so a
8
+ * downgraded loader (which silently drops unknown shapes) cannot destroy refresh tokens.
9
+ *
10
+ * Exceptions:
11
+ * - `chatgpt` stays single-slot (always replaced): codex-auth-api uses it as a scratch slot
12
+ * for Codex pool logins, which have their own ledger (codex-accounts.json).
13
+ * - Credentials without identity (no accountId/email — kimi, kiro, cursor) replace the
14
+ * active slot instead of appending: their refresh tokens rotate, so a derived id would
15
+ * duplicate the same human on every re-login.
16
+ */
17
+ import { createHash } from "node:crypto";
18
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, chmodSync } from "node:fs";
3
19
  import { join } from "node:path";
4
20
  import { getConfigDir, atomicWriteFile, backupInvalidConfig, hardenConfigDir, hardenExistingSecret } from "../config";
5
- import type { OAuthCredentialSource, OAuthCredentials } from "./types";
21
+ import type { OAuthCredentialSource, OAuthCredentials, ProviderAccount, ProviderAccountSet } from "./types";
6
22
 
7
- type AuthStore = Record<string, OAuthCredentials>;
23
+ type AuthStore = Record<string, ProviderAccountSet>;
24
+
25
+ /** Providers whose account set is pinned to a single slot (see module doc). */
26
+ const SINGLE_SLOT_PROVIDERS = new Set(["chatgpt"]);
8
27
 
9
28
  function authPath(): string {
10
29
  return join(getConfigDir(), "auth.json");
11
30
  }
12
31
 
13
- export function loadAuthStore(): AuthStore {
32
+ function loadAuthStoreInternal(): { store: AuthStore; hadLegacy: boolean } {
14
33
  const path = authPath();
15
34
  hardenConfigDir();
16
35
  hardenExistingSecret(path);
17
- if (!existsSync(path)) return {};
36
+ if (!existsSync(path)) return { store: {}, hadLegacy: false };
18
37
  try {
19
38
  return normalizeAuthStore(JSON.parse(readFileSync(path, "utf-8")));
20
39
  } catch {
21
40
  backupInvalidConfig(path);
22
- return {};
41
+ return { store: {}, hadLegacy: false };
23
42
  }
24
43
  }
25
44
 
45
+ export function loadAuthStore(): AuthStore {
46
+ return loadAuthStoreInternal().store;
47
+ }
48
+
26
49
  function persist(store: AuthStore): void {
27
50
  const dir = getConfigDir();
28
51
  if (!existsSync(dir)) {
@@ -33,6 +56,22 @@ function persist(store: AuthStore): void {
33
56
  atomicWriteFile(authPath(), JSON.stringify(store, null, 2) + "\n");
34
57
  }
35
58
 
59
+ /**
60
+ * One-time downgrade safety net: the first time we persist the NEW shape over a file that
61
+ * still contains legacy single-credential entries, keep a pristine copy. An older opencodex
62
+ * would silently drop the new shape (normalizeCredential -> null) and then persist an empty
63
+ * store, destroying refresh tokens; the backup makes that recoverable.
64
+ */
65
+ function backupLegacyOnce(): void {
66
+ const path = authPath();
67
+ const backup = `${path}.pre-multiauth`;
68
+ if (!existsSync(path) || existsSync(backup)) return;
69
+ try {
70
+ copyFileSync(path, backup);
71
+ try { chmodSync(backup, 0o600); } catch { /* best-effort */ }
72
+ } catch { /* best-effort */ }
73
+ }
74
+
36
75
  function isCredentialSource(value: unknown): value is OAuthCredentialSource {
37
76
  return value === "oauth" || value === "local-cli" || value === "credential-file" || value === "environment" || value === "manual";
38
77
  }
@@ -55,30 +94,207 @@ function normalizeCredential(cred: unknown): OAuthCredentials | null {
55
94
  return normalized;
56
95
  }
57
96
 
58
- function normalizeAuthStore(raw: unknown): AuthStore {
59
- if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
97
+ /**
98
+ * Stable short account id. MUST be deterministic for a given credential: legacy
99
+ * single-credential stores are re-normalized on EVERY load without being persisted,
100
+ * so a time-salted id would differ between two loads (getAccountSet vs
101
+ * getAccountCredential), surfacing as a spurious OAuthLoginRequiredError and making
102
+ * refresh persists silently miss the account (rotated refresh token lost).
103
+ */
104
+ function newAccountId(cred: OAuthCredentials): string {
105
+ const identity = cred.accountId ?? cred.email ?? cred.refresh;
106
+ return createHash("sha256").update(identity).digest("hex").slice(0, 8);
107
+ }
108
+
109
+ function normalizeAccount(value: unknown): ProviderAccount | null {
110
+ if (!value || typeof value !== "object") return null;
111
+ const candidate = value as Partial<ProviderAccount>;
112
+ if (typeof candidate.id !== "string" || candidate.id.length === 0) return null;
113
+ const credential = normalizeCredential(candidate.credential);
114
+ if (!credential) return null;
115
+ const account: ProviderAccount = { id: candidate.id, credential };
116
+ if (candidate.needsReauth === true) account.needsReauth = true;
117
+ if (typeof candidate.addedAt === "number") account.addedAt = candidate.addedAt;
118
+ return account;
119
+ }
120
+
121
+ function normalizeAccountSet(raw: unknown): { set: ProviderAccountSet | null; wasLegacy: boolean } {
122
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return { set: null, wasLegacy: false };
123
+ const candidate = raw as Partial<ProviderAccountSet>;
124
+ if (Array.isArray(candidate.accounts)) {
125
+ const accounts = candidate.accounts.map(normalizeAccount).filter((a): a is ProviderAccount => a !== null);
126
+ if (accounts.length === 0) return { set: null, wasLegacy: false };
127
+ const active = typeof candidate.activeAccountId === "string" && accounts.some(a => a.id === candidate.activeAccountId)
128
+ ? candidate.activeAccountId
129
+ : accounts[0]!.id;
130
+ return { set: { activeAccountId: active, accounts }, wasLegacy: false };
131
+ }
132
+ // Legacy single-credential value.
133
+ const cred = normalizeCredential(raw);
134
+ if (!cred) return { set: null, wasLegacy: false };
135
+ const id = newAccountId(cred);
136
+ return { set: { activeAccountId: id, accounts: [{ id, credential: cred }] }, wasLegacy: true };
137
+ }
138
+
139
+ function normalizeAuthStore(raw: unknown): { store: AuthStore; hadLegacy: boolean } {
140
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return { store: {}, hadLegacy: false };
60
141
  const normalized: AuthStore = {};
61
- for (const [provider, cred] of Object.entries(raw)) {
62
- const safe = normalizeCredential(cred);
63
- if (safe) normalized[provider] = safe;
142
+ let hadLegacy = false;
143
+ for (const [provider, value] of Object.entries(raw)) {
144
+ const { set, wasLegacy } = normalizeAccountSet(value);
145
+ if (set) normalized[provider] = set;
146
+ if (wasLegacy) hadLegacy = true;
64
147
  }
65
- return normalized;
148
+ return { store: normalized, hadLegacy };
149
+ }
150
+
151
+ /**
152
+ * In-process write serialization: every mutation runs load-modify-persist under this queue so
153
+ * a guardian refresh persisting a non-active account cannot roll back a concurrent
154
+ * active-account switch (lost update). Cross-process races are accepted (single proxy).
155
+ */
156
+ let writeQueue: Promise<unknown> = Promise.resolve();
157
+ function enqueueWrite<T>(fn: () => T): T {
158
+ // Synchronous mutations: chain onto the queue for ordering, but run eagerly since all
159
+ // current callers are sync. The queue exists so future async mutators serialize too.
160
+ const result = fn();
161
+ writeQueue = writeQueue.then(() => result);
162
+ return result;
163
+ }
164
+
165
+ function mutateStore<T>(fn: (store: AuthStore) => T): T {
166
+ return enqueueWrite(() => {
167
+ const { store, hadLegacy } = loadAuthStoreInternal();
168
+ if (hadLegacy) backupLegacyOnce();
169
+ const result = fn(store);
170
+ persist(store);
171
+ return result;
172
+ });
66
173
  }
67
174
 
175
+ /** The ACTIVE account's credential for a provider (what requests should use). */
68
176
  export function getCredential(provider: string): OAuthCredentials | null {
69
- return loadAuthStore()[provider] ?? null;
177
+ const set = loadAuthStore()[provider];
178
+ if (!set) return null;
179
+ return set.accounts.find(a => a.id === set.activeAccountId)?.credential ?? null;
70
180
  }
71
181
 
182
+ /**
183
+ * Persist a credential as the ACTIVE account. Identity-matching (accountId ?? email) upserts
184
+ * the same human's slot; a new identity appends a new account. Credentials without identity
185
+ * (rotating refresh tokens would fabricate duplicates) and single-slot providers replace the
186
+ * active slot / whole set instead.
187
+ */
72
188
  export function saveCredential(provider: string, cred: OAuthCredentials): void {
73
- const store = loadAuthStore();
74
189
  const safe = normalizeCredential(cred);
75
190
  if (!safe) return;
76
- store[provider] = safe;
77
- persist(store);
191
+ mutateStore(store => {
192
+ const set = store[provider];
193
+ const identity = safe.accountId ?? safe.email;
194
+ if (!set || SINGLE_SLOT_PROVIDERS.has(provider)) {
195
+ const id = newAccountId(safe);
196
+ store[provider] = { activeAccountId: id, accounts: [{ id, credential: safe, addedAt: Date.now() }] };
197
+ return;
198
+ }
199
+ if (identity) {
200
+ const existing = set.accounts.find(a => (a.credential.accountId ?? a.credential.email) === identity);
201
+ if (existing) {
202
+ existing.credential = safe;
203
+ delete existing.needsReauth;
204
+ set.activeAccountId = existing.id;
205
+ return;
206
+ }
207
+ const id = newAccountId(safe);
208
+ set.accounts.push({ id, credential: safe, addedAt: Date.now() });
209
+ set.activeAccountId = id;
210
+ return;
211
+ }
212
+ // No identity: replace the active slot in place (single-account semantics).
213
+ const active = set.accounts.find(a => a.id === set.activeAccountId);
214
+ if (active) {
215
+ active.credential = safe;
216
+ delete active.needsReauth;
217
+ } else {
218
+ const id = newAccountId(safe);
219
+ set.accounts.push({ id, credential: safe, addedAt: Date.now() });
220
+ set.activeAccountId = id;
221
+ }
222
+ });
78
223
  }
79
224
 
225
+ /** Remove the ACTIVE account; remaining accounts promote the first one. */
80
226
  export function removeCredential(provider: string): void {
81
- const store = loadAuthStore();
82
- delete store[provider];
83
- persist(store);
227
+ mutateStore(store => {
228
+ const set = store[provider];
229
+ if (!set) return;
230
+ set.accounts = set.accounts.filter(a => a.id !== set.activeAccountId);
231
+ if (set.accounts.length === 0) {
232
+ delete store[provider];
233
+ return;
234
+ }
235
+ set.activeAccountId = set.accounts[0]!.id;
236
+ });
237
+ }
238
+
239
+ // ---------------------------------------------------------------------------
240
+ // Multi-account API
241
+ // ---------------------------------------------------------------------------
242
+
243
+ export function getAccountSet(provider: string): ProviderAccountSet | null {
244
+ return loadAuthStore()[provider] ?? null;
245
+ }
246
+
247
+ export function listAccounts(provider: string): ProviderAccount[] {
248
+ return loadAuthStore()[provider]?.accounts ?? [];
249
+ }
250
+
251
+ export function getAccountCredential(provider: string, accountId: string): OAuthCredentials | null {
252
+ return loadAuthStore()[provider]?.accounts.find(a => a.id === accountId)?.credential ?? null;
253
+ }
254
+
255
+ /** Persist a refreshed credential for a SPECIFIC account without touching activeAccountId. */
256
+ export function saveAccountCredential(provider: string, accountId: string, cred: OAuthCredentials): void {
257
+ const safe = normalizeCredential(cred);
258
+ if (!safe) return;
259
+ mutateStore(store => {
260
+ const account = store[provider]?.accounts.find(a => a.id === accountId);
261
+ if (!account) return;
262
+ account.credential = safe;
263
+ delete account.needsReauth;
264
+ });
265
+ }
266
+
267
+ export function setActiveAccount(provider: string, accountId: string): boolean {
268
+ return mutateStore(store => {
269
+ const set = store[provider];
270
+ if (!set || !set.accounts.some(a => a.id === accountId)) return false;
271
+ set.activeAccountId = accountId;
272
+ return true;
273
+ });
274
+ }
275
+
276
+ /** Remove one account by id; active removal promotes the first remaining account. */
277
+ export function removeAccount(provider: string, accountId: string): boolean {
278
+ return mutateStore(store => {
279
+ const set = store[provider];
280
+ if (!set) return false;
281
+ const before = set.accounts.length;
282
+ set.accounts = set.accounts.filter(a => a.id !== accountId);
283
+ if (set.accounts.length === before) return false;
284
+ if (set.accounts.length === 0) {
285
+ delete store[provider];
286
+ return true;
287
+ }
288
+ if (set.activeAccountId === accountId) set.activeAccountId = set.accounts[0]!.id;
289
+ return true;
290
+ });
291
+ }
292
+
293
+ export function markAccountNeedsReauth(provider: string, accountId: string, needsReauth: boolean): void {
294
+ mutateStore(store => {
295
+ const account = store[provider]?.accounts.find(a => a.id === accountId);
296
+ if (!account) return;
297
+ if (needsReauth) account.needsReauth = true;
298
+ else delete account.needsReauth;
299
+ });
84
300
  }