tokenmaxxing 0.1.0 → 0.2.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.
@@ -0,0 +1,40 @@
1
+ // Read/replace the `oauthAccount` identity object in ~/.claude.json.
2
+ // We touch ONLY oauthAccount and preserve every other key verbatim.
3
+
4
+ import { existsSync, readFileSync } from "node:fs";
5
+ import { z } from "zod";
6
+ import { paths } from "./paths.ts";
7
+ import { writeFileAtomic } from "./atomic.ts";
8
+ import { OAuthAccountSchema, type OAuthAccount } from "./types.ts";
9
+
10
+ export function readClaudeJson(): Record<string, unknown> {
11
+ if (!existsSync(paths.claudeJson)) return {};
12
+ return JSON.parse(readFileSync(paths.claudeJson, "utf8")) as Record<string, unknown>;
13
+ }
14
+
15
+ export function readOAuthAccount(): OAuthAccount | null {
16
+ const parsed = OAuthAccountSchema.safeParse(readClaudeJson()["oauthAccount"]);
17
+ return parsed.success ? parsed.data : null;
18
+ }
19
+
20
+ /** Detect API-key / helper auth mode (no poolable subscription credential). */
21
+ export function isApiKeyMode(): boolean {
22
+ if (process.env.ANTHROPIC_API_KEY || process.env.ANTHROPIC_AUTH_TOKEN) return true;
23
+ const j = readClaudeJson();
24
+ // apiKeyHelper is configured in settings, but a persisted flag may appear here too.
25
+ if (z.string().min(1).safeParse(j["apiKeyHelper"]).success) return true;
26
+ return false;
27
+ }
28
+
29
+ /**
30
+ * Atomically replace ONLY oauthAccount, preserving all other keys and their
31
+ * insertion order. Reads fresh from disk so we never clobber concurrent edits
32
+ * to unrelated keys with a stale in-memory copy.
33
+ */
34
+ export function swapOAuthAccount(next: OAuthAccount): void {
35
+ const j = existsSync(paths.claudeJson)
36
+ ? (JSON.parse(readFileSync(paths.claudeJson, "utf8")) as Record<string, unknown>)
37
+ : {};
38
+ j["oauthAccount"] = next;
39
+ writeFileAtomic(paths.claudeJson, JSON.stringify(j, null, 2) + "\n", 0o600);
40
+ }
@@ -0,0 +1,54 @@
1
+ // Best-effort interlock with Claude Code's OWN credential-refresh lock so our
2
+ // keychain write can't collide with a concurrent token refresh. Claude uses the
3
+ // npm `proper-lockfile` (mkdir-based) at <claudeDir>/.oauth_refresh.lock - the
4
+ // lock is the DIRECTORY itself. We mkdir it; on contention we wait briefly, then
5
+ // proceed anyway (our own flock already serializes tokenmaxxing swaps, and claude
6
+ // only refreshes near token expiry / on 401, rarely at the 95% usage trigger).
7
+
8
+ import { mkdirSync, rmdirSync, statSync } from "node:fs";
9
+ import { join } from "node:path";
10
+ import { paths } from "./paths.ts";
11
+ import { log } from "./log.ts";
12
+
13
+ const STALE_MS = 20_000; // proper-lockfile default stale is 10s; be generous
14
+
15
+ export async function withClaudeRefreshLock<T>(
16
+ fn: () => Promise<T> | T,
17
+ opts: { timeoutMs?: number } = {},
18
+ ): Promise<T> {
19
+ const lockDir = join(paths.claudeDir, ".oauth_refresh.lock");
20
+ const timeoutMs = opts.timeoutMs ?? 3000;
21
+ const start = Date.now();
22
+ let held = false;
23
+
24
+ while (Date.now() - start < timeoutMs) {
25
+ try {
26
+ mkdirSync(lockDir); // atomic; EEXIST means someone holds it
27
+ held = true;
28
+ break;
29
+ } catch (e) {
30
+ const code = (e as { code?: string }).code;
31
+ if (code === "EEXIST") {
32
+ try {
33
+ if (Date.now() - statSync(lockDir).mtimeMs > STALE_MS) {
34
+ rmdirSync(lockDir); // reclaim a stale lock
35
+ continue;
36
+ }
37
+ } catch { /* raced away */ }
38
+ await Bun.sleep(150);
39
+ continue;
40
+ }
41
+ // parent dir missing or other error → skip the interlock entirely
42
+ break;
43
+ }
44
+ }
45
+ if (!held) log("claudelock.proceed_without", {});
46
+
47
+ try {
48
+ return await fn();
49
+ } finally {
50
+ if (held) {
51
+ try { rmdirSync(lockDir); } catch { /* already gone */ }
52
+ }
53
+ }
54
+ }
@@ -0,0 +1,97 @@
1
+ // Platform credential store - ONE backend per platform, selected when a target
2
+ // is constructed:
3
+ // darwin : login-keychain generic-password via security(1) (keychain.ts)
4
+ // linux : plaintext files, matching claude's own Linux store
5
+ // (live = <configDir>/.credentials.json, mode 0600; verified 2.1.205:
6
+ // the Linux build has no keyring path at all)
7
+ // Parked backups live in the keychain (darwin) or ~/.config/tokenmaxxing/creds
8
+ // (linux). Call sites never branch on platform - they pass targets around.
9
+
10
+ import { mkdirSync, readFileSync, unlinkSync } from "node:fs";
11
+ import { dirname, join } from "node:path";
12
+ import { z } from "zod";
13
+ import { writeFileAtomic } from "./atomic.ts";
14
+ import * as kc from "./keychain.ts";
15
+ import { credDir, keychain as kcNames, namespacedCredService, paths } from "./paths.ts";
16
+ import { CredentialBlobSchema } from "./types.ts";
17
+
18
+ const CredTargetSchema = z.discriminatedUnion("kind", [
19
+ z.object({ kind: z.literal("keychain"), service: z.string(), account: z.string() }),
20
+ z.object({ kind: z.literal("file"), path: z.string() }),
21
+ ]);
22
+ export type CredTarget = z.infer<typeof CredTargetSchema>;
23
+
24
+ const darwin = process.platform === "darwin";
25
+
26
+ function isEnoent(e: unknown): boolean {
27
+ return (e as { code?: string }).code === "ENOENT";
28
+ }
29
+
30
+ /** Read a target's credential blob. Returns null if it does not exist. */
31
+ export async function readItem(t: CredTarget): Promise<string | null> {
32
+ if (t.kind === "keychain") return kc.readItem(t);
33
+ try {
34
+ return readFileSync(t.path, "utf8");
35
+ } catch (e) {
36
+ if (isEnoent(e)) return null;
37
+ throw e;
38
+ }
39
+ }
40
+
41
+ /** Create-or-update a target with `secret` as its blob. Throws on failure. */
42
+ export async function writeItem(t: CredTarget, secret: string): Promise<void> {
43
+ if (t.kind === "keychain") return kc.writeItem(t, secret);
44
+ mkdirSync(dirname(t.path), { recursive: true, mode: 0o700 });
45
+ writeFileAtomic(t.path, secret, 0o600);
46
+ }
47
+
48
+ /** Delete a target. Returns true if it existed and was removed. */
49
+ export async function deleteItem(t: CredTarget): Promise<boolean> {
50
+ if (t.kind === "keychain") return kc.deleteItem(t);
51
+ try {
52
+ unlinkSync(t.path);
53
+ return true;
54
+ } catch (e) {
55
+ if (isEnoent(e)) return false;
56
+ throw e;
57
+ }
58
+ }
59
+
60
+ /** The live default credential claude reads (`Claude Code-credentials` / $USER
61
+ * on darwin, `<credDir()>/.credentials.json` on linux). */
62
+ export function liveTarget(): CredTarget {
63
+ return darwin
64
+ ? { kind: "keychain", service: kcNames.service, account: kcNames.account }
65
+ : { kind: "file", path: join(credDir(), ".credentials.json") };
66
+ }
67
+
68
+ /** A parked account's backup (`tokenmaxxing-cred-<uuid8>` item / .json file). */
69
+ export function parkedTarget(itemName: string): CredTarget {
70
+ return darwin
71
+ ? { kind: "keychain", service: itemName, account: kcNames.account }
72
+ : { kind: "file", path: join(paths.credsDir, `${itemName}.json`) };
73
+ }
74
+
75
+ /** The credential claude uses under CLAUDE_CONFIG_DIR=`configDirRaw`: a
76
+ * hash-namespaced keychain service on darwin, the file inside the dir on linux.
77
+ * The dir string must be byte-stable on darwin (hash is over the RAW string). */
78
+ export function isolatedTarget(configDirRaw: string): CredTarget {
79
+ return darwin
80
+ ? { kind: "keychain", service: namespacedCredService(configDirRaw), account: kcNames.account }
81
+ : { kind: "file", path: join(configDirRaw, ".credentials.json") };
82
+ }
83
+
84
+ /** Reduce a full credential blob to just `{claudeAiOauth}` - what parked items
85
+ * store (small → always the ps-safe keychain write path on darwin). */
86
+ export function claudeAiOauthOnly(fullBlobRaw: string): string {
87
+ const b = CredentialBlobSchema.parse(JSON.parse(fullBlobRaw));
88
+ return JSON.stringify({ claudeAiOauth: b.claudeAiOauth });
89
+ }
90
+
91
+ /** Merge a fresh `claudeAiOauth` into the CURRENT live blob, preserving every
92
+ * sibling key (MCP OAuth state, etc.). Returns the full blob string to install. */
93
+ export function mergeIntoLive(currentLiveRaw: string | null, freshClaudeAiOauth: unknown): string {
94
+ const base = currentLiveRaw ? (JSON.parse(currentLiveRaw) as Record<string, unknown>) : {};
95
+ base["claudeAiOauth"] = freshClaudeAiOauth;
96
+ return JSON.stringify(base);
97
+ }
@@ -0,0 +1,149 @@
1
+ // Shared switch decision used by both the Stop hook and the SessionStart hook.
2
+ // Cheap pre-check off the lock; the authoritative re-check + swap under the flock.
3
+ //
4
+ // Two limit families are checked, both metered against the CURRENTLY-active org:
5
+ // 1. AGGREGATE windows (session=five_hour, week-all=seven_day) from statusLine.
6
+ // 2. PER-MODEL weekly cap (e.g. "week (Fable)") - only when the active model is
7
+ // capacity-constrained (config policy.switchModels). This isn't in statusLine,
8
+ // so we read it from `claude -p '/usage'`, TTL-cached (not every turn).
9
+ //
10
+ // The org guard is load-bearing: right after a respawn, usage.json still reflects
11
+ // the OLD account, so org != activeOrg → we correctly do nothing until fresh usage
12
+ // for the new account arrives.
13
+
14
+ import { z } from "zod";
15
+ import { withLock } from "./lock.ts";
16
+ import { paths } from "./paths.ts";
17
+ import { loadAccounts, loadConfig, loadUsage, loadModelUsage, saveAccounts, saveModelUsage } from "./state.ts";
18
+ import { readOAuthAccount } from "./claudejson.ts";
19
+ import { chooseAndSwap, performSwap } from "./swap.ts";
20
+ import { pickEarliestReset, usableAt } from "./picker.ts";
21
+ import { InvalidGrantError } from "./oauth.ts";
22
+ import { probeUsage } from "./usage.ts";
23
+ import { log } from "./log.ts";
24
+ import { AccountSchema, type Account, type Config, type ModelUsageState, type UsageState, type UsageWindow } from "./types.ts";
25
+
26
+ const SwapDecisionSchema = z.object({
27
+ swapped: z.boolean(),
28
+ account: AccountSchema.nullable(),
29
+ reason: z.string(),
30
+ /** set when every account is depleted: epoch ms the chosen account recovers. */
31
+ waitUntil: z.number().optional(),
32
+ });
33
+ export type SwapDecision = z.infer<typeof SwapDecisionSchema>;
34
+
35
+ /** Cold-start aggregate fallback when statusLine hasn't written usage.json yet. */
36
+ async function probeAggregate(org: string | null): Promise<UsageState | null> {
37
+ const full = await probeUsage();
38
+ if (!full) return null;
39
+ return { fiveHour: full.session, sevenDay: full.weekAll, org, ts: Date.now(), model: null };
40
+ }
41
+
42
+ /** Ensure a fresh-enough per-model cache for the active org; poll `/usage` if stale. */
43
+ async function ensurePerModel(cfg: Config, org: string | null): Promise<ModelUsageState | null> {
44
+ const cached = loadModelUsage();
45
+ const fresh = cached && cached.org === org && Date.now() - cached.ts < cfg.policy.usagePollTtlMs;
46
+ if (fresh) return cached;
47
+ const full = await probeUsage();
48
+ if (!full) return cached; // keep stale on poll failure
49
+ const state: ModelUsageState = { perModel: full.perModel, org, ts: Date.now() };
50
+ saveModelUsage(state);
51
+ return state;
52
+ }
53
+
54
+ function capForModel(mu: ModelUsageState | null, display: string): UsageWindow | undefined {
55
+ if (!mu) return undefined;
56
+ const key = Object.keys(mu.perModel).find((k) => k.toLowerCase() === display.toLowerCase());
57
+ return key ? mu.perModel[key] : undefined;
58
+ }
59
+
60
+ /** True if the active account is over the floor on ANY applicable limit. */
61
+ function isOver(u: UsageState | null, mu: ModelUsageState | null, org: string | null, cfg: Config, floor: number): boolean {
62
+ if (!u || !org || u.org !== org) return false;
63
+ if (u.fiveHour.usedPercentage >= floor || u.sevenDay.usedPercentage >= floor) return true;
64
+ const display = u.model?.display;
65
+ if (display && cfg.policy.switchModels.includes(display.toLowerCase()) && mu && mu.org === org) {
66
+ const cap = capForModel(mu, display);
67
+ if (cap && cap.usedPercentage >= floor) return true;
68
+ }
69
+ return false;
70
+ }
71
+
72
+ /** Does the active model warrant a per-model `/usage` poll? */
73
+ function needsPerModel(u: UsageState | null, cfg: Config): boolean {
74
+ const display = u?.model?.display;
75
+ return !!display && cfg.policy.switchModels.includes(display.toLowerCase());
76
+ }
77
+
78
+ export async function evaluateAndMaybeSwap(now = Date.now()): Promise<SwapDecision> {
79
+ const cfg = loadConfig();
80
+ const floor = cfg.threshold - cfg.policy.projectionMargin;
81
+ const activeOrg = readOAuthAccount()?.organizationUuid ?? null;
82
+
83
+ let usage = loadUsage();
84
+ if (!usage && activeOrg) usage = await probeAggregate(activeOrg);
85
+
86
+ // per-model poll (TTL-cached) only when on a capacity-constrained model
87
+ const mu = needsPerModel(usage, cfg) ? await ensurePerModel(cfg, activeOrg) : null;
88
+
89
+ // cheap pre-check off the lock - the common case exits here.
90
+ if (!isOver(usage, mu, activeOrg, cfg, floor)) {
91
+ return { swapped: false, account: null, reason: "under-threshold-or-stale" };
92
+ }
93
+
94
+ return withLock(paths.lockFile, async () => {
95
+ const idx = loadAccounts();
96
+ const org2 = readOAuthAccount()?.organizationUuid ?? null;
97
+ const u2 = loadUsage() ?? usage;
98
+ const mu2 = needsPerModel(u2, cfg) ? loadModelUsage() ?? mu : null;
99
+
100
+ // record the active account's aggregate usage so the picker + `status` see it.
101
+ if (u2 && org2 && u2.org === org2) {
102
+ const active = idx.accounts.find((a) => a.accountUuid === idx.activeAccountUuid);
103
+ if (active) {
104
+ active.lastUsage = { fiveHour: u2.fiveHour, sevenDay: u2.sevenDay };
105
+ // Snapshot per-model caps too, so they still show after we switch away.
106
+ if (mu2 && mu2.org === org2) active.lastPerModel = mu2.perModel;
107
+ saveAccounts(idx);
108
+ }
109
+ }
110
+
111
+ if (!isOver(u2, mu2, org2, cfg, floor)) {
112
+ return { swapped: false, account: null, reason: "raced-already-swapped" };
113
+ }
114
+
115
+ const landed = await chooseAndSwap({ now, threshold: cfg.threshold });
116
+ if (landed) return { swapped: true, account: landed, reason: "swapped" };
117
+
118
+ // Every account is depleted. Wait for whichever recovers soonest (including the
119
+ // current one), if that reset is within the auto-wait window.
120
+ const fresh = loadAccounts();
121
+ const ctx = { now, threshold: cfg.threshold, currentAccountUuid: fresh.activeAccountUuid };
122
+ const current = fresh.accounts.find((a) => a.accountUuid === fresh.activeAccountUuid);
123
+ const currentAt = current ? usableAt(current, cfg.threshold, now) : Number.POSITIVE_INFINITY;
124
+ const other = pickEarliestReset(fresh.accounts, ctx);
125
+
126
+ let target: Account | null = null;
127
+ let waitUntil = Number.POSITIVE_INFINITY;
128
+ if (other && other.availableAt < currentAt) { target = other.account; waitUntil = other.availableAt; }
129
+ else if (current) { target = current; waitUntil = currentAt; }
130
+ else if (other) { target = other.account; waitUntil = other.availableAt; }
131
+
132
+ if (!target || waitUntil - now > cfg.policy.maxWaitMs) {
133
+ log("decide.depleted", { waitUntil: Number.isFinite(waitUntil) ? waitUntil : 0 });
134
+ return { swapped: false, account: null, reason: "all-depleted" };
135
+ }
136
+
137
+ const isCurrent = target.accountUuid === fresh.activeAccountUuid;
138
+ if (!isCurrent) {
139
+ try {
140
+ await performSwap(target);
141
+ } catch (e) {
142
+ if (e instanceof InvalidGrantError) return { swapped: false, account: null, reason: "all-depleted" };
143
+ throw e;
144
+ }
145
+ }
146
+ log("decide.depleted_wait", { account: target.accountUuid.slice(0, 8), waitUntil });
147
+ return { swapped: !isCurrent, account: target, reason: "depleted-wait", waitUntil };
148
+ });
149
+ }
@@ -0,0 +1,19 @@
1
+ // Shared HTTP client. Sampling every account at once, or swapping right at a
2
+ // limit, can trip an endpoint's burst throttle, so GETs retry a bounded number
3
+ // of times, honoring the server's Retry-After (capped). throwHttpErrors is off
4
+ // so callers read the body and shape their own fail-fast error; retry still runs.
5
+
6
+ import ky from "ky";
7
+
8
+ export const http = ky.create({
9
+ timeout: 15_000,
10
+ throwHttpErrors: false,
11
+ retry: {
12
+ limit: 2,
13
+ methods: ["get"],
14
+ statusCodes: [429, 500, 502, 503, 504],
15
+ afterStatusCodes: [429, 503],
16
+ maxRetryAfter: 10_000,
17
+ backoffLimit: 3000,
18
+ },
19
+ });
@@ -0,0 +1,87 @@
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
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, realpathSync, rmSync } from "node:fs";
6
+ import { basename, dirname, join } from "node:path";
7
+ import { z } from "zod";
8
+ import { HOME, paths } from "./paths.ts";
9
+ import { writeFileAtomic } from "./atomic.ts";
10
+ import { installedBin, installSettings, uninstallSettings } from "./settings.ts";
11
+ import { resolveRealClaude } from "./claudebin.ts";
12
+
13
+ const InstallOutcomeSchema = z.object({
14
+ claudeWrapper: z.string(),
15
+ installedBin: z.string(),
16
+ priorStatusLine: z.string().nullable(),
17
+ pathAhead: z.boolean(),
18
+ });
19
+ export type InstallOutcome = z.infer<typeof InstallOutcomeSchema>;
20
+
21
+ /** True if our binDir comes before the real claude's dir on PATH. */
22
+ export function isBinDirAhead(): boolean {
23
+ const dirs = (process.env.PATH ?? "").split(":");
24
+ const ourIdx = dirs.indexOf(paths.binDir);
25
+ if (ourIdx < 0) return false;
26
+ try {
27
+ const realDir = dirname(resolveRealClaude());
28
+ const realIdx = dirs.indexOf(realDir);
29
+ return realIdx < 0 || ourIdx < realIdx;
30
+ } catch {
31
+ return ourIdx >= 0;
32
+ }
33
+ }
34
+
35
+ export function installSupervisor(): InstallOutcome {
36
+ mkdirSync(paths.binDir, { recursive: true });
37
+ const target = installedBin(); // binDir/tokenmaxxing
38
+ // Resolve the entry through the global-bin symlink (bun add -g links
39
+ // ~/.bun/bin/tokenmaxxing → the package's src/main.ts) so the shim points
40
+ // into the installed package tree, where its imports resolve.
41
+ const entry = realpathSync(Bun.main);
42
+ writeFileAtomic(target, `#!/bin/sh\nexec ${JSON.stringify(process.execPath)} run ${JSON.stringify(entry)} "$@"\n`, 0o755);
43
+
44
+ // the on-PATH `claude` wrapper
45
+ writeFileAtomic(paths.supervisorLink, `#!/bin/sh\nexec ${JSON.stringify(target)} __supervise "$@"\n`, 0o755);
46
+ // the `xx` short alias → tokenmaxxing
47
+ writeFileAtomic(join(paths.binDir, "xx"), `#!/bin/sh\nexec ${JSON.stringify(target)} "$@"\n`, 0o755);
48
+
49
+ const { priorStatusLine } = installSettings();
50
+ return {
51
+ claudeWrapper: paths.supervisorLink,
52
+ installedBin: target,
53
+ priorStatusLine,
54
+ pathAhead: isBinDirAhead(),
55
+ };
56
+ }
57
+
58
+ /** The rc file of the user's login shell, or null when the shell is unknown.
59
+ * Overridable for hermetic tests. */
60
+ export function shellRcPath(): string | null {
61
+ const override = process.env.TOKENMAXXING_SHELL_RC;
62
+ if (override && override.length > 0) return override;
63
+ const shell = basename(process.env.SHELL ?? "");
64
+ if (shell === "zsh") return join(process.env.ZDOTDIR || HOME, ".zshrc");
65
+ if (shell === "bash") return join(HOME, ".bashrc");
66
+ return null;
67
+ }
68
+
69
+ const PATH_LINE_MARK = "# tokenmaxxing PATH";
70
+
71
+ /** Idempotently append the supervisor-bin PATH line to `rc` (created if absent).
72
+ * A pre-existing hand-added line for the bin dir also counts as present. */
73
+ export function ensurePathInRc(rc: string): "added" | "present" {
74
+ const dir = paths.binDir.startsWith(`${HOME}/`) ? `$HOME${paths.binDir.slice(HOME.length)}` : paths.binDir;
75
+ const current = existsSync(rc) ? readFileSync(rc, "utf8") : "";
76
+ if (current.includes(PATH_LINE_MARK) || current.includes(`${paths.binDir}:`) || current.includes(`${dir}:`)) return "present";
77
+ const sep = current === "" || current.endsWith("\n") ? "" : "\n";
78
+ appendFileSync(rc, `${sep}export PATH="${dir}:$PATH" ${PATH_LINE_MARK}\n`);
79
+ return "added";
80
+ }
81
+
82
+ export function uninstallSupervisor(): void {
83
+ uninstallSettings();
84
+ for (const f of [paths.supervisorLink, join(paths.binDir, "xx"), installedBin()]) {
85
+ if (existsSync(f)) rmSync(f, { force: true });
86
+ }
87
+ }
@@ -0,0 +1,84 @@
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
+ import { z } from "zod";
9
+
10
+ const KeychainTargetSchema = z.object({ service: z.string(), account: z.string() });
11
+ export type KeychainTarget = z.infer<typeof KeychainTargetSchema>;
12
+
13
+ const SECURITY = "/usr/bin/security";
14
+ // security(1) interactive mode has a ~4KB line buffer; above this we must use argv.
15
+ const INTERACTIVE_MAX = 3800;
16
+
17
+ /** Double-quote + backslash-escape for security(1)'s interactive tokenizer. */
18
+ function quoteDouble(s: string): string {
19
+ return `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
20
+ }
21
+
22
+ /** Wrap a value for the `-i` line. Single-quote when possible (blob has no `'`). */
23
+ function quoteValue(s: string): string {
24
+ return s.includes("'") ? quoteDouble(s) : `'${s}'`;
25
+ }
26
+
27
+ /** Read an item's password blob. Returns null if the item does not exist. */
28
+ export async function readItem(t: KeychainTarget): Promise<string | null> {
29
+ const p = Bun.spawn([SECURITY, "find-generic-password", "-s", t.service, "-a", t.account, "-w"], {
30
+ stdout: "pipe",
31
+ stderr: "ignore",
32
+ });
33
+ const out = await new Response(p.stdout).text();
34
+ await p.exited;
35
+ if (p.exitCode !== 0) return null; // 44 = not found
36
+ return out.replace(/\n$/, ""); // security appends exactly one trailing newline
37
+ }
38
+
39
+ /** ps-safe write: the command line + secret arrive on stdin, never in argv.
40
+ * Bounded by security(1)'s ~4KB interactive line buffer. */
41
+ async function writeViaInteractive(t: KeychainTarget, secret: string): Promise<void> {
42
+ const line =
43
+ `add-generic-password -U -a ${quoteDouble(t.account)} -s ${quoteDouble(t.service)} ` +
44
+ `-w ${quoteValue(secret)}\n`;
45
+ const p = Bun.spawn([SECURITY, "-i"], {
46
+ stdin: new TextEncoder().encode(line),
47
+ stdout: "ignore",
48
+ stderr: "pipe",
49
+ });
50
+ const err = await new Response(p.stderr).text();
51
+ await p.exited;
52
+ if (p.exitCode !== 0) throw new Error(`keychain write (interactive) failed (exit ${p.exitCode}): ${err.trim()}`);
53
+ }
54
+
55
+ /** Fallback for blobs over the interactive line limit. The secret is briefly
56
+ * visible in `ps` for this one short-lived process - used only when the payload
57
+ * (e.g. a live blob with lots of MCP OAuth state) exceeds ~4KB. */
58
+ async function writeViaArgv(t: KeychainTarget, secret: string): Promise<void> {
59
+ const p = Bun.spawn([SECURITY, "add-generic-password", "-U", "-a", t.account, "-s", t.service, "-w", secret], {
60
+ stdout: "ignore",
61
+ stderr: "pipe",
62
+ });
63
+ const err = await new Response(p.stderr).text();
64
+ await p.exited;
65
+ if (p.exitCode !== 0) throw new Error(`keychain write (argv) failed (exit ${p.exitCode}): ${err.trim()}`);
66
+ }
67
+
68
+ /** Create-or-update an item (`-U`) with `secret` as its password. Prefers the
69
+ * ps-safe stdin path; falls back to argv only when the blob exceeds the ~4KB
70
+ * interactive line limit. Throws on failure. */
71
+ export async function writeItem(t: KeychainTarget, secret: string): Promise<void> {
72
+ if (secret.length <= INTERACTIVE_MAX) return writeViaInteractive(t, secret);
73
+ return writeViaArgv(t, secret);
74
+ }
75
+
76
+ /** Delete an item. Returns true if it existed and was removed. */
77
+ export async function deleteItem(t: KeychainTarget): Promise<boolean> {
78
+ const p = Bun.spawn([SECURITY, "delete-generic-password", "-s", t.service, "-a", t.account], {
79
+ stdout: "ignore",
80
+ stderr: "ignore",
81
+ });
82
+ await p.exited;
83
+ return p.exitCode === 0;
84
+ }
@@ -0,0 +1,78 @@
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
+ import { closeSync, mkdirSync, openSync } from "node:fs";
7
+ import { dirname } from "node:path";
8
+ import { dlopen, FFIType, suffix } from "bun:ffi";
9
+
10
+ // sys/file.h (identical on darwin + linux): LOCK_SH=1 LOCK_EX=2 LOCK_NB=4 LOCK_UN=8
11
+ const LOCK_EX = 2;
12
+ const LOCK_UN = 8;
13
+
14
+ let _flock: ((fd: number, op: number) => number) | null = null;
15
+
16
+ function flockFn(): (fd: number, op: number) => number {
17
+ if (_flock) return _flock;
18
+ // darwin: flock(2) is in libSystem; the bare name resolves via the dyld shared
19
+ // cache even though no physical .dylib exists on disk (verified 2026-07-08).
20
+ // linux: glibc's libc.so.6 (verified in-container 2026-07-09, arm64).
21
+ const candidates =
22
+ process.platform === "darwin"
23
+ ? ["libSystem.B.dylib", "/usr/lib/libSystem.B.dylib", `libc.${suffix}`]
24
+ : [`libc.so.6`, `libc.${suffix}`];
25
+ let lib: ReturnType<typeof dlopen> | null = null;
26
+ let lastErr: unknown;
27
+ for (const path of candidates) {
28
+ try {
29
+ lib = dlopen(path, {
30
+ flock: { args: [FFIType.i32, FFIType.i32], returns: FFIType.i32 },
31
+ });
32
+ break;
33
+ } catch (e) {
34
+ lastErr = e;
35
+ }
36
+ }
37
+ if (!lib) throw new Error(`could not load flock(2): ${String(lastErr)}`);
38
+ const sym = lib.symbols.flock as unknown as (fd: number, op: number) => number;
39
+ _flock = sym;
40
+ return _flock;
41
+ }
42
+
43
+ /**
44
+ * Acquire an exclusive advisory lock on `lockPath`, blocking until available.
45
+ * Returns a handle whose release() drops the lock. The lock is process-scoped
46
+ * (held via an open fd) so racing hooks in separate processes serialize.
47
+ */
48
+ export function acquireLock(lockPath: string): { release: () => void } {
49
+ mkdirSync(dirname(lockPath), { recursive: true });
50
+ const fd = openSync(lockPath, "a", 0o600);
51
+ const flock = flockFn();
52
+ const rc = flock(fd, LOCK_EX);
53
+ if (rc !== 0) {
54
+ closeSync(fd);
55
+ throw new Error(`flock LOCK_EX failed on ${lockPath} (rc=${rc})`);
56
+ }
57
+ let released = false;
58
+ const release = () => {
59
+ if (released) return;
60
+ released = true;
61
+ try {
62
+ flock(fd, LOCK_UN);
63
+ } finally {
64
+ closeSync(fd);
65
+ }
66
+ };
67
+ return { release };
68
+ }
69
+
70
+ /** Run `fn` while holding `lockPath`; always releases, even on throw. */
71
+ export async function withLock<T>(lockPath: string, fn: () => Promise<T> | T): Promise<T> {
72
+ const held = acquireLock(lockPath);
73
+ try {
74
+ return await fn();
75
+ } finally {
76
+ held.release();
77
+ }
78
+ }
package/src/lib/log.ts ADDED
@@ -0,0 +1,28 @@
1
+ // Append-only logging. NEVER logs secret material - callers must pass only
2
+ // non-secret context (account uuids/emails, percentages, status strings).
3
+
4
+ import { appendFileSync, mkdirSync } from "node:fs";
5
+ import { dirname } from "node:path";
6
+ import { z } from "zod";
7
+ import { paths } from "./paths.ts";
8
+
9
+ /** Redact anything that looks like a token so an accidental pass-through can't leak. */
10
+ export function redact(s: string): string {
11
+ return s
12
+ // JWT-ish / long opaque tokens
13
+ .replace(/\b(sk-ant-[A-Za-z0-9._-]{6,})/g, "sk-ant-***")
14
+ .replace(/\b([A-Za-z0-9_-]{40,})\b/g, (m) => `${m.slice(0, 4)}…(${m.length})`);
15
+ }
16
+
17
+ export function log(event: string, fields: Record<string, unknown> = {}): void {
18
+ try {
19
+ mkdirSync(dirname(paths.logFile), { recursive: true });
20
+ const parts = Object.entries(fields).map(([k, v]) => {
21
+ const str = z.string().safeParse(v);
22
+ return `${k}=${redact(str.success ? str.data : JSON.stringify(v))}`;
23
+ });
24
+ appendFileSync(paths.logFile, `${new Date().toISOString()} ${event} ${parts.join(" ")}\n`);
25
+ } catch {
26
+ // logging must never throw into a hook / supervisor path
27
+ }
28
+ }