tokenmaxxing 1.18.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/agent-plugin/plugin.json +1 -1
- package/package.json +1 -1
- package/src/cli/switch.ts +63 -38
- package/src/lib/decide.ts +2 -2
- package/src/lib/oauth.ts +17 -4
- package/src/lib/sample.ts +62 -17
- package/src/lib/swap.ts +87 -12
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.18.
|
|
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.18.
|
|
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/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
|
@@ -209,7 +209,7 @@ export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = fals
|
|
|
209
209
|
const cur = loadAccounts();
|
|
210
210
|
const active = seatOf(cur);
|
|
211
211
|
const pool = usable(cur.accounts);
|
|
212
|
-
const ctxAll = { now, thresholds: barsOf(
|
|
212
|
+
const ctxAll = { now, thresholds: barsOf(cur.accounts), currentAccountUuid: null, switchFamilies, holdMargin };
|
|
213
213
|
if (currentWins(active, pool, ctxAll)) {
|
|
214
214
|
return { swapped: false, account: null, reason: "current-best" };
|
|
215
215
|
}
|
|
@@ -231,7 +231,7 @@ export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = fals
|
|
|
231
231
|
const cur = loadAccounts();
|
|
232
232
|
const seat = seatOf(cur);
|
|
233
233
|
const pool = usable(cur.accounts);
|
|
234
|
-
const thresholds = barsOf(
|
|
234
|
+
const thresholds = barsOf(cur.accounts);
|
|
235
235
|
if (!enforced2 && seat && !seat.needsReauth && !isExhausted(seat, { now, thresholds, currentAccountUuid: null, switchFamilies })) return greedy(0);
|
|
236
236
|
const best = pickBest(pool, { now, thresholds, currentAccountUuid: seat?.accountUuid ?? null, switchFamilies });
|
|
237
237
|
if (!best) break;
|
package/src/lib/oauth.ts
CHANGED
|
@@ -29,11 +29,19 @@ export class RefreshRejectedError extends Error {
|
|
|
29
29
|
}
|
|
30
30
|
}
|
|
31
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
|
+
|
|
32
39
|
export function isDeadCredential(creds: OAuthCreds): boolean {
|
|
33
40
|
return creds.refreshToken === "" || creds.accessToken === "";
|
|
34
41
|
}
|
|
35
42
|
|
|
36
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");
|
|
37
45
|
const scope = (creds.scopes?.length ? creds.scopes : DEFAULT_SCOPES).join(" ");
|
|
38
46
|
const body = {
|
|
39
47
|
grant_type: "refresh_token",
|
|
@@ -93,14 +101,19 @@ export async function fetchTokenIdentity(accessToken: string): Promise<TokenIden
|
|
|
93
101
|
headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json" },
|
|
94
102
|
});
|
|
95
103
|
} catch (e) {
|
|
96
|
-
throw new
|
|
104
|
+
throw new IdentityUnavailableError(null, e instanceof Error ? e.message : String(e));
|
|
97
105
|
}
|
|
98
|
-
|
|
99
|
-
|
|
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 }));
|
|
100
113
|
const parsed = ProfileResponseSchema.safeParse((() => {
|
|
101
114
|
try { return JSON.parse(text); } catch { return null; }
|
|
102
115
|
})());
|
|
103
|
-
if (!parsed.success) throw new
|
|
116
|
+
if (!parsed.success) throw new IdentityUnavailableError(res.status, `profile endpoint returned an unrecognized body (${text.length} bytes, withheld)`);
|
|
104
117
|
return TokenIdentitySchema.parse({
|
|
105
118
|
accountUuid: parsed.data.account.uuid,
|
|
106
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, isDeadCredential, 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 {
|
|
@@ -54,6 +61,8 @@ export async function probeParkedUsage(account: Account, opts: { ping?: boolean
|
|
|
54
61
|
}
|
|
55
62
|
|
|
56
63
|
const liveRaw = await readItem(liveTarget());
|
|
64
|
+
let liveAccount: string | null = null;
|
|
65
|
+
let liveToken: string | null = null;
|
|
57
66
|
if (liveRaw != null) {
|
|
58
67
|
let liveCreds: OAuthCreds;
|
|
59
68
|
try {
|
|
@@ -62,7 +71,7 @@ export async function probeParkedUsage(account: Account, opts: { ping?: boolean
|
|
|
62
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` };
|
|
63
72
|
}
|
|
64
73
|
if (!isDeadCredential(liveCreds)) {
|
|
65
|
-
|
|
74
|
+
liveToken = liveCreds.accessToken;
|
|
66
75
|
try {
|
|
67
76
|
liveAccount = (await fetchTokenIdentity(liveCreds.accessToken)).accountUuid;
|
|
68
77
|
} catch (e) {
|
|
@@ -74,28 +83,64 @@ export async function probeParkedUsage(account: Account, opts: { ping?: boolean
|
|
|
74
83
|
}
|
|
75
84
|
}
|
|
76
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
|
+
|
|
77
94
|
if (isAccessTokenExpiring(creds, 300_000)) {
|
|
78
95
|
const owner = await checkIdentity(creds, account);
|
|
79
96
|
if (owner.status === "mismatch") {
|
|
97
|
+
const kept = await relocate(owner.owner);
|
|
80
98
|
account.needsReauth = true;
|
|
81
|
-
return { ok: false, reason: `${owner.reason} - refusing to spend another account's grant; re-auth with \`tokenmaxxing auth\`` };
|
|
99
|
+
return { ok: false, reason: `${owner.reason} - refusing to spend another account's grant; the pair ${kept}; re-auth with \`tokenmaxxing auth\`` };
|
|
82
100
|
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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)}` };
|
|
90
132
|
}
|
|
91
|
-
return { ok: false, reason: `token refresh failed: ${e instanceof Error ? e.message : String(e)}` };
|
|
92
133
|
}
|
|
134
|
+
if ("failed" in result) return result.failed;
|
|
135
|
+
creds = result.fresh;
|
|
136
|
+
await writeItem(backup, JSON.stringify({ claudeAiOauth: creds }));
|
|
93
137
|
}
|
|
94
138
|
|
|
95
139
|
const identity = await checkIdentity(creds, account);
|
|
96
140
|
if (identity.status === "mismatch") {
|
|
141
|
+
const kept = await relocate(identity.owner);
|
|
97
142
|
account.needsReauth = true;
|
|
98
|
-
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\`` };
|
|
99
144
|
}
|
|
100
145
|
if (identity.status === "unavailable") {
|
|
101
146
|
return { ok: false, reason: identity.reason };
|
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, isDeadCredential, fetchTokenIdentity, describeIdentity, InvalidGrantError, RefreshRejectedError } 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));
|
|
@@ -44,7 +44,12 @@ export async function performSwap(target: Account): Promise<void> {
|
|
|
44
44
|
}
|
|
45
45
|
}
|
|
46
46
|
if (liveCreds != null) {
|
|
47
|
-
|
|
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
|
+
}
|
|
48
53
|
liveOwner = idx.accounts.find((a) => a.accountUuid === identity.accountUuid) ?? null;
|
|
49
54
|
if (!liveOwner) {
|
|
50
55
|
throw new Error(
|
|
@@ -73,16 +78,51 @@ export async function performSwap(target: Account): Promise<void> {
|
|
|
73
78
|
return new InvalidGrantError(detail);
|
|
74
79
|
};
|
|
75
80
|
if (isDeadCredential(parked)) throw markDead("parked credential was cleared after a failed refresh - re-auth with `tokenmaxxing auth`");
|
|
76
|
-
|
|
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
|
-
}
|
|
81
|
+
let parkedOwner: TokenIdentity | null = null;
|
|
80
82
|
try {
|
|
81
|
-
|
|
83
|
+
parkedOwner = await fetchTokenIdentity(parked.accessToken);
|
|
82
84
|
} catch (e) {
|
|
83
|
-
if (e instanceof
|
|
84
|
-
|
|
85
|
-
|
|
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";
|
|
95
|
+
}
|
|
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
|
+
});
|
|
86
126
|
}
|
|
87
127
|
await writeItem(parkedTarget(target.keychainItem), JSON.stringify({ claudeAiOauth: fresh }));
|
|
88
128
|
}
|
|
@@ -117,8 +157,43 @@ export async function performSwap(target: Account): Promise<void> {
|
|
|
117
157
|
log("swap.done", { account: target.accountUuid.slice(0, 8), email: target.email });
|
|
118
158
|
}
|
|
119
159
|
|
|
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
|
+
|
|
120
195
|
export function isSkippableSwapError(e: unknown): boolean {
|
|
121
|
-
return e instanceof InvalidGrantError || e instanceof RefreshRejectedError;
|
|
196
|
+
return e instanceof InvalidGrantError || e instanceof RefreshRejectedError || e instanceof IdentityUnavailableError;
|
|
122
197
|
}
|
|
123
198
|
|
|
124
199
|
export async function chooseAndSwap(ctx: PickCtx, exclude: ReadonlySet<string> = new Set()): Promise<Account | null> {
|