@bitkyc08/opencodex 2.7.21 → 2.7.23

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.
@@ -16,8 +16,8 @@
16
16
  } catch (e) {}
17
17
  })();
18
18
  </script>
19
- <script type="module" crossorigin src="/assets/index-DYIS0tTL.js"></script>
20
- <link rel="stylesheet" crossorigin href="/assets/index-CILVKWmx.css">
19
+ <script type="module" crossorigin src="/assets/index-DQjt6Hly.js"></script>
20
+ <link rel="stylesheet" crossorigin href="/assets/index-Bk_GgFrh.css">
21
21
  </head>
22
22
  <body>
23
23
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitkyc08/opencodex",
3
- "version": "2.7.21",
3
+ "version": "2.7.23",
4
4
  "description": "Universal provider proxy for OpenAI Codex & Claude Code — use any LLM with Codex CLI/App/SDK and Claude Code",
5
5
  "type": "module",
6
6
  "main": "./bin/package-main.mjs",
@@ -9,6 +9,7 @@
9
9
  * - message_delta.usage is cumulative; message_start embeds a full message snapshot.
10
10
  * - errors: {type:"error", error:{type,message}}; may arrive mid-stream after HTTP 200.
11
11
  */
12
+ import { isTransientUpstreamStatus } from "../lib/upstream-retry";
12
13
 
13
14
  type Rec = Record<string, unknown>;
14
15
 
@@ -202,12 +203,19 @@ export function responsesSseToAnthropicSse(
202
203
  });
203
204
  emit("message_stop", { type: "message_stop" });
204
205
  };
