tokenmaxxing 0.12.0 → 0.13.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.
@@ -0,0 +1,150 @@
1
+ // The `codex` supervisor. Invoked in place of codex (via ~/.config/tokenmaxxing/
2
+ // bin/codex on PATH). Codex REQUIRES a restart to change accounts: a running
3
+ // process refuses an auth.json swap to a different account (verified
4
+ // rust-v0.144.5 reload_if_account_id_matches), so unlike the claude supervisor
5
+ // (whose child hot-adopts swaps) this respawn IS the switch mechanism, not just
6
+ // UX. The codex Stop hook performs the swap at an idle turn boundary and drops
7
+ // a marker keyed by THIS supervisor's id (passed down via env, so N concurrent
8
+ // sessions pair correctly); the supervisor then SIGTERMs its child and
9
+ // relaunches `codex resume <session-id>` on the freshly-installed account.
10
+
11
+ import { existsSync, mkdirSync, rmSync } from "node:fs";
12
+ import { join } from "node:path";
13
+ import { codexPaths, paths } from "../lib/paths.ts";
14
+ import { LOOP_DIAGNOSIS, MAX_WRAP_DEPTH, WRAP_DEPTH_ENV, WRAP_RATE_MAX, WRAP_RATE_WINDOW_MS, wrapDepth, wrapperEntryRateTripped } from "../lib/claudebin.ts";
15
+ import { resolveRealCodex } from "../lib/codexbin.ts";
16
+ import { clearCodexPresence, writeCodexPresence } from "../lib/codexpresence.ts";
17
+ import { liveCodexAccountId } from "../lib/codexsample.ts";
18
+ import { saveTermios, restoreTermios } from "../lib/tty.ts";
19
+ import { CodexRespawnMarkerSchema } from "../lib/types.ts";
20
+ import { log } from "../lib/log.ts";
21
+
22
+ export const CODEX_SUPERVISOR_ID_ENV = "TOKENMAXXING_CODEX_SUPERVISOR_ID";
23
+
24
+ /** Subcommands that never host an interactive session worth managing. */
25
+ const NONINTERACTIVE_SUBCMDS = new Set([
26
+ "exec", "review", "login", "logout", "mcp", "plugin", "mcp-server", "app-server",
27
+ "remote-control", "app", "completion", "update", "doctor", "sandbox", "debug",
28
+ "apply", "archive", "delete", "unarchive", "cloud", "exec-server", "features", "help",
29
+ ]);
30
+
31
+ const PASSTHROUGH_FLAGS = new Set(["--version", "-V", "--help", "-h"]);
32
+
33
+ /** Root options that consume the NEXT token as their value (verified against
34
+ * `codex --help` 0.144.4): without skipping them, `codex -m gpt exec ...`
35
+ * would read "gpt" as the subcommand and wrongly supervise an exec run. */
36
+ const VALUE_TAKING_ROOT_FLAGS = new Set([
37
+ "-c", "--config", "-i", "--image", "-m", "--model", "--local-provider", "-p", "--profile",
38
+ "-s", "--sandbox", "-a", "--ask-for-approval", "-C", "--cd", "--add-dir", "--enable",
39
+ ]);
40
+
41
+ export function shouldManageCodex(input: { argv: string[] }): boolean {
42
+ if (process.env.TOKENMAXXING_PROBE) return false;
43
+ let firstPositional: string | null = null;
44
+ for (let i = 0; i < input.argv.length; i++) {
45
+ const arg = input.argv[i]!;
46
+ if (PASSTHROUGH_FLAGS.has(arg)) return false;
47
+ if (VALUE_TAKING_ROOT_FLAGS.has(arg)) {
48
+ i++;
49
+ continue;
50
+ }
51
+ if (!arg.startsWith("-") && firstPositional === null) firstPositional = arg;
52
+ }
53
+ return firstPositional === null || !NONINTERACTIVE_SUBCMDS.has(firstPositional);
54
+ }
55
+
56
+ /** Entry point: `codex ...args` through the on-PATH shim. */
57
+ export async function runCodexSupervisor(input: { argv: string[] }): Promise<number> {
58
+ const { argv } = input;
59
+ const depth = wrapDepth();
60
+ if (depth >= MAX_WRAP_DEPTH) {
61
+ console.error(
62
+ `tokenmaxxing: ${LOOP_DIAGNOSIS} (depth ${depth}) - codexBin in ${paths.configJson} does not launch the real codex binary. Fix codexBin, then run \`tokenmaxxing doctor\`.`,
63
+ );
64
+ log("codexsupervisor.loop_abort", { depth });
65
+ return 1;
66
+ }
67
+ if (wrapperEntryRateTripped(Date.now())) {
68
+ console.error(
69
+ `tokenmaxxing: ${LOOP_DIAGNOSIS} (over ${WRAP_RATE_MAX} wrapper entries in ${WRAP_RATE_WINDOW_MS / 1000}s) - codexBin in ${paths.configJson} does not launch the real codex binary. Fix codexBin, then run \`tokenmaxxing doctor\`.`,
70
+ );
71
+ log("codexsupervisor.rate_abort", { max: WRAP_RATE_MAX });
72
+ return 1;
73
+ }
74
+
75
+ const real = resolveRealCodex();
76
+ const childEnv = { ...process.env, [WRAP_DEPTH_ENV]: String(depth + 1) };
77
+
78
+ if (!shouldManageCodex({ argv })) {
79
+ const p = Bun.spawn([real, ...argv], { stdin: "inherit", stdout: "inherit", stderr: "inherit", env: childEnv });
80
+ await p.exited;
81
+ return p.exitCode ?? (p.signalCode ? 1 : 0);
82
+ }
83
+
84
+ const supervisorId = crypto.randomUUID();
85
+ mkdirSync(codexPaths.respawnDir, { recursive: true });
86
+ const marker = join(codexPaths.respawnDir, supervisorId);
87
+ const savedTermios = saveTermios();
88
+
89
+ process.on("SIGINT", () => {});
90
+ process.on("SIGHUP", () => {});
91
+
92
+ // On respawn the ONLY reliable relaunch is `codex resume <session-id>`:
93
+ // codex generates its own session ids (there is no flag to pin one at
94
+ // launch), so original launch args are used verbatim only for the first
95
+ // spawn. Model/sandbox preferences persist in config.toml either way.
96
+ let launchArgs = argv;
97
+ let respawns = 0;
98
+ while (true) {
99
+ if (existsSync(marker)) rmSync(marker, { force: true });
100
+ log("codexsupervisor.launch", { supervisorId: supervisorId.slice(0, 8), respawns, args: launchArgs.join(" ") });
101
+
102
+ // Declare which account THIS session runs on (the live identity at spawn):
103
+ // the picker must never target it and the sampler must never rotate its
104
+ // parked token while the session lives. Rewritten every respawn (the swap
105
+ // changed the live identity); cleared on exit; PID-validated by readers.
106
+ const spawnAccountId = liveCodexAccountId();
107
+ if (spawnAccountId) writeCodexPresence({ supervisorId, accountId: spawnAccountId });
108
+
109
+ const child = Bun.spawn([real, ...launchArgs], {
110
+ stdin: "inherit",
111
+ stdout: "inherit",
112
+ stderr: "inherit",
113
+ env: { ...childEnv, [CODEX_SUPERVISOR_ID_ENV]: supervisorId },
114
+ });
115
+
116
+ let done = false;
117
+ const markerWatch = (async () => {
118
+ while (!done) {
119
+ if (await Bun.file(marker).exists()) return true;
120
+ await Bun.sleep(150);
121
+ }
122
+ return false;
123
+ })();
124
+ const exited = child.exited.then(() => {
125
+ done = true;
126
+ return "exit";
127
+ });
128
+ const winner = await Promise.race([exited, markerWatch.then((found) => (found ? "marker" : "exit"))]);
129
+
130
+ if (winner === "marker") {
131
+ child.kill(); // SIGTERM at the committed turn boundary the Stop hook chose
132
+ }
133
+ await child.exited;
134
+ done = true;
135
+ await markerWatch.catch(() => false);
136
+ restoreTermios(savedTermios);
137
+
138
+ if (existsSync(marker)) {
139
+ const payload = CodexRespawnMarkerSchema.parse(await Bun.file(marker).json());
140
+ rmSync(marker, { force: true });
141
+ respawns++;
142
+ process.stdout.write(`\n\x1b[36m↻ tokenmaxxing: switched codex to ${payload.account} - resuming...\x1b[0m\n`);
143
+ launchArgs = payload.sessionId ? ["resume", payload.sessionId] : ["resume", "--last"];
144
+ continue;
145
+ }
146
+ clearCodexPresence({ supervisorId });
147
+ log("codexsupervisor.exit", { supervisorId: supervisorId.slice(0, 8), respawns, code: child.exitCode, signal: child.signalCode });
148
+ return child.exitCode ?? (child.signalCode ? 1 : 0);
149
+ }
150
+ }
@@ -0,0 +1,122 @@
1
+ // Read/write codex credential blobs ($CODEX_HOME/auth.json and parked copies)
2
+ // and decode the id_token's identity claims. Codex has NO cross-process lock on
3
+ // auth.json (its refresh serialization is an in-process semaphore, verified
4
+ // rust-v0.144.5 manager.rs), so every mutation here runs under tokenmaxxing's
5
+ // own codex flock, held by the caller.
6
+
7
+ import { readFileSync } from "node:fs";
8
+ import { join } from "node:path";
9
+ import { z } from "zod";
10
+ import { writeFileAtomic } from "./atomic.ts";
11
+ import { codexPaths } from "./paths.ts";
12
+ import { CodexAuthJsonSchema, type CodexAuthJson } from "./types.ts";
13
+
14
+ function isEnoent(e: unknown): boolean {
15
+ return e instanceof Error && "code" in e && e.code === "ENOENT";
16
+ }
17
+
18
+ /** auth.json at an explicit path (the live file, or an onboard dir's), or null
19
+ * when absent. Throws on a present-but-unparsable file: that is drift to
20
+ * surface, not to paper over. */
21
+ export function readCodexAuthAt(input: { path: string }): CodexAuthJson | null {
22
+ let raw: string;
23
+ try {
24
+ raw = readFileSync(input.path, "utf8");
25
+ } catch (e) {
26
+ if (isEnoent(e)) return null;
27
+ throw e;
28
+ }
29
+ return CodexAuthJsonSchema.parse(JSON.parse(raw));
30
+ }
31
+
32
+ /** The live auth.json, or null when codex has no login here. */
33
+ export function readLiveCodexAuth(): CodexAuthJson | null {
34
+ return readCodexAuthAt({ path: codexPaths.authJson });
35
+ }
36
+
37
+ export function writeLiveCodexAuth(input: { auth: CodexAuthJson }): void {
38
+ writeFileAtomic(codexPaths.authJson, JSON.stringify(CodexAuthJsonSchema.parse(input.auth), null, 2), 0o600);
39
+ }
40
+
41
+ function parkedPath(input: { credFile: string }): string {
42
+ return join(codexPaths.credsDir, `${input.credFile}.json`);
43
+ }
44
+
45
+ export function readParkedCodexAuth(input: { credFile: string }): CodexAuthJson | null {
46
+ let raw: string;
47
+ try {
48
+ raw = readFileSync(parkedPath(input), "utf8");
49
+ } catch (e) {
50
+ if (isEnoent(e)) return null;
51
+ throw e;
52
+ }
53
+ return CodexAuthJsonSchema.parse(JSON.parse(raw));
54
+ }
55
+
56
+ export function writeParkedCodexAuth(input: { credFile: string; auth: CodexAuthJson }): void {
57
+ writeFileAtomic(parkedPath(input), JSON.stringify(CodexAuthJsonSchema.parse(input.auth), null, 2), 0o600);
58
+ }
59
+
60
+ /** Identity claims inside the id_token JWT payload (verified against a live
61
+ * 0.144.4 token: the chatgpt fields sit under the api.openai.com/auth claim). */
62
+ const IdClaimsSchema = z.looseObject({
63
+ email: z.string().optional(),
64
+ "https://api.openai.com/auth": z
65
+ .looseObject({
66
+ chatgpt_account_id: z.string().optional(),
67
+ chatgpt_plan_type: z.string().optional(),
68
+ })
69
+ .optional(),
70
+ });
71
+
72
+ const JwtNumericClaimsSchema = z.looseObject({ exp: z.number().optional() });
73
+
74
+ function decodeJwtPayload(input: { jwt: string }): unknown {
75
+ const segments = input.jwt.split(".");
76
+ if (segments.length !== 3) throw new Error("not a JWT: expected three dot-separated segments");
77
+ const payload = Buffer.from(segments[1]!, "base64url").toString("utf8");
78
+ return JSON.parse(payload);
79
+ }
80
+
81
+ const CodexIdentitySchema = z.object({
82
+ accountId: z.string(),
83
+ email: z.string().nullable(),
84
+ planType: z.string().nullable(),
85
+ });
86
+ export type CodexIdentity = z.infer<typeof CodexIdentitySchema>;
87
+
88
+ /**
89
+ * The blob's own identity, from its token material alone (no network): the
90
+ * explicit tokens.account_id, else the id_token's chatgpt_account_id claim.
91
+ * Parking MUST key on this (or the usage endpoint's echo of it), never on a
92
+ * stored label - labels drift, tokens cannot lie.
93
+ */
94
+ export function codexIdentityOf(input: { auth: CodexAuthJson }): CodexIdentity {
95
+ const { auth } = input;
96
+ const claims = IdClaimsSchema.parse(decodeJwtPayload({ jwt: auth.tokens.id_token }));
97
+ const authClaims = claims["https://api.openai.com/auth"];
98
+ const accountId = auth.tokens.account_id ?? authClaims?.chatgpt_account_id;
99
+ if (!accountId) {
100
+ throw new Error("codex credential carries no account id (neither tokens.account_id nor the id_token claim)");
101
+ }
102
+ return CodexIdentitySchema.parse({
103
+ accountId,
104
+ email: claims.email ?? null,
105
+ planType: authClaims?.chatgpt_plan_type ?? null,
106
+ });
107
+ }
108
+
109
+ /** True when the access token is within `skewMs` of its JWT exp (or exp is
110
+ * unreadable). Codex's own proactive refresh margin is 5 minutes; matching it
111
+ * means we never install a token codex would immediately refresh. */
112
+ export function isCodexAccessExpiring(input: { auth: CodexAuthJson; skewMs?: number; now?: number }): boolean {
113
+ const { auth, skewMs = 300_000, now = Date.now() } = input;
114
+ let exp: number | undefined;
115
+ try {
116
+ exp = JwtNumericClaimsSchema.parse(decodeJwtPayload({ jwt: auth.tokens.access_token })).exp;
117
+ } catch {
118
+ return true;
119
+ }
120
+ if (exp == null) return true;
121
+ return exp * 1000 - now <= skewMs;
122
+ }
@@ -0,0 +1,64 @@
1
+ // Resolve the REAL codex binary (never our shim on PATH). Same guarantees as
2
+ // claudebin.ts: a configured-but-missing pin fails fast instead of degrading
3
+ // to a scan, and anything that realpath-resolves into our binDir is refused
4
+ // (spawning it as codex would recurse through the codex supervisor).
5
+
6
+ import { existsSync, statSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import { LOOP_DIAGNOSIS, MAX_WRAP_DEPTH, WRAP_DEPTH_ENV, pointsBackAtUs } from "./claudebin.ts";
9
+ import { loadConfig } from "./state.ts";
10
+ import { paths } from "./paths.ts";
11
+
12
+ /** First PATH entry with a `codex` that is not us. null when PATH has none. */
13
+ export function scanPathForCodex(): string | null {
14
+ for (const d of (process.env.PATH ?? "").split(":")) {
15
+ if (!d) continue;
16
+ const cand = join(d, "codex");
17
+ try {
18
+ if (existsSync(cand) && statSync(cand).isFile() && !pointsBackAtUs(cand)) return cand;
19
+ } catch {
20
+ continue;
21
+ }
22
+ }
23
+ return null;
24
+ }
25
+
26
+ export function resolveRealCodex(): string {
27
+ const cfg = loadConfig();
28
+ if (cfg.codexBin) {
29
+ if (!existsSync(cfg.codexBin)) {
30
+ throw new Error(`configured codexBin does not exist: ${cfg.codexBin} - fix config.json`);
31
+ }
32
+ if (pointsBackAtUs(cfg.codexBin)) {
33
+ throw new Error(
34
+ `configured codexBin (${cfg.codexBin}) is tokenmaxxing's own wrapper - spawning it recurses. Point codexBin at the real codex binary in ${paths.configJson}`,
35
+ );
36
+ }
37
+ return cfg.codexBin;
38
+ }
39
+ const scanned = scanPathForCodex();
40
+ if (scanned) return scanned;
41
+ throw new Error("could not locate the real `codex` binary (set codexBin in config.json)");
42
+ }
43
+
44
+ /** Behavioral check used by `init --codex` before pinning: the binary must
45
+ * answer `--version` identifying itself as codex, without re-entering our
46
+ * wrapper (depth preset to the cap kills a poisoned pin on first entry).
47
+ * Returns null when the binary passes, else the failure detail. */
48
+ export function verifyRealCodex(input: { bin: string }): string | null {
49
+ const env = { ...process.env, [WRAP_DEPTH_ENV]: String(MAX_WRAP_DEPTH), TOKENMAXXING_PROBE: "1" };
50
+ let p: ReturnType<typeof Bun.spawnSync>;
51
+ try {
52
+ p = Bun.spawnSync([input.bin, "--version"], { env, stdout: "pipe", stderr: "pipe", timeout: 15_000, killSignal: "SIGKILL" });
53
+ } catch (e) {
54
+ return e instanceof Error ? e.message : String(e);
55
+ }
56
+ const outText = (p.stdout?.toString() ?? "").trim();
57
+ const err = (p.stderr?.toString() ?? "").trim();
58
+ if (p.exitCode === 0) {
59
+ if (outText.toLowerCase().includes("codex")) return null;
60
+ return `--version output does not identify codex: "${outText.slice(0, 80)}"`;
61
+ }
62
+ if (err.includes(LOOP_DIAGNOSIS)) return "it leads back into the tokenmaxxing wrapper (recursion)";
63
+ return `--version exited ${p.exitCode ?? "on signal/timeout"}: ${(err || outText).slice(0, 160)}`;
64
+ }
@@ -0,0 +1,157 @@
1
+ // Shared codex switch decision, used by the codex Stop hook and `xx switch
2
+ // --codex`. Same policy as decide.ts, reshaped for codex mechanics: usage
3
+ // comes from the free direct GET (no statusLine tee exists), the greedy floor
4
+ // reads against every window class (current codex plans have no 5h window:
5
+ // the weekly aggregate is primary, verified live 2026-07-16), and there is no
6
+ // depleted pre-park (a swap only lands where a window is usable NOW; a
7
+ // depleted pool stays put and recovers when a cached reset passes).
8
+
9
+ import { z } from "zod";
10
+ import { withLock } from "./lock.ts";
11
+ import { codexPaths } from "./paths.ts";
12
+ import { loadConfig } from "./state.ts";
13
+ import { loadCodexAccounts, loadCodexLastSwapAt, saveCodexAccounts } from "./codexstate.ts";
14
+ import { codexCurrentWins, isCodexEngaged, isCodexExhausted, pickBestCodex } from "./codexpick.ts";
15
+ import { performCodexSwap } from "./codexswap.ts";
16
+ import { CodexInvalidGrantError, refreshCodexAuth } from "./codexoauth.ts";
17
+ import { fetchCodexUsage } from "./codexusage.ts";
18
+ import { isCodexAccessExpiring, readLiveCodexAuth, writeLiveCodexAuth, writeParkedCodexAuth } from "./codexauth.ts";
19
+ import { liveCodexAccountId } from "./codexsample.ts";
20
+ import { targetableCodexAccounts } from "./codexpresence.ts";
21
+ import { effectiveBars } from "./picker.ts";
22
+ import { log } from "./log.ts";
23
+ import { CodexAccountSchema } from "./types.ts";
24
+
25
+ const CodexSwapDecisionSchema = z.object({
26
+ swapped: z.boolean(),
27
+ account: CodexAccountSchema.nullable(),
28
+ reason: z.string(),
29
+ });
30
+ export type CodexSwapDecision = z.infer<typeof CodexSwapDecisionSchema>;
31
+
32
+ const POST_SWAP_COOLDOWN_MS = 45_000;
33
+
34
+ /**
35
+ * Sample the LIVE credential's usage and stamp it onto its TRUE owner in the
36
+ * pool (the usage read echoes the token's own account id: labels drift, the
37
+ * token cannot lie). Refreshes the live blob first when it is near expiry,
38
+ * persisting the rotation to both the live file and the owner's parked copy.
39
+ * Returns the owner, or null when the live credential is absent or unpooled.
40
+ */
41
+ async function sampleLiveOntoOwner(input: { now: number }): Promise<string | null> {
42
+ const { now } = input;
43
+ let live = readLiveCodexAuth();
44
+ if (!live) return null;
45
+
46
+ if (isCodexAccessExpiring({ auth: live, now })) {
47
+ live = await refreshCodexAuth({ auth: live, now });
48
+ writeLiveCodexAuth({ auth: live });
49
+ }
50
+ const usage = await fetchCodexUsage({ auth: live });
51
+
52
+ const index = loadCodexAccounts();
53
+ const owner = index.accounts.find((account) => account.accountId === usage.accountId);
54
+ if (!owner) return null;
55
+ writeParkedCodexAuth({ credFile: owner.credFile, auth: live });
56
+ owner.lastUsage = { aggregate: usage.aggregate, perLimit: usage.perLimit };
57
+ owner.lastUsageAt = now;
58
+ if (usage.email != null) owner.email = usage.email;
59
+ if (usage.planType != null) owner.planType = usage.planType;
60
+ saveCodexAccounts({ index });
61
+ return owner.accountId;
62
+ }
63
+
64
+ export async function evaluateAndMaybeSwapCodex(input: { now?: number }): Promise<CodexSwapDecision> {
65
+ const now = input.now ?? Date.now();
66
+ const lastSwapAt = loadCodexLastSwapAt();
67
+ if (lastSwapAt != null && now - lastSwapAt < POST_SWAP_COOLDOWN_MS) {
68
+ return { swapped: false, account: null, reason: "post-swap-cooldown" };
69
+ }
70
+
71
+ const cfg = loadConfig();
72
+ const bars = effectiveBars(cfg);
73
+
74
+ return withLock(codexPaths.lockFile, async () => {
75
+ let index = loadCodexAccounts();
76
+ if (index.accounts.length === 0) return { swapped: false, account: null, reason: "no-pool" };
77
+
78
+ // The current account is ALWAYS the live auth.json's own identity: the
79
+ // stored activeAccountId label drifts (manual `codex login`, a crash
80
+ // before saveCodexAccounts), and trusting it once let the decision target
81
+ // the RUNNING account (adversarial review catch, 2026-07-16). A live
82
+ // identity outside the pool is the org-guard analog: do nothing, a swap
83
+ // over an unknown credential could destroy its only copy.
84
+ const activeId = liveCodexAccountId();
85
+ if (activeId == null || !index.accounts.some((account) => account.accountId === activeId)) {
86
+ return { swapped: false, account: null, reason: "live-credential-not-in-pool" };
87
+ }
88
+
89
+ // Freshness: re-sample the live credential once its owner's cached
90
+ // snapshot ages past the poll TTL (there is no push feed in between).
91
+ const activeEntry = index.accounts.find((account) => account.accountId === activeId);
92
+ const stale = activeEntry?.lastUsageAt == null || now - activeEntry.lastUsageAt > cfg.policy.usagePollTtlMs;
93
+ if (stale) {
94
+ const sampledId = await sampleLiveOntoOwner({ now });
95
+ if (sampledId == null) {
96
+ return { swapped: false, account: null, reason: "live-credential-not-in-pool" };
97
+ }
98
+ index = loadCodexAccounts();
99
+ }
100
+
101
+ const active = index.accounts.find((account) => account.accountId === activeId) ?? null;
102
+ if (!active) return { swapped: false, account: null, reason: "no-active-account" };
103
+
104
+ const engaged =
105
+ isCodexEngaged({ account: active, floor: cfg.policy.greedySessionFloor, now }) ||
106
+ isCodexExhausted({ account: active, thresholds: bars, now });
107
+ if (!engaged) return { swapped: false, account: null, reason: "under-threshold-or-stale" };
108
+
109
+ // Greedy path: engaged but under every bar. Swap only onto an account
110
+ // that beats the seat by the respawn-cost margin, never onto one RUNNING
111
+ // in another session (presence files); re-rank after a dead grant
112
+ // (performCodexSwap persists needs-reauth before throwing, so the loop
113
+ // terminates).
114
+ if (!isCodexExhausted({ account: active, thresholds: bars, now })) {
115
+ while (true) {
116
+ const current = loadCodexAccounts();
117
+ const candidates = targetableCodexAccounts({ accounts: current.accounts, activeAccountId: activeId });
118
+ const cur = candidates.find((account) => account.accountId === activeId) ?? null;
119
+ if (codexCurrentWins({ active: cur, accounts: candidates, thresholds: bars, now })) {
120
+ return { swapped: false, account: null, reason: "current-best" };
121
+ }
122
+ const best = pickBestCodex({ accounts: candidates, thresholds: bars, now, currentAccountId: activeId });
123
+ if (!best) return { swapped: false, account: null, reason: "no-usable-target" };
124
+ try {
125
+ await performCodexSwap({ target: best });
126
+ } catch (e) {
127
+ if (e instanceof CodexInvalidGrantError) continue;
128
+ throw e;
129
+ }
130
+ log("codexdecide.greedy_swap", { account: best.accountId.slice(0, 8) });
131
+ return { swapped: true, account: best, reason: "swapped" };
132
+ }
133
+ }
134
+
135
+ // Hard path: a bar is crossed. Land on the best usable candidate, walking
136
+ // past dead grants; a fully depleted pool stays put (no pre-park: nothing
137
+ // can pause a codex session for a countdown yet).
138
+ const tried = new Set<string>();
139
+ while (true) {
140
+ const current = loadCodexAccounts();
141
+ const candidates = targetableCodexAccounts({ accounts: current.accounts, activeAccountId: activeId }).filter(
142
+ (account) => !tried.has(account.accountId),
143
+ );
144
+ const best = pickBestCodex({ accounts: candidates, thresholds: bars, now, currentAccountId: activeId });
145
+ if (!best) return { swapped: false, account: null, reason: "all-depleted" };
146
+ tried.add(best.accountId);
147
+ try {
148
+ await performCodexSwap({ target: best });
149
+ } catch (e) {
150
+ if (e instanceof CodexInvalidGrantError) continue;
151
+ throw e;
152
+ }
153
+ log("codexdecide.hard_swap", { account: best.accountId.slice(0, 8) });
154
+ return { swapped: true, account: best, reason: "swapped" };
155
+ }
156
+ });
157
+ }
@@ -0,0 +1,99 @@
1
+ // The one OAuth call tokenmaxxing makes for codex: the refresh_token grant.
2
+ // Verified against openai/codex rust-v0.144.5 (login/src/auth/manager.rs):
3
+ // POST auth.openai.com/oauth/token, JSON body {client_id, grant_type,
4
+ // refresh_token}, no auth headers. The server ROTATES the refresh token and
5
+ // punishes reuse of a superseded one (refresh_token_reused), so every rotation
6
+ // must be persisted back into the blob it came from immediately.
7
+
8
+ import { z } from "zod";
9
+ import { http, safeErrorDetail } from "./http.ts";
10
+ import { CodexAuthJsonSchema, type CodexAuthJson } from "./types.ts";
11
+
12
+ const EnvOverrideSchema = z.string().min(1).optional().catch(undefined);
13
+ const TOKEN_URL = EnvOverrideSchema.parse(process.env.TOKENMAXXING_CODEX_TOKEN_URL) ?? "https://auth.openai.com/oauth/token";
14
+ const CLIENT_ID = EnvOverrideSchema.parse(process.env.TOKENMAXXING_CODEX_CLIENT_ID) ?? "app_EMoamEEZ73f0CkXaXp7hrann";
15
+
16
+ /** Refresh token dead, superseded, or revoked: mark needs-reauth and move on. */
17
+ export class CodexInvalidGrantError extends Error {
18
+ constructor(detail: string) {
19
+ super(`codex invalid grant: ${detail}`);
20
+ this.name = "CodexInvalidGrantError";
21
+ }
22
+ }
23
+
24
+ /** The refresh could not run (endpoint unreachable, throttled, drifted body):
25
+ * an operational miss to retry later, NOT a dead grant and NOT a bug. */
26
+ export class CodexRefreshFailedError extends Error {
27
+ constructor(detail: string) {
28
+ super(`codex token refresh failed: ${detail}`);
29
+ this.name = "CodexRefreshFailedError";
30
+ }
31
+ }
32
+
33
+ const CodexRefreshResponseSchema = z.looseObject({
34
+ id_token: z.string().optional(),
35
+ access_token: z.string(),
36
+ refresh_token: z.string().optional(),
37
+ });
38
+
39
+ const DEAD_GRANT_MARKERS = [
40
+ "invalid_grant",
41
+ "refresh_token_reused",
42
+ "refresh_token_expired",
43
+ "refresh_token_invalidated",
44
+ ];
45
+
46
+ /**
47
+ * Exchange the blob's refresh token for fresh tokens. Returns a NEW auth.json
48
+ * value with rotated token material and last_refresh restamped; every sibling
49
+ * field rides along verbatim. Throws CodexInvalidGrantError on a dead grant.
50
+ * Error paths only ever surface response-body snippets, never request headers.
51
+ */
52
+ export async function refreshCodexAuth(input: { auth: CodexAuthJson; now?: number }): Promise<CodexAuthJson> {
53
+ const { auth, now = Date.now() } = input;
54
+ let res: Response;
55
+ try {
56
+ res = await http.post(TOKEN_URL, {
57
+ headers: { "Content-Type": "application/json" },
58
+ body: JSON.stringify({
59
+ client_id: CLIENT_ID,
60
+ grant_type: "refresh_token",
61
+ refresh_token: auth.tokens.refresh_token,
62
+ }),
63
+ });
64
+ } catch (e) {
65
+ const message = e instanceof Error ? e.message : String(e);
66
+ throw new CodexRefreshFailedError(`endpoint unreachable: ${message}`);
67
+ }
68
+
69
+ const text = await res.text();
70
+ if (!res.ok) {
71
+ const detail = safeErrorDetail({ text });
72
+ if (DEAD_GRANT_MARKERS.some((marker) => text.includes(marker))) {
73
+ throw new CodexInvalidGrantError(detail);
74
+ }
75
+ throw new CodexRefreshFailedError(`HTTP ${res.status}: ${detail}`);
76
+ }
77
+
78
+ const parsed = CodexRefreshResponseSchema.safeParse((() => {
79
+ try {
80
+ return JSON.parse(text);
81
+ } catch {
82
+ return null;
83
+ }
84
+ })());
85
+ if (!parsed.success) {
86
+ throw new CodexRefreshFailedError("endpoint returned an unexpected body shape (withheld: may carry tokens)");
87
+ }
88
+
89
+ return CodexAuthJsonSchema.parse({
90
+ ...auth,
91
+ tokens: {
92
+ ...auth.tokens,
93
+ access_token: parsed.data.access_token,
94
+ refresh_token: parsed.data.refresh_token ?? auth.tokens.refresh_token,
95
+ id_token: parsed.data.id_token ?? auth.tokens.id_token,
96
+ },
97
+ last_refresh: new Date(now).toISOString(),
98
+ });
99
+ }