tokenmaxxing 0.19.1 → 1.0.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 +34 -25
- package/README.md +6 -5
- package/package.json +1 -1
- package/src/cli/add.ts +1 -0
- package/src/cli/auth.ts +25 -14
- package/src/cli/check.ts +3 -2
- package/src/cli/codexadd.ts +44 -40
- package/src/cli/codexinit.ts +59 -12
- package/src/cli/codexrm.ts +49 -0
- package/src/cli/codexswitch.ts +20 -2
- package/src/cli/config.ts +25 -1
- package/src/cli/doctor.ts +3 -3
- package/src/cli/init.ts +54 -32
- package/src/cli/onboard.ts +62 -45
- package/src/cli/rename.ts +20 -0
- package/src/cli/render.ts +0 -16
- package/src/cli/rm.ts +40 -2
- package/src/cli/serve.ts +638 -115
- package/src/cli/status.ts +69 -23
- package/src/cli/switch.ts +54 -19
- package/src/entries/codexstophook.ts +123 -4
- package/src/entries/codexsupervisor.ts +87 -13
- package/src/entries/sessionstart.ts +1 -1
- package/src/entries/statusline.ts +56 -20
- package/src/entries/stophook.ts +23 -9
- package/src/entries/supervisor.ts +184 -20
- package/src/lib/atomic.ts +28 -6
- package/src/lib/claudebin.ts +2 -2
- package/src/lib/claudejson.ts +5 -5
- package/src/lib/claudelock.ts +112 -37
- package/src/lib/codexauth.ts +18 -3
- package/src/lib/codexbin.ts +1 -1
- package/src/lib/codexdecide.ts +149 -19
- package/src/lib/codexpick.ts +17 -6
- package/src/lib/codexpresence.ts +59 -21
- package/src/lib/codexsample.ts +17 -8
- package/src/lib/codexswap.ts +10 -1
- package/src/lib/credstore.ts +6 -2
- package/src/lib/decide.ts +136 -49
- package/src/lib/install.ts +125 -17
- package/src/lib/keychain.ts +41 -15
- package/src/lib/lock.ts +57 -35
- package/src/lib/log.ts +36 -7
- package/src/lib/oauth.ts +18 -11
- package/src/lib/paths.ts +17 -11
- package/src/lib/picker.ts +11 -3
- package/src/lib/proc.ts +37 -0
- package/src/lib/sample.ts +91 -31
- package/src/lib/sessions.ts +23 -1
- package/src/lib/settings.ts +59 -18
- package/src/lib/slackbridge.ts +581 -81
- package/src/lib/slackstate.ts +159 -12
- package/src/lib/slackstream.ts +123 -20
- package/src/lib/state.ts +131 -35
- package/src/lib/swap.ts +109 -47
- package/src/lib/types.ts +92 -38
- package/src/lib/usage.ts +114 -16
- package/src/main.ts +70 -9
- package/src/serve-plugin/.claude-plugin/plugin.json +4 -0
- package/src/serve-plugin/skills/ask-the-user/SKILL.md +36 -0
- package/src/serve-plugin/skills/serve-session/SKILL.md +50 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// `tokenmaxxing rm --codex <selector>` - remove a pooled codex account (not
|
|
2
|
+
// the live one). Until this existed the uninstall message pointed users at
|
|
3
|
+
// `xx rm` for every parked credential while codex blobs were unremovable
|
|
4
|
+
// (adversarial-review catch).
|
|
5
|
+
|
|
6
|
+
import { withLock } from "../lib/lock.ts";
|
|
7
|
+
import { codexPaths } from "../lib/paths.ts";
|
|
8
|
+
import { loadCodexAccounts, saveCodexAccounts } from "../lib/codexstate.ts";
|
|
9
|
+
import { deleteParkedCodexAuth } from "../lib/codexauth.ts";
|
|
10
|
+
import { liveCodexAccountId } from "../lib/codexsample.ts";
|
|
11
|
+
import { presentCodexAccountIds } from "../lib/codexpresence.ts";
|
|
12
|
+
import { findCodexAccount } from "./rename.ts";
|
|
13
|
+
import { c } from "./render.ts";
|
|
14
|
+
|
|
15
|
+
export async function cmdCodexRm(selector?: string): Promise<number> {
|
|
16
|
+
if (!selector) {
|
|
17
|
+
console.error("usage: tokenmaxxing rm --codex <email|label|id>");
|
|
18
|
+
return 2;
|
|
19
|
+
}
|
|
20
|
+
// under the codex flock: a concurrent swap's index write must not be clobbered.
|
|
21
|
+
return withLock(codexPaths.lockFile, async () => {
|
|
22
|
+
const index = loadCodexAccounts();
|
|
23
|
+
const account = findCodexAccount(index.accounts, selector);
|
|
24
|
+
if (!account) {
|
|
25
|
+
console.error(c.red(`no codex account matches "${selector}"`));
|
|
26
|
+
return 1;
|
|
27
|
+
}
|
|
28
|
+
// The live identity is decoded from auth.json itself (id_token claims),
|
|
29
|
+
// offline ground truth - labels drift, the blob cannot lie. An unreadable
|
|
30
|
+
// live blob THROWS out of liveCodexAccountId (the codex loaders' contract),
|
|
31
|
+
// which fails this destructive command loudly rather than trusting a label.
|
|
32
|
+
if (liveCodexAccountId() === account.accountId) {
|
|
33
|
+
console.error(c.red(`${account.label} is the LIVE codex account - run \`tokenmaxxing switch --codex\` to move off it first.`));
|
|
34
|
+
return 1;
|
|
35
|
+
}
|
|
36
|
+
if (presentCodexAccountIds().has(account.accountId)) {
|
|
37
|
+
console.error(c.red(`${account.label} is running in a live codex session - close that session before removing it.`));
|
|
38
|
+
return 1;
|
|
39
|
+
}
|
|
40
|
+
// Parked codex blobs are plain 0600 files; hard delete on purpose (the
|
|
41
|
+
// credential-dir cleanup exception - trashing would move a credential
|
|
42
|
+
// into the Trash folder).
|
|
43
|
+
deleteParkedCodexAuth({ credFile: account.credFile });
|
|
44
|
+
index.accounts = index.accounts.filter((x) => x.accountId !== account.accountId);
|
|
45
|
+
saveCodexAccounts({ index });
|
|
46
|
+
console.log(`removed codex account ${c.bold(account.label)} from the pool (${index.accounts.length} left)`);
|
|
47
|
+
return 0;
|
|
48
|
+
});
|
|
49
|
+
}
|
package/src/cli/codexswitch.ts
CHANGED
|
@@ -32,7 +32,11 @@ export async function cmdCodexSwitch(sel?: string): Promise<number> {
|
|
|
32
32
|
}
|
|
33
33
|
const currentId = liveCodexAccountId();
|
|
34
34
|
|
|
35
|
-
|
|
35
|
+
// truthiness on purpose, mirroring the claude switch: an EMPTY selector
|
|
36
|
+
// must mean "no selector" - `startsWith("")` matches every account, so
|
|
37
|
+
// `sel !== undefined` let `xx switch --codex ""` swap onto the first
|
|
38
|
+
// account (adversarial-review catch)
|
|
39
|
+
if (sel) {
|
|
36
40
|
const target = index.accounts.find(
|
|
37
41
|
(account) => account.label === sel || account.email === sel || account.accountId.startsWith(sel),
|
|
38
42
|
);
|
|
@@ -49,7 +53,21 @@ export async function cmdCodexSwitch(sel?: string): Promise<number> {
|
|
|
49
53
|
console.error(c.red(`${target.label} is running in a live codex session - swapping onto it would break that session's credential`));
|
|
50
54
|
return 1;
|
|
51
55
|
}
|
|
52
|
-
|
|
56
|
+
if (target.needsReauth) {
|
|
57
|
+
console.error(c.red(`${target.label} needs re-auth - run \`codex login\` in an isolated home and \`tokenmaxxing add --codex\``));
|
|
58
|
+
return 1;
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
await performCodexSwap({ target });
|
|
62
|
+
} catch (e) {
|
|
63
|
+
// a dead grant is an expected operational state, not a stack trace
|
|
64
|
+
// (closing-review catch; mirrors the claude selector path).
|
|
65
|
+
if (e instanceof CodexInvalidGrantError) {
|
|
66
|
+
console.error(c.red(`${target.label}'s refresh token is dead - re-add it with \`tokenmaxxing add --codex\``));
|
|
67
|
+
return 1;
|
|
68
|
+
}
|
|
69
|
+
throw e;
|
|
70
|
+
}
|
|
53
71
|
console.log(`${c.green("✓")} switched codex to ${c.bold(target.label)} (takes effect on the next codex start)`);
|
|
54
72
|
return 0;
|
|
55
73
|
}
|
package/src/cli/config.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { isPlainObject } from "es-toolkit";
|
|
|
10
10
|
import { get, set, unset } from "es-toolkit/compat";
|
|
11
11
|
import { z } from "zod";
|
|
12
12
|
import { paths, realClaudeBinFromEnv, realCodexBinFromEnv } from "../lib/paths.ts";
|
|
13
|
-
import { ConfigFileSchema, loadConfig } from "../lib/state.ts";
|
|
13
|
+
import { ConfigFileSchema, loadConfig, mergeConfigFile } from "../lib/state.ts";
|
|
14
14
|
import { writeFileAtomic } from "../lib/atomic.ts";
|
|
15
15
|
import { c } from "./render.ts";
|
|
16
16
|
|
|
@@ -115,6 +115,15 @@ function cmdSet(key: string, valueText: string): number {
|
|
|
115
115
|
console.error(c.red(`rejected: ${validated.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ")}`));
|
|
116
116
|
return 1;
|
|
117
117
|
}
|
|
118
|
+
// The per-field gate passed; the MERGED whole must too, or this write makes
|
|
119
|
+
// every later loadConfig throw (the projectionMargin-vs-thresholds refine),
|
|
120
|
+
// silently disabling status/switch/hooks/statusline until the file is
|
|
121
|
+
// hand-repaired (closing-review catch).
|
|
122
|
+
const mergedCheck = mergeConfigFile(validated.data);
|
|
123
|
+
if (!mergedCheck.ok) {
|
|
124
|
+
console.error(c.red(`rejected: ${mergedCheck.detail}`));
|
|
125
|
+
return 1;
|
|
126
|
+
}
|
|
118
127
|
writeRawFile({ raw: next });
|
|
119
128
|
// Report the FILE-level change: with an env override in place, the effective
|
|
120
129
|
// value would not move, and an unchanged-looking arrow would misrepresent
|
|
@@ -151,6 +160,21 @@ function cmdUnset(key: string): number {
|
|
|
151
160
|
const next = structuredClone(raw);
|
|
152
161
|
unset(next, key);
|
|
153
162
|
pruneEmptyParents(next);
|
|
163
|
+
// Same merged-whole gate as `set` (adversarial-review catch): removing one
|
|
164
|
+
// override shifts the merged value back to its default, and the
|
|
165
|
+
// projectionMargin-vs-thresholds refine can fail on the RESULT even though
|
|
166
|
+
// every remaining field is individually valid - writing that file would make
|
|
167
|
+
// every later loadConfig throw, bricking status/switch/hooks/statusline.
|
|
168
|
+
const validated = ConfigFileSchema.safeParse(next);
|
|
169
|
+
if (!validated.success) {
|
|
170
|
+
console.error(c.red(`rejected: ${validated.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ")}`));
|
|
171
|
+
return 1;
|
|
172
|
+
}
|
|
173
|
+
const mergedCheck = mergeConfigFile(validated.data);
|
|
174
|
+
if (!mergedCheck.ok) {
|
|
175
|
+
console.error(c.red(`rejected: ${mergedCheck.detail} (adjust the conflicting override before unsetting ${key})`));
|
|
176
|
+
return 1;
|
|
177
|
+
}
|
|
154
178
|
writeRawFile({ raw: next });
|
|
155
179
|
console.log(`${key} unset -> ${JSON.stringify(get(loadConfig(), key))} (default)`);
|
|
156
180
|
return 0;
|
package/src/cli/doctor.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// `tokenmaxxing doctor` - verify the supervisor +
|
|
1
|
+
// `tokenmaxxing doctor` - verify the supervisor + four settings entries survived
|
|
2
2
|
// and the pool is healthy.
|
|
3
3
|
|
|
4
4
|
import { existsSync, readFileSync } from "node:fs";
|
|
@@ -55,7 +55,7 @@ export async function cmdDoctor(): Promise<number> {
|
|
|
55
55
|
if (org) check(org.organization_uuid === active.organizationUuid, `live credential identity matches active (${active.email})`, `token belongs to ${org.organization_name} - run \`tokenmaxxing switch\``);
|
|
56
56
|
else console.log(c.dim(` - live credential identity unverifiable (access token expired)`));
|
|
57
57
|
} catch (e) {
|
|
58
|
-
check(false, `live credential identity matches active (${active.email})`,
|
|
58
|
+
check(false, `live credential identity matches active (${active.email})`, (e instanceof Error ? e.message : String(e)).slice(0, 100));
|
|
59
59
|
}
|
|
60
60
|
}
|
|
61
61
|
|
|
@@ -68,7 +68,7 @@ export async function cmdDoctor(): Promise<number> {
|
|
|
68
68
|
if (org) check(org.organization_uuid === a.organizationUuid, `parked credential identity matches ${a.email}`, `token belongs to ${org.organization_name} - run \`tokenmaxxing auth ${a.label}\``);
|
|
69
69
|
else console.log(c.dim(` - ${a.email} identity unverifiable (access token expired)`));
|
|
70
70
|
} catch (e) {
|
|
71
|
-
check(false, `parked credential identity matches ${a.email}`,
|
|
71
|
+
check(false, `parked credential identity matches ${a.email}`, (e instanceof Error ? e.message : String(e)).slice(0, 100));
|
|
72
72
|
}
|
|
73
73
|
}
|
|
74
74
|
if (a.needsReauth) check(false, `${a.email} needs re-auth`, `run \`tokenmaxxing auth ${a.label}\` to re-login`);
|
package/src/cli/init.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
// `tokenmaxxing init` - import the account you're already on (no prompts), then
|
|
2
|
-
// install the supervisor + the
|
|
2
|
+
// install the supervisor + the four settings entries.
|
|
3
3
|
|
|
4
4
|
import { mkdirSync } from "node:fs";
|
|
5
5
|
import { isApiKeyMode, readOAuthAccount } from "../lib/claudejson.ts";
|
|
6
6
|
import { readItem, writeItem, liveTarget, parkedTarget, mergeIntoLive } from "../lib/credstore.ts";
|
|
7
7
|
import { refreshCredential, isAccessTokenExpiring, fetchTokenOrg } from "../lib/oauth.ts";
|
|
8
|
-
import {
|
|
8
|
+
import { withClaudeRefreshLock } from "../lib/claudelock.ts";
|
|
9
|
+
import { loadAccounts, saveAccounts, loadConfig, pinBinOverride } from "../lib/state.ts";
|
|
10
|
+
import { withLock } from "../lib/lock.ts";
|
|
9
11
|
import { installSupervisor, shellRcPath, ensurePathInRc, timerActivationHint, type InstallOutcome } from "../lib/install.ts";
|
|
10
12
|
import { resolveVerifiedClaude } from "../lib/claudebin.ts";
|
|
11
13
|
import { credItemFor, paths } from "../lib/paths.ts";
|
|
@@ -46,6 +48,13 @@ export function printUsage(): void {
|
|
|
46
48
|
|
|
47
49
|
export async function cmdInit(): Promise<number> {
|
|
48
50
|
mkdirSync(paths.home, { recursive: true });
|
|
51
|
+
// Fail fast on a broken merged config BEFORE installing or claiming a
|
|
52
|
+
// successful repair: pinBinOverride writes sparsely without validating, so
|
|
53
|
+
// without this gate a re-init printed success while hooks, switching, and
|
|
54
|
+
// the statusline kept throwing on every loadConfig until the file was
|
|
55
|
+
// hand-repaired (bugbot review catch, PR #33). The throw carries the
|
|
56
|
+
// fix-or-remove recovery message.
|
|
57
|
+
loadConfig();
|
|
49
58
|
|
|
50
59
|
// Already initialized → repair install ONLY. Never re-import: ~/.claude.json's
|
|
51
60
|
// oauthAccount can drift from the live keychain cred (after swaps / concurrent
|
|
@@ -56,10 +65,9 @@ export async function cmdInit(): Promise<number> {
|
|
|
56
65
|
// repair the claudeBin pin too - hooks run with claude's PATH and must
|
|
57
66
|
// never have to guess which binary is the real claude. Verified pinning:
|
|
58
67
|
// a pin that fails --version (or loops back into the wrapper) is replaced
|
|
59
|
-
// by a fresh PATH scan instead of being re-saved.
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
saveConfig(cfg);
|
|
68
|
+
// by a fresh PATH scan instead of being re-saved. Sparse write: only the
|
|
69
|
+
// pin lands in the file, never the merged config.
|
|
70
|
+
pinBinOverride({ key: "claudeBin", bin: resolveVerifiedClaude() });
|
|
63
71
|
const active = existingIdx.accounts.find((a) => a.accountUuid === existingIdx.activeAccountUuid);
|
|
64
72
|
console.log(`${c.green("✓")} re-installed supervisor + hooks (pool already has ${existingIdx.accounts.length} account${existingIdx.accounts.length === 1 ? "" : "s"} - not re-importing)`);
|
|
65
73
|
reportTimer(out);
|
|
@@ -96,8 +104,16 @@ export async function cmdInit(): Promise<number> {
|
|
|
96
104
|
// keychain credential, and importing on drifted state parks a mislabeled blob.
|
|
97
105
|
let creds = blob.claudeAiOauth;
|
|
98
106
|
if (isAccessTokenExpiring(creds)) {
|
|
99
|
-
|
|
100
|
-
|
|
107
|
+
await withClaudeRefreshLock(async (lock) => {
|
|
108
|
+
// re-read inside the lock: a running claude may have rotated it already.
|
|
109
|
+
const raw2 = await readItem(liveTarget());
|
|
110
|
+
if (raw2 == null) throw new Error("live credential vanished while waiting for the refresh lock");
|
|
111
|
+
const current = CredentialBlobSchema.parse(JSON.parse(raw2)).claudeAiOauth;
|
|
112
|
+
creds = isAccessTokenExpiring(current) ? await refreshCredential(current) : current;
|
|
113
|
+
if (creds === current) return;
|
|
114
|
+
if (lock.compromised()) throw new Error("refresh lock compromised mid-refresh - discarding the live rewrite");
|
|
115
|
+
await writeItem(liveTarget(), mergeIntoLive(raw2, creds));
|
|
116
|
+
});
|
|
101
117
|
}
|
|
102
118
|
const org = await fetchTokenOrg(creds.accessToken);
|
|
103
119
|
if (org.organization_uuid !== oauthAccount.organizationUuid) {
|
|
@@ -108,30 +124,35 @@ export async function cmdInit(): Promise<number> {
|
|
|
108
124
|
|
|
109
125
|
const uuid = oauthAccount.accountUuid;
|
|
110
126
|
const keychainItem = credItemFor(uuid);
|
|
111
|
-
|
|
127
|
+
// Park + index update under the tokenmaxxing flock, like every sibling
|
|
128
|
+
// index writer (add/auth/rm/status/rename): unlocked, a concurrent `xx add`
|
|
129
|
+
// completing between this path's load and save had its just-registered
|
|
130
|
+
// account silently clobbered out of the index (closing-review catch).
|
|
131
|
+
const account = await withLock(paths.lockFile, async () => {
|
|
132
|
+
await writeItem(parkedTarget(keychainItem), JSON.stringify({ claudeAiOauth: creds })); // park a small backup
|
|
133
|
+
const idx = loadAccounts();
|
|
134
|
+
const existing = idx.accounts.find((a) => a.accountUuid === uuid);
|
|
135
|
+
const imported: Account = {
|
|
136
|
+
accountUuid: uuid,
|
|
137
|
+
email: oauthAccount.emailAddress,
|
|
138
|
+
organizationUuid: oauthAccount.organizationUuid,
|
|
139
|
+
label: existing?.label ?? oauthAccount.emailAddress,
|
|
140
|
+
keychainItem,
|
|
141
|
+
oauthAccount,
|
|
142
|
+
addedAt: existing?.addedAt ?? new Date().toISOString(),
|
|
143
|
+
subscriptionType: blob.claudeAiOauth.subscriptionType,
|
|
144
|
+
rateLimitTier: blob.claudeAiOauth.rateLimitTier,
|
|
145
|
+
needsReauth: false,
|
|
146
|
+
};
|
|
147
|
+
if (existing) Object.assign(existing, imported);
|
|
148
|
+
else idx.accounts.push(imported);
|
|
149
|
+
idx.activeAccountUuid = uuid;
|
|
150
|
+
saveAccounts(idx);
|
|
151
|
+
return imported;
|
|
152
|
+
});
|
|
112
153
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
const account: Account = {
|
|
116
|
-
accountUuid: uuid,
|
|
117
|
-
email: oauthAccount.emailAddress,
|
|
118
|
-
organizationUuid: oauthAccount.organizationUuid,
|
|
119
|
-
label: existing?.label ?? oauthAccount.emailAddress,
|
|
120
|
-
keychainItem,
|
|
121
|
-
oauthAccount,
|
|
122
|
-
addedAt: existing?.addedAt ?? new Date().toISOString(),
|
|
123
|
-
subscriptionType: blob.claudeAiOauth.subscriptionType,
|
|
124
|
-
rateLimitTier: blob.claudeAiOauth.rateLimitTier,
|
|
125
|
-
needsReauth: false,
|
|
126
|
-
};
|
|
127
|
-
if (existing) Object.assign(existing, account);
|
|
128
|
-
else idx.accounts.push(account);
|
|
129
|
-
idx.activeAccountUuid = uuid;
|
|
130
|
-
saveAccounts(idx);
|
|
131
|
-
|
|
132
|
-
const cfg = loadConfig();
|
|
133
|
-
cfg.claudeBin = resolveVerifiedClaude();
|
|
134
|
-
saveConfig(cfg);
|
|
154
|
+
// Sparse write: only the pin lands in the file, never the merged config.
|
|
155
|
+
pinBinOverride({ key: "claudeBin", bin: resolveVerifiedClaude() });
|
|
135
156
|
|
|
136
157
|
const out = installSupervisor();
|
|
137
158
|
|
|
@@ -142,8 +163,9 @@ export async function cmdInit(): Promise<number> {
|
|
|
142
163
|
console.log();
|
|
143
164
|
ensurePathAhead();
|
|
144
165
|
}
|
|
166
|
+
const poolSize = loadAccounts().accounts.length;
|
|
145
167
|
console.log();
|
|
146
|
-
console.log(` pool ready (${
|
|
168
|
+
console.log(` pool ready (${poolSize} account${poolSize === 1 ? "" : "s"})`);
|
|
147
169
|
printUsage();
|
|
148
170
|
return 0;
|
|
149
171
|
}
|
package/src/cli/onboard.ts
CHANGED
|
@@ -10,13 +10,13 @@ import { join } from "node:path";
|
|
|
10
10
|
import { z } from "zod";
|
|
11
11
|
import { readItem, deleteItem, isolatedTarget } from "../lib/credstore.ts";
|
|
12
12
|
import { resolveRealClaude } from "../lib/claudebin.ts";
|
|
13
|
-
import { probeUsage, FullUsageSchema } from "../lib/usage.ts";
|
|
13
|
+
import { CRED_ENV_OVERRIDES, probeUsage, FullUsageSchema } from "../lib/usage.ts";
|
|
14
14
|
import { saveTermios, restoreTermios } from "../lib/tty.ts";
|
|
15
15
|
import { paths } from "../lib/paths.ts";
|
|
16
16
|
import { CredentialBlobSchema, OAuthAccountSchema } from "../lib/types.ts";
|
|
17
17
|
import { c } from "./render.ts";
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
const HarvestedLoginSchema = z.object({
|
|
20
20
|
/** the raw isolated credential blob (park it via claudeAiOauthOnly). */
|
|
21
21
|
blobRaw: z.string(),
|
|
22
22
|
blob: CredentialBlobSchema,
|
|
@@ -46,6 +46,15 @@ export async function harvestIsolatedLogin(): Promise<HarvestedLogin | null> {
|
|
|
46
46
|
rmSync(onboardDir, { recursive: true, force: true });
|
|
47
47
|
mkdirSync(onboardDir, { recursive: true });
|
|
48
48
|
const iso = isolatedTarget(onboardDir);
|
|
49
|
+
// Reap a PREVIOUS run's stranded isolated credential too: on macOS the
|
|
50
|
+
// isolated store is a namespaced KEYCHAIN item, not a file in onboardDir,
|
|
51
|
+
// so the rmSync above never touched it - a signal-killed harvest (Ctrl-C
|
|
52
|
+
// after /login, before the ~400ms poll) left a live credential in the
|
|
53
|
+
// keychain that the next run's spawned claude would silently reuse as an
|
|
54
|
+
// already-authenticated session (closing-review catch). deleteItem on a
|
|
55
|
+
// missing item is a no-op; on Linux iso is a file inside onboardDir and the
|
|
56
|
+
// rmSync already covered it.
|
|
57
|
+
await deleteItem(iso);
|
|
49
58
|
const cjPath = join(onboardDir, ".claude.json");
|
|
50
59
|
const real = resolveRealClaude();
|
|
51
60
|
|
|
@@ -54,10 +63,7 @@ export async function harvestIsolatedLogin(): Promise<HarvestedLogin | null> {
|
|
|
54
63
|
// keychain lookup (verified 2.1.205) - the onboard session must authenticate
|
|
55
64
|
// only via the /login the user performs inside it.
|
|
56
65
|
const env: Record<string, string> = { ...process.env, CLAUDE_CONFIG_DIR: onboardDir, TOKENMAXXING_PROBE: "1", TOKENMAXXING_SUPERVISED: "" };
|
|
57
|
-
delete env
|
|
58
|
-
delete env.ANTHROPIC_AUTH_TOKEN;
|
|
59
|
-
delete env.CLAUDE_CODE_OAUTH_TOKEN;
|
|
60
|
-
delete env.CLAUDE_SECURESTORAGE_CONFIG_DIR;
|
|
66
|
+
for (const key of CRED_ENV_OVERRIDES) delete env[key];
|
|
61
67
|
const p = Bun.spawn([real], {
|
|
62
68
|
stdin: "inherit",
|
|
63
69
|
stdout: "inherit",
|
|
@@ -65,48 +71,59 @@ export async function harvestIsolatedLogin(): Promise<HarvestedLogin | null> {
|
|
|
65
71
|
env,
|
|
66
72
|
});
|
|
67
73
|
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
74
|
+
// The finally makes the "always destroyed before returning" header true for
|
|
75
|
+
// every non-signal exit - INCLUDING a failure while the login session is
|
|
76
|
+
// still being polled (review catch, PR #31): an exception must not strand a
|
|
77
|
+
// plaintext credential on disk or leave the spawned claude running. (An
|
|
78
|
+
// interactive Ctrl-C is reaped by the next run's cleanup-first, which
|
|
79
|
+
// deletes the isolated keychain item as well as the dir.)
|
|
80
|
+
try {
|
|
81
|
+
// Auto-exit (#17): watch for a completed login - identity written AND the
|
|
82
|
+
// isolated credential present - then SIGTERM claude. No manual /exit.
|
|
83
|
+
let exited = false;
|
|
84
|
+
const onExit = p.exited.then(() => { exited = true; });
|
|
85
|
+
while (!exited) {
|
|
86
|
+
await Bun.sleep(400);
|
|
87
|
+
if (identityReady(cjPath) && (await readItem(iso))) {
|
|
88
|
+
p.kill();
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
77
91
|
}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
92
|
+
await p.exited;
|
|
93
|
+
await onExit;
|
|
94
|
+
// restore promptly so the harvest's own output renders on a sane terminal;
|
|
95
|
+
// the finally's second restore is an idempotent stty and covers the
|
|
96
|
+
// thrown-mid-poll path.
|
|
97
|
+
restoreTermios(savedTermios);
|
|
82
98
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
const blobRaw = await readItem(iso);
|
|
89
|
-
if (!blobRaw || !identityReady(cjPath)) {
|
|
90
|
-
console.error(c.red("no login detected in the isolated session - nothing changed."));
|
|
91
|
-
await cleanup();
|
|
92
|
-
return null;
|
|
93
|
-
}
|
|
99
|
+
const blobRaw = await readItem(iso);
|
|
100
|
+
if (!blobRaw || !identityReady(cjPath)) {
|
|
101
|
+
console.error(c.red("no login detected in the isolated session - nothing changed."));
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
94
104
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
}
|
|
105
|
+
let blob, oauthAccount;
|
|
106
|
+
try {
|
|
107
|
+
blob = CredentialBlobSchema.parse(JSON.parse(blobRaw));
|
|
108
|
+
oauthAccount = OAuthAccountSchema.parse(JSON.parse(readFileSync(cjPath, "utf8")).oauthAccount);
|
|
109
|
+
} catch {
|
|
110
|
+
console.error(c.red("could not parse the onboarded account's credential/identity."));
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
104
113
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
114
|
+
// Sample usage now (#16) so the account isn't "not sampled yet" in status/ls.
|
|
115
|
+
console.log(c.dim("sampling usage..."));
|
|
116
|
+
const sampled = await probeUsage(onboardDir);
|
|
117
|
+
if (!sampled) console.log(c.yellow("could not sample usage now - it will fill in on first use."));
|
|
109
118
|
|
|
110
|
-
|
|
111
|
-
|
|
119
|
+
return { blobRaw, blob, oauthAccount, sampled };
|
|
120
|
+
} finally {
|
|
121
|
+
if (p.exitCode === null) {
|
|
122
|
+
p.kill();
|
|
123
|
+
await p.exited;
|
|
124
|
+
}
|
|
125
|
+
restoreTermios(savedTermios);
|
|
126
|
+
await deleteItem(iso);
|
|
127
|
+
rmSync(onboardDir, { recursive: true, force: true });
|
|
128
|
+
}
|
|
112
129
|
}
|
package/src/cli/rename.ts
CHANGED
|
@@ -39,6 +39,16 @@ async function renameCodexAccount(input: { selector: string; newLabel: string })
|
|
|
39
39
|
console.error(c.red(`no codex account matches "${input.selector}"`));
|
|
40
40
|
return 1;
|
|
41
41
|
}
|
|
42
|
+
// labels resolve selectors first-match: a duplicate would make the other
|
|
43
|
+
// account unreachable by label and misdirect destructive commands like
|
|
44
|
+
// `rm` onto the wrong one (adversarial-review catch)
|
|
45
|
+
// case-insensitive, matching how findCodexAccount resolves selectors (PR
|
|
46
|
+
// #37 review catch: a casing-only duplicate slipped the === guard)
|
|
47
|
+
const taken = index.accounts.find((x) => x.accountId !== account.accountId && x.label.toLowerCase() === input.newLabel.toLowerCase());
|
|
48
|
+
if (taken) {
|
|
49
|
+
console.error(c.red(`label "${input.newLabel}" is already used by ${taken.accountId.slice(0, 8)} - labels must be unique within the pool`));
|
|
50
|
+
return 1;
|
|
51
|
+
}
|
|
42
52
|
const old = account.label;
|
|
43
53
|
account.label = input.newLabel;
|
|
44
54
|
saveCodexAccounts({ index });
|
|
@@ -63,6 +73,16 @@ export async function cmdRename(argv: string[]): Promise<number> {
|
|
|
63
73
|
console.error(c.red(`no claude account matches "${selector}" (codex accounts rename via --codex)`));
|
|
64
74
|
return 1;
|
|
65
75
|
}
|
|
76
|
+
// labels resolve selectors first-match: a duplicate would make the other
|
|
77
|
+
// account unreachable by label and misdirect destructive commands like
|
|
78
|
+
// `rm` onto the wrong one (adversarial-review catch)
|
|
79
|
+
// case-insensitive, matching how findAccount resolves selectors (PR #37
|
|
80
|
+
// review catch: a casing-only duplicate slipped the === guard)
|
|
81
|
+
const taken = idx.accounts.find((x) => x.accountUuid !== a.accountUuid && x.label.toLowerCase() === newLabel.toLowerCase());
|
|
82
|
+
if (taken) {
|
|
83
|
+
console.error(c.red(`label "${newLabel}" is already used by ${taken.email} - labels must be unique within the pool`));
|
|
84
|
+
return 1;
|
|
85
|
+
}
|
|
66
86
|
const old = a.label;
|
|
67
87
|
a.label = newLabel;
|
|
68
88
|
saveAccounts(idx);
|
package/src/cli/render.ts
CHANGED
|
@@ -98,19 +98,3 @@ export function fmtAgo(epochMs: number, now = Date.now()): string {
|
|
|
98
98
|
return `${m}m ago`;
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
-
/** Compact time-until-reset for the statusLine: the largest unit only ("6d",
|
|
102
|
-
* "2h", "45m"), floored to "1m" so a live window never reads as zero, and ""
|
|
103
|
-
* once the reset has passed (the window is simply empty again). Non-empty
|
|
104
|
-
* output always ends in a unit letter, so the digit-leading used-percent glued
|
|
105
|
-
* after it stays parseable. */
|
|
106
|
-
export function fmtResetShort(epochMs: number | null | undefined, now = Date.now()): string {
|
|
107
|
-
if (epochMs == null) return "";
|
|
108
|
-
const dsec = Math.round((epochMs - now) / 1000);
|
|
109
|
-
if (dsec <= 0) return "";
|
|
110
|
-
const d = Math.floor(dsec / 86400);
|
|
111
|
-
const h = Math.floor((dsec % 86400) / 3600);
|
|
112
|
-
const m = Math.floor((dsec % 3600) / 60);
|
|
113
|
-
if (d > 0) return `${d}d`;
|
|
114
|
-
if (h > 0) return `${h}h`;
|
|
115
|
-
return `${Math.max(m, 1)}m`;
|
|
116
|
-
}
|
package/src/cli/rm.ts
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
// `tokenmaxxing rm <selector>` - remove a pooled account (not the active one).
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { rmSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { deleteItem, isolatedTarget, liveTarget, parkedTarget, readItem } from "../lib/credstore.ts";
|
|
6
|
+
import { fetchTokenOrg } from "../lib/oauth.ts";
|
|
4
7
|
import { withLock } from "../lib/lock.ts";
|
|
5
|
-
import { paths } from "../lib/paths.ts";
|
|
8
|
+
import { credItemFor, paths } from "../lib/paths.ts";
|
|
6
9
|
import { loadAccounts, saveAccounts } from "../lib/state.ts";
|
|
10
|
+
import { CredentialBlobSchema } from "../lib/types.ts";
|
|
7
11
|
import { findAccount } from "./rename.ts";
|
|
8
12
|
import { c } from "./render.ts";
|
|
9
13
|
|
|
@@ -24,7 +28,41 @@ export async function cmdRm(selector?: string): Promise<number> {
|
|
|
24
28
|
console.error(c.red(`${a.email} is the ACTIVE account - switch away before removing it.`));
|
|
25
29
|
return 1;
|
|
26
30
|
}
|
|
31
|
+
// The label drifts (manual /login); the token cannot lie. Removing the
|
|
32
|
+
// account whose credential is actually LIVE would destroy its only backup
|
|
33
|
+
// and leave the next swap refusing over an unpooled credential. Fail
|
|
34
|
+
// CLOSED (review catch, PR #31): when the live owner cannot be verified -
|
|
35
|
+
// unparsable blob, expired token, roles outage - refuse rather than trust
|
|
36
|
+
// the stale label; rm is destructive and can wait. The check is read-only:
|
|
37
|
+
// an expired bearer simply fails the roles call, nothing is ever rotated.
|
|
38
|
+
const live = await readItem(liveTarget());
|
|
39
|
+
if (live != null) {
|
|
40
|
+
let liveOrg: string;
|
|
41
|
+
try {
|
|
42
|
+
const liveCreds = CredentialBlobSchema.parse(JSON.parse(live)).claudeAiOauth;
|
|
43
|
+
liveOrg = (await fetchTokenOrg(liveCreds.accessToken)).organization_uuid;
|
|
44
|
+
} catch (e) {
|
|
45
|
+
console.error(c.red(`cannot verify which account the LIVE credential belongs to (${e instanceof Error ? e.message : String(e)}) - refusing to remove while the live owner is unknown; repair the live credential or retry once the roles endpoint is reachable.`));
|
|
46
|
+
return 1;
|
|
47
|
+
}
|
|
48
|
+
if (liveOrg === a.organizationUuid) {
|
|
49
|
+
console.error(c.red(`${a.email}'s credential is currently LIVE (the active label is stale - a manual /login drifted it); run \`tokenmaxxing switch\` to move off it first.`));
|
|
50
|
+
return 1;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
27
53
|
await deleteItem(parkedTarget(a.keychainItem));
|
|
54
|
+
// Sweep sample-probe residue too: a probe killed mid-run strands the
|
|
55
|
+
// account's isolated credential (macOS: a namespaced keychain item), and
|
|
56
|
+
// once the account leaves the pool nothing would ever probe-and-heal it
|
|
57
|
+
// again (closing-review critic gap). deleteItem on a missing item is a
|
|
58
|
+
// no-op. Hard delete, not trash, on purpose: the sample dir is a
|
|
59
|
+
// throwaway CLAUDE_CONFIG_DIR that can hold PLAINTEXT credential material
|
|
60
|
+
// on Linux - the credential-dir cleanup exception (owner 2026-07-16, same
|
|
61
|
+
// rule sample.ts and onboard.ts follow); trashing would move credentials
|
|
62
|
+
// into the Trash folder.
|
|
63
|
+
const sampleDir = join(paths.sampleDir, credItemFor(a.accountUuid));
|
|
64
|
+
await deleteItem(isolatedTarget(sampleDir));
|
|
65
|
+
rmSync(sampleDir, { recursive: true, force: true });
|
|
28
66
|
idx.accounts = idx.accounts.filter((x) => x.accountUuid !== a.accountUuid);
|
|
29
67
|
saveAccounts(idx);
|
|
30
68
|
console.log(`removed ${c.bold(a.label)} from the pool (${idx.accounts.length} left)`);
|