205
- const fail = (status: number, message: string) => {
206
+ // upstreamDerived: transient upstream statuses become overloaded_error so the
207
+ // Anthropic-SDK client retries with backoff; proxy-internal exceptions stay
208
+ // api_error — a deterministic ocx bug must not be masked as retryable
209
+ // (devlog/_plan/260716_claudecode_hardening/020). On win32 mid-stream socket
210
+ // resets reach the reader catch (no failed-tail relay) and stay api_error —
211
+ // same as today, deliberate residual.
212
+ const fail = (status: number, message: string, upstreamDerived = false) => {
206
213
  if (terminated) return;
207
214
  terminated = true;
208
215
  ensureStarted();
209
216
  closeOpenBlock();
210
- emit("error", anthropicErrorBody(status, message));
217
+ const type = upstreamDerived && isTransientUpstreamStatus(status) ? "overloaded_error" : undefined;
218
+ emit("error", anthropicErrorBody(status, message, type));
211
219
  };
212
220
 
213
221
  const handleFrame = (eventName: string, data: Rec) => {
@@ -323,7 +331,10 @@ export function responsesSseToAnthropicSse(
323
331
  const error = isRec(response.error) ? response.error : {};
324
332
  const message = typeof error.message === "string" ? error.message : "upstream request failed";
325
333
  const status = typeof error.status === "number" ? error.status : 500;
326
- fail(status, message);
334
+ // status-absent response.failed (relaySseWithFailedTail synthetic tail) defaults
335
+ // to 500, which is in the transient set — the mid-stream reset shape maps to
336
+ // overloaded_error by design.
337
+ fail(status, message, true);
327
338
  break;
328
339
  }
329
340
  default:
@@ -360,7 +371,7 @@ export function responsesSseToAnthropicSse(
360
371
  // gateways that close such streams politely hand Claude Code an empty/partial
361
372
  // turn with no retryable error — CLIProxyAPI#2189 failure pattern). Fail closed
362
373
  // with a mid-stream Anthropic error event so the client can retry.
363
- if (!cancelled) fail(502, "upstream stream ended before a terminal frame (truncated response)");
374
+ if (!cancelled) fail(502, "upstream stream ended before a terminal frame (truncated response)", true);
364
375
  } catch (err) {
365
376
  fail(500, err instanceof Error ? err.message : String(err));
366
377
  } finally {
package/src/cli/index.ts CHANGED
@@ -500,7 +500,7 @@ switch (command) {
500
500
  case "logout": {
501
501
  const { removeCredential } = await import("../oauth/store");
502
502
  const name = (args[1] ?? "").trim().toLowerCase();
503
- removeCredential(name);
503
+ await removeCredential(name);
504
504
  console.log(`Logged out of ${name || "(none)"}.`);
505
505
  break;
506
506
  }
@@ -237,9 +237,7 @@ async function fetchMainAccountInfo(forceRefresh = false): Promise<{ email: stri
237
237
  updateAccountQuota(
238
238
  MAIN_CODEX_ACCOUNT_ID,
239
239
  result.quota.weeklyPercent,
240
- result.quota.fiveHourPercent,
241
240
  result.quota.weeklyResetAt,
242
- result.quota.fiveHourResetAt,
243
241
  result.quota.monthlyPercent,
244
242
  result.quota.monthlyResetAt,
245
243
  result.quota.resetCredits,
@@ -285,9 +283,7 @@ async function fetchPoolAccountQuota(accountId: string, forceRefresh = false, co
285
283
  updateAccountQuota(
286
284
  accountId,
287
285
  quota.weeklyPercent,
288
- quota.fiveHourPercent,
289
286
  quota.weeklyResetAt,
290
- quota.fiveHourResetAt,
291
287
  quota.monthlyPercent,
292
288
  quota.monthlyResetAt,
293
289
  quota.resetCredits,
@@ -574,6 +570,13 @@ export async function handleCodexAuthAPI(
574
570
  const { startLoginFlow, getLoginStatus } = await import("../oauth");
575
571
  const result = await startLoginFlow("chatgpt", { forceLogin: true });
576
572
 
573
+ // Open the browser server-side (same pattern as /api/oauth/login in management-api.ts).
574
+ // The GUI's window.open is popup-blocked because it runs after an await, not a direct click.
575
+ if (result.url) {
576
+ const { openUrl } = await import("../lib/open-url");
577
+ openUrl(result.url);
578
+ }
579
+
577
580
  (async () => {
578
581
  let completed = false;
579
582
  for (let i = 0; i < 150; i++) {
@@ -644,9 +647,7 @@ export async function handleCodexAuthAPI(
644
647
  updateAccountQuota(
645
648
  accountId,
646
649
  quota.weeklyPercent,
647
- quota.fiveHourPercent,
648
650
  quota.weeklyResetAt,
649
- quota.fiveHourResetAt,
650
651
  quota.monthlyPercent,
651
652
  quota.monthlyResetAt,
652
653
  quota.resetCredits,
@@ -1,9 +1,7 @@
1
1
  export type StoredAccountQuota = {
2
2
  weeklyPercent?: number;
3
- fiveHourPercent?: number;
4
3
  monthlyPercent?: number;
5
4
  weeklyResetAt?: number;
6
- fiveHourResetAt?: number;
7
5
  monthlyResetAt?: number;
8
6
  resetCredits?: number;
9
7
  updatedAt: number;
@@ -47,48 +45,38 @@ function normalizeResetAt(value: unknown): number | undefined {
47
45
  }
48
46
 
49
47
  function hasKnownQuotaValue(quota: Omit<StoredAccountQuota, "updatedAt">): boolean {
50
- return [quota.weeklyPercent, quota.fiveHourPercent, quota.monthlyPercent]
48
+ return [quota.weeklyPercent, quota.monthlyPercent]
51
49
  .some(value => typeof value === "number" && Number.isFinite(value));
52
50
  }
53
51
 
54
52
  export function updateAccountQuota(
55
53
  accountId: string,
56
54
  weekly: unknown,
57
- fiveHour: unknown,
58
55
  weeklyResetAt?: unknown,
59
- fiveHourResetAt?: unknown,
60
56
  monthly?: unknown,
61
57
  monthlyResetAt?: unknown,
62
58
  resetCredits?: number,
63
59
  ): void {
64
60
  const existing = accountQuota.get(accountId);
65
61
  const nextWeekly = normalizeUsagePercent(weekly);
66
- const nextFiveHour = normalizeUsagePercent(fiveHour);
67
62
  const nextMonthly = normalizeUsagePercent(monthly);
68
- if (nextWeekly === undefined && nextFiveHour === undefined && nextMonthly === undefined && resetCredits === undefined) return;
63
+ if (nextWeekly === undefined && nextMonthly === undefined && resetCredits === undefined) return;
69
64
 
70
65
  const quota: StoredAccountQuota = {
71
66
  ...(existing?.weeklyPercent !== undefined ? { weeklyPercent: existing.weeklyPercent } : {}),
72
- ...(existing?.fiveHourPercent !== undefined ? { fiveHourPercent: existing.fiveHourPercent } : {}),
73
67
  ...(existing?.monthlyPercent !== undefined ? { monthlyPercent: existing.monthlyPercent } : {}),
74
68
  ...(existing?.weeklyResetAt !== undefined ? { weeklyResetAt: existing.weeklyResetAt } : {}),
75
- ...(existing?.fiveHourResetAt !== undefined ? { fiveHourResetAt: existing.fiveHourResetAt } : {}),
76
69
  ...(existing?.monthlyResetAt !== undefined ? { monthlyResetAt: existing.monthlyResetAt } : {}),
77
70
  ...(existing?.resetCredits !== undefined ? { resetCredits: existing.resetCredits } : {}),
78
71
  updatedAt: Date.now(),
79
72
  };
80
73
 
81
74
  const nextWeeklyResetAt = normalizeResetAt(weeklyResetAt);
82
- const nextFiveHourResetAt = normalizeResetAt(fiveHourResetAt);
83
75
  const nextMonthlyResetAt = normalizeResetAt(monthlyResetAt);
84
76
  if (nextWeekly !== undefined) {
85
77
  quota.weeklyPercent = nextWeekly;
86
78
  if (nextWeeklyResetAt !== undefined) quota.weeklyResetAt = nextWeeklyResetAt;
87
79
  }
88
- if (nextFiveHour !== undefined) {
89
- quota.fiveHourPercent = nextFiveHour;
90
- if (nextFiveHourResetAt !== undefined) quota.fiveHourResetAt = nextFiveHourResetAt;
91
- }
92
80
  if (nextMonthly !== undefined) {
93
81
  quota.monthlyPercent = nextMonthly;
94
82
  if (nextMonthlyResetAt !== undefined) quota.monthlyResetAt = nextMonthlyResetAt;
@@ -122,27 +110,25 @@ export function parseUsageQuota(data: WhamUsageResponse): Omit<StoredAccountQuot
122
110
 
123
111
  const quota: Omit<StoredAccountQuota, "updatedAt"> = {};
124
112
  const thirtyDayOnly = data.plan_type?.trim().toLowerCase() === "go" || data.plan_type?.trim().toLowerCase() === "free";
125
- const weeklyPercent = normalizeUsagePercent(data.rate_limit.secondary_window?.used_percent);
126
- const fiveHourPercent = normalizeUsagePercent(data.rate_limit.primary_window?.used_percent);
113
+ // primary_window was the 5h window; it now carries weekly data for GPT plans.
114
+ // secondary_window is the legacy weekly source; prefer primary when present.
115
+ const primaryPercent = normalizeUsagePercent(data.rate_limit.primary_window?.used_percent);
116
+ const secondaryPercent = normalizeUsagePercent(data.rate_limit.secondary_window?.used_percent);
117
+ const weeklyPercent = primaryPercent ?? secondaryPercent;
127
118
  const monthlyPercent = normalizeUsagePercent(data.rate_limit.tertiary_window?.used_percent);
128
- const weeklyResetAt = normalizeResetAt(data.rate_limit.secondary_window?.reset_at);
129
- const fiveHourResetAt = normalizeResetAt(data.rate_limit.primary_window?.reset_at);
119
+ const primaryResetAt = normalizeResetAt(data.rate_limit.primary_window?.reset_at);
120
+ const secondaryResetAt = normalizeResetAt(data.rate_limit.secondary_window?.reset_at);
121
+ const weeklyResetAt = primaryPercent !== undefined ? primaryResetAt : secondaryResetAt;
130
122
  const monthlyResetAt = normalizeResetAt(data.rate_limit.tertiary_window?.reset_at);
131
123
  if (thirtyDayOnly) {
132
- const goMonthlyPercent = monthlyPercent ?? fiveHourPercent;
133
- const goMonthlyResetAt = monthlyResetAt ?? fiveHourResetAt;
134
- if (goMonthlyPercent !== undefined) {
135
- quota.monthlyPercent = goMonthlyPercent;
136
- if (goMonthlyResetAt !== undefined) quota.monthlyResetAt = goMonthlyResetAt;
124
+ if (monthlyPercent !== undefined) {
125
+ quota.monthlyPercent = monthlyPercent;
126
+ if (monthlyResetAt !== undefined) quota.monthlyResetAt = monthlyResetAt;
137
127
  }
138
128
  } else if (weeklyPercent !== undefined) {
139
129
  quota.weeklyPercent = weeklyPercent;
140
130
  if (weeklyResetAt !== undefined) quota.weeklyResetAt = weeklyResetAt;
141
131
  }
142
- if (!thirtyDayOnly && fiveHourPercent !== undefined) {
143
- quota.fiveHourPercent = fiveHourPercent;
144
- if (fiveHourResetAt !== undefined) quota.fiveHourResetAt = fiveHourResetAt;
145
- }
146
132
  if (!thirtyDayOnly && monthlyPercent !== undefined) {
147
133
  quota.monthlyPercent = monthlyPercent;
148
134
  if (monthlyResetAt !== undefined) quota.monthlyResetAt = monthlyResetAt;
@@ -80,7 +80,6 @@ export function getCodexUpstreamHealth(
80
80
 
81
81
  export function computeCodexUsageScore(quota: {
82
82
  weeklyPercent?: number;
83
- fiveHourPercent?: number;
84
83
  monthlyPercent?: number;
85
84
  } | null, plan?: string | null): number {
86
85
  if (!quota) return CODEX_UNKNOWN_USAGE_SCORE;
@@ -90,7 +89,7 @@ export function computeCodexUsageScore(quota: {
90
89
  ? quota.monthlyPercent
91
90
  : CODEX_UNKNOWN_USAGE_SCORE;
92
91
  }
93
- const values = [quota.weeklyPercent, quota.fiveHourPercent, quota.monthlyPercent]
92
+ const values = [quota.weeklyPercent, quota.monthlyPercent]
94
93
  .filter((value): value is number => typeof value === "number" && Number.isFinite(value));
95
94
  return values.length > 0 ? Math.max(...values) : CODEX_UNKNOWN_USAGE_SCORE;
96
95
  }
package/src/lib/abort.ts CHANGED
@@ -14,6 +14,61 @@ export interface ClearableDeadline {
14
14
  clear: () => void;
15
15
  }
16
16
 
17
+ export interface IdleDeadline {
18
+ /** (Re-)arm the timer for one idle window. Call when a wait for progress BEGINS. No-op after fire/cancel. */
19
+ reset: () => void;
20
+ /** Disarm the timer WITHOUT retiring the deadline (call when the awaited progress arrives). No-op after fire/cancel. */
21
+ pause: () => void;
22
+ /** Retire permanently (success/teardown paths). Idempotent. */
23
+ cancel: () => void;
24
+ }
25
+
26
+ /**
27
+ * Resettable inactivity deadline (devlog 260716_passthrough_followups/010).
28
+ *
29
+ * Fires `onIdle` at most ONCE after `idleMs` elapses with no `reset()`/`pause()`.
30
+ * Contract:
31
+ * - `idleMs <= 0` returns an inert no-op deadline — the 0-disable responsibility
32
+ * lives here so callers cannot mis-handle it.
33
+ * - The timer starts DISARMED: callers arm it with `reset()` when a wait begins and
34
+ * `pause()` it when the wait settles, so pull-based relays never count downstream
35
+ * backpressure (no pending read) as upstream inactivity.
36
+ * - First terminal wins: after `onIdle` runs or `cancel()` is called, every method
37
+ * is a no-op and `onIdle` never runs again.
38
+ * - Never linked to fetch signals — the consumer decides how to kill its stream
39
+ * (e.g. reader.cancel), keeping body-lifetime semantics unchanged.
40
+ */
41
+ export function idleDeadline(idleMs: number, onIdle: () => void): IdleDeadline {
42
+ if (idleMs <= 0) return { reset: () => {}, pause: () => {}, cancel: () => {} };
43
+ let timer: ReturnType<typeof setTimeout> | undefined;
44
+ let done = false;
45
+ const disarm = () => {
46
+ if (timer !== undefined) clearTimeout(timer);
47
+ timer = undefined;
48
+ };
49
+ return {
50
+ reset: () => {
51
+ if (done) return;
52
+ disarm();
53
+ timer = setTimeout(() => {
54
+ if (done) return;
55
+ done = true;
56
+ timer = undefined;
57
+ onIdle();
58
+ }, idleMs);
59
+ },
60
+ pause: () => {
61
+ if (done) return;
62
+ disarm();
63
+ },
64
+ cancel: () => {
65
+ if (done) return;
66
+ done = true;
67
+ disarm();
68
+ },
69
+ };
70
+ }
71
+
17
72
  /**
18
73
  * Response-header deadline whose timer can be cleared without severing body-lifetime cancellation.
19
74
  *
@@ -21,6 +21,25 @@ const RESET_RETRY_MAX_ATTEMPTS = 3;
21
21
  const RESET_RETRY_BASE_DELAY_MS = 150;
22
22
  const RESET_RETRY_MAX_DELAY_MS = 1_000;
23
23
 
24
+ // Transient-5xx status retry layer (pre-stream only; devlog/_plan/260716_claudecode_hardening/010).
25
+ const TRANSIENT_RETRY_MAX_ATTEMPTS = 3; // 1 initial + 2 retries
26
+ const TRANSIENT_RETRY_BASE_DELAY_MS = 400;
27
+ const TRANSIENT_RETRY_MAX_DELAY_MS = 5_000;
28
+ // A failed attempt slower than this is the "slow 502" incident shape (191s observed on
29
+ // 2026-07-15): retrying it only duplicates upstream load past client timeouts — return it.
30
+ const TRANSIENT_RETRY_SLOW_ATTEMPT_MS = 15_000;
31
+
32
+ /**
33
+ * Upstream statuses treated as transient: gateway errors and Cloudflare 52x.
34
+ * 500 is included per the OpenAI SDK default (auto-retries >=500; Tier-2 proven in
35
+ * devlog/260716_ocx_claude_sol_502_midstream/02). 507 was observed in the 48h ledger
36
+ * but is deliberately excluded (storage-class, not gateway-transient).
37
+ */
38
+ export function isTransientUpstreamStatus(status: number): boolean {
39
+ return status === 500 || status === 502 || status === 503 || status === 504
40
+ || status === 520 || status === 521 || status === 522;
41
+ }
42
+
24
43
  export interface RetryBackoffOptions {
25
44
  baseDelayMs: number;
26
45
  maxDelayMs: number;
@@ -121,6 +140,11 @@ export interface ResetRetryOptions {
121
140
  attempts?: number;
122
141
  }
123
142
 
143
+ export interface TransientRetryOptions extends ResetRetryOptions {
144
+ /** Test seam: per-attempt slow budget override (defaults to TRANSIENT_RETRY_SLOW_ATTEMPT_MS). */
145
+ slowAttemptMs?: number;
146
+ }
147
+
124
148
  /**
125
149
  * Run `doFetch`, retrying only connection-reset-shaped rejections (see
126
150
  * isConnectionResetError) with jittered backoff. The caller's thunk must be replay-safe
@@ -150,3 +174,41 @@ export async function fetchWithResetRetry(
150
174
  }
151
175
  throw lastError ?? new Error("upstream fetch failed");
152
176
  }
177
+
178
+ /**
179
+ * fetchWithResetRetry plus a transient-5xx status retry layer, PRE-STREAM only: a
180
+ * returned Response has by definition not been relayed to the client yet, so replaying
181
+ * the (string-body) request is safe. The failed attempt's body is cancelled before the
182
+ * retry; every returned response (ok, non-transient, aborted, slow, exhausted) keeps
183
+ * its body intact. Honors Retry-After via retryBackoffDelayMs.
184
+ *
185
+ * A failed attempt slower than the slow budget is returned as-is (slow-502 shape);
186
+ * note `opts.attempts` is shared with the inner reset layer (no caller passes it today).
187
+ */
188
+ export async function fetchWithTransientRetry(
189
+ doFetch: () => Promise<Response>,
190
+ opts: TransientRetryOptions = {},
191
+ ): Promise<Response> {
192
+ const attempts = Math.max(1, opts.attempts ?? TRANSIENT_RETRY_MAX_ATTEMPTS);
193
+ const slowAttemptMs = opts.slowAttemptMs ?? TRANSIENT_RETRY_SLOW_ATTEMPT_MS;
194
+ let attemptStart = Date.now();
195
+ let res = await fetchWithResetRetry(doFetch, opts);
196
+ for (let attempt = 0; attempt < attempts - 1; attempt++) {
197
+ if (res.ok || !isTransientUpstreamStatus(res.status)) return res;
198
+ if (opts.abortSignal?.aborted) return res;
199
+ if (Date.now() - attemptStart > slowAttemptMs) return res;
200
+ console.warn(
201
+ `[upstream-retry] transient ${res.status}${opts.label ? ` (${opts.label})` : ""} — retrying (${attempt + 2}/${attempts})`,
202
+ );
203
+ const delay = retryBackoffDelayMs(attempt, {
204
+ baseDelayMs: TRANSIENT_RETRY_BASE_DELAY_MS,
205
+ maxDelayMs: TRANSIENT_RETRY_MAX_DELAY_MS,
206
+ headers: res.headers,
207
+ });
208
+ cancelResponseBodyBestEffort(res);
209
+ await sleepWithAbort(delay, opts.abortSignal);
210
+ attemptStart = Date.now();
211
+ res = await fetchWithResetRetry(doFetch, opts);
212
+ }
213
+ return res;
214
+ }
@@ -3,8 +3,8 @@ import { parseCallbackInput } from "./callback-server";
3
3
  import type { OcxConfig, OcxProviderConfig, RefreshPolicy } from "../types";
4
4
  import { loadConfig, resolveEnvValue, saveConfig } from "../config";
5
5
  import { maskEmail } from "../lib/privacy";
6
- import { getAccountCredential, getAccountSet, saveAccountCredential, saveCredential, markAccountNeedsReauth, getCredential } from "./store";
7
- import { loginXai, refreshXaiToken } from "./xai";
6
+ import { getAccountCredential, getAccountSet, saveAccountCredential, saveCredential, markAccountNeedsReauth, getCredential, credentialGeneration, createOAuthRefreshIntentLock, mergeAccountCredential, markAccountNeedsReauthIfGeneration } from "./store";
7
+ import { loginXai, refreshXaiToken, XAI_LOCAL_CLI_DETACH_WARNING, XaiTokenRequestError } from "./xai";
8
8
  import { ANTHROPIC_OAUTH_BETA, loginAnthropic, refreshAnthropicToken } from "./anthropic";
9
9
  import { loginKimi, refreshKimiToken } from "./kimi";
10
10
  import { loginKiro, readKiroCliSqlite, refreshKiroToken } from "./kiro";
@@ -14,9 +14,22 @@ import { loginCursor, refreshCursorToken } from "./cursor";
14
14
  import { deriveOAuthDefaultModel, deriveOAuthProviderConfig } from "../providers/derive";
15
15
  import { effectiveGoogleMode } from "../providers/registry";
16
16
  import { resolveProviderTransport } from "../providers/xai-transport";
17
+ import { detectGrokCliToken, hasComparableGrokIdentity, isSameGrokIdentity, shouldAdoptGrokGeneration } from "./local-token-detect";
17
18
 
18
19
  const REFRESH_SKEW_MS = 60_000;
19
- const tokenRefreshes = new Map<string, Promise<string>>();
20
+ export interface OAuthAccessSnapshot {
21
+ provider: string;
22
+ accountId: string;
23
+ generation: string;
24
+ accessToken: string;
25
+ }
26
+
27
+ const tokenRefreshes = new Map<string, Promise<OAuthAccessSnapshot>>();
28
+ const XAI_PERMANENT_FAILURE_TTL_MS=30_000;
29
+ const permanentRefreshFailures=new Map<string,number>();
30
+ interface XaiRefreshDeps { intentLock?:ReturnType<typeof createOAuthRefreshIntentLock>; now?:()=>number; afterPrePersistRead?:()=>void|Promise<void> }
31
+ function verdictKey(p:string,a:string,c:OAuthCredentials){return `${p}\0${a}\0${credentialGeneration(c)}`;}
32
+ function cached(p:string,a:string,c:OAuthCredentials,now:()=>number){const k=verdictKey(p,a,c),u=permanentRefreshFailures.get(k);if(u===undefined)return false;if(u<=now()){permanentRefreshFailures.delete(k);return false;}return true;}
20
33
 
21
34
  export interface LoginOpts { forceLogin?: boolean }
22
35
 
@@ -139,36 +152,74 @@ export class OAuthLoginRequiredError extends Error {
139
152
  }
140
153
  }
141
154
 
142
- /** Return a valid access token for the ACTIVE account, refreshing + persisting if expired. */
143
- export async function getValidAccessToken(provider: string): Promise<string> {
144
- const def = OAUTH_PROVIDERS[provider];
145
- if (!def) throw new UnsupportedOAuthProviderError(provider);
146
- const set = getAccountSet(provider);
147
- if (!set) throw new OAuthLoginRequiredError(provider);
148
- return getValidAccessTokenForAccount(provider, set.activeAccountId);
155
+ function accessSnapshot(provider: string, accountId: string, cred: OAuthCredentials): OAuthAccessSnapshot {
156
+ return {
157
+ provider,
158
+ accountId,
159
+ generation: credentialGeneration(cred),
160
+ accessToken: cred.access,
161
+ };
149
162
  }
150
163
 
151
- /**
152
- * Account-scoped token resolver (multiauth): refresh is single-flighted per
153
- * (provider, account), and the rotated credential is persisted for THAT account only —
154
- * a guardian refresh of a background account never switches the active account.
155
- */
156
- export async function getValidAccessTokenForAccount(provider: string, accountId: string): Promise<string> {
164
+ async function resolveAccessSnapshotForAccount(
165
+ provider: string,
166
+ accountId: string,
167
+ rejectedGeneration?: string,
168
+ ): Promise<OAuthAccessSnapshot> {
157
169
  const def = OAUTH_PROVIDERS[provider];
158
170
  if (!def) throw new UnsupportedOAuthProviderError(provider);
159
171
  const cred = getAccountCredential(provider, accountId);
160
172
  if (!cred) throw new OAuthLoginRequiredError(provider);
161
- if (cred.expires > Date.now() + REFRESH_SKEW_MS) return cred.access;
173
+ const current = accessSnapshot(provider, accountId, cred);
174
+ if (rejectedGeneration !== undefined && current.generation !== rejectedGeneration) return current;
175
+ if (rejectedGeneration === undefined && cred.expires > Date.now() + REFRESH_SKEW_MS) return current;
176
+
162
177
  const key = `${provider}\u0000${accountId}`;
163
178
  const existing = tokenRefreshes.get(key);
164
179
  if (existing) return existing;
165
- const refresh = refreshAndPersistAccessToken(provider, accountId, def, cred).finally(() => {
180
+
181
+ const refresh = (async (): Promise<OAuthAccessSnapshot> => {
182
+ const accessToken = await refreshAndPersistAccessToken(provider, accountId, def, cred);
183
+ const persisted = getAccountCredential(provider, accountId);
184
+ if (!persisted) throw new OAuthLoginRequiredError(provider);
185
+ if (persisted.access !== accessToken) {
186
+ throw new Error(`OAuth refresh persisted an unexpected access token for ${provider}`);
187
+ }
188
+ return accessSnapshot(provider, accountId, persisted);
189
+ })().finally(() => {
166
190
  if (tokenRefreshes.get(key) === refresh) tokenRefreshes.delete(key);
167
191
  });
168
192
  tokenRefreshes.set(key, refresh);
169
193
  return refresh;
170
194
  }
171
195
 
196
+ export async function getValidAccessTokenSnapshot(provider: string): Promise<OAuthAccessSnapshot> {
197
+ const set = getAccountSet(provider);
198
+ if (!set) throw new OAuthLoginRequiredError(provider);
199
+ return resolveAccessSnapshotForAccount(provider, set.activeAccountId);
200
+ }
201
+
202
+ export async function forceRefreshOAuthAccessSnapshot(
203
+ rejected: OAuthAccessSnapshot,
204
+ ): Promise<OAuthAccessSnapshot> {
205
+ if (rejected.provider !== "xai") throw new UnsupportedOAuthProviderError(rejected.provider);
206
+ return resolveAccessSnapshotForAccount(rejected.provider, rejected.accountId, rejected.generation);
207
+ }
208
+
209
+ /** Return a valid access token for the ACTIVE account, refreshing + persisting if expired. */
210
+ export async function getValidAccessToken(provider: string): Promise<string> {
211
+ return (await getValidAccessTokenSnapshot(provider)).accessToken;
212
+ }
213
+
214
+ /**
215
+ * Account-scoped token resolver (multiauth): refresh is single-flighted per
216
+ * (provider, account), and the rotated credential is persisted for THAT account only —
217
+ * a guardian refresh of a background account never switches the active account.
218
+ */
219
+ export async function getValidAccessTokenForAccount(provider: string, accountId: string): Promise<string> {
220
+ return (await resolveAccessSnapshotForAccount(provider, accountId)).accessToken;
221
+ }
222
+
172
223
  function readFreshKiroCliCredential(): OAuthCredentials | undefined {
173
224
  const imported = readKiroCliSqlite();
174
225
  if (!imported || imported.expires <= Date.now() + REFRESH_SKEW_MS) return undefined;
@@ -180,6 +231,10 @@ function isTerminalRefreshError(err: unknown): boolean {
180
231
  const msg = (err instanceof Error ? err.message : String(err)).toLowerCase();
181
232
  return msg.includes("invalid_grant") || msg.includes("refresh_token_reused") || msg.includes("revoked");
182
233
  }
234
+ function terminal(error:unknown):boolean{return error instanceof XaiTokenRequestError?["invalid_grant","refresh_token_reused","revoked_token"].includes(error.oauthError??""):isTerminalRefreshError(error);}
235
+ function authoritative(stored:OAuthCredentials,active:boolean,now:()=>number):OAuthCredentials{if(stored.source!=="local-cli")return stored;const disk=detectGrokCliToken();if(!disk)return stored;const allowed=isSameGrokIdentity(stored,disk)||(active&&!hasComparableGrokIdentity(stored,disk));return allowed&&shouldAdoptGrokGeneration(stored,disk,now(),REFRESH_SKEW_MS)?disk:stored;}
236
+ function merged(fresh:OAuthCredentials,previous:OAuthCredentials):OAuthCredentials{return{...fresh,source:previous.source==="local-cli"?"oauth":fresh.source??previous.source??"oauth",...(fresh.projectId===undefined&&previous.projectId?{projectId:previous.projectId}:{}),...(fresh.email===undefined&&previous.email?{email:previous.email}:{}),...(fresh.accountId===undefined&&previous.accountId?{accountId:previous.accountId}:{})};}
237
+ export async function refreshXaiAccountWithLock(provider:string,accountId:string,def:OAuthProviderDef,callerCredential:OAuthCredentials,deps:XaiRefreshDeps={}):Promise<string>{const now=deps.now??Date.now;const guard=await(deps.intentLock??createOAuthRefreshIntentLock(provider,accountId)).acquire();try{const stored=getAccountCredential(provider,accountId);if(!stored)throw new OAuthLoginRequiredError(provider);const active=getAccountSet(provider)?.activeAccountId===accountId,candidate=authoritative(stored,active,now);if(credentialGeneration(candidate)!==credentialGeneration(callerCredential)&&candidate.expires>now()+REFRESH_SKEW_MS){if(credentialGeneration(candidate)!==credentialGeneration(stored)){const o=await mergeAccountCredential(provider,accountId,candidate,{expectedGeneration:credentialGeneration(stored),afterPrePersistRead:deps.afterPrePersistRead});if(o.superseded){if(o.stored.expires>now()+REFRESH_SKEW_MS)return o.stored.access;throw new OAuthLoginRequiredError(provider);}}return candidate.access;}if(cached(provider,accountId,candidate,now))throw new OAuthLoginRequiredError(provider);const generation=credentialGeneration(candidate);try{const fresh=merged(await def.refresh(candidate.refresh),candidate);const o=await mergeAccountCredential(provider,accountId,fresh,{expectedGeneration:generation,afterPrePersistRead:deps.afterPrePersistRead});if(o.superseded){if(o.stored.expires>now()+REFRESH_SKEW_MS)return o.stored.access;throw new OAuthLoginRequiredError(provider);}permanentRefreshFailures.delete(verdictKey(provider,accountId,candidate));if(candidate.source==="local-cli")console.warn(XAI_LOCAL_CLI_DETACH_WARNING);return fresh.access;}catch(error){if(!terminal(error))throw error;permanentRefreshFailures.set(verdictKey(provider,accountId,candidate),now()+XAI_PERMANENT_FAILURE_TTL_MS);await markAccountNeedsReauthIfGeneration(provider,accountId,generation);throw new OAuthLoginRequiredError(provider);}}finally{guard.release();}}
183
238
 
184
239
  async function refreshAndPersistAccessToken(
185
240
  provider: string,
@@ -193,17 +248,20 @@ async function refreshAndPersistAccessToken(
193
248
  if (provider === "kiro" && isActive) {
194
249
  const imported = readFreshKiroCliCredential();
195
250
  if (imported) {
196
- saveCredential(provider, imported);
251
+ await saveCredential(provider, imported);
197
252
  return imported.access;
198
253
  }
199
254
  }
255
+ if (provider === "xai") return refreshXaiAccountWithLock(provider, accountId, def, cred);
200
256
  try {
201
257
  const fresh = await def.refresh(cred.refresh);
258
+ const detachedLocalCli = provider === "xai" && cred.source === "local-cli";
259
+ if (detachedLocalCli) console.warn(XAI_LOCAL_CLI_DETACH_WARNING);
202
260
  // Persist to THIS account (rotation-safe: new refresh token hits disk before use) without
203
261
  // touching activeAccountId.
204
- saveAccountCredential(provider, accountId, {
262
+ await saveAccountCredential(provider, accountId, {
205
263
  ...fresh,
206
- source: fresh.source ?? cred.source ?? "oauth",
264
+ source: detachedLocalCli ? "oauth" : fresh.source ?? cred.source ?? "oauth",
207
265
  // Preserve a previously-discovered project id when a refresh-time re-discovery comes back empty
208
266
  // (e.g. a transient network blip), so Antigravity does not lose its CCA project across refresh.
209
267
  ...(fresh.projectId === undefined && cred.projectId ? { projectId: cred.projectId } : {}),
@@ -216,12 +274,12 @@ async function refreshAndPersistAccessToken(
216
274
  if (provider === "kiro" && isActive) {
217
275
  const imported = readFreshKiroCliCredential();
218
276
  if (imported) {
219
- saveCredential(provider, imported);
277
+ await saveCredential(provider, imported);
220
278
  return imported.access;
221
279
  }
222
280
  }
223
281
  if (isTerminalRefreshError(err)) {
224
- markAccountNeedsReauth(provider, accountId, true);
282
+ await markAccountNeedsReauth(provider, accountId, true);
225
283
  throw new OAuthLoginRequiredError(provider);
226
284
  }
227
285
  throw err;
@@ -352,7 +410,7 @@ export async function runLogin(provider: string, ctrl: OAuthController, opts?: L
352
410
  if (!def) throw new UnsupportedOAuthProviderError(provider);
353
411
  const rawCred = await def.login(ctrl, opts);
354
412
  const cred: OAuthCredentials = rawCred.source ? rawCred : { ...rawCred, source: "oauth" };
355
- saveCredential(provider, cred);
413
+ await saveCredential(provider, cred);
356
414
  const config = loadConfig();
357
415
  upsertOAuthProvider(config, provider);
358
416
  saveConfig(config);
@@ -13,7 +13,7 @@ const XAI_AUTH_KEY_PREFIX = "https://auth.x.ai::";
13
13
  const CLAUDE_KEYCHAIN_SERVICE = "Claude Code-credentials";
14
14
 
15
15
  export function detectGrokCliToken(): OAuthCredentials | null {
16
- const authPath = join(homedir(), ".grok", "auth.json");
16
+ const authPath = join(process.env.HOME ?? homedir(), ".grok", "auth.json");
17
17
  if (!existsSync(authPath)) return null;
18
18
 
19
19
  try {
@@ -39,6 +39,28 @@ export function detectGrokCliToken(): OAuthCredentials | null {
39
39
  }
40
40
  }
41
41
 
42
+ export function hasComparableGrokIdentity(stored: OAuthCredentials, disk: OAuthCredentials): boolean {
43
+ return Boolean((stored.accountId && disk.accountId) || (stored.email && disk.email));
44
+ }
45
+
46
+ export function isSameGrokIdentity(stored: OAuthCredentials, disk: OAuthCredentials): boolean {
47
+ if (stored.accountId && disk.accountId) return stored.accountId === disk.accountId;
48
+ if (stored.email && disk.email) return stored.email.toLowerCase() === disk.email.toLowerCase();
49
+ return false;
50
+ }
51
+
52
+ export function shouldAdoptGrokGeneration(
53
+ stored: OAuthCredentials,
54
+ disk: OAuthCredentials,
55
+ now = Date.now(),
56
+ refreshSkewMs = 60_000,
57
+ ): boolean {
58
+ if (disk.expires <= now + refreshSkewMs) return false;
59
+ const bothExpiriesExist = stored.expires > 0 && disk.expires > 0;
60
+ if (bothExpiriesExist) return disk.expires >= stored.expires;
61
+ return true;
62
+ }
63
+
42
64
  /** Claude Code config dir: `CLAUDE_CONFIG_DIR` override, else `~/.claude`. */
43
65
  function claudeConfigDir(): string {
44
66
  const explicit = process.env.CLAUDE_CONFIG_DIR?.trim();