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 +1 -1
- package/agent-plugin/plugin.json +1 -1
- package/package.json +1 -1
- package/src/cli/init.ts +6 -1
- package/src/cli/switch.ts +63 -38
- package/src/lib/decide.ts +22 -13
- package/src/lib/http.ts +40 -13
- package/src/lib/log.ts +1 -0
- package/src/lib/oauth.ts +33 -9
- package/src/lib/sample.ts +84 -21
- package/src/lib/swap.ts +104 -13
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.
|
package/agent-plugin/plugin.json
CHANGED
|
@@ -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.
|
|
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.
|
|
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,
|
|
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(
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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,
|
|
213
|
+
if (currentWins(active, pool, ctxAll)) {
|
|
206
214
|
return { swapped: false, account: null, reason: "current-best" };
|
|
207
215
|
}
|
|
208
|
-
const best = pickBest(
|
|
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
|
-
|
|
214
|
-
|
|
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(
|
|
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
|
-
|
|
233
|
-
|
|
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
|
-
|
|
279
|
-
|
|
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
|
|
5
|
-
error: z.string()
|
|
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
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
throw new Error(`token refresh failed (HTTP ${res.status}): ${
|
|
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
|
|
104
|
+
throw new IdentityUnavailableError(null, e instanceof Error ? e.message : String(e));
|
|
86
105
|
}
|
|
87
|
-
|
|
88
|
-
|
|
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
|
|
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 {
|
|
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 {
|
|
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
|
|
67
|
+
let liveCreds: OAuthCreds;
|
|
55
68
|
try {
|
|
56
|
-
|
|
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
|
|
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 (
|
|
62
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
await
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
83
|
+
parkedOwner = await fetchTokenIdentity(parked.accessToken);
|
|
67
84
|
} catch (e) {
|
|
68
|
-
if (e instanceof
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
|
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
|
|
109
|
-
|
|
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
|
|
211
|
+
if (isSkippableSwapError(e)) continue;
|
|
121
212
|
throw e;
|
|
122
213
|
}
|
|
123
214
|
}
|