tokenmaxxing 1.17.0 → 1.18.0
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/lib/decide.ts +24 -15
- package/src/lib/http.ts +40 -13
- package/src/lib/log.ts +1 -0
- package/src/lib/oauth.ts +16 -5
- package/src/lib/sample.ts +25 -7
- package/src/lib/swap.ts +27 -11
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.0",
|
|
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.0",
|
|
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/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);
|
|
204
|
-
const
|
|
205
|
-
|
|
211
|
+
const pool = usable(cur.accounts);
|
|
212
|
+
const ctxAll = { now, thresholds: barsOf(pool), currentAccountUuid: null, switchFamilies, holdMargin };
|
|
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);
|
|
225
|
-
const
|
|
233
|
+
const pool = usable(cur.accounts);
|
|
234
|
+
const thresholds = barsOf(pool);
|
|
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,6 +22,17 @@ 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 function isDeadCredential(creds: OAuthCreds): boolean {
|
|
33
|
+
return creds.refreshToken === "" || creds.accessToken === "";
|
|
34
|
+
}
|
|
35
|
+
|
|
25
36
|
export async function refreshCredential(creds: OAuthCreds, now = Date.now()): Promise<OAuthCreds> {
|
|
26
37
|
const scope = (creds.scopes?.length ? creds.scopes : DEFAULT_SCOPES).join(" ");
|
|
27
38
|
const body = {
|
|
@@ -43,10 +54,10 @@ export async function refreshCredential(creds: OAuthCreds, now = Date.now()): Pr
|
|
|
43
54
|
|
|
44
55
|
const text = await res.text();
|
|
45
56
|
if (!res.ok) {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
throw new Error(`token refresh failed (HTTP ${res.status}): ${
|
|
57
|
+
const detail = safeErrorDetail({ text });
|
|
58
|
+
if (res.status === 400 && oauthErrorCode({ text }) === "invalid_grant") throw new InvalidGrantError(detail);
|
|
59
|
+
if (res.status >= 400 && res.status < 500) throw new RefreshRejectedError(res.status, detail);
|
|
60
|
+
throw new Error(`token refresh failed (HTTP ${res.status}): ${detail}`);
|
|
50
61
|
}
|
|
51
62
|
|
|
52
63
|
const parsed = RefreshResponseSchema.safeParse((() => {
|
package/src/lib/sample.ts
CHANGED
|
@@ -4,7 +4,7 @@ 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, InvalidGrantError } from "./oauth.ts";
|
|
8
8
|
import { FullUsageSchema, pingSession, probeUsage } from "./usage.ts";
|
|
9
9
|
import { CredentialBlobSchema, type Account, type OAuthCreds, type TokenIdentity } from "./types.ts";
|
|
10
10
|
|
|
@@ -48,22 +48,38 @@ export async function probeParkedUsage(account: Account, opts: { ping?: boolean
|
|
|
48
48
|
} catch (e) {
|
|
49
49
|
return { ok: false, reason: `parked credential unreadable (${(e instanceof Error ? e.message : String(e)).slice(0, 80)}) - run \`tokenmaxxing auth\`` };
|
|
50
50
|
}
|
|
51
|
+
if (isDeadCredential(creds)) {
|
|
52
|
+
account.needsReauth = true;
|
|
53
|
+
return { ok: false, reason: "parked credential was cleared after a failed refresh - re-auth with `tokenmaxxing auth`" };
|
|
54
|
+
}
|
|
51
55
|
|
|
52
56
|
const liveRaw = await readItem(liveTarget());
|
|
53
57
|
if (liveRaw != null) {
|
|
54
|
-
let
|
|
58
|
+
let liveCreds: OAuthCreds;
|
|
55
59
|
try {
|
|
56
|
-
|
|
57
|
-
liveAccount = (await fetchTokenIdentity(liveCreds.accessToken)).accountUuid;
|
|
60
|
+
liveCreds = CredentialBlobSchema.parse(JSON.parse(liveRaw)).claudeAiOauth;
|
|
58
61
|
} catch (e) {
|
|
59
|
-
return { ok: false, reason: `cannot
|
|
62
|
+
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
63
|
}
|
|
61
|
-
if (
|
|
62
|
-
|
|
64
|
+
if (!isDeadCredential(liveCreds)) {
|
|
65
|
+
let liveAccount: string;
|
|
66
|
+
try {
|
|
67
|
+
liveAccount = (await fetchTokenIdentity(liveCreds.accessToken)).accountUuid;
|
|
68
|
+
} catch (e) {
|
|
69
|
+
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` };
|
|
70
|
+
}
|
|
71
|
+
if (liveAccount === account.accountUuid) {
|
|
72
|
+
return { ok: false, reason: "this account holds the LIVE login (active label drifted) - run `tokenmaxxing switch` to reconcile" };
|
|
73
|
+
}
|
|
63
74
|
}
|
|
64
75
|
}
|
|
65
76
|
|
|
66
77
|
if (isAccessTokenExpiring(creds, 300_000)) {
|
|
78
|
+
const owner = await checkIdentity(creds, account);
|
|
79
|
+
if (owner.status === "mismatch") {
|
|
80
|
+
account.needsReauth = true;
|
|
81
|
+
return { ok: false, reason: `${owner.reason} - refusing to spend another account's grant; re-auth with \`tokenmaxxing auth\`` };
|
|
82
|
+
}
|
|
67
83
|
try {
|
|
68
84
|
creds = await refreshCredential(creds);
|
|
69
85
|
await writeItem(backup, JSON.stringify({ claudeAiOauth: creds }));
|
|
@@ -122,11 +138,13 @@ export async function ensureLiveTokenFresh(): Promise<void> {
|
|
|
122
138
|
} catch {
|
|
123
139
|
return;
|
|
124
140
|
}
|
|
141
|
+
if (isDeadCredential(creds)) throw new InvalidGrantError("live credential was cleared after a failed refresh");
|
|
125
142
|
if (!isAccessTokenExpiring(creds, 300_000)) return;
|
|
126
143
|
await withClaudeRefreshLock(async (lock) => {
|
|
127
144
|
const raw2 = await readItem(liveTarget());
|
|
128
145
|
if (raw2 == null) throw new Error("live credential vanished while waiting for the refresh lock");
|
|
129
146
|
const current = CredentialBlobSchema.parse(JSON.parse(raw2)).claudeAiOauth;
|
|
147
|
+
if (isDeadCredential(current)) throw new InvalidGrantError("live credential was cleared after a failed refresh");
|
|
130
148
|
const next = isAccessTokenExpiring(current, 300_000) ? await refreshCredential(current) : current;
|
|
131
149
|
if (next === current) return;
|
|
132
150
|
if (lock.compromised()) throw new Error("refresh lock compromised mid-refresh - discarding the live rewrite");
|
package/src/lib/swap.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
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, 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";
|
|
@@ -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());
|
|
@@ -62,14 +65,23 @@ export async function performSwap(target: Account): Promise<void> {
|
|
|
62
65
|
if (!selfSwap) {
|
|
63
66
|
const parkedRaw = await readItem(parkedTarget(target.keychainItem));
|
|
64
67
|
if (!parkedRaw) throw new Error(`no parked credential for ${target.email}`);
|
|
68
|
+
const parked = parseBlob(parkedRaw).claudeAiOauth;
|
|
69
|
+
const markDead = (detail: string): InvalidGrantError => {
|
|
70
|
+
const t = idx.accounts.find((a) => a.accountUuid === target.accountUuid);
|
|
71
|
+
if (t) { t.needsReauth = true; saveAccounts(idx); }
|
|
72
|
+
log("swap.invalid_grant", { account: target.accountUuid.slice(0, 8), detail });
|
|
73
|
+
return new InvalidGrantError(detail);
|
|
74
|
+
};
|
|
75
|
+
if (isDeadCredential(parked)) throw markDead("parked credential was cleared after a failed refresh - re-auth with `tokenmaxxing auth`");
|
|
76
|
+
const parkedOwner = await fetchTokenIdentity(parked.accessToken).catch(() => null);
|
|
77
|
+
if (parkedOwner != null && parkedOwner.accountUuid !== target.accountUuid) {
|
|
78
|
+
throw markDead(`parked credential belongs to ${describeIdentity(parkedOwner)} - refusing to spend another account's grant; re-auth with \`tokenmaxxing auth ${target.label}\``);
|
|
79
|
+
}
|
|
65
80
|
try {
|
|
66
|
-
fresh = await refreshCredential(
|
|
81
|
+
fresh = await refreshCredential(parked);
|
|
67
82
|
} catch (e) {
|
|
68
|
-
if (e instanceof InvalidGrantError)
|
|
69
|
-
|
|
70
|
-
if (t) { t.needsReauth = true; saveAccounts(idx); }
|
|
71
|
-
log("swap.invalid_grant", { account: target.accountUuid.slice(0, 8) });
|
|
72
|
-
}
|
|
83
|
+
if (e instanceof InvalidGrantError) throw markDead(e.detail);
|
|
84
|
+
if (e instanceof RefreshRejectedError) log("swap.refresh_rejected", { account: target.accountUuid.slice(0, 8), status: e.status, detail: e.detail });
|
|
73
85
|
throw e;
|
|
74
86
|
}
|
|
75
87
|
await writeItem(parkedTarget(target.keychainItem), JSON.stringify({ claudeAiOauth: fresh }));
|
|
@@ -105,8 +117,12 @@ export async function performSwap(target: Account): Promise<void> {
|
|
|
105
117
|
log("swap.done", { account: target.accountUuid.slice(0, 8), email: target.email });
|
|
106
118
|
}
|
|
107
119
|
|
|
108
|
-
export
|
|
109
|
-
|
|
120
|
+
export function isSkippableSwapError(e: unknown): boolean {
|
|
121
|
+
return e instanceof InvalidGrantError || e instanceof RefreshRejectedError;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export async function chooseAndSwap(ctx: PickCtx, exclude: ReadonlySet<string> = new Set()): Promise<Account | null> {
|
|
125
|
+
const tried = new Set<string>(exclude);
|
|
110
126
|
while (true) {
|
|
111
127
|
const idx = loadAccounts();
|
|
112
128
|
const candidates = idx.accounts.filter((a) => !tried.has(a.accountUuid));
|
|
@@ -117,7 +133,7 @@ export async function chooseAndSwap(ctx: PickCtx): Promise<Account | null> {
|
|
|
117
133
|
await performSwap(best);
|
|
118
134
|
return best;
|
|
119
135
|
} catch (e) {
|
|
120
|
-
if (e
|
|
136
|
+
if (isSkippableSwapError(e)) continue;
|
|
121
137
|
throw e;
|
|
122
138
|
}
|
|
123
139
|
}
|