@bitkyc08/opencodex 2.6.26-preview.20260705 → 2.6.28-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 (118) 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-CkV5xFA8.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} +21 -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} +1 -1
  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} +6 -5
  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/{process-control.ts → lib/process-control.ts} +1 -1
  59. package/src/{service-secrets.ts → lib/service-secrets.ts} +1 -1
  60. package/src/oauth/callback-server.ts +1 -1
  61. package/src/oauth/google-antigravity.ts +7 -4
  62. package/src/oauth/index.ts +67 -16
  63. package/src/oauth/login-cli.ts +2 -2
  64. package/src/oauth/store.ts +236 -20
  65. package/src/oauth/token-guardian.ts +24 -20
  66. package/src/oauth/types.ts +16 -0
  67. package/src/providers/api-keys.ts +121 -0
  68. package/src/{provider-context-cap.ts → providers/context-cap.ts} +1 -1
  69. package/src/providers/derive.ts +2 -0
  70. package/src/providers/key-failover.ts +145 -0
  71. package/src/{provider-label.ts → providers/label.ts} +1 -1
  72. package/src/{provider-quota.ts → providers/quota.ts} +12 -7
  73. package/src/providers/registry.ts +66 -4
  74. package/src/responses/compaction.ts +117 -0
  75. package/src/responses/parser.ts +89 -13
  76. package/src/responses/reasoning-envelope.ts +52 -0
  77. package/src/responses/schema.ts +15 -3
  78. package/src/responses/state.ts +117 -2
  79. package/src/router.ts +2 -0
  80. package/src/server/auth-cors.ts +231 -0
  81. package/src/server/index.ts +523 -0
  82. package/src/server/lifecycle.ts +73 -0
  83. package/src/server/management-api.ts +628 -0
  84. package/src/{proxy-liveness.ts → server/proxy-liveness.ts} +1 -1
  85. package/src/server/relay.ts +534 -0
  86. package/src/server/request-decompress.ts +46 -0
  87. package/src/server/request-log.ts +310 -0
  88. package/src/server/responses.ts +775 -0
  89. package/src/{ws-bridge.ts → server/ws-bridge.ts} +44 -13
  90. package/src/service.ts +19 -11
  91. package/src/types.ts +27 -0
  92. package/src/{update.ts → update/index.ts} +5 -5
  93. package/src/{update-job.ts → update/job.ts} +7 -6
  94. package/src/{update-notify.ts → update/notify.ts} +3 -3
  95. package/src/{usage-debug.ts → usage/debug.ts} +3 -3
  96. package/src/{usage-log.ts → usage/log.ts} +3 -3
  97. package/src/{usage-summary.ts → usage/summary.ts} +3 -3
  98. package/src/{usage-totals.ts → usage/totals.ts} +1 -1
  99. package/src/vision/describe.ts +3 -3
  100. package/src/vision/index.ts +21 -1
  101. package/src/web-search/executor.ts +4 -4
  102. package/src/web-search/index.ts +1 -1
  103. package/src/web-search/loop.ts +84 -24
  104. package/gui/dist/assets/index-BcHhxo1I.css +0 -1
  105. package/gui/dist/assets/index-DCC1q_Jx.js +0 -15
  106. package/src/server.ts +0 -2501
  107. /package/src/{codex-account-runtime-state.ts → codex/account-runtime-state.ts} +0 -0
  108. /package/src/{codex-quota.ts → codex/quota.ts} +0 -0
  109. /package/src/{abort.ts → lib/abort.ts} +0 -0
  110. /package/src/{debug.ts → lib/debug.ts} +0 -0
  111. /package/src/{errors.ts → lib/errors.ts} +0 -0
  112. /package/src/{open-url.ts → lib/open-url.ts} +0 -0
  113. /package/src/{privacy.ts → lib/privacy.ts} +0 -0
  114. /package/src/{redact.ts → lib/redact.ts} +0 -0
  115. /package/src/{sidecar-tracker.ts → lib/sidecar-tracker.ts} +0 -0
  116. /package/src/{upstream-retry.ts → lib/upstream-retry.ts} +0 -0
  117. /package/src/{win-paths.ts → lib/win-paths.ts} +0 -0
  118. /package/src/{ports.ts → server/ports.ts} +0 -0
@@ -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). */
@@ -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
  }
@@ -13,14 +13,14 @@
13
13
  */
