tokenmaxxing 1.17.0 → 1.18.1

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/DESIGN.md CHANGED
@@ -58,7 +58,7 @@ The supervisor sees `respawn/<sid>`, SIGTERMs its child at the already-committed
58
58
 
59
59
  ### 3.4 Swap sequence (under the lock)
60
60
  1. **Harvest the live credential into its TRUE owner's backup** - read the current `Claude Code-credentials` blob and resolve which account it actually belongs to via the profile endpoint (`GET /api/oauth/profile`, matched on `account.uuid`), NOT the `accounts.json` active label and NOT the organization (team seats share one). The label drifts from the live blob (a kill mid-swap, a manual `/login`), and harvesting by label once overwrote another account's backup and destroyed its only credential. Mandatory anyway: Claude rotates the refresh token in place, so older backups are dead. Refuse the swap if the live credential belongs to no pooled account.
61
- 2. **Refresh B** - OAuth refresh-grant with B's parked refresh token → fresh access token; persist the rotated refresh token. On `invalid_grant`, mark B `needs_reauth`, notify, try the next account.
61
+ 2. **Refresh B** - a blob Claude Code dead-cleared (empty tokens) is dead without a request. Resolve the owner of B's parked token through the profile endpoint first; a token that belongs to another account is never refreshed, because a refresh rotation revokes that account's live sessions, and B is marked `needs_reauth` instead. Then OAuth refresh-grant with B's parked refresh token → fresh access token; persist the rotated refresh token. On `invalid_grant`, mark B `needs_reauth`, notify, try the next account. On any other 4xx, skip B for this evaluation without marking it and try the next account.
62
62
  3. **Install B** - `security add-generic-password -U ... 'Claude Code-credentials' ...` with B's fresh (non-expired) `claudeAiOauth` JSON.
63
63
  4. **Swap identity + mark B active** - atomically rewrite only the `oauthAccount` object in `~/.claude.json` (temp+rename) to B's, and write `activeAccountUuid = B` in the SAME critical section, so a crash can't leave the installed credential and the active label pointing at different accounts.
64
64
  5. Do steps 1, 3, 4 inside Claude's own refresh locks - the primary `<credDir>/.oauth_refresh.lock` plus the legacy sibling `<realpath(credDir)>.lock` (`~/.claude.lock` by default), both mkdir-based proper-lockfile locks, binary-verified against 2.1.214 - so the writes can't collide with a token refresh. Contention fails the swap fast (claude itself gives up with `lock_timeout` rather than refreshing unlocked); the next check retries.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
3
  "name": "tokenmaxxing",
4
- "version": "1.17.0",
4
+ "version": "1.18.1",
5
5
  "description": "Pool Claude Code and Codex logins, switch on pace pressure, and expose safe ops tools to agent clients.",
