@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.
- package/README.md +1 -0
- package/bin/ocx.mjs +4 -4
- package/gui/dist/assets/index-ByGC8-Bm.css +1 -0
- package/gui/dist/assets/index-CkV5xFA8.js +15 -0
- package/gui/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/adapters/anthropic-image-guard.ts +195 -0
- package/src/adapters/anthropic.ts +85 -14
- package/src/adapters/cursor/cursor-errors.ts +2 -2
- package/src/adapters/cursor/live-transport.ts +1 -1
- package/src/adapters/cursor/transport-retry.ts +2 -2
- package/src/adapters/google-errors.ts +1 -1
- package/src/adapters/google-http.ts +1 -1
- package/src/adapters/google-truncation.ts +1 -1
- package/src/adapters/google.ts +1 -1
- package/src/adapters/kiro-errors.ts +1 -1
- package/src/adapters/kiro-retry.ts +1 -1
- package/src/adapters/kiro-truncation.ts +1 -1
- package/src/adapters/kiro.ts +1 -1
- package/src/adapters/openai-chat.ts +12 -2
- package/src/adapters/openai-responses.ts +126 -3
- package/src/bridge.ts +164 -7
- package/src/{doctor.ts → cli/doctor.ts} +21 -4
- package/src/{cli-help.ts → cli/help.ts} +1 -1
- package/src/cli/index.ts +584 -0
- package/src/{init.ts → cli/init.ts} +6 -6
- package/src/{cli-models.ts → cli/models.ts} +2 -2
- package/src/{cli-provider.ts → cli/provider.ts} +12 -8
- package/src/{star-prompt.ts → cli/star-prompt.ts} +1 -1
- package/src/{cli-status.ts → cli/status.ts} +7 -7
- package/src/cli.ts +9 -575
- package/src/{codex-account-label.ts → codex/account-label.ts} +1 -1
- package/src/{codex-account-lifecycle.ts → codex/account-lifecycle.ts} +6 -6
- package/src/{codex-account-store.ts → codex/account-store.ts} +2 -2
- package/src/{codex-account-usability.ts → codex/account-usability.ts} +4 -4
- package/src/{codex-auth-api.ts → codex/auth-api.ts} +18 -18
- package/src/{codex-auth-collision.ts → codex/auth-collision.ts} +4 -4
- package/src/{codex-auth-context.ts → codex/auth-context.ts} +9 -9
- package/src/{codex-catalog.ts → codex/catalog.ts} +49 -20
- package/src/codex/history-migration-guardian.ts +102 -0
- package/src/{codex-history-provider.ts → codex/history-provider.ts} +111 -7
- package/src/{codex-home.ts → codex/home.ts} +1 -1
- package/src/{codex-inject.ts → codex/inject.ts} +204 -26
- package/src/{codex-journal.ts → codex/journal.ts} +2 -2
- package/src/{codex-main-account.ts → codex/main-account.ts} +2 -2
- package/src/{model-cache.ts → codex/model-cache.ts} +1 -1
- package/src/{codex-paths.ts → codex/paths.ts} +2 -2
- package/src/{codex-plugins-doctor.ts → codex/plugins-doctor.ts} +2 -2
- package/src/{codex-refresh.ts → codex/refresh.ts} +4 -4
- package/src/{codex-routing.ts → codex/routing.ts} +8 -8
- package/src/{codex-shim.ts → codex/shim.ts} +6 -5
- package/src/{codex-sync.ts → codex/sync.ts} +4 -4
- package/src/{codex-websocket-registry.ts → codex/websocket-registry.ts} +1 -1
- package/src/config.ts +2 -0
- package/src/generated/jawcode-model-metadata.ts +2 -0
- package/src/{bun-runtime.ts → lib/bun-runtime.ts} +1 -1
- package/src/{crash-guard.ts → lib/crash-guard.ts} +1 -1
- package/src/{process-control.ts → lib/process-control.ts} +1 -1
- package/src/{service-secrets.ts → lib/service-secrets.ts} +1 -1
- package/src/oauth/callback-server.ts +1 -1
- package/src/oauth/google-antigravity.ts +7 -4
- package/src/oauth/index.ts +67 -16
- package/src/oauth/login-cli.ts +2 -2
- package/src/oauth/store.ts +236 -20
- package/src/oauth/token-guardian.ts +24 -20
- package/src/oauth/types.ts +16 -0
- package/src/providers/api-keys.ts +121 -0
- package/src/{provider-context-cap.ts → providers/context-cap.ts} +1 -1
- package/src/providers/derive.ts +2 -0
- package/src/providers/key-failover.ts +145 -0
- package/src/{provider-label.ts → providers/label.ts} +1 -1
- package/src/{provider-quota.ts → providers/quota.ts} +12 -7
- package/src/providers/registry.ts +66 -4
- package/src/responses/compaction.ts +117 -0
- package/src/responses/parser.ts +89 -13
- package/src/responses/reasoning-envelope.ts +52 -0
- package/src/responses/schema.ts +15 -3
- package/src/responses/state.ts +117 -2
- package/src/router.ts +2 -0
- package/src/server/auth-cors.ts +231 -0
- package/src/server/index.ts +523 -0
- package/src/server/lifecycle.ts +73 -0
- package/src/server/management-api.ts +628 -0
- package/src/{proxy-liveness.ts → server/proxy-liveness.ts} +1 -1
- package/src/server/relay.ts +534 -0
- package/src/server/request-decompress.ts +46 -0
- package/src/server/request-log.ts +310 -0
- package/src/server/responses.ts +775 -0
- package/src/{ws-bridge.ts → server/ws-bridge.ts} +44 -13
- package/src/service.ts +19 -11
- package/src/types.ts +27 -0
- package/src/{update.ts → update/index.ts} +5 -5
- package/src/{update-job.ts → update/job.ts} +7 -6
- package/src/{update-notify.ts → update/notify.ts} +3 -3
- package/src/{usage-debug.ts → usage/debug.ts} +3 -3
- package/src/{usage-log.ts → usage/log.ts} +3 -3
- package/src/{usage-summary.ts → usage/summary.ts} +3 -3
- package/src/{usage-totals.ts → usage/totals.ts} +1 -1
- package/src/vision/describe.ts +3 -3
- package/src/vision/index.ts +21 -1
- package/src/web-search/executor.ts +4 -4
- package/src/web-search/index.ts +1 -1
- package/src/web-search/loop.ts +84 -24
- package/gui/dist/assets/index-BcHhxo1I.css +0 -1
- package/gui/dist/assets/index-DCC1q_Jx.js +0 -15
- package/src/server.ts +0 -2501
- /package/src/{codex-account-runtime-state.ts → codex/account-runtime-state.ts} +0 -0
- /package/src/{codex-quota.ts → codex/quota.ts} +0 -0
- /package/src/{abort.ts → lib/abort.ts} +0 -0
- /package/src/{debug.ts → lib/debug.ts} +0 -0
- /package/src/{errors.ts → lib/errors.ts} +0 -0
- /package/src/{open-url.ts → lib/open-url.ts} +0 -0
- /package/src/{privacy.ts → lib/privacy.ts} +0 -0
- /package/src/{redact.ts → lib/redact.ts} +0 -0
- /package/src/{sidecar-tracker.ts → lib/sidecar-tracker.ts} +0 -0
- /package/src/{upstream-retry.ts → lib/upstream-retry.ts} +0 -0
- /package/src/{win-paths.ts → lib/win-paths.ts} +0 -0
- /package/src/{ports.ts → server/ports.ts} +0 -0
package/src/oauth/index.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
|
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(
|
|
163
|
+
const refresh = refreshAndPersistAccessToken(provider, accountId, def, cred).finally(() => {
|
|
164
|
+
if (tokenRefreshes.get(key) === refresh) tokenRefreshes.delete(key);
|
|
150
165
|
});
|
|
151
|
-
tokenRefreshes.set(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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). */
|
package/src/oauth/login-cli.ts
CHANGED
|
@@ -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";
|
package/src/oauth/store.ts
CHANGED
|
@@ -1,28 +1,51 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
59
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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
|
|
77
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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 {
|
|
17
|
-
import {
|
|
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
|
|
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)
|
|
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
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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)
|
package/src/oauth/types.ts
CHANGED
|
@@ -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;
|