14
14
  import { loadConfig } from "../config";
15
15
  import type { OcxConfig, OcxTokenGuardianConfig } from "../types";
16
- import { getCredential } from "./store";
17
- import { getValidAccessToken, listOAuthProviders, resolveRefreshPolicy } from "./index";
16
+ import { listAccounts } from "./store";
17
+ import { getValidAccessTokenForAccount, listOAuthProviders, OAuthLoginRequiredError, resolveRefreshPolicy } from "./index";
18
18
  import {
19
19
  getValidCodexToken,
20
20
  listCodexAccountIds,
21
21
  readCodexAccountRecord,
22
22
  TokenRefreshError,
23
- } from "../codex-account-store";
23
+ } from "../codex/account-store";
24
24
 
25
25
  export interface TokenGuardianHandle {
26
26
  stop(): void;
@@ -113,25 +113,29 @@ export async function guardianSweep(nowMs: number = Date.now()): Promise<Guardia
113
113
  const horizonMs = (opts.tickSeconds + opts.leadSeconds) * 1000;
114
114
  const tasks: Array<() => Promise<void>> = [];
115
115
 
116
- // A) single-account OAuth providers
116
+ // A) OAuth providers — every account in each provider's set (multiauth keep-alive),
117
+ // skipping accounts already marked needsReauth (terminal; only a re-login fixes them).
117
118
  for (const provider of listOAuthProviders()) {
118
119
  if (resolveRefreshPolicy(provider, config) !== "proactive") continue;
119
- const cred = getCredential(provider);
120
- if (!cred) continue;
121
- if (cred.expires > nowMs + horizonMs) continue;
122
- const key = `oauth:${provider}`;
123
- if (inBackoff(key, nowMs)) { result.skippedBackoff.push(key); continue; }
124
- tasks.push(async () => {
125
- try {
126
- await getValidAccessToken(provider);
127
- backoff.delete(key);
128
- result.refreshed.push(key);
129
- } catch {
130
- // Single-account refresh throws generic errors; treat as transient (exponential backoff).
131
- recordFailure(key, nowMs, opts.backoffBaseSeconds, opts.backoffMaxSeconds, false);
132
- result.failed.push(key);
133
- }
134
- });
120
+ for (const account of listAccounts(provider)) {
121
+ if (account.needsReauth) continue;
122
+ if (account.credential.expires > nowMs + horizonMs) continue;
123
+ const key = `oauth:${provider}:${account.id}`;
124
+ if (inBackoff(key, nowMs)) { result.skippedBackoff.push(key); continue; }
125
+ tasks.push(async () => {
126
+ try {
127
+ await getValidAccessTokenForAccount(provider, account.id);
128
+ backoff.delete(key);
129
+ result.refreshed.push(key);
130
+ } catch (err) {
131
+ // Terminal grant failures surface as OAuthLoginRequiredError (account marked
132
+ // needsReauth by the resolver) — back off at the ceiling; transient errors backoff exponentially.
133
+ const permanent = err instanceof OAuthLoginRequiredError;
134
+ recordFailure(key, nowMs, opts.backoffBaseSeconds, opts.backoffMaxSeconds, permanent);
135
+ result.failed.push(key);
136
+ }
137
+ });
138
+ }
135
139
  }
136
140
 
137
141
  // B) multi-account Codex pool (gated on the chatgpt provider's policy)
@@ -12,6 +12,22 @@ export type OAuthCredentials = {
12
12
  projectId?: string;
13
13
  };
14
14
 
15
+ /** One logged-in account inside a provider's account set (multiauth). */
16
+ export interface ProviderAccount {
17
+ /** Stable short id, generated once at append time; never re-derived after rotation. */
18
+ id: string;
19
+ credential: OAuthCredentials;
20
+ /** Terminal refresh failure (invalid_grant / reused / revoked) — re-login required. */
21
+ needsReauth?: boolean;
22
+ addedAt?: number;
23
+ }
24
+
25
+ /** auth.json value per provider: N accounts + which one requests use. */
26
+ export interface ProviderAccountSet {
27
+ activeAccountId: string;
28
+ accounts: ProviderAccount[];
29
+ }
30
+
15
31
  export interface OAuthController {
16
32
  onAuth?(info: { url: string; instructions?: string }): void;
17
33
  onProgress?(message: string): void;