6
6
  "author": {
7
7
  "name": "anaclumos",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokenmaxxing",
3
- "version": "1.17.0",
3
+ "version": "1.18.1",
4
4
  "description": "Automatic Claude Code account switching: pool multiple accounts and hot-swap when quota fills, resuming your session on the fresh account.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/cli/init.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { mkdirSync } from "node:fs";
2
2
  import { isApiKeyMode, readOAuthAccount } from "../lib/claudejson.ts";
3
3
  import { readItem, writeItem, liveTarget, parkedTarget, mergeIntoLive } from "../lib/credstore.ts";
4
- import { refreshCredential, isAccessTokenExpiring, fetchTokenIdentity, describeIdentity } from "../lib/oauth.ts";
4
+ import { refreshCredential, isAccessTokenExpiring, isDeadCredential, fetchTokenIdentity, describeIdentity } from "../lib/oauth.ts";
5
5
  import { withClaudeRefreshLock } from "../lib/claudelock.ts";
6
6
  import { loadAccounts, saveAccounts, loadConfig, pinBinOverride } from "../lib/state.ts";
7
7
  import { withLock } from "../lib/lock.ts";
@@ -83,6 +83,11 @@ export async function cmdInit(): Promise<number> {
83
83
  }
84
84
 
85
85
  let creds = blob.claudeAiOauth;
86
+ if (isDeadCredential(creds)) {
87
+ console.error(c.red("the live credential was cleared after a failed refresh."));
88
+ console.error(`Run ${c.cyan("claude")} → ${c.cyan("/login")} first, then re-run ${c.cyan("tokenmaxxing init")}.`);
89
+ return 1;
90
+ }
86
91
  if (isAccessTokenExpiring(creds)) {
87
92
  await withClaudeRefreshLock(async (lock) => {
88
93
  const raw2 = await readItem(liveTarget());
package/src/cli/switch.ts CHANGED
@@ -2,7 +2,7 @@ import { withLock } from "../lib/lock.ts";
2
2
  import { paths } from "../lib/paths.ts";
3
3
  import { loadAccounts, loadConfig, loadUsage } from "../lib/state.ts";
4
4
  import { readOAuthAccount } from "../lib/claudejson.ts";
5
- import { performSwap } from "../lib/swap.ts";
5
+ import { isSkippableSwapError, performSwap } from "../lib/swap.ts";
6
6
  import { currentWins, effectiveBars, pickBest, pickEarliestReset, weeklyExpiry, type PickCtx } from "../lib/picker.ts";
7
7
  import { InvalidGrantError } from "../lib/oauth.ts";
8
8
  import { gatedFamilies } from "../lib/usage.ts";
@@ -66,14 +66,16 @@ export async function cmdSwitch(selector?: string, json = false): Promise<number
66
66
  currentAccountUuid: null,
67
67
  switchFamilies,
68
68
  });
69
+ const rejected = new Set<string>();
69
70
  while (true) {
70
71
  const cur = loadAccounts();
71
72
  const everyone = everyoneIn(cur.accounts);
73
+ const pool = cur.accounts.filter((a) => !rejected.has(a.accountUuid));
72
74
  const active =
73
75
  (claimed != null ? cur.accounts.find((a) => a.accountUuid === claimed) : null) ??
74
76
  cur.accounts.find((a) => a.accountUuid === cur.activeAccountUuid) ??
75
77
  null;
76
- if (active != null && currentWins(active, cur.accounts, everyone)) {
78
+ if (active != null && currentWins(active, pool, everyone)) {
77
79
  if (drifted) return swapTo(active, "drift-reconciled");
78
80
  const expiry = weeklyExpiry(active, now);
79
81
  const why = Number.isFinite(expiry) ? ` (weekly ${fmtReset(expiry, now)})` : "";
@@ -85,60 +87,83 @@ export async function cmdSwitch(selector?: string, json = false): Promise<number
85
87
  });
86
88
  return 0;
87
89
  }
88
- const best = pickBest(cur.accounts, { ...everyone, currentAccountUuid: active?.accountUuid ?? null });
90
+ const best = pickBest(pool, { ...everyone, currentAccountUuid: active?.accountUuid ?? null });
89
91
  if (!best) break;
90
92
  try {
91
93
  await performSwap(best);
92
94
  } catch (e) {
95
+ rejected.add(best.accountUuid);
93
96
  if (e instanceof InvalidGrantError) {
94
97
  deadGrants.push(best.label);
95
98
  if (!json) console.error(c.red(deadGrantMessage(best)));
96
99
  continue;
97
100
  }
101
+ if (isSkippableSwapError(e)) {
102
+ if (!json) console.error(c.yellow(`${best.label}: ${e instanceof Error ? e.message : String(e)} - skipped for this run`));
103
+ continue;
104
+ }
98
105
  throw e;
99
106
  }
100
107
  emit(`${c.green("↻")} switched to ${c.bold(best.label)}`, { switched: true, account: best.label, reason: "best" });
101
108
  return 0;
102
109
  }
103
110
 
104
- const fresh = loadAccounts();
105
- const earliest = pickEarliestReset(fresh.accounts, everyoneIn(fresh.accounts));
106
- const reauth = fresh.accounts.filter((a) => a.needsReauth).map((a) => a.label);
107
- if (!earliest) {
108
- if (reauth.length > 0) {
109
- return fail(`no switchable account - reauth needed (run \`tokenmaxxing auth --all\`): ${reauth.join(", ")}`, {
110
- paint: c.yellow,
111
- extra: { reauthNeeded: reauth },
111
+ while (true) {
112
+ const fresh = loadAccounts();
113
+ const pool = fresh.accounts.filter((a) => !rejected.has(a.accountUuid));
114
+ const earliest = pickEarliestReset(pool, everyoneIn(fresh.accounts));
115
+ const reauth = fresh.accounts.filter((a) => a.needsReauth).map((a) => a.label);
116
+ if (!earliest) {
117
+ if (reauth.length > 0) {
118
+ return fail(`no switchable account - reauth needed (run \`tokenmaxxing auth --all\`): ${reauth.join(", ")}`, {
119
+ paint: c.yellow,
120
+ extra: { reauthNeeded: reauth },
121
+ });
122
+ }
123
+ const freshActive = fresh.accounts.find((a) => a.accountUuid === fresh.activeAccountUuid) ?? null;
124
+ if (drifted && freshActive) return swapTo(freshActive, "drift-reconciled");
125
+ emit(c.yellow("all accounts at their limit with unknown reset times (unparsed reset clocks? see tokenmaxxing.log) - staying put"), {
126
+ switched: false,
127
+ account: freshActive?.label ?? null,
128
+ reason: "unknown-resets",
129
+ });
130
+ return 0;
131
+ }
132
+ const reauthNote = reauth.length ? ` - re-auth needed: ${reauth.join(", ")}` : "";
133
+ const availableAt = earliest.availableAt > now ? earliest.availableAt : null;
134
+ if (earliest.account.accountUuid === fresh.activeAccountUuid && !drifted) {
135
+ const msg = availableAt == null
136
+ ? `staying on ${c.bold(earliest.account.label)} - no usable switch target${reauthNote}`
137
+ : `all accounts at limit - staying on ${c.bold(earliest.account.label)} (${fmtReset(availableAt, now)})${reauthNote}`;
138
+ emit(c.yellow(msg), {
139
+ switched: false,
140
+ account: earliest.account.label,
141
+ reason: availableAt == null ? "no-target" : "all-at-limit",
142
+ availableAt,
143
+ reauthNeeded: reauth,
112
144
  });
145
+ return 0;
146
+ }
147
+ try {
148
+ await performSwap(earliest.account);
149
+ } catch (e) {
150
+ rejected.add(earliest.account.accountUuid);
151
+ if (e instanceof InvalidGrantError) {
152
+ deadGrants.push(earliest.account.label);
153
+ if (!json) console.error(c.red(deadGrantMessage(earliest.account)));
154
+ continue;
155
+ }
156
+ if (isSkippableSwapError(e)) {
157
+ if (!json) console.error(c.yellow(`${earliest.account.label}: ${e instanceof Error ? e.message : String(e)} - skipped for this run`));
158
+ continue;
159
+ }
160
+ throw e;
161
+ }
162
+ emit(`${c.green("↻")} switched to ${c.bold(earliest.account.label)}`, { switched: true, account: earliest.account.label, reason: "earliest-reset", availableAt, reauthNeeded: reauth });
163
+ if (availableAt != null && !json) {
164
+ console.log(c.yellow(`all accounts at limit - ${c.bold(earliest.account.label)} recovers soonest (${fmtReset(availableAt, now)})${reauthNote}`));
113
165
  }
114
- const freshActive = fresh.accounts.find((a) => a.accountUuid === fresh.activeAccountUuid) ?? null;
115
- if (drifted && freshActive) return swapTo(freshActive, "drift-reconciled");
116
- emit(c.yellow("all accounts at their limit with unknown reset times (unparsed reset clocks? see tokenmaxxing.log) - staying put"), {
117
- switched: false,
118
- account: freshActive?.label ?? null,
119
- reason: "unknown-resets",
120
- });
121
- return 0;
122
- }
123
- const reauthNote = reauth.length ? ` - re-auth needed: ${reauth.join(", ")}` : "";
124
- const availableAt = earliest.availableAt > now ? earliest.availableAt : null;
125
- if (earliest.account.accountUuid === fresh.activeAccountUuid && !drifted) {
126
- const msg = availableAt == null
127
- ? `staying on ${c.bold(earliest.account.label)} - no usable switch target${reauthNote}`
128
- : `all accounts at limit - staying on ${c.bold(earliest.account.label)} (${fmtReset(availableAt, now)})${reauthNote}`;
129
- emit(c.yellow(msg), {
130
- switched: false,
131
- account: earliest.account.label,
132
- reason: availableAt == null ? "no-target" : "all-at-limit",
133
- availableAt,
134
- reauthNeeded: reauth,
135
- });
136
166
  return 0;
137
167
  }
138
- const code = await swapTo(earliest.account, "earliest-reset", { availableAt, reauthNeeded: reauth });
139
- if (code === 0 && availableAt != null && !json) {
140
- console.log(c.yellow(`all accounts at limit - ${c.bold(earliest.account.label)} recovers soonest (${fmtReset(availableAt, now)})${reauthNote}`));
141
- }
142
- return code;
143
168
  });
144
169
  }
package/src/lib/decide.ts CHANGED
@@ -4,9 +4,8 @@ import { withLock } from "./lock.ts";
4
4
  import { paths } from "./paths.ts";
5
5
  import { MAX_CHECK_DELAY_TICKS, POST_SWAP_COOLDOWN_MS, maxCheckDelayMs, loadAccounts, loadConfig, loadDepletedWait, loadLastSwapAt, loadUsage, loadUsageSnapshot, loadModelUsage, saveAccounts, saveDepletedWait, saveModelUsage, writeUsage } from "./state.ts";
6
6
  import { readOAuthAccount } from "./claudejson.ts";
7
- import { chooseAndSwap, performSwap } from "./swap.ts";
7
+ import { chooseAndSwap, isSkippableSwapError, performSwap } from "./swap.ts";
8
8
  import { currentWins, effectiveBars, hardBars, isExhausted, nextWeeklyReset, pickBest, pickEarliestReset, sessionLadder, usableAt } from "./picker.ts";
9
- import { InvalidGrantError } from "./oauth.ts";
10
9
  import { familyTokens, gatedFamilies, probeUsage, type EnforcedClass } from "./usage.ts";
11
10
  import { log } from "./log.ts";
12
11
  import { AccountSchema, ModelUsageStateSchema, UsageStateSchema, type Account, type Config, type EnforcedLimit, type ModelUsageState, type Thresholds, type UsageState, type UsageWindow } from "./types.ts";
@@ -197,21 +196,30 @@ export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = fals
197
196
  idx2.accounts.find((a) => a.accountUuid === idx2.activeAccountUuid) ??
198
197
  null;
199
198
 
199
+ const rejected = new Set<string>();
200
+ const usable = (accounts: Account[]): Account[] => accounts.filter((a) => !rejected.has(a.accountUuid));
201
+ const skipOrThrow = (e: unknown, candidate: Account): void => {
202
+ if (!isSkippableSwapError(e)) throw e;
203
+ rejected.add(candidate.accountUuid);
204
+ log("decide.candidate_rejected", { account: candidate.accountUuid.slice(0, 8), error: e instanceof Error ? e.message : String(e) });
205
+ };
206
+
200
207
  const greedy = async (holdMargin: number): Promise<SwapDecision> => {
201
208
  while (true) {
202
209
  const cur = loadAccounts();
203
210
  const active = seatOf(cur);
211
+ const pool = usable(cur.accounts);
204
212
  const ctxAll = { now, thresholds: barsOf(cur.accounts), currentAccountUuid: null, switchFamilies, holdMargin };
205
- if (currentWins(active, cur.accounts, ctxAll)) {
213
+ if (currentWins(active, pool, ctxAll)) {
206
214
  return { swapped: false, account: null, reason: "current-best" };
207
215
  }
208
- const best = pickBest(cur.accounts, { ...ctxAll, currentAccountUuid: active?.accountUuid ?? null });
216
+ const best = pickBest(pool, { ...ctxAll, currentAccountUuid: active?.accountUuid ?? null });
209
217
  if (!best) return { swapped: false, account: null, reason: "no-usable-target" };
210
218
  try {
211
219
  await performSwap(best);
212
220
  } catch (e) {
213
- if (e instanceof InvalidGrantError) continue;
214
- throw e;
221
+ skipOrThrow(e, best);
222
+ continue;
215
223
  }
216
224
  log("decide.greedy_swap", { account: best.accountUuid.slice(0, 8) });
217
225
  return { swapped: true, account: best, reason: "swapped" };
@@ -222,15 +230,16 @@ export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = fals
222
230
  while (true) {
223
231
  const cur = loadAccounts();
224
232
  const seat = seatOf(cur);
233
+ const pool = usable(cur.accounts);
225
234
  const thresholds = barsOf(cur.accounts);
226
235
  if (!enforced2 && seat && !seat.needsReauth && !isExhausted(seat, { now, thresholds, currentAccountUuid: null, switchFamilies })) return greedy(0);
227
- const best = pickBest(cur.accounts, { now, thresholds, currentAccountUuid: seat?.accountUuid ?? null, switchFamilies });
236
+ const best = pickBest(pool, { now, thresholds, currentAccountUuid: seat?.accountUuid ?? null, switchFamilies });
228
237
  if (!best) break;
229
238
  try {
230
239
  await performSwap(best);
231
240
  } catch (e) {
232
- if (e instanceof InvalidGrantError) continue;
233
- throw e;
241
+ skipOrThrow(e, best);
242
+ continue;
234
243
  }
235
244
  return { swapped: true, account: best, reason: "swapped" };
236
245
  }
@@ -241,7 +250,7 @@ export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = fals
241
250
  log("decide.last_drop_hold", { account: seat.accountUuid.slice(0, 8) });
242
251
  return { swapped: false, account: null, reason: "last-drop-hold" };
243
252
  }
244
- const squeezed = await chooseAndSwap({ ...hardCtx, currentAccountUuid: seat?.accountUuid ?? null });
253
+ const squeezed = await chooseAndSwap({ ...hardCtx, currentAccountUuid: seat?.accountUuid ?? null }, rejected);
245
254
  if (squeezed) {
246
255
  log("decide.last_drop_swap", { account: squeezed.accountUuid.slice(0, 8) });
247
256
  return { swapped: true, account: squeezed, reason: "last-drop-swap" };
@@ -253,7 +262,7 @@ export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = fals
253
262
  const ctx = { now, thresholds: hardBars(cfg), currentAccountUuid: current?.accountUuid ?? null, switchFamilies };
254
263
  const enforcedUntil = enforced2 && current && current.accountUuid === enforced2.account ? (enforced2.resetsAt ?? now + enforced2.windowMs) : 0;
255
264
  const currentAt = current ? Math.max(usableAt(current, ctx), enforcedUntil) : Number.POSITIVE_INFINITY;
256
- const other = pickEarliestReset(fresh.accounts, ctx);
265
+ const other = pickEarliestReset(usable(fresh.accounts), ctx);
257
266
 
258
267
  let target: Account | null = null;
259
268
  let waitUntil = Number.POSITIVE_INFINITY;
@@ -275,8 +284,8 @@ export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = fals
275
284
  try {
276
285
  await performSwap(target);
277
286
  } catch (e) {
278
- if (e instanceof InvalidGrantError) continue;
279
- throw e;
287
+ skipOrThrow(e, target);
288
+ continue;
280
289
  }
281
290
  }
282
291
  saveDepletedWait({ waitUntil, accountUuid: target.accountUuid, ts: now });
package/src/lib/http.ts CHANGED
@@ -1,25 +1,52 @@
1
1
  import ky from "ky";
2
2
  import { z } from "zod";
3
3
 
4
- const ErrorBodySchema = z.looseObject({
5
- error: z.string().optional(),
4
+ const OAuthErrorBodySchema = z.looseObject({
5
+ error: z.string(),
6
6
  error_description: z.string().optional(),
7
+ });
8
+
9
+ const NestedErrorBodySchema = z.looseObject({
10
+ error: z.looseObject({ type: z.string().optional(), message: z.string().optional() }),
11
+ request_id: z.string().nullish(),
12
+ });
13
+
14
+ const PlainErrorBodySchema = z.looseObject({
7
15
  detail: z.string().optional(),
8
16
  message: z.string().optional(),
9
17
  });
10
18
 
19
+ const ErrorDetailSchema = z.object({ code: z.string().nullable(), fields: z.array(z.string()) });
20
+ type ErrorDetail = z.infer<typeof ErrorDetailSchema>;
21
+
22
+ function parseErrorBody(text: string): ErrorDetail | null {
23
+ let json: unknown;
24
+ try {
25
+ json = JSON.parse(text);
26
+ } catch {
27
+ return null;
28
+ }
29
+ const oauth = OAuthErrorBodySchema.safeParse(json);
30
+ if (oauth.success) return { code: oauth.data.error, fields: [oauth.data.error, oauth.data.error_description ?? ""] };
31
+ const nested = NestedErrorBodySchema.safeParse(json);
32
+ if (nested.success) {
33
+ const requestId = nested.data.request_id ? ` (${nested.data.request_id})` : "";
34
+ return { code: nested.data.error.type ?? null, fields: [nested.data.error.type ?? "", (nested.data.error.message ?? "") + requestId] };
35
+ }
36
+ const plain = PlainErrorBodySchema.safeParse(json);
37
+ if (plain.success) return { code: null, fields: [plain.data.detail ?? "", plain.data.message ?? ""] };
38
+ return null;
39
+ }
40
+
41
+ export function oauthErrorCode(input: { text: string }): string | null {
42
+ return parseErrorBody(input.text)?.code ?? null;
43
+ }
44
+
11
45
  export function safeErrorDetail(input: { text: string }): string {
12
- const parsed = ErrorBodySchema.safeParse((() => {
13
- try {
14
- return JSON.parse(input.text);
15
- } catch {
16
- return null;
17
- }
18
- })());
19
- if (!parsed.success) return "(unparsable error body withheld)";
20
- const fields = [parsed.data.error, parsed.data.error_description, parsed.data.detail, parsed.data.message];
21
- const detail = fields.filter((field): field is string => field != null && field.length > 0).join(": ");
22
- return detail.length > 0 ? detail.slice(0, 200) : "(no error detail)";
46
+ const body = parseErrorBody(input.text);
47
+ if (!body) return `(unrecognized error body, ${input.text.length} bytes, content withheld)`;
48
+ const detail = body.fields.filter((field) => field.length > 0).join(": ");
49
+ return detail.length > 0 ? detail.slice(0, 240) : "(no error detail)";
23
50
  }
24
51
 
25
52
  export const http = ky.create({
package/src/lib/log.ts CHANGED
@@ -21,6 +21,7 @@ export function log(event: string, fields: Record<string, unknown> = {}): void {
21
21
  let line = "";
22
22
  try {
23
23
  line = Object.entries(fields)
24
+ .filter(([, v]) => v !== undefined)
24
25
  .map(([k, v]) => {
25
26
  const str = z.string().safeParse(v);
26
27
  return `${k}=${redact(str.success ? str.data : JSON.stringify(v))}`;
package/src/lib/oauth.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { http, safeErrorDetail } from "./http.ts";
2
+ import { http, oauthErrorCode, safeErrorDetail } from "./http.ts";
3
3
  import { ProfileResponseSchema, RefreshResponseSchema, TokenIdentitySchema, type OAuthCreds, type TokenIdentity } from "./types.ts";
4
4
 
5
5
  const EnvOverrideSchema = z.string().min(1).optional().catch(undefined);
@@ -22,7 +22,26 @@ export class InvalidGrantError extends Error {
22
22
  }
23
23
  }
24
24
 
25
+ export class RefreshRejectedError extends Error {
26
+ constructor(public readonly status: number, public readonly detail: string) {
27
+ super(`token refresh rejected (HTTP ${status}): ${detail}`);
28
+ this.name = "RefreshRejectedError";
29
+ }
30
+ }
31
+
32
+ export class IdentityUnavailableError extends Error {
33
+ constructor(public readonly status: number | null, public readonly detail: string) {
34
+ super(status == null ? `profile endpoint unreachable: ${detail}` : `identity check failed (HTTP ${status}): ${detail}`);
35
+ this.name = "IdentityUnavailableError";
36
+ }
37
+ }
38
+
39
+ export function isDeadCredential(creds: OAuthCreds): boolean {
40
+ return creds.refreshToken === "" || creds.accessToken === "";
41
+ }
42
+
25
43
  export async function refreshCredential(creds: OAuthCreds, now = Date.now()): Promise<OAuthCreds> {
44
+ if (isDeadCredential(creds)) throw new InvalidGrantError("credential was cleared after a failed refresh");
26
45
  const scope = (creds.scopes?.length ? creds.scopes : DEFAULT_SCOPES).join(" ");
27
46
  const body = {
28
47
  grant_type: "refresh_token",
@@ -43,10 +62,10 @@ export async function refreshCredential(creds: OAuthCreds, now = Date.now()): Pr
43
62
 
44
63
  const text = await res.text();
45
64
  if (!res.ok) {
46
- if (res.status === 400 && /invalid_grant/.test(text)) {
47
- throw new InvalidGrantError(safeErrorDetail({ text }));
48
- }
49
- throw new Error(`token refresh failed (HTTP ${res.status}): ${safeErrorDetail({ text })}`);
65
+ const detail = safeErrorDetail({ text });
66
+ if (res.status === 400 && oauthErrorCode({ text }) === "invalid_grant") throw new InvalidGrantError(detail);
67
+ if (res.status >= 400 && res.status < 500) throw new RefreshRejectedError(res.status, detail);
68
+ throw new Error(`token refresh failed (HTTP ${res.status}): ${detail}`);
50
69
  }
51
70
 
52
71
  const parsed = RefreshResponseSchema.safeParse((() => {
@@ -82,14 +101,19 @@ export async function fetchTokenIdentity(accessToken: string): Promise<TokenIden
82
101
  headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json" },
83
102
  });
84
103
  } catch (e) {
85
- throw new Error(`profile endpoint unreachable: ${e instanceof Error ? e.message : String(e)}`);
104
+ throw new IdentityUnavailableError(null, e instanceof Error ? e.message : String(e));
86
105
  }
87
- const text = await res.text();
88
- if (!res.ok) throw new Error(`identity check failed (HTTP ${res.status}): ${safeErrorDetail({ text })}`);
106
+ let text: string;
107
+ try {
108
+ text = await res.text();
109
+ } catch (e) {
110
+ throw new IdentityUnavailableError(res.status, `profile response body unreadable: ${e instanceof Error ? e.message : String(e)}`);
111
+ }
112
+ if (!res.ok) throw new IdentityUnavailableError(res.status, safeErrorDetail({ text }));
89
113
  const parsed = ProfileResponseSchema.safeParse((() => {
90
114
  try { return JSON.parse(text); } catch { return null; }
91
115
  })());
92
- if (!parsed.success) throw new Error(`profile endpoint returned an unrecognized body (${text.length} bytes, withheld)`);
116
+ if (!parsed.success) throw new IdentityUnavailableError(res.status, `profile endpoint returned an unrecognized body (${text.length} bytes, withheld)`);
93
117
  return TokenIdentitySchema.parse({
94
118
  accountUuid: parsed.data.account.uuid,
95
119
  email: parsed.data.account.email ?? null,
package/src/lib/sample.ts CHANGED
@@ -4,9 +4,12 @@ import { z } from "zod";
4
4
  import { readItem, writeItem, deleteItem, liveTarget, parkedTarget, isolatedTarget, claudeAiOauthOnly, mergeIntoLive } from "./credstore.ts";
5
5
  import { credItemFor, paths } from "./paths.ts";
6
6
  import { withClaudeRefreshLock } from "./claudelock.ts";
7
- import { refreshCredential, isAccessTokenExpiring, fetchTokenIdentity, describeIdentity, InvalidGrantError } from "./oauth.ts";
7
+ import { refreshCredential, isAccessTokenExpiring, isDeadCredential, fetchTokenIdentity, describeIdentity, IdentityUnavailableError, InvalidGrantError } from "./oauth.ts";
8
8
  import { FullUsageSchema, pingSession, probeUsage } from "./usage.ts";
9
- import { CredentialBlobSchema, type Account, type OAuthCreds, type TokenIdentity } from "./types.ts";
9
+ import { loadAccounts } from "./state.ts";
10
+ import { keepRotatedPair } from "./swap.ts";
11
+ import { log } from "./log.ts";
12
+ import { CredentialBlobSchema, TokenIdentitySchema, type Account, type OAuthCreds, type TokenIdentity } from "./types.ts";
10
13
 
11
14
  const SampleOutcomeSchema = z.discriminatedUnion("ok", [
12
15
  z.object({ ok: z.literal(true), usage: FullUsageSchema, pingError: z.string().optional(), pingRejected: z.boolean().optional() }),
@@ -16,8 +19,8 @@ export type SampleOutcome = z.infer<typeof SampleOutcomeSchema>;
16
19
 
17
20
  const IdentityCheckSchema = z.discriminatedUnion("status", [
18
21
  z.object({ status: z.literal("match") }),
19
- z.object({ status: z.literal("mismatch"), reason: z.string() }),
20
- z.object({ status: z.literal("unavailable"), reason: z.string() }),
22
+ z.object({ status: z.literal("mismatch"), reason: z.string(), owner: TokenIdentitySchema }),
23
+ z.object({ status: z.literal("unavailable"), reason: z.string(), stale: z.boolean() }),
21
24
  ]);
22
25
  type IdentityCheck = z.infer<typeof IdentityCheckSchema>;
23
26
 
@@ -26,10 +29,14 @@ async function checkIdentity(creds: OAuthCreds, account: Account): Promise<Ident
26
29
  try {
27
30
  identity = await fetchTokenIdentity(creds.accessToken);
28
31
  } catch (e) {
29
- return { status: "unavailable", reason: `credential identity check failed: ${e instanceof Error ? e.message : String(e)}` };
32
+ return {
33
+ status: "unavailable",
34
+ reason: `credential identity check failed: ${e instanceof Error ? e.message : String(e)}`,
35
+ stale: e instanceof IdentityUnavailableError && e.status === 401,
36
+ };
30
37
  }
31
38
  if (identity.accountUuid === account.accountUuid) return { status: "match" };
32
- return { status: "mismatch", reason: `credential actually belongs to ${describeIdentity(identity)}` };
39
+ return { status: "mismatch", reason: `credential actually belongs to ${describeIdentity(identity)}`, owner: identity };
33
40
  }
34
41
 
35
42
  function refreshPlanFields(account: Account, creds: OAuthCreds): void {
@@ -48,38 +55,92 @@ export async function probeParkedUsage(account: Account, opts: { ping?: boolean
48
55
  } catch (e) {
49
56
  return { ok: false, reason: `parked credential unreadable (${(e instanceof Error ? e.message : String(e)).slice(0, 80)}) - run \`tokenmaxxing auth\`` };
50
57
  }
58
+ if (isDeadCredential(creds)) {
59
+ account.needsReauth = true;
60
+ return { ok: false, reason: "parked credential was cleared after a failed refresh - re-auth with `tokenmaxxing auth`" };
61
+ }
51
62
 
52
63
  const liveRaw = await readItem(liveTarget());
64
+ let liveAccount: string | null = null;
65
+ let liveToken: string | null = null;
53
66
  if (liveRaw != null) {
54
- let liveAccount: string;
67
+ let liveCreds: OAuthCreds;
55
68
  try {
56
- const liveCreds = CredentialBlobSchema.parse(JSON.parse(liveRaw)).claudeAiOauth;
57
- liveAccount = (await fetchTokenIdentity(liveCreds.accessToken)).accountUuid;
69
+ liveCreds = CredentialBlobSchema.parse(JSON.parse(liveRaw)).claudeAiOauth;
58
70
  } catch (e) {
59
- return { ok: false, reason: `cannot verify the live credential's owner (${(e instanceof Error ? e.message : String(e)).slice(0, 80)}) - refusing to sample a possibly-live account` };
71
+ return { ok: false, reason: `cannot read the live credential (${(e instanceof Error ? e.message : String(e)).slice(0, 80)}) - refusing to sample a possibly-live account` };
60
72
  }
61
- if (liveAccount === account.accountUuid) {
62
- return { ok: false, reason: "this account holds the LIVE login (active label drifted) - run `tokenmaxxing switch` to reconcile" };
73
+ if (!isDeadCredential(liveCreds)) {
74
+ liveToken = liveCreds.accessToken;
75
+ try {
76
+ liveAccount = (await fetchTokenIdentity(liveCreds.accessToken)).accountUuid;
77
+ } catch (e) {
78
+ return { ok: false, reason: `cannot verify the live credential's owner (${(e instanceof Error ? e.message : String(e)).slice(0, 80)}) - refusing to sample a possibly-live account` };
79
+ }
80
+ if (liveAccount === account.accountUuid) {
81
+ return { ok: false, reason: "this account holds the LIVE login (active label drifted) - run `tokenmaxxing switch` to reconcile" };
82
+ }
63
83
  }
64
84
  }
65
85
 
86
+ const relocate = async (owner: TokenIdentity): Promise<string> => {
87
+ const pooled = loadAccounts().accounts.find((a) => a.accountUuid === owner.accountUuid) ?? null;
88
+ if (pooled == null || pooled.accountUuid === liveAccount) return "was left in place";
89
+ await writeItem(parkedTarget(pooled.keychainItem), JSON.stringify({ claudeAiOauth: creds }));
90
+ log("swap.parked_relocated", { account: pooled.accountUuid.slice(0, 8) });
91
+ return "was copied to that account's slot";
92
+ };
93
+
66
94
  if (isAccessTokenExpiring(creds, 300_000)) {
67
- try {
68
- creds = await refreshCredential(creds);
69
- await writeItem(backup, JSON.stringify({ claudeAiOauth: creds }));
70
- } catch (e) {
71
- if (e instanceof InvalidGrantError) {
72
- account.needsReauth = true;
73
- return { ok: false, reason: "refresh token dead - re-auth with `tokenmaxxing auth`" };
95
+ const owner = await checkIdentity(creds, account);
96
+ if (owner.status === "mismatch") {
97
+ const kept = await relocate(owner.owner);
98
+ account.needsReauth = true;
99
+ return { ok: false, reason: `${owner.reason} - refusing to spend another account's grant; the pair ${kept}; re-auth with \`tokenmaxxing auth\`` };
100
+ }
101
+ if (owner.status === "unavailable" && !owner.stale) {
102
+ return { ok: false, reason: `${owner.reason} - refusing to refresh a parked credential whose owner cannot be verified` };
103
+ }
104
+ const rotate = async (): Promise<{ fresh: OAuthCreds } | { failed: SampleOutcome }> => {
105
+ try {
106
+ return { fresh: await refreshCredential(creds) };
107
+ } catch (e) {
108
+ if (e instanceof InvalidGrantError) {
109
+ account.needsReauth = true;
110
+ return { failed: { ok: false, reason: "refresh token dead - re-auth with `tokenmaxxing auth`" } };
111
+ }
112
+ return { failed: { ok: false, reason: `token refresh failed: ${e instanceof Error ? e.message : String(e)}` } };
113
+ }
114
+ };
115
+ let result: { fresh: OAuthCreds } | { failed: SampleOutcome };
116
+ if (owner.status === "match") {
117
+ result = await rotate();
118
+ } else {
119
+ try {
120
+ result = await withClaudeRefreshLock(async (lock) => {
121
+ const rotated = await rotate();
122
+ if ("failed" in rotated) return rotated;
123
+ const verified = await checkIdentity(rotated.fresh, account);
124
+ if (verified.status !== "mismatch") return rotated;
125
+ const trueOwner = loadAccounts().accounts.find((a) => a.accountUuid === verified.owner.accountUuid) ?? null;
126
+ const kept = await keepRotatedPair({ fresh: rotated.fresh, owner: trueOwner, fallback: account, liveOwnerUuid: liveAccount, expectedLiveToken: liveToken, lock });
127
+ account.needsReauth = true;
128
+ return { failed: { ok: false, reason: `${verified.reason}, whose grant this refresh rotated - the rotated token ${kept}; re-auth with \`tokenmaxxing auth\`` } };
129
+ });
130
+ } catch (e) {
131
+ return { ok: false, reason: `cannot arbitrate a stale parked credential: ${e instanceof Error ? e.message : String(e)}` };
74
132
  }
75
- return { ok: false, reason: `token refresh failed: ${e instanceof Error ? e.message : String(e)}` };
76
133
  }
134
+ if ("failed" in result) return result.failed;
135
+ creds = result.fresh;
136
+ await writeItem(backup, JSON.stringify({ claudeAiOauth: creds }));
77
137
  }
78
138
 
79
139
  const identity = await checkIdentity(creds, account);
80
140
  if (identity.status === "mismatch") {
141
+ const kept = await relocate(identity.owner);
81
142
  account.needsReauth = true;
82
- return { ok: false, reason: `${identity.reason} - this account's own credential is gone; re-auth with \`tokenmaxxing auth\`` };
143
+ return { ok: false, reason: `${identity.reason} - this account's own credential is gone; the pair ${kept}; re-auth with \`tokenmaxxing auth\`` };
83
144
  }
84
145
  if (identity.status === "unavailable") {
85
146
  return { ok: false, reason: identity.reason };
@@ -122,11 +183,13 @@ export async function ensureLiveTokenFresh(): Promise<void> {
122
183
  } catch {
123
184
  return;
124
185
  }
186
+ if (isDeadCredential(creds)) throw new InvalidGrantError("live credential was cleared after a failed refresh");
125
187
  if (!isAccessTokenExpiring(creds, 300_000)) return;
126
188
  await withClaudeRefreshLock(async (lock) => {
127
189
  const raw2 = await readItem(liveTarget());
128
190
  if (raw2 == null) throw new Error("live credential vanished while waiting for the refresh lock");
129
191
  const current = CredentialBlobSchema.parse(JSON.parse(raw2)).claudeAiOauth;
192
+ if (isDeadCredential(current)) throw new InvalidGrantError("live credential was cleared after a failed refresh");
130
193
  const next = isAccessTokenExpiring(current, 300_000) ? await refreshCredential(current) : current;
131
194
  if (next === current) return;
132
195
  if (lock.compromised()) throw new Error("refresh lock compromised mid-refresh - discarding the live rewrite");
package/src/lib/swap.ts CHANGED
@@ -1,11 +1,11 @@
1
1
  import { clearDepletedWait, clearNextCheck, clearUsageSnapshots, loadAccounts, saveAccounts, saveLastSwapAt } from "./state.ts";
2
2
  import { readItem, writeItem, liveTarget, parkedTarget, claudeAiOauthOnly, mergeIntoLive } from "./credstore.ts";
3
- import { refreshCredential, isAccessTokenExpiring, fetchTokenIdentity, describeIdentity, InvalidGrantError } from "./oauth.ts";
3
+ import { refreshCredential, isAccessTokenExpiring, isDeadCredential, fetchTokenIdentity, describeIdentity, IdentityUnavailableError, InvalidGrantError, RefreshRejectedError } from "./oauth.ts";
4
4
  import { swapOAuthAccount } from "./claudejson.ts";
5
5
  import { withClaudeRefreshLock } from "./claudelock.ts";
6
6
  import { log } from "./log.ts";
7
7
  import { pickBest, type PickCtx } from "./picker.ts";
8
- import { CredentialBlobSchema, type Account, type OAuthCreds } from "./types.ts";
8
+ import { CredentialBlobSchema, type Account, type OAuthCreds, type TokenIdentity } from "./types.ts";
9
9
 
10
10
  function parseBlob(raw: string) {
11
11
  return CredentialBlobSchema.parse(JSON.parse(raw));
@@ -21,7 +21,10 @@ export async function performSwap(target: Account): Promise<void> {
21
21
  if (preLive) {
22
22
  liveCreds = parseBlob(preLive).claudeAiOauth;
23
23
  expectedLiveToken = liveCreds.accessToken;
24
- if (isAccessTokenExpiring(liveCreds, 60_000)) {
24
+ if (isDeadCredential(liveCreds)) {
25
+ liveCreds = null;
26
+ log("swap.live_dead", {});
27
+ } else if (isAccessTokenExpiring(liveCreds, 60_000)) {
25
28
  try {
26
29
  await withClaudeRefreshLock(async (lock) => {
27
30
  const raw2 = await readItem(liveTarget());
@@ -41,7 +44,12 @@ export async function performSwap(target: Account): Promise<void> {
41
44
  }
42
45
  }
43
46
  if (liveCreds != null) {
44
- const identity = await fetchTokenIdentity(liveCreds.accessToken);
47
+ let identity: TokenIdentity;
48
+ try {
49
+ identity = await fetchTokenIdentity(liveCreds.accessToken);
50
+ } catch (e) {
51
+ throw new Error(`cannot resolve the live credential's owner (${e instanceof Error ? e.message : String(e)}) - refusing to swap over it; the next check retries`);
52
+ }
45
53
  liveOwner = idx.accounts.find((a) => a.accountUuid === identity.accountUuid) ?? null;
46
54
  if (!liveOwner) {
47
55
  throw new Error(
@@ -62,15 +70,59 @@ export async function performSwap(target: Account): Promise<void> {
62
70
  if (!selfSwap) {
63
71
  const parkedRaw = await readItem(parkedTarget(target.keychainItem));
64
72
  if (!parkedRaw) throw new Error(`no parked credential for ${target.email}`);
73
+ const parked = parseBlob(parkedRaw).claudeAiOauth;
74
+ const markDead = (detail: string): InvalidGrantError => {
75
+ const t = idx.accounts.find((a) => a.accountUuid === target.accountUuid);
76
+ if (t) { t.needsReauth = true; saveAccounts(idx); }
77
+ log("swap.invalid_grant", { account: target.accountUuid.slice(0, 8), detail });
78
+ return new InvalidGrantError(detail);
79
+ };
80
+ if (isDeadCredential(parked)) throw markDead("parked credential was cleared after a failed refresh - re-auth with `tokenmaxxing auth`");
81
+ let parkedOwner: TokenIdentity | null = null;
65
82
  try {
66
- fresh = await refreshCredential(parseBlob(parkedRaw).claudeAiOauth);
83
+ parkedOwner = await fetchTokenIdentity(parked.accessToken);
67
84
  } catch (e) {
68
- if (e instanceof InvalidGrantError) {
69
- const t = idx.accounts.find((a) => a.accountUuid === target.accountUuid);
70
- if (t) { t.needsReauth = true; saveAccounts(idx); }
71
- log("swap.invalid_grant", { account: target.accountUuid.slice(0, 8) });
85
+ if (!(e instanceof IdentityUnavailableError && e.status === 401)) throw e;
86
+ log("swap.parked_token_stale", { account: target.accountUuid.slice(0, 8) });
87
+ }
88
+ if (parkedOwner != null && parkedOwner.accountUuid !== target.accountUuid) {
89
+ const owner = idx.accounts.find((a) => a.accountUuid === parkedOwner.accountUuid) ?? null;
90
+ let kept = "was left in place";
91
+ if (owner != null && owner.accountUuid !== liveOwner?.accountUuid) {
92
+ await writeItem(parkedTarget(owner.keychainItem), JSON.stringify({ claudeAiOauth: parked }));
93
+ log("swap.parked_relocated", { account: owner.accountUuid.slice(0, 8) });
94
+ kept = "was copied to that account's slot";
72
95
  }
73
- throw e;
96
+ throw markDead(`parked credential belongs to ${describeIdentity(parkedOwner)} - refusing to spend another account's grant; the pair ${kept}; re-auth with \`tokenmaxxing auth ${target.label}\``);
97
+ }
98
+ const refreshParked = async (): Promise<OAuthCreds> => {
99
+ try {
100
+ return await refreshCredential(parked);
101
+ } catch (e) {
102
+ if (e instanceof InvalidGrantError) throw markDead(e.detail);
103
+ if (e instanceof RefreshRejectedError) log("swap.refresh_rejected", { account: target.accountUuid.slice(0, 8), status: e.status, detail: e.detail });
104
+ throw e;
105
+ }
106
+ };
107
+ if (parkedOwner != null) {
108
+ fresh = await refreshParked();
109
+ } else {
110
+ fresh = await withClaudeRefreshLock(async (lock) => {
111
+ const rotated = await refreshParked();
112
+ let freshOwner: TokenIdentity;
113
+ try {
114
+ freshOwner = await fetchTokenIdentity(rotated.accessToken);
115
+ } catch (e) {
116
+ await writeItem(parkedTarget(target.keychainItem), JSON.stringify({ claudeAiOauth: rotated }));
117
+ throw new Error(`refreshed the parked credential but cannot verify its owner (${e instanceof Error ? e.message : String(e)}) - kept the rotated token parked; the next check retries`);
118
+ }
119
+ if (freshOwner.accountUuid !== target.accountUuid) {
120
+ const owner = idx.accounts.find((a) => a.accountUuid === freshOwner.accountUuid) ?? null;
121
+ const kept = await keepRotatedPair({ fresh: rotated, owner, fallback: target, liveOwnerUuid: liveOwner?.accountUuid ?? null, expectedLiveToken, lock });
122
+ throw markDead(`parked credential belongs to ${describeIdentity(freshOwner)}, whose grant this refresh rotated - the rotated token ${kept}; re-auth with \`tokenmaxxing auth ${target.label}\``);
123
+ }
124
+ return rotated;
125
+ });
74
126
  }
75
127
  await writeItem(parkedTarget(target.keychainItem), JSON.stringify({ claudeAiOauth: fresh }));
76
128
  }
@@ -105,8 +157,47 @@ export async function performSwap(target: Account): Promise<void> {
105
157
  log("swap.done", { account: target.accountUuid.slice(0, 8), email: target.email });
106
158
  }
107
159
 
108
- export async function chooseAndSwap(ctx: PickCtx): Promise<Account | null> {
109
- const tried = new Set<string>();
160
+ export async function keepRotatedPair(input: {
161
+ fresh: OAuthCreds;
162
+ owner: Account | null;
163
+ fallback: Account;
164
+ liveOwnerUuid: string | null;
165
+ expectedLiveToken: string | null;
166
+ lock: { compromised: () => boolean };
167
+ }): Promise<string> {
168
+ const { fresh, owner } = input;
169
+ if (owner == null) {
170
+ await writeItem(parkedTarget(input.fallback.keychainItem), JSON.stringify({ claudeAiOauth: fresh }));
171
+ log("swap.rotated_kept_in_stamped_slot", { account: input.fallback.accountUuid.slice(0, 8) });
172
+ return "was kept in this account's stamped slot because its owner is not in the pool";
173
+ }
174
+ const park = async (): Promise<void> => {
175
+ await writeItem(parkedTarget(owner.keychainItem), JSON.stringify({ claudeAiOauth: fresh }));
176
+ log("swap.rotated_parked_rescued", { account: owner.accountUuid.slice(0, 8) });
177
+ };
178
+ if (input.liveOwnerUuid !== owner.accountUuid) {
179
+ await park();
180
+ return "was parked under that account";
181
+ }
182
+ try {
183
+ const currentLive = await readItem(liveTarget());
184
+ if (input.lock.compromised()) throw new Error("refresh lock compromised");
185
+ if (currentLive == null || parseBlob(currentLive).claudeAiOauth.accessToken !== input.expectedLiveToken) throw new Error("live credential changed while unlocked");
186
+ await writeItem(liveTarget(), mergeIntoLive(currentLive, fresh));
187
+ log("swap.rotated_live_rescued", { account: owner.accountUuid.slice(0, 8) });
188
+ return "was installed into the live store so that account's sessions continue";
189
+ } catch (e) {
190
+ await park();
191
+ return `was parked under that account (${e instanceof Error ? e.message : String(e)})`;
192
+ }
193
+ }
194
+
195
+ export function isSkippableSwapError(e: unknown): boolean {
196
+ return e instanceof InvalidGrantError || e instanceof RefreshRejectedError || e instanceof IdentityUnavailableError;
197
+ }
198
+
199
+ export async function chooseAndSwap(ctx: PickCtx, exclude: ReadonlySet<string> = new Set()): Promise<Account | null> {
200
+ const tried = new Set<string>(exclude);
110
201
  while (true) {
111
202
  const idx = loadAccounts();
112
203
  const candidates = idx.accounts.filter((a) => !tried.has(a.accountUuid));
@@ -117,7 +208,7 @@ export async function chooseAndSwap(ctx: PickCtx): Promise<Account | null> {
117
208
  await performSwap(best);
118
209
  return best;
119
210
  } catch (e) {
120
- if (e instanceof InvalidGrantError) continue;
211
+ if (isSkippableSwapError(e)) continue;
121
212
  throw e;
122
213
  }
123
214
  }