@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.
- 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-D6YMHYXe.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} +109 -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} +69 -14
- 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} +80 -15
- 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/lib/gcp-adc.ts +13 -2
- 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/anthropic.ts +2 -2
- 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/local-token-detect.ts +36 -5
- 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 +23 -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-z8MZ_KPP.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/anthropic.ts
CHANGED
|
@@ -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
|
|
141
|
-
: "Claude Code
|
|
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
|
-
|
|
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> {
|
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). */
|
|
@@ -39,8 +39,14 @@ export function detectGrokCliToken(): OAuthCredentials | null {
|
|
|
39
39
|
}
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
/**
|
|
43
|
-
function
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
+
}
|
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
|
}
|