tokenmaxxing 1.8.0 → 1.9.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 +2 -4
- package/README.md +1 -1
- package/agent-plugin/plugin.json +1 -1
- package/package.json +1 -1
- package/src/cli/add.ts +1 -8
- package/src/cli/auth.ts +0 -23
- package/src/cli/check.ts +19 -13
- package/src/cli/codexadd.ts +0 -17
- package/src/cli/codexinit.ts +0 -40
- package/src/cli/codexrm.ts +0 -13
- package/src/cli/codexswitch.ts +0 -15
- package/src/cli/config.ts +0 -30
- package/src/cli/doctor.ts +1 -14
- package/src/cli/init.ts +1 -33
- package/src/cli/ls.ts +0 -2
- package/src/cli/onboard.ts +0 -37
- package/src/cli/rename.ts +0 -19
- package/src/cli/render.ts +0 -23
- package/src/cli/rm.ts +0 -19
- package/src/cli/status.ts +0 -80
- package/src/cli/switch.ts +1 -49
- package/src/cli/watch.ts +0 -17
- package/src/entries/codexstophook.ts +2 -73
- package/src/entries/codexsupervisor.ts +1 -67
- package/src/entries/mcp.ts +0 -11
- package/src/entries/sessionstart.ts +1 -8
- package/src/entries/statusline.ts +0 -66
- package/src/entries/stopfailurehook.ts +93 -0
- package/src/entries/stophook.ts +3 -34
- package/src/entries/subagentstatusline.ts +0 -19
- package/src/entries/supervisor.ts +32 -132
- package/src/lib/atomic.ts +0 -16
- package/src/lib/claudebin.ts +4 -55
- package/src/lib/claudejson.ts +0 -10
- package/src/lib/claudelock.ts +13 -35
- package/src/lib/codexauth.ts +0 -29
- package/src/lib/codexbin.ts +0 -10
- package/src/lib/codexdecide.ts +1 -112
- package/src/lib/codexoauth.ts +0 -16
- package/src/lib/codexpick.ts +0 -31
- package/src/lib/codexpresence.ts +0 -35
- package/src/lib/codexsample.ts +0 -23
- package/src/lib/codexstate.ts +0 -7
- package/src/lib/codexswap.ts +0 -32
- package/src/lib/codexusage.ts +0 -28
- package/src/lib/credstore.ts +0 -24
- package/src/lib/decide.ts +127 -180
- package/src/lib/http.ts +0 -9
- package/src/lib/install.ts +6 -127
- package/src/lib/keychain.ts +1 -39
- package/src/lib/lock.ts +0 -24
- package/src/lib/log.ts +0 -14
- package/src/lib/oauth.ts +1 -31
- package/src/lib/paths.ts +1 -48
- package/src/lib/picker.ts +1 -84
- package/src/lib/proc.ts +0 -17
- package/src/lib/sample.ts +0 -68
- package/src/lib/sessions.ts +0 -13
- package/src/lib/settings.ts +15 -42
- package/src/lib/state.ts +23 -77
- package/src/lib/swap.ts +3 -87
- package/src/lib/tty.ts +0 -4
- package/src/lib/types.ts +13 -140
- package/src/lib/usage.ts +108 -196
- package/src/lib/worktree.ts +0 -8
- package/src/main.ts +5 -40
- package/src/sdk.ts +0 -59
- package/agent-plugin/agents/tokenmaxxing-claude.md +0 -43
- package/agent-plugin/agents/tokenmaxxing-codex.md +0 -40
- package/agent-plugin/hooks/cursor-relay.json +0 -14
- package/agent-plugin/skills/relay-session/SKILL.md +0 -118
- package/agent-plugin/skills/relay-session/references/ipc.md +0 -23
- package/src/cli/relay.ts +0 -323
- package/src/entries/relaypermission.ts +0 -105
- package/src/lib/relay/config.ts +0 -84
- package/src/lib/relay/decide.ts +0 -75
- package/src/lib/relay/gc.ts +0 -80
- package/src/lib/relay/install.ts +0 -143
- package/src/lib/relay/markers.ts +0 -148
- package/src/lib/relay/modes.ts +0 -82
- package/src/lib/relay/protocol.ts +0 -61
- package/src/lib/relay/registry.ts +0 -175
- package/src/lib/relay/tmux.ts +0 -109
- package/src/lib/relay/turn.ts +0 -137
- package/src/lib/relay/worker.ts +0 -141
package/src/lib/install.ts
CHANGED
|
@@ -1,7 +1,3 @@
|
|
|
1
|
-
// Install/uninstall the on-PATH `claude` supervisor wrapper + settings entries.
|
|
2
|
-
// The wrapper is a 2-line `exec ... __supervise "$@"` shim so dispatch never
|
|
3
|
-
// depends on argv0 semantics.
|
|
4
|
-
|
|
5
1
|
import { accessSync, appendFileSync, constants, existsSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync } from "node:fs";
|
|
6
2
|
import { basename, dirname, join } from "node:path";
|
|
7
3
|
import { escape } from "es-toolkit";
|
|
@@ -19,7 +15,6 @@ const InstallOutcomeSchema = z.object({
|
|
|
19
15
|
});
|
|
20
16
|
export type InstallOutcome = z.infer<typeof InstallOutcomeSchema>;
|
|
21
17
|
|
|
22
|
-
/** True if our binDir comes before the real claude's dir on PATH. */
|
|
23
18
|
export function isBinDirAhead(): boolean {
|
|
24
19
|
const dirs = (process.env.PATH ?? "").split(":");
|
|
25
20
|
const ourIdx = dirs.indexOf(paths.binDir);
|
|
@@ -33,12 +28,8 @@ export function isBinDirAhead(): boolean {
|
|
|
33
28
|
}
|
|
34
29
|
}
|
|
35
30
|
|
|
36
|
-
// Optional "1"/"true"/"yes" flag; unset/empty → undefined (feature off).
|
|
37
31
|
const EnvFlagSchema = z.enum(["1", "true", "yes"]).optional().catch(undefined);
|
|
38
32
|
|
|
39
|
-
/** True when this process is the Nix-packaged CLI (flake startScript sets
|
|
40
|
-
* TOKENMAXXING_NIX=1; store-path Bun.main is the fallback for wraps that
|
|
41
|
-
* forget the env). Env overrides parse at the read site. */
|
|
42
33
|
export function isNixPackaged(): boolean {
|
|
43
34
|
if (EnvFlagSchema.parse(process.env.TOKENMAXXING_NIX) != null) return true;
|
|
44
35
|
try {
|
|
@@ -48,8 +39,6 @@ export function isNixPackaged(): boolean {
|
|
|
48
39
|
}
|
|
49
40
|
}
|
|
50
41
|
|
|
51
|
-
/** True when a Nix module owns the periodic check timer; init must not write
|
|
52
|
-
* a second imperative unit. */
|
|
53
42
|
export function skipImperativeTimer(): boolean {
|
|
54
43
|
return EnvFlagSchema.parse(process.env.TOKENMAXXING_SKIP_TIMER) != null;
|
|
55
44
|
}
|
|
@@ -62,9 +51,6 @@ function isEacces(e: unknown): boolean {
|
|
|
62
51
|
return typeof e === "object" && e != null && "code" in e && e.code === "EACCES";
|
|
63
52
|
}
|
|
64
53
|
|
|
65
|
-
/** Home Manager (and similar) point ~/.zshrc at a nix-store file. Writing
|
|
66
|
-
* through that symlink throws EACCES; soft-skip instead. Still write through
|
|
67
|
-
* ordinary writable symlink targets (PR #36). */
|
|
68
54
|
function cannotWriteRcTarget(target: string): boolean {
|
|
69
55
|
if (EnvFlagSchema.parse(process.env.TOKENMAXXING_SKIP_SHELL_RC) != null) return true;
|
|
70
56
|
if (isNixStorePath(target)) return true;
|
|
@@ -77,7 +63,6 @@ function cannotWriteRcTarget(target: string): boolean {
|
|
|
77
63
|
}
|
|
78
64
|
}
|
|
79
65
|
|
|
80
|
-
/** User-facing lines when ensurePathInRc soft-skips a managed shell rc. */
|
|
81
66
|
export function managedShellRcSkipLines(): { headline: string; detail: string; exportLine: string } {
|
|
82
67
|
return {
|
|
83
68
|
headline: "shell rc is managed (Home Manager / nix-store) - PATH was not auto-edited",
|
|
@@ -86,11 +71,6 @@ export function managedShellRcSkipLines(): { headline: string; detail: string; e
|
|
|
86
71
|
};
|
|
87
72
|
}
|
|
88
73
|
|
|
89
|
-
/** Nix supervisor shim: prefer a PATH-stable `tokenmaxxing` (profile /
|
|
90
|
-
* current-system, excluding this binDir) so upgrades/GC of an old store
|
|
91
|
-
* generation stay reachable; fall back to bun+entry for the rare
|
|
92
|
-
* `nix run ... -- init` case where nothing is on PATH yet (works until that
|
|
93
|
-
* generation is GC'd — docs steer users to `nix profile install` first). */
|
|
94
74
|
function nixSupervisorShim(bun: string, entry: string): string {
|
|
95
75
|
return `#!/bin/sh
|
|
96
76
|
dir=$(CDPATH= cd -- "$(dirname "$0")" && pwd)
|
|
@@ -113,11 +93,7 @@ exec ${JSON.stringify(bun)} run ${JSON.stringify(entry)} "$@"
|
|
|
113
93
|
|
|
114
94
|
export function installSupervisor(): InstallOutcome {
|
|
115
95
|
mkdirSync(paths.binDir, { recursive: true });
|
|
116
|
-
const target = installedBin();
|
|
117
|
-
// Resolve the entry through the global-bin symlink (bun add -g links
|
|
118
|
-
// ~/.bun/bin/tokenmaxxing → the package's src/main.ts) so the shim points
|
|
119
|
-
// into the installed package tree, where its imports resolve. Nix shims
|
|
120
|
-
// prefer PATH first (see nixSupervisorShim).
|
|
96
|
+
const target = installedBin();
|
|
121
97
|
const entry = realpathSync(Bun.main);
|
|
122
98
|
if (isNixPackaged()) {
|
|
123
99
|
writeFileAtomic(target, nixSupervisorShim(process.execPath, entry), 0o755);
|
|
@@ -125,9 +101,7 @@ export function installSupervisor(): InstallOutcome {
|
|
|
125
101
|
writeFileAtomic(target, `#!/bin/sh\nexec ${JSON.stringify(process.execPath)} run ${JSON.stringify(entry)} "$@"\n`, 0o755);
|
|
126
102
|
}
|
|
127
103
|
|
|
128
|
-
// the on-PATH `claude` wrapper
|
|
129
104
|
writeFileAtomic(paths.supervisorLink, `#!/bin/sh\nexec ${JSON.stringify(target)} __supervise "$@"\n`, 0o755);
|
|
130
|
-
// the `xx` short alias → tokenmaxxing
|
|
131
105
|
writeFileAtomic(join(paths.binDir, "xx"), `#!/bin/sh\nexec ${JSON.stringify(target)} "$@"\n`, 0o755);
|
|
132
106
|
|
|
133
107
|
installSettings();
|
|
@@ -139,14 +113,6 @@ export function installSupervisor(): InstallOutcome {
|
|
|
139
113
|
};
|
|
140
114
|
}
|
|
141
115
|
|
|
142
|
-
// ---- codex supervisor + Stop hook -------------------------------------------
|
|
143
|
-
|
|
144
|
-
/** Codex hook declarations we merge into. The FILE nests the event map under a
|
|
145
|
-
* `hooks` field (`struct HooksFile { description?, hooks }`, binary-verified
|
|
146
|
-
* 0.144.4 after a live parse failure proved the docs' claude-style top-level
|
|
147
|
-
* event map wrong: "unknown field Stop, expected description or hooks").
|
|
148
|
-
* Loose everywhere: every other event and every foreign Stop entry rides
|
|
149
|
-
* along verbatim. */
|
|
150
116
|
const CodexHookEventsSchema = z.looseObject({
|
|
151
117
|
Stop: z.array(z.looseObject({ hooks: z.array(z.looseObject({ command: z.string().optional() })).default([]) })).default([]),
|
|
152
118
|
});
|
|
@@ -158,20 +124,9 @@ const CodexHooksFileSchema = z.looseObject({
|
|
|
158
124
|
const CODEX_STOP_HOOK_SUBCOMMAND = "__codex-stop-hook";
|
|
159
125
|
|
|
160
126
|
function codexStopHookCommand(): string {
|
|
161
|
-
// Quoted like the claude shim commands: an install path with a space would
|
|
162
|
-
// otherwise mis-split and the hook would silently never run.
|
|
163
127
|
return `${JSON.stringify(installedBin())} ${CODEX_STOP_HOOK_SUBCOMMAND}`;
|
|
164
128
|
}
|
|
165
129
|
|
|
166
|
-
/** Idempotently install the tokenmaxxing Stop entry in ~/.codex/hooks.json,
|
|
167
|
-
* preserving every other declaration. Codex skips new hooks until the user
|
|
168
|
-
* trusts them via /hooks (trust is recorded against the hook's hash), so the
|
|
169
|
-
* caller must surface that step. */
|
|
170
|
-
/** Surgical WITHIN groups, ownership verified structurally (closing-review
|
|
171
|
-
* catch, mirroring settings.ts's removeHook fix): the old whole-group filter
|
|
172
|
-
* deleted a foreign hook the user had appended into our group - the natural
|
|
173
|
-
* edit, since install writes exactly one group - and its includes() match
|
|
174
|
-
* claimed any command merely mentioning the subcommand. */
|
|
175
130
|
function withoutOurCodexStopHooks(groups: { hooks: { type?: string; command?: string }[] }[]): typeof groups {
|
|
176
131
|
return groups
|
|
177
132
|
.map((group) => ({ ...group, hooks: group.hooks.filter((hook) => !isOurHookCommand(hook.command ?? "", CODEX_STOP_HOOK_SUBCOMMAND)) }))
|
|
@@ -213,7 +168,6 @@ export function codexSupervisorLink(): string {
|
|
|
213
168
|
return join(paths.binDir, "codex");
|
|
214
169
|
}
|
|
215
170
|
|
|
216
|
-
/** The on-PATH `codex` wrapper + the Stop hook declaration. */
|
|
217
171
|
export function installCodexSupervisor(): void {
|
|
218
172
|
mkdirSync(paths.binDir, { recursive: true });
|
|
219
173
|
writeFileAtomic(codexSupervisorLink(), `#!/bin/sh\nexec ${JSON.stringify(installedBin())} __supervise-codex "$@"\n`, 0o755);
|
|
@@ -225,26 +179,18 @@ export function uninstallCodexSupervisor(): void {
|
|
|
225
179
|
if (existsSync(codexSupervisorLink())) rmSync(codexSupervisorLink(), { force: true });
|
|
226
180
|
}
|
|
227
181
|
|
|
228
|
-
|
|
229
|
-
// The hooks evaluate only at turn boundaries; one long agentic turn can burn a
|
|
230
|
-
// window from healthy to depleted with zero boundaries (2026-07-10 incident).
|
|
231
|
-
// A timer closes that gap: launchd on macOS, a systemd user timer on Linux.
|
|
232
|
-
|
|
233
|
-
const CHECK_INTERVAL_S = 180;
|
|
182
|
+
const CHECK_INTERVAL_S = 60;
|
|
234
183
|
const LAUNCHD_LABEL = "com.tokenmaxxing.check";
|
|
235
184
|
|
|
236
185
|
function launchdPlist(): string {
|
|
237
186
|
return join(paths.launchdAgentsDir, `${LAUNCHD_LABEL}.plist`);
|
|
238
187
|
}
|
|
239
188
|
|
|
240
|
-
/** `gui/<uid>` launchd domain, or null when the platform has no getuid. */
|
|
241
189
|
function launchdDomain(): string | null {
|
|
242
190
|
const uid = process.getuid?.();
|
|
243
191
|
return uid == null ? null : `gui/${uid}`;
|
|
244
192
|
}
|
|
245
193
|
|
|
246
|
-
/** launchctl/systemctl may be absent (spawnSync throws ENOENT) or hang on a
|
|
247
|
-
* dead session bus (ssh without lingering) - degrade, never crash or block. */
|
|
248
194
|
function run(cmd: string[]): boolean {
|
|
249
195
|
try {
|
|
250
196
|
return Bun.spawnSync(cmd, { stdout: "ignore", stderr: "ignore", timeout: 10_000 }).exitCode === 0;
|
|
@@ -253,12 +199,7 @@ function run(cmd: string[]): boolean {
|
|
|
253
199
|
}
|
|
254
200
|
}
|
|
255
201
|
|
|
256
|
-
/** Install + activate the periodic check job. False means the unit files are in
|
|
257
|
-
* place but activation failed (e.g. systemd user session absent over ssh) -
|
|
258
|
-
* the caller prints the manual activation step. */
|
|
259
202
|
function installCheckTimer(): boolean {
|
|
260
|
-
// Nix module owns the timer (TOKENMAXXING_SKIP_TIMER): do not write a second
|
|
261
|
-
// unit that would double-fire or clobber the declarative one.
|
|
262
203
|
if (skipImperativeTimer()) return true;
|
|
263
204
|
|
|
264
205
|
if (process.platform === "darwin") {
|
|
@@ -270,7 +211,7 @@ function installCheckTimer(): boolean {
|
|
|
270
211
|
<plist version="1.0">
|
|
271
212
|
<dict>
|
|
272
213
|
<key>Label</key><string>${LAUNCHD_LABEL}</string>
|
|
273
|
-
<key>ProgramArguments</key><array><string>${escape(installedBin())}</string><string>check</string></array>
|
|
214
|
+
<key>ProgramArguments</key><array><string>${escape(installedBin())}</string><string>check</string><string>--if-due</string></array>
|
|
274
215
|
<key>StartInterval</key><integer>${CHECK_INTERVAL_S}</integer>
|
|
275
216
|
<key>StandardOutPath</key><string>/dev/null</string>
|
|
276
217
|
<key>StandardErrorPath</key><string>${escape(join(paths.home, "check.stderr.log"))}</string>
|
|
@@ -281,13 +222,11 @@ function installCheckTimer(): boolean {
|
|
|
281
222
|
);
|
|
282
223
|
const domain = launchdDomain();
|
|
283
224
|
if (domain == null) return false;
|
|
284
|
-
run(["launchctl", "bootout", `${domain}/${LAUNCHD_LABEL}`]);
|
|
285
|
-
// bootstrap can lose a benign race with an in-flight bootout; loaded is loaded.
|
|
225
|
+
run(["launchctl", "bootout", `${domain}/${LAUNCHD_LABEL}`]);
|
|
286
226
|
return run(["launchctl", "bootstrap", domain, plist]) || checkTimerHealthy();
|
|
287
227
|
}
|
|
288
228
|
|
|
289
|
-
|
|
290
|
-
const exec = `"${installedBin().replaceAll("%", "%%")}" check`;
|
|
229
|
+
const exec = `"${installedBin().replaceAll("%", "%%")}" check --if-due`;
|
|
291
230
|
writeFileAtomic(
|
|
292
231
|
join(paths.systemdUserDir, "tokenmaxxing-check.service"),
|
|
293
232
|
`[Unit]
|
|
@@ -307,7 +246,7 @@ Description=tokenmaxxing periodic account-switch check
|
|
|
307
246
|
[Timer]
|
|
308
247
|
OnBootSec=60
|
|
309
248
|
OnUnitActiveSec=${CHECK_INTERVAL_S}
|
|
310
|
-
AccuracySec=
|
|
249
|
+
AccuracySec=5
|
|
311
250
|
|
|
312
251
|
[Install]
|
|
313
252
|
WantedBy=timers.target
|
|
@@ -320,7 +259,6 @@ WantedBy=timers.target
|
|
|
320
259
|
);
|
|
321
260
|
}
|
|
322
261
|
|
|
323
|
-
/** The manual activation command for an unloaded timer, per platform. */
|
|
324
262
|
export function timerActivationHint(): string {
|
|
325
263
|
if (process.platform === "darwin") {
|
|
326
264
|
return `launchctl bootstrap gui/$(id -u) ${launchdPlist()}`;
|
|
@@ -328,10 +266,7 @@ export function timerActivationHint(): string {
|
|
|
328
266
|
return "systemctl --user daemon-reload && systemctl --user enable --now tokenmaxxing-check.timer";
|
|
329
267
|
}
|
|
330
268
|
|
|
331
|
-
/** True when the timer unit exists AND the service manager reports it loaded. */
|
|
332
269
|
export function checkTimerHealthy(): boolean {
|
|
333
|
-
// Declarative Nix timer: init wrote nothing; doctor must not demand the
|
|
334
|
-
// imperative unit.
|
|
335
270
|
if (skipImperativeTimer()) return true;
|
|
336
271
|
if (process.platform === "darwin") {
|
|
337
272
|
const domain = launchdDomain();
|
|
@@ -343,7 +278,6 @@ export function checkTimerHealthy(): boolean {
|
|
|
343
278
|
);
|
|
344
279
|
}
|
|
345
280
|
|
|
346
|
-
/** The manual deactivation command for a still-loaded timer, per platform. */
|
|
347
281
|
export function timerDeactivationHint(): string {
|
|
348
282
|
if (process.platform === "darwin") {
|
|
349
283
|
return `launchctl bootout gui/$(id -u)/${LAUNCHD_LABEL}`;
|
|
@@ -351,10 +285,6 @@ export function timerDeactivationHint(): string {
|
|
|
351
285
|
return "systemctl --user disable --now tokenmaxxing-check.timer";
|
|
352
286
|
}
|
|
353
287
|
|
|
354
|
-
/** Is the launchd check job loaded? Exit contract verified on this machine
|
|
355
|
-
* (macOS 26, 2026-07-20): `launchctl print` exits 0 for a loaded job and 113
|
|
356
|
-
* for a missing one. Anything else - including a spawn failure or timeout -
|
|
357
|
-
* is "unavailable": an unanswerable probe must never read as "not loaded". */
|
|
358
288
|
function launchdJobLoaded(): "loaded" | "not-loaded" | "unavailable" {
|
|
359
289
|
const domain = launchdDomain();
|
|
360
290
|
if (domain == null) return "unavailable";
|
|
@@ -367,15 +297,6 @@ function launchdJobLoaded(): "loaded" | "not-loaded" | "unavailable" {
|
|
|
367
297
|
}
|
|
368
298
|
}
|
|
369
299
|
|
|
370
|
-
/** Is the systemd check timer active? Classification goes by the state
|
|
371
|
-
* string, never the exit code: systemctl(1) documents only "0 if at least
|
|
372
|
-
* one is active, non-zero otherwise" for is-active, but guarantees "unless
|
|
373
|
-
* --quiet is specified, this will also print the current unit state to
|
|
374
|
-
* standard output" (verified against the systemd manpage 2026-07-20). The
|
|
375
|
-
* failure mode is container-verified (ubuntu:24.04 systemd, no user bus,
|
|
376
|
-
* 2026-07-20): a dead session bus prints NOTHING to stdout ("Failed to
|
|
377
|
-
* connect to bus" goes to stderr, exit 1), so an empty or unrecognized
|
|
378
|
-
* stdout is "unavailable" - "cannot ask" never reads as "inactive". */
|
|
379
300
|
function systemdTimerActive(): "active" | "not-active" | "unavailable" {
|
|
380
301
|
try {
|
|
381
302
|
const proc = Bun.spawnSync(["systemctl", "--user", "is-active", "tokenmaxxing-check.timer"], { stdout: "pipe", stderr: "ignore", timeout: 10_000 });
|
|
@@ -388,14 +309,7 @@ function systemdTimerActive(): "active" | "not-active" | "unavailable" {
|
|
|
388
309
|
}
|
|
389
310
|
}
|
|
390
311
|
|
|
391
|
-
/** True when the job is verifiably no longer loaded. A swallowed bootout
|
|
392
|
-
* failure once meant a half-uninstalled state kept firing `tokenmaxxing
|
|
393
|
-
* check` every 180s against a package that may be gone, silently
|
|
394
|
-
* (closing-review catch): deactivation is checked, not assumed - a job seen
|
|
395
|
-
* loaded must deactivate successfully, and an unanswerable probe (service
|
|
396
|
-
* manager unusable) reports false rather than pretending it is gone. */
|
|
397
312
|
function uninstallCheckTimer(): boolean {
|
|
398
|
-
// Nix owns the timer: do not bootout/disable the declarative unit.
|
|
399
313
|
if (skipImperativeTimer()) return true;
|
|
400
314
|
if (process.platform === "darwin") {
|
|
401
315
|
const domain = launchdDomain();
|
|
@@ -412,8 +326,6 @@ function uninstallCheckTimer(): boolean {
|
|
|
412
326
|
return deactivated;
|
|
413
327
|
}
|
|
414
328
|
|
|
415
|
-
/** The rc file of the user's login shell, or null when the shell is unknown.
|
|
416
|
-
* Overridable for hermetic tests. */
|
|
417
329
|
export function shellRcPath(): string | null {
|
|
418
330
|
const override = process.env.TOKENMAXXING_SHELL_RC;
|
|
419
331
|
if (override && override.length > 0) return override;
|
|
@@ -425,35 +337,18 @@ export function shellRcPath(): string | null {
|
|
|
425
337
|
|
|
426
338
|
const PATH_LINE_MARK = "# tokenmaxxing PATH";
|
|
427
339
|
|
|
428
|
-
/** Idempotently append the supervisor-bin PATH line to `rc` (created if absent).
|
|
429
|
-
* A pre-existing hand-added line for the bin dir also counts as present.
|
|
430
|
-
* Returns `"skipped"` when the resolved target is immutable (nix-store /
|
|
431
|
-
* non-writable / TOKENMAXXING_SKIP_SHELL_RC) so callers can print guidance
|
|
432
|
-
* instead of surfacing EACCES. */
|
|
433
340
|
export function ensurePathInRc(rc: string): "added" | "present" | "skipped" {
|
|
434
341
|
const dir = paths.binDir.startsWith(`${HOME}/`) ? `$HOME${paths.binDir.slice(HOME.length)}` : paths.binDir;
|
|
435
|
-
// Write through a dotfile-managed symlink, never over it: writeFileAtomic
|
|
436
|
-
// renames a sibling temp over its target, which would replace the link with
|
|
437
|
-
// a plain file while the dotfiles target keeps the stale line (PR #36
|
|
438
|
-
// second-round catch).
|
|
439
342
|
const target = existsSync(rc) ? realpathSync(rc) : rc;
|
|
440
343
|
const current = existsSync(target) ? readFileSync(target, "utf8") : "";
|
|
441
344
|
const isCurrentExport = (line: string) => line.includes(`${paths.binDir}:`) || line.includes(`${dir}:`);
|
|
442
345
|
const lines = current === "" ? [] : current.split("\n");
|
|
443
|
-
// A marked line for a DIFFERENT dir is removed even when the current dir is
|
|
444
|
-
// also exported: PATH prepends stack, so a stale marked line BELOW the
|
|
445
|
-
// current one would still win resolution - the recursion incident's exact
|
|
446
|
-
// vector (closing-review catch + PR #36 second-round catch). A bare marker
|
|
447
|
-
// check alone once kept such a line alive after a TOKENMAXXING_HOME
|
|
448
|
-
// relocation.
|
|
449
346
|
const kept = lines.filter((line) => isCurrentExport(line) || !line.includes(PATH_LINE_MARK));
|
|
450
347
|
if (kept.length !== lines.length) {
|
|
451
348
|
if (cannotWriteRcTarget(target)) return "skipped";
|
|
452
349
|
const body = kept.join("\n");
|
|
453
350
|
const sep0 = body === "" || body.endsWith("\n") ? "" : "\n";
|
|
454
351
|
const addition = kept.some(isCurrentExport) ? "" : `export PATH="${dir}:$PATH" ${PATH_LINE_MARK}\n`;
|
|
455
|
-
// preserve the rc's own mode: writeFileAtomic defaults to 0600, which
|
|
456
|
-
// would silently tighten a normally 0644 shell rc (PR #36 review catch)
|
|
457
352
|
try {
|
|
458
353
|
writeFileAtomic(target, `${body}${sep0}${addition}`, statSync(target).mode & 0o777);
|
|
459
354
|
} catch (e) {
|
|
@@ -475,19 +370,12 @@ export function ensurePathInRc(rc: string): "added" | "present" | "skipped" {
|
|
|
475
370
|
}
|
|
476
371
|
|
|
477
372
|
const ShellShadowerSchema = z.object({
|
|
478
|
-
/** shadow: a `claude` alias/function hides the wrapper entirely.
|
|
479
|
-
* bypass: another alias (e.g. `cc`, `cco`) hardcodes an absolute path to a
|
|
480
|
-
* claude binary, so launches through it skip supervision. */
|
|
481
373
|
kind: z.enum(["shadow", "bypass"]),
|
|
482
374
|
name: z.string(),
|
|
483
375
|
line: z.string(),
|
|
484
376
|
});
|
|
485
377
|
export type ShellShadower = z.infer<typeof ShellShadowerSchema>;
|
|
486
378
|
|
|
487
|
-
/** Scan shell-rc text for aliases/functions that shadow `claude` or hardcode a
|
|
488
|
-
* path to a claude binary. Aliases whose body starts with plain `claude` are
|
|
489
|
-
* fine (they expand through PATH into the wrapper); an absolute path is not.
|
|
490
|
-
* Lines referencing the wrapper itself are deliberate and skipped. */
|
|
491
379
|
export function findClaudeShadowers(rcText: string): ShellShadower[] {
|
|
492
380
|
const out: ShellShadower[] = [];
|
|
493
381
|
const absClaude = /(?:^|[\s"'=])(\/[^\s"']*\/claude)(?:[\s"']|$)/;
|
|
@@ -510,17 +398,8 @@ export function findClaudeShadowers(rcText: string): ShellShadower[] {
|
|
|
510
398
|
return out;
|
|
511
399
|
}
|
|
512
400
|
|
|
513
|
-
/** Remove ONLY the marker-tagged PATH line this tool added; a hand-added
|
|
514
|
-
* PATH entry without the marker is the user's own. A stale `# tokenmaxxing
|
|
515
|
-
* PATH` line pointing at an emptied binDir is exactly how the supervisor
|
|
516
|
-
* recursion incident started (.memory/supervisor-recursion-guards.md), so
|
|
517
|
-
* uninstall must not leave one behind (closing-review catch).
|
|
518
|
-
* Returns true when a line was removed. Soft-skips (returns false, no throw)
|
|
519
|
-
* when the resolved target is immutable. */
|
|
520
401
|
export function removePathFromRc(rc: string): boolean {
|
|
521
402
|
if (!existsSync(rc)) return false;
|
|
522
|
-
// same symlink + mode treatment as ensurePathInRc: write through a
|
|
523
|
-
// dotfile-managed link, keep the rc's own permissions (PR #36 catches)
|
|
524
403
|
const target = realpathSync(rc);
|
|
525
404
|
const lines = readFileSync(target, "utf8").split("\n");
|
|
526
405
|
const kept = lines.filter((line) => !line.includes(PATH_LINE_MARK));
|
package/src/lib/keychain.ts
CHANGED
|
@@ -1,42 +1,19 @@
|
|
|
1
|
-
// macOS login-keychain generic-password I/O, ps-safe. The darwin backend of
|
|
2
|
-
// credstore.ts - construct targets there, not here.
|
|
3
|
-
// READ : `security find-generic-password -w` → secret only ever in stdout.
|
|
4
|
-
// WRITE : pipe an `add-generic-password -U ... -w <secret>` line into `security -i`
|
|
5
|
-
// over STDIN - the secret never appears in any process's argv (verified).
|
|
6
|
-
// The service/account are non-secret and may sit in argv.
|
|
7
|
-
|
|
8
1
|
import { z } from "zod";
|
|
9
2
|
|
|
10
3
|
const KeychainTargetSchema = z.object({ service: z.string(), account: z.string() });
|
|
11
4
|
export type KeychainTarget = z.infer<typeof KeychainTargetSchema>;
|
|
12
5
|
|
|
13
6
|
const SECURITY = "/usr/bin/security";
|
|
14
|
-
// security(1) interactive mode has a 4096-byte line buffer. The gate below
|
|
15
|
-
// measures the ASSEMBLED line against this (with margin), never the raw
|
|
16
|
-
// secret: quoteDouble expansion (one byte per `"`/`\` when the blob contains
|
|
17
|
-
// an apostrophe) once pushed a raw-length-passing line over the buffer, which
|
|
18
|
-
// SPLITS the line - the write exits 1 ("unknown command" for the spilled
|
|
19
|
-
// remainder) AND the item is left holding a TRUNCATED secret (empirically
|
|
20
|
-
// verified 2026-07-20: a 3667-byte secret assembling to a 5574-byte line
|
|
21
|
-
// corrupted the item to its first 2682 bytes before the error surfaced).
|
|
22
7
|
const INTERACTIVE_MAX_LINE = 4000;
|
|
23
8
|
|
|
24
|
-
/** Double-quote + backslash-escape for security(1)'s interactive tokenizer. */
|
|
25
9
|
function quoteDouble(s: string): string {
|
|
26
10
|
return `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
27
11
|
}
|
|
28
12
|
|
|
29
|
-
/** Wrap a value for the `-i` line. Single-quote when possible (blob has no `'`). */
|
|
30
13
|
function quoteValue(s: string): string {
|
|
31
14
|
return s.includes("'") ? quoteDouble(s) : `'${s}'`;
|
|
32
15
|
}
|
|
33
16
|
|
|
34
|
-
/** Read an item's password blob. Returns null ONLY when the item verifiably
|
|
35
|
-
* does not exist (exit 44, errSecItemNotFound - empirically pinned on this
|
|
36
|
-
* Mac 2026-07-20). Every other failure THROWS: a locked keychain or denied
|
|
37
|
-
* ACL prompt reading as "absent" silently disarmed every fail-closed
|
|
38
|
-
* live-owner guard and the mandatory pre-swap harvest, all of which key off
|
|
39
|
-
* a null read (closing-review catch). stderr never carries the secret. */
|
|
40
17
|
export async function readItem(t: KeychainTarget): Promise<string | null> {
|
|
41
18
|
const p = Bun.spawn([SECURITY, "find-generic-password", "-s", t.service, "-a", t.account, "-w"], {
|
|
42
19
|
stdout: "pipe",
|
|
@@ -48,11 +25,9 @@ export async function readItem(t: KeychainTarget): Promise<string | null> {
|
|
|
48
25
|
if (p.exitCode !== 0) {
|
|
49
26
|
throw new Error(`keychain read failed (exit ${p.exitCode}): ${err.trim().slice(0, 200)} - a locked keychain or denied ACL must fail loudly, never read as absent`);
|
|
50
27
|
}
|
|
51
|
-
return out.replace(/\n$/, "");
|
|
28
|
+
return out.replace(/\n$/, "");
|
|
52
29
|
}
|
|
53
30
|
|
|
54
|
-
/** The full `security -i` line for a write; its LENGTH is the argv-fallback
|
|
55
|
-
* gate, so it is assembled once, here. */
|
|
56
31
|
function interactiveLine(t: KeychainTarget, secret: string): string {
|
|
57
32
|
return (
|
|
58
33
|
`add-generic-password -U -a ${quoteDouble(t.account)} -s ${quoteDouble(t.service)} ` +
|
|
@@ -60,8 +35,6 @@ function interactiveLine(t: KeychainTarget, secret: string): string {
|
|
|
60
35
|
);
|
|
61
36
|
}
|
|
62
37
|
|
|
63
|
-
/** ps-safe write: the command line + secret arrive on stdin, never in argv.
|
|
64
|
-
* The caller guarantees the encoded line fits the interactive buffer. */
|
|
65
38
|
async function writeViaInteractive(encodedLine: Uint8Array): Promise<void> {
|
|
66
39
|
const p = Bun.spawn([SECURITY, "-i"], {
|
|
67
40
|
stdin: encodedLine,
|
|
@@ -73,9 +46,6 @@ async function writeViaInteractive(encodedLine: Uint8Array): Promise<void> {
|
|
|
73
46
|
if (p.exitCode !== 0) throw new Error(`keychain write (interactive) failed (exit ${p.exitCode}): ${err.trim()}`);
|
|
74
47
|
}
|
|
75
48
|
|
|
76
|
-
/** Fallback for blobs over the interactive line limit. The secret is briefly
|
|
77
|
-
* visible in `ps` for this one short-lived process - used only when the payload
|
|
78
|
-
* (e.g. a live blob with lots of MCP OAuth state) exceeds ~4KB. */
|
|
79
49
|
async function writeViaArgv(t: KeychainTarget, secret: string): Promise<void> {
|
|
80
50
|
const p = Bun.spawn([SECURITY, "add-generic-password", "-U", "-a", t.account, "-s", t.service, "-w", secret], {
|
|
81
51
|
stdout: "ignore",
|
|
@@ -86,20 +56,12 @@ async function writeViaArgv(t: KeychainTarget, secret: string): Promise<void> {
|
|
|
86
56
|
if (p.exitCode !== 0) throw new Error(`keychain write (argv) failed (exit ${p.exitCode}): ${err.trim()}`);
|
|
87
57
|
}
|
|
88
58
|
|
|
89
|
-
/** Create-or-update an item (`-U`) with `secret` as its password. Prefers the
|
|
90
|
-
* ps-safe stdin path; falls back to argv when the ASSEMBLED interactive line
|
|
91
|
-
* would exceed the buffer (see INTERACTIVE_MAX_LINE - gating on the raw
|
|
92
|
-
* secret length let quote expansion corrupt the item). Throws on failure. */
|
|
93
59
|
export async function writeItem(t: KeychainTarget, secret: string): Promise<void> {
|
|
94
|
-
// measured in UTF-8 BYTES, the unit the stdin buffer actually consumes:
|
|
95
|
-
// String.length counts UTF-16 code units and under-counts multibyte
|
|
96
|
-
// characters (cubic review catch, PR #35).
|
|
97
60
|
const encoded = new TextEncoder().encode(interactiveLine(t, secret));
|
|
98
61
|
if (encoded.length <= INTERACTIVE_MAX_LINE) return writeViaInteractive(encoded);
|
|
99
62
|
return writeViaArgv(t, secret);
|
|
100
63
|
}
|
|
101
64
|
|
|
102
|
-
/** Delete an item. Returns true if it existed and was removed. */
|
|
103
65
|
export async function deleteItem(t: KeychainTarget): Promise<boolean> {
|
|
104
66
|
const p = Bun.spawn([SECURITY, "delete-generic-password", "-s", t.service, "-a", t.account], {
|
|
105
67
|
stdout: "ignore",
|
package/src/lib/lock.ts
CHANGED
|
@@ -1,34 +1,17 @@
|
|
|
1
|
-
// Cross-process advisory locking via flock(2) through bun:ffi on a real file
|
|
2
|
-
// descriptor (macOS ships no flock(1) binary, and one codepath serves both
|
|
3
|
-
// platforms). The lock is released when we close the fd (explicitly or on
|
|
4
|
-
// process exit).
|
|
5
|
-
//
|
|
6
|
-
// The acquire is NON-BLOCKING (LOCK_EX|LOCK_NB) with an async retry loop: a
|
|
7
|
-
// blocking LOCK_EX from this runtime freezes the whole event loop.
|
|
8
|
-
// EWOULDBLOCK is told apart from real failures via errno, so a bad fd still
|
|
9
|
-
// fails fast instead of spinning.
|
|
10
|
-
|
|
11
1
|
import { closeSync, mkdirSync, openSync } from "node:fs";
|
|
12
2
|
import { dirname } from "node:path";
|
|
13
3
|
import { dlopen, FFIType, read } from "bun:ffi";
|
|
14
4
|
import { delay } from "es-toolkit";
|
|
15
5
|
|
|
16
|
-
// sys/file.h (identical on darwin + linux): LOCK_SH=1 LOCK_EX=2 LOCK_NB=4 LOCK_UN=8
|
|
17
6
|
const LOCK_EX = 2;
|
|
18
7
|
const LOCK_NB = 4;
|
|
19
8
|
const LOCK_UN = 8;
|
|
20
|
-
// errno.h: EWOULDBLOCK/EAGAIN is 35 on darwin, 11 on linux; EINTR is 4 on both.
|
|
21
9
|
const EAGAIN = process.platform === "darwin" ? 35 : 11;
|
|
22
10
|
const EINTR = 4;
|
|
23
11
|
const RETRY_MS = 75;
|
|
24
12
|
|
|
25
13
|
const FLOCK_DEF = { flock: { args: [FFIType.i32, FFIType.i32], returns: FFIType.i32 } } as const;
|
|
26
14
|
|
|
27
|
-
// darwin: flock(2) + __error live in libSystem; the bare name resolves via the
|
|
28
|
-
// dyld shared cache even though no physical .dylib exists on disk (verified
|
|
29
|
-
// 2026-07-08). linux: glibc's libc.so.6 + __errno_location (verified
|
|
30
|
-
// in-container 2026-07-09, arm64). Exactly one verified path per platform -
|
|
31
|
-
// a broken environment fails fast instead of falling through a guess list.
|
|
32
15
|
function loadLibc() {
|
|
33
16
|
if (process.platform === "darwin") {
|
|
34
17
|
const lib = dlopen("libSystem.B.dylib", { ...FLOCK_DEF, __error: { args: [], returns: FFIType.ptr } });
|
|
@@ -50,12 +33,6 @@ function currentErrno(): number {
|
|
|
50
33
|
return p == null ? -1 : read.i32(p, 0);
|
|
51
34
|
}
|
|
52
35
|
|
|
53
|
-
/**
|
|
54
|
-
* Acquire an exclusive advisory lock on `lockPath`, waiting (without blocking
|
|
55
|
-
* the event loop) until available. Returns a handle whose release() drops the
|
|
56
|
-
* lock. The lock is fd-scoped, so racing hooks in separate processes serialize
|
|
57
|
-
* and same-process actors queue on the retry loop.
|
|
58
|
-
*/
|
|
59
36
|
export async function acquireLock(lockPath: string): Promise<{ release: () => void }> {
|
|
60
37
|
mkdirSync(dirname(lockPath), { recursive: true });
|
|
61
38
|
const fd = openSync(lockPath, "a", 0o600);
|
|
@@ -85,7 +62,6 @@ export async function acquireLock(lockPath: string): Promise<{ release: () => vo
|
|
|
85
62
|
return { release };
|
|
86
63
|
}
|
|
87
64
|
|
|
88
|
-
/** Run `fn` while holding `lockPath`; always releases, even on throw. */
|
|
89
65
|
export async function withLock<T>(lockPath: string, fn: () => Promise<T> | T): Promise<T> {
|
|
90
66
|
const held = await acquireLock(lockPath);
|
|
91
67
|
try {
|
package/src/lib/log.ts
CHANGED
|
@@ -1,6 +1,3 @@
|
|
|
1
|
-
// Append-only logging. NEVER logs secret material - callers must pass only
|
|
2
|
-
// non-secret context (account uuids/emails, percentages, status strings).
|
|
3
|
-
|
|
4
1
|
import { appendFileSync, existsSync, mkdirSync, renameSync, statSync } from "node:fs";
|
|
5
2
|
import { dirname } from "node:path";
|
|
6
3
|
import { z } from "zod";
|
|
@@ -8,19 +5,14 @@ import { paths } from "./paths.ts";
|
|
|
8
5
|
|
|
9
6
|
const LOG_MAX_BYTES = 5_000_000;
|
|
10
7
|
|
|
11
|
-
/** Redact anything that looks like a token so an accidental pass-through can't leak. */
|
|
12
8
|
function redact(s: string): string {
|
|
13
9
|
return s
|
|
14
|
-
// JWT-ish / long opaque tokens
|
|
15
10
|
.replace(/\b(sk-ant-[A-Za-z0-9._-]{6,})/g, "sk-ant-***")
|
|
16
11
|
.replace(/\b([A-Za-z0-9_-]{40,})\b/g, (m) => `${m.slice(0, 4)}...(${m.length})`);
|
|
17
12
|
}
|
|
18
13
|
|
|
19
14
|
let echo: ((input: { event: string; parts: string }) => void) | null = null;
|
|
20
15
|
|
|
21
|
-
/** Tee every subsequent log() line to a terminal printer. Off by default
|
|
22
|
-
* (hooks and the statusline own their stdout protocol). The printer receives
|
|
23
|
-
* the same redacted parts the file line gets. */
|
|
24
16
|
export function setLogEcho(input: { printer: (input: { event: string; parts: string }) => void }): void {
|
|
25
17
|
echo = input.printer;
|
|
26
18
|
}
|
|
@@ -35,20 +27,14 @@ export function log(event: string, fields: Record<string, unknown> = {}): void {
|
|
|
35
27
|
})
|
|
36
28
|
.join(" ");
|
|
37
29
|
mkdirSync(dirname(paths.logFile), { recursive: true });
|
|
38
|
-
// Rotation cap: the check timer logs every 180s, so an uncapped
|
|
39
|
-
// append-only file grows forever on a live install. One .old generation
|
|
40
|
-
// bounds total disk at ~2x the cap; older history is disposable diagnostics.
|
|
41
30
|
if (existsSync(paths.logFile) && statSync(paths.logFile).size > LOG_MAX_BYTES) {
|
|
42
31
|
renameSync(paths.logFile, `${paths.logFile}.old`);
|
|
43
32
|
}
|
|
44
33
|
appendFileSync(paths.logFile, `${new Date().toISOString()} ${event} ${line}\n`);
|
|
45
34
|
} catch {
|
|
46
|
-
// logging must never throw into a hook / supervisor path
|
|
47
35
|
}
|
|
48
|
-
// separate from the file sink: an unwritable log file must not also silence the echo.
|
|
49
36
|
try {
|
|
50
37
|
echo?.({ event, parts: line });
|
|
51
38
|
} catch {
|
|
52
|
-
// the echo printer must never throw into a caller path
|
|
53
39
|
}
|
|
54
40
|
}
|
package/src/lib/oauth.ts
CHANGED
|
@@ -1,19 +1,7 @@
|
|
|
1
|
-
// The two OAuth calls tokenmaxxing makes:
|
|
2
|
-
// 1. the refresh_token grant that turns a parked account's (possibly expired)
|
|
3
|
-
// access token into a fresh one before we install it. Verified against
|
|
4
|
-
// Claude Code 2.1.204: POST platform.claude.com, JSON body, public PKCE
|
|
5
|
-
// client (no secret), no auth/beta headers on this call.
|
|
6
|
-
// 2. the roles lookup that reports which org a token ACTUALLY belongs to.
|
|
7
|
-
// Endpoint string extracted from the Claude Code 2.1.205 binary; verified
|
|
8
|
-
// live to answer HTTP 200 with plain Bearer auth (with or without the
|
|
9
|
-
// oauth beta header - we send it to match the CLI's OAuth convention).
|
|
10
|
-
|
|
11
1
|
import { z } from "zod";
|
|
12
2
|
import { http, safeErrorDetail } from "./http.ts";
|
|
13
3
|
import { RefreshResponseSchema, RolesResponseSchema, type OAuthCreds, type RolesResponse } from "./types.ts";
|
|
14
4
|
|
|
15
|
-
// zod-parsed like every other env override (repo rule): a set-but-EMPTY value
|
|
16
|
-
// parses to undefined and the default applies, instead of posting to "".
|
|
17
5
|
const EnvOverrideSchema = z.string().min(1).optional().catch(undefined);
|
|
18
6
|
const TOKEN_URL = EnvOverrideSchema.parse(process.env.TOKENMAXXING_OAUTH_TOKEN_URL) ?? "https://platform.claude.com/v1/oauth/token";
|
|
19
7
|
const CLIENT_ID = EnvOverrideSchema.parse(process.env.TOKENMAXXING_OAUTH_CLIENT_ID) ?? "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
|
|
@@ -27,7 +15,6 @@ const DEFAULT_SCOPES = [
|
|
|
27
15
|
"user:file_upload",
|
|
28
16
|
];
|
|
29
17
|
|
|
30
|
-
/** Refresh token is dead / revoked - caller should mark needs_reauth and skip. */
|
|
31
18
|
export class InvalidGrantError extends Error {
|
|
32
19
|
constructor(public readonly detail: string) {
|
|
33
20
|
super(`invalid_grant: ${detail}`);
|
|
@@ -35,12 +22,6 @@ export class InvalidGrantError extends Error {
|
|
|
35
22
|
}
|
|
36
23
|
}
|
|
37
24
|
|
|
38
|
-
/**
|
|
39
|
-
* Exchange `creds.refreshToken` for a fresh access token. Returns a NEW OAuthCreds
|
|
40
|
-
* with the fresh access token, the rotated refresh token (or the old one if the
|
|
41
|
-
* server omitted it), and recomputed absolute expiries. Non-token fields are
|
|
42
|
-
* preserved. Throws InvalidGrantError on a dead refresh token.
|
|
43
|
-
*/
|
|
44
25
|
export async function refreshCredential(creds: OAuthCreds, now = Date.now()): Promise<OAuthCreds> {
|
|
45
26
|
const scope = (creds.scopes?.length ? creds.scopes : DEFAULT_SCOPES).join(" ");
|
|
46
27
|
const body = {
|
|
@@ -62,9 +43,6 @@ export async function refreshCredential(creds: OAuthCreds, now = Date.now()): Pr
|
|
|
62
43
|
|
|
63
44
|
const text = await res.text();
|
|
64
45
|
if (!res.ok) {
|
|
65
|
-
// invalid_grant → dead refresh token; anything else is transient/unknown.
|
|
66
|
-
// Bodies go through the safeErrorDetail allowlist, never raw: a token
|
|
67
|
-
// endpoint's failure body can echo request material.
|
|
68
46
|
if (res.status === 400 && /invalid_grant/.test(text)) {
|
|
69
47
|
throw new InvalidGrantError(safeErrorDetail({ text }));
|
|
70
48
|
}
|
|
@@ -75,7 +53,6 @@ export async function refreshCredential(creds: OAuthCreds, now = Date.now()): Pr
|
|
|
75
53
|
try { return JSON.parse(text); } catch { return null; }
|
|
76
54
|
})());
|
|
77
55
|
if (!parsed.success) {
|
|
78
|
-
// A success-status body holds live tokens: never echo any of it.
|
|
79
56
|
throw new Error(`token endpoint returned an unrecognized body (${text.length} bytes, withheld)`);
|
|
80
57
|
}
|
|
81
58
|
const json = parsed.data;
|
|
@@ -86,7 +63,7 @@ export async function refreshCredential(creds: OAuthCreds, now = Date.now()): Pr
|
|
|
86
63
|
return {
|
|
87
64
|
...creds,
|
|
88
65
|
accessToken: json.access_token,
|
|
89
|
-
refreshToken: json.refresh_token ?? creds.refreshToken,
|
|
66
|
+
refreshToken: json.refresh_token ?? creds.refreshToken,
|
|
90
67
|
expiresAt: now + expiresIn * 1000,
|
|
91
68
|
refreshTokenExpiresAt:
|
|
92
69
|
refreshExpiresIn != null ? now + refreshExpiresIn * 1000 : creds.refreshTokenExpiresAt,
|
|
@@ -94,17 +71,10 @@ export async function refreshCredential(creds: OAuthCreds, now = Date.now()): Pr
|
|
|
94
71
|
};
|
|
95
72
|
}
|
|
96
73
|
|
|
97
|
-
/** True if the access token is within `skewMs` of expiry (or already expired). */
|
|
98
74
|
export function isAccessTokenExpiring(creds: OAuthCreds, skewMs = 120_000, now = Date.now()): boolean {
|
|
99
75
|
return !creds.expiresAt || creds.expiresAt - now <= skewMs;
|
|
100
76
|
}
|
|
101
77
|
|
|
102
|
-
/**
|
|
103
|
-
* Ask the API which org `accessToken` ACTUALLY belongs to. Stored labels
|
|
104
|
-
* (accounts.json, oauthAccount) can drift from the credential they describe;
|
|
105
|
-
* the token itself cannot lie. Requires a non-expired access token. Read-only:
|
|
106
|
-
* never rotates anything.
|
|
107
|
-
*/
|
|
108
78
|
export async function fetchTokenOrg(accessToken: string): Promise<RolesResponse> {
|
|
109
79
|
let res: Response;
|
|
110
80
|
try {
|