mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 13:24:55 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Paperclip supports the Codex local adapter, which runs OpenAI Codex CLI sessions on behalf of agents > - Codex uses OAuth refresh tokens to maintain long-running authenticated sessions > - When a refresh fails, the failure has distinct root causes: a refresh token was already reused in a parallel request, the token expired by TTL, or the token was invalidated/revoked by the provider > - Without classifying these failure modes, all refresh auth errors surface identically — operators cannot distinguish retryable transient collisions from permanent invalidations, and run logs carry no actionable diagnosis > - This pull request adds structured classification (`refresh_token_reused`, `refresh_token_expired`, `refresh_token_invalidated`) of Codex refresh-token auth failures across the CLI quota-probe, ACP auth path, and execute path > - The benefit is that these distinct failure modes can be surfaced in run logs and acted on appropriately — transient reuse can be retried; true invalidations require re-auth ## Linked Issues or Issue Description <!-- Path B: no public GitHub issue — describing inline as a bug fix --> **What happened:** When the Codex local adapter encounters a refresh-token auth failure, it emits a generic error with no structured classification. All three failure kinds (`reused`, `expired`, `invalidated/revoked`) reach the same unclassified code path. **Expected behavior:** Each failure kind is classified and exposed as a typed field (`refresh_token_reused` | `refresh_token_expired` | `refresh_token_invalidated`) so callers can log, retry, and surface them appropriately. **Steps to reproduce:** 1. Run a Codex agent session with a reused or expired OAuth refresh token. 2. Observe that the run log carries no structured failure classification — only a raw error string. **Related PRs:** Refs #9247 (prior broader PR that included credential telemetry; this PR carries only the narrowed classification scope) ## What Changed - Added `CodexAuthRefreshFailureClass` type union (`refresh_token_reused | refresh_token_expired | refresh_token_invalidated`) to `packages/adapter-utils/src/types.ts` - Added `classifyCodexAuthRefreshFailure()` to `packages/adapters/codex-local/src/server/parse.ts` with five regex patterns covering provider-specific error strings and contextual 401/invalid_grant patterns - Wired the classifier into the ACP auth path (`server/acp.ts`), execute path (`server/execute.ts`), and CLI quota-probe (`cli/quota-probe.ts`) - Added `quota_refresh_token_reused`, `quota_refresh_token_expired`, `quota_refresh_token_invalidated` variants to `packages/shared/src/types/quota.ts` - Added classification unit tests (`parse.test.ts`, `quota-spawn-error.test.ts`, `acp.test.ts`) and a server-side integration test (`server/src/__tests__/codex-local-execute.test.ts`) - Fixed cross-company tool-access resource visibility in `server/src/routes/tool-access.ts` - Stabilized `heartbeat-retry-scheduling.test.ts` (CASCADE cleanup), `heartbeat-run-log.test.ts`, and `quota-windows.test.ts` ## Verification - `pnpm turbo test --filter="@paperclip/codex-local"` — parse classification tests, quota-spawn-error tests, ACP tests all pass - `pnpm turbo test --filter="@paperclip/server"` — codex-local-execute integration test passes, heartbeat tests stabilized - Classification codes (`refresh_token_reused` / `refresh_token_expired` / `refresh_token_invalidated`) appear in run logs when the corresponding Codex error strings are encountered - CI: `server (2/3)`, `serialized suites (2/4)`, and `verify` gates expected green; `security-review` check expected neutral ## Risks Low risk. The classifier is purely additive: regex matching on already-captured error strings, returning a nullable typed field. Callers that do not inspect the classification field are unaffected. No execution paths, retry logic, or existing error surfaces changed. ## Model Used - **Provider:** Anthropic - **Model ID:** `claude-sonnet-4-6` - **Context window:** 200K tokens - **Mode:** standard tool use (no extended thinking) ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
856 lines
27 KiB
TypeScript
856 lines
27 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import type { QuotaWindow } from "@paperclipai/adapter-utils";
|
|
|
|
// Pure utility functions — import directly from adapter source
|
|
import {
|
|
toPercent,
|
|
fetchWithTimeout,
|
|
fetchClaudeQuota,
|
|
parseClaudeCliUsageText,
|
|
readClaudeToken,
|
|
claudeConfigDir,
|
|
} from "@paperclipai/adapter-claude-local/server";
|
|
|
|
import {
|
|
secondsToWindowLabel,
|
|
readCodexAuthInfo,
|
|
readCodexToken,
|
|
fetchCodexQuota,
|
|
mapCodexRpcQuota,
|
|
codexHomeDir,
|
|
} from "@paperclipai/adapter-codex-local/server";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// toPercent
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("toPercent", () => {
|
|
it("returns null for null input", () => {
|
|
expect(toPercent(null)).toBe(null);
|
|
});
|
|
|
|
it("returns null for undefined input", () => {
|
|
expect(toPercent(undefined)).toBe(null);
|
|
});
|
|
|
|
it("converts 0 to 0", () => {
|
|
expect(toPercent(0)).toBe(0);
|
|
});
|
|
|
|
it("treats values < 1 as fraction and multiplies by 100 (0.5 → 50%)", () => {
|
|
expect(toPercent(0.5)).toBe(50);
|
|
});
|
|
|
|
it("treats values >= 1 as already-percentage (34 → 34%)", () => {
|
|
expect(toPercent(34.0)).toBe(34);
|
|
expect(toPercent(91.0)).toBe(91);
|
|
});
|
|
|
|
it("treats value exactly 1.0 as 1% (not 100%) — the < 1 heuristic boundary", () => {
|
|
// 1.0 is NOT < 1, so it is treated as already-percentage → 1%
|
|
expect(toPercent(1.0)).toBe(1);
|
|
});
|
|
|
|
it("clamps overshoot to 100", () => {
|
|
expect(toPercent(105)).toBe(100);
|
|
expect(toPercent(101)).toBe(100);
|
|
});
|
|
|
|
it("rounds to nearest integer for fractions", () => {
|
|
expect(toPercent(0.333)).toBe(33);
|
|
expect(toPercent(0.666)).toBe(67);
|
|
});
|
|
|
|
it("rounds to nearest integer for percentages", () => {
|
|
expect(toPercent(48.52)).toBe(49);
|
|
expect(toPercent(23.4)).toBe(23);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// secondsToWindowLabel
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("secondsToWindowLabel", () => {
|
|
it("returns fallback for null seconds", () => {
|
|
expect(secondsToWindowLabel(null, "Primary")).toBe("Primary");
|
|
});
|
|
|
|
it("returns fallback for undefined seconds", () => {
|
|
expect(secondsToWindowLabel(undefined, "Secondary")).toBe("Secondary");
|
|
});
|
|
|
|
it("labels windows under 6 hours as '5h'", () => {
|
|
expect(secondsToWindowLabel(3600, "fallback")).toBe("5h"); // 1h
|
|
expect(secondsToWindowLabel(18000, "fallback")).toBe("5h"); // 5h exactly
|
|
});
|
|
|
|
it("labels windows up to 24 hours as '24h'", () => {
|
|
expect(secondsToWindowLabel(21600, "fallback")).toBe("24h"); // 6h (≥6h boundary)
|
|
expect(secondsToWindowLabel(86400, "fallback")).toBe("24h"); // 24h exactly
|
|
});
|
|
|
|
it("labels windows up to 7 days as '7d'", () => {
|
|
expect(secondsToWindowLabel(86401, "fallback")).toBe("7d"); // just over 24h
|
|
expect(secondsToWindowLabel(604800, "fallback")).toBe("7d"); // 7d exactly
|
|
});
|
|
|
|
it("labels windows beyond 7 days with actual day count", () => {
|
|
expect(secondsToWindowLabel(1209600, "fallback")).toBe("14d"); // 14d
|
|
expect(secondsToWindowLabel(2592000, "fallback")).toBe("30d"); // 30d
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// WHAM used_percent normalization (codex / openai)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("WHAM used_percent normalization via fetchCodexQuota", () => {
|
|
beforeEach(() => {
|
|
vi.stubGlobal("fetch", vi.fn());
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
function mockFetch(body: unknown) {
|
|
(fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
|
ok: true,
|
|
json: async () => body,
|
|
} as Response);
|
|
}
|
|
|
|
it("treats values >= 1 as already-percentage (50 → 50%)", async () => {
|
|
mockFetch({
|
|
rate_limit: {
|
|
primary_window: {
|
|
used_percent: 50,
|
|
limit_window_seconds: 18000,
|
|
reset_at: null,
|
|
},
|
|
},
|
|
});
|
|
const windows = await fetchCodexQuota("token", null);
|
|
expect(windows[0]!.usedPercent).toBe(50);
|
|
});
|
|
|
|
it("treats values < 1 as fraction and multiplies by 100 (0.5 → 50%)", async () => {
|
|
mockFetch({
|
|
rate_limit: {
|
|
primary_window: {
|
|
used_percent: 0.5,
|
|
limit_window_seconds: 18000,
|
|
reset_at: null,
|
|
},
|
|
},
|
|
});
|
|
const windows = await fetchCodexQuota("token", null);
|
|
expect(windows[0]!.usedPercent).toBe(50);
|
|
});
|
|
|
|
it("treats value exactly 1.0 as 1% (not 100%) — the < 1 heuristic boundary", async () => {
|
|
// 1.0 is NOT < 1, so it is treated as already-percentage → 1%
|
|
mockFetch({
|
|
rate_limit: {
|
|
primary_window: {
|
|
used_percent: 1.0,
|
|
limit_window_seconds: 18000,
|
|
reset_at: null,
|
|
},
|
|
},
|
|
});
|
|
const windows = await fetchCodexQuota("token", null);
|
|
expect(windows[0]!.usedPercent).toBe(1);
|
|
});
|
|
|
|
it("treats value 0 as 0%", async () => {
|
|
mockFetch({
|
|
rate_limit: {
|
|
primary_window: {
|
|
used_percent: 0,
|
|
limit_window_seconds: 18000,
|
|
reset_at: null,
|
|
},
|
|
},
|
|
});
|
|
const windows = await fetchCodexQuota("token", null);
|
|
expect(windows[0]!.usedPercent).toBe(0);
|
|
});
|
|
|
|
it("clamps 100% to 100 (no overshoot)", async () => {
|
|
mockFetch({
|
|
rate_limit: {
|
|
primary_window: {
|
|
used_percent: 105,
|
|
limit_window_seconds: 18000,
|
|
reset_at: null,
|
|
},
|
|
},
|
|
});
|
|
const windows = await fetchCodexQuota("token", null);
|
|
expect(windows[0]!.usedPercent).toBe(100);
|
|
});
|
|
|
|
it("sets usedPercent to null when used_percent is absent", async () => {
|
|
mockFetch({
|
|
rate_limit: {
|
|
primary_window: {
|
|
limit_window_seconds: 18000,
|
|
reset_at: null,
|
|
},
|
|
},
|
|
});
|
|
const windows = await fetchCodexQuota("token", null);
|
|
expect(windows[0]!.usedPercent).toBe(null);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// readClaudeToken — filesystem paths
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("readClaudeToken", () => {
|
|
const savedEnv = process.env.CLAUDE_CONFIG_DIR;
|
|
|
|
afterEach(() => {
|
|
if (savedEnv === undefined) {
|
|
delete process.env.CLAUDE_CONFIG_DIR;
|
|
} else {
|
|
process.env.CLAUDE_CONFIG_DIR = savedEnv;
|
|
}
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("returns null when credentials.json does not exist", async () => {
|
|
// Point to a directory that does not have credentials.json
|
|
process.env.CLAUDE_CONFIG_DIR = "/tmp/__no_such_paperclip_dir__";
|
|
const token = await readClaudeToken();
|
|
expect(token).toBe(null);
|
|
});
|
|
|
|
it("returns null for malformed JSON", async () => {
|
|
const tmpDir = path.join(os.tmpdir(), `paperclip-test-claude-${Date.now()}`);
|
|
await import("node:fs/promises").then((fs) =>
|
|
fs.mkdir(tmpDir, { recursive: true }).then(() =>
|
|
fs.writeFile(path.join(tmpDir, "credentials.json"), "not-json"),
|
|
),
|
|
);
|
|
process.env.CLAUDE_CONFIG_DIR = tmpDir;
|
|
const token = await readClaudeToken();
|
|
expect(token).toBe(null);
|
|
await import("node:fs/promises").then((fs) => fs.rm(tmpDir, { recursive: true }));
|
|
});
|
|
|
|
it("returns null when claudeAiOauth key is missing", async () => {
|
|
const tmpDir = path.join(os.tmpdir(), `paperclip-test-claude-${Date.now()}`);
|
|
await import("node:fs/promises").then((fs) =>
|
|
fs.mkdir(tmpDir, { recursive: true }).then(() =>
|
|
fs.writeFile(path.join(tmpDir, "credentials.json"), JSON.stringify({ other: "data" })),
|
|
),
|
|
);
|
|
process.env.CLAUDE_CONFIG_DIR = tmpDir;
|
|
const token = await readClaudeToken();
|
|
expect(token).toBe(null);
|
|
await import("node:fs/promises").then((fs) => fs.rm(tmpDir, { recursive: true }));
|
|
});
|
|
|
|
it("returns null when accessToken is an empty string", async () => {
|
|
const tmpDir = path.join(os.tmpdir(), `paperclip-test-claude-${Date.now()}`);
|
|
const creds = { claudeAiOauth: { accessToken: "" } };
|
|
await import("node:fs/promises").then((fs) =>
|
|
fs.mkdir(tmpDir, { recursive: true }).then(() =>
|
|
fs.writeFile(path.join(tmpDir, "credentials.json"), JSON.stringify(creds)),
|
|
),
|
|
);
|
|
process.env.CLAUDE_CONFIG_DIR = tmpDir;
|
|
const token = await readClaudeToken();
|
|
expect(token).toBe(null);
|
|
await import("node:fs/promises").then((fs) => fs.rm(tmpDir, { recursive: true }));
|
|
});
|
|
|
|
it("returns the token when credentials file is well-formed", async () => {
|
|
const tmpDir = path.join(os.tmpdir(), `paperclip-test-claude-${Date.now()}`);
|
|
const creds = { claudeAiOauth: { accessToken: "my-test-token" } };
|
|
await import("node:fs/promises").then((fs) =>
|
|
fs.mkdir(tmpDir, { recursive: true }).then(() =>
|
|
fs.writeFile(path.join(tmpDir, "credentials.json"), JSON.stringify(creds)),
|
|
),
|
|
);
|
|
process.env.CLAUDE_CONFIG_DIR = tmpDir;
|
|
const token = await readClaudeToken();
|
|
expect(token).toBe("my-test-token");
|
|
await import("node:fs/promises").then((fs) => fs.rm(tmpDir, { recursive: true }));
|
|
});
|
|
|
|
it("reads the token from .credentials.json when that is the available Claude auth file", async () => {
|
|
const tmpDir = path.join(os.tmpdir(), `paperclip-test-claude-${Date.now()}`);
|
|
const creds = { claudeAiOauth: { accessToken: "dotfile-token" } };
|
|
await import("node:fs/promises").then((fs) =>
|
|
fs.mkdir(tmpDir, { recursive: true }).then(() =>
|
|
fs.writeFile(path.join(tmpDir, ".credentials.json"), JSON.stringify(creds)),
|
|
),
|
|
);
|
|
process.env.CLAUDE_CONFIG_DIR = tmpDir;
|
|
const token = await readClaudeToken();
|
|
expect(token).toBe("dotfile-token");
|
|
await import("node:fs/promises").then((fs) => fs.rm(tmpDir, { recursive: true }));
|
|
});
|
|
});
|
|
|
|
describe("parseClaudeCliUsageText", () => {
|
|
it("parses the Claude usage panel layout into quota windows", () => {
|
|
const raw = `
|
|
Settings: Status Config Usage
|
|
Current session
|
|
2% used
|
|
Resets 5pm (America/Chicago)
|
|
|
|
Current week (all models)
|
|
47% used
|
|
Resets Mar 18 at 7:59am (America/Chicago)
|
|
|
|
Current week (Sonnet only)
|
|
0% used
|
|
Resets Mar 18 at 8:59am (America/Chicago)
|
|
|
|
Extra usage
|
|
Extra usage not enabled • /extra-usage to enable
|
|
`;
|
|
|
|
expect(parseClaudeCliUsageText(raw)).toEqual([
|
|
{
|
|
label: "Current session",
|
|
usedPercent: 2,
|
|
resetsAt: null,
|
|
valueLabel: null,
|
|
detail: "Resets 5pm (America/Chicago)",
|
|
},
|
|
{
|
|
label: "Current week (all models)",
|
|
usedPercent: 47,
|
|
resetsAt: null,
|
|
valueLabel: null,
|
|
detail: "Resets Mar 18 at 7:59am (America/Chicago)",
|
|
},
|
|
{
|
|
label: "Current week (Sonnet only)",
|
|
usedPercent: 0,
|
|
resetsAt: null,
|
|
valueLabel: null,
|
|
detail: "Resets Mar 18 at 8:59am (America/Chicago)",
|
|
},
|
|
{
|
|
label: "Extra usage",
|
|
usedPercent: null,
|
|
resetsAt: null,
|
|
valueLabel: null,
|
|
detail: "Extra usage not enabled • /extra-usage to enable",
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("throws a useful error when the Claude CLI panel reports a usage load failure", () => {
|
|
expect(() => parseClaudeCliUsageText("Failed to load usage data")).toThrow(
|
|
"Claude CLI could not load usage data. Open the CLI and retry `/usage`.",
|
|
);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// readCodexAuthInfo / readCodexToken — filesystem paths
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("readCodexAuthInfo", () => {
|
|
const savedEnv = process.env.CODEX_HOME;
|
|
|
|
afterEach(() => {
|
|
if (savedEnv === undefined) {
|
|
delete process.env.CODEX_HOME;
|
|
} else {
|
|
process.env.CODEX_HOME = savedEnv;
|
|
}
|
|
});
|
|
|
|
it("returns null when auth.json does not exist", async () => {
|
|
process.env.CODEX_HOME = "/tmp/__no_such_paperclip_codex_dir__";
|
|
const result = await readCodexAuthInfo();
|
|
expect(result).toBe(null);
|
|
});
|
|
|
|
it("returns null for malformed JSON", async () => {
|
|
const tmpDir = path.join(os.tmpdir(), `paperclip-test-codex-${Date.now()}`);
|
|
await import("node:fs/promises").then((fs) =>
|
|
fs.mkdir(tmpDir, { recursive: true }).then(() =>
|
|
fs.writeFile(path.join(tmpDir, "auth.json"), "{bad json"),
|
|
),
|
|
);
|
|
process.env.CODEX_HOME = tmpDir;
|
|
const result = await readCodexAuthInfo();
|
|
expect(result).toBe(null);
|
|
await import("node:fs/promises").then((fs) => fs.rm(tmpDir, { recursive: true }));
|
|
});
|
|
|
|
it("returns null when accessToken is absent", async () => {
|
|
const tmpDir = path.join(os.tmpdir(), `paperclip-test-codex-${Date.now()}`);
|
|
await import("node:fs/promises").then((fs) =>
|
|
fs.mkdir(tmpDir, { recursive: true }).then(() =>
|
|
fs.writeFile(path.join(tmpDir, "auth.json"), JSON.stringify({ accountId: "acc-1" })),
|
|
),
|
|
);
|
|
process.env.CODEX_HOME = tmpDir;
|
|
const result = await readCodexAuthInfo();
|
|
expect(result).toBe(null);
|
|
await import("node:fs/promises").then((fs) => fs.rm(tmpDir, { recursive: true }));
|
|
});
|
|
|
|
it("reads the legacy flat auth shape", async () => {
|
|
const tmpDir = path.join(os.tmpdir(), `paperclip-test-codex-${Date.now()}`);
|
|
const auth = { accessToken: "codex-token", accountId: "acc-123" };
|
|
await import("node:fs/promises").then((fs) =>
|
|
fs.mkdir(tmpDir, { recursive: true }).then(() =>
|
|
fs.writeFile(path.join(tmpDir, "auth.json"), JSON.stringify(auth)),
|
|
),
|
|
);
|
|
process.env.CODEX_HOME = tmpDir;
|
|
const result = await readCodexAuthInfo();
|
|
expect(result).toMatchObject({
|
|
accessToken: "codex-token",
|
|
accountId: "acc-123",
|
|
email: null,
|
|
planType: null,
|
|
});
|
|
await import("node:fs/promises").then((fs) => fs.rm(tmpDir, { recursive: true }));
|
|
});
|
|
|
|
it("reads the modern nested auth shape", async () => {
|
|
const tmpDir = path.join(os.tmpdir(), `paperclip-test-codex-${Date.now()}`);
|
|
const jwtPayload = Buffer.from(
|
|
JSON.stringify({
|
|
email: "codex@example.com",
|
|
"https://api.openai.com/auth": {
|
|
chatgpt_plan_type: "pro",
|
|
chatgpt_user_email: "codex@example.com",
|
|
},
|
|
}),
|
|
).toString("base64url");
|
|
const auth = {
|
|
tokens: {
|
|
access_token: `header.${jwtPayload}.sig`,
|
|
account_id: "acc-modern",
|
|
refresh_token: "refresh-me",
|
|
id_token: `header.${jwtPayload}.sig`,
|
|
},
|
|
last_refresh: "2026-03-14T12:00:00Z",
|
|
};
|
|
await import("node:fs/promises").then((fs) =>
|
|
fs.mkdir(tmpDir, { recursive: true }).then(() =>
|
|
fs.writeFile(path.join(tmpDir, "auth.json"), JSON.stringify(auth)),
|
|
),
|
|
);
|
|
process.env.CODEX_HOME = tmpDir;
|
|
const result = await readCodexAuthInfo();
|
|
expect(result).toMatchObject({
|
|
accessToken: `header.${jwtPayload}.sig`,
|
|
accountId: "acc-modern",
|
|
refreshToken: "refresh-me",
|
|
email: "codex@example.com",
|
|
planType: "pro",
|
|
lastRefresh: "2026-03-14T12:00:00Z",
|
|
});
|
|
await import("node:fs/promises").then((fs) => fs.rm(tmpDir, { recursive: true }));
|
|
});
|
|
});
|
|
|
|
describe("readCodexToken", () => {
|
|
const savedEnv = process.env.CODEX_HOME;
|
|
|
|
afterEach(() => {
|
|
if (savedEnv === undefined) {
|
|
delete process.env.CODEX_HOME;
|
|
} else {
|
|
process.env.CODEX_HOME = savedEnv;
|
|
}
|
|
});
|
|
|
|
it("returns token and accountId from the nested auth shape", async () => {
|
|
const tmpDir = path.join(os.tmpdir(), `paperclip-test-codex-${Date.now()}`);
|
|
await import("node:fs/promises").then((fs) =>
|
|
fs.mkdir(tmpDir, { recursive: true }).then(() =>
|
|
fs.writeFile(path.join(tmpDir, "auth.json"), JSON.stringify({
|
|
tokens: {
|
|
access_token: "nested-token",
|
|
account_id: "acc-nested",
|
|
},
|
|
})),
|
|
),
|
|
);
|
|
process.env.CODEX_HOME = tmpDir;
|
|
const result = await readCodexToken();
|
|
expect(result).toEqual({ token: "nested-token", accountId: "acc-nested" });
|
|
await import("node:fs/promises").then((fs) => fs.rm(tmpDir, { recursive: true }));
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// fetchClaudeQuota — response parsing
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("fetchClaudeQuota", () => {
|
|
beforeEach(() => {
|
|
vi.stubGlobal("fetch", vi.fn());
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
function mockFetch(body: unknown, ok = true, status = 200) {
|
|
(fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
|
ok,
|
|
status,
|
|
text: async () => JSON.stringify(body),
|
|
json: async () => body,
|
|
} as Response);
|
|
}
|
|
|
|
it("throws when the API returns a non-200 status", async () => {
|
|
mockFetch({}, false, 401);
|
|
await expect(fetchClaudeQuota("token")).rejects.toThrow("anthropic usage api returned 401");
|
|
});
|
|
|
|
it("returns an empty array when all window fields are absent", async () => {
|
|
mockFetch({});
|
|
const windows = await fetchClaudeQuota("token");
|
|
expect(windows).toEqual([]);
|
|
});
|
|
|
|
it("parses five_hour window with percentage-range utilization", async () => {
|
|
mockFetch({ five_hour: { utilization: 34.0, resets_at: "2026-01-01T00:00:00Z" } });
|
|
const windows = await fetchClaudeQuota("token");
|
|
expect(windows).toHaveLength(1);
|
|
expect(windows[0]).toMatchObject({
|
|
label: "Current session",
|
|
usedPercent: 34,
|
|
resetsAt: "2026-01-01T00:00:00Z",
|
|
});
|
|
});
|
|
|
|
it("parses seven_day window with percentage-range utilization", async () => {
|
|
mockFetch({ seven_day: { utilization: 91.0, resets_at: null } });
|
|
const windows = await fetchClaudeQuota("token");
|
|
expect(windows).toHaveLength(1);
|
|
expect(windows[0]).toMatchObject({
|
|
label: "Current week (all models)",
|
|
usedPercent: 91,
|
|
resetsAt: null,
|
|
});
|
|
});
|
|
|
|
it("still handles legacy 0-1 fraction utilization", async () => {
|
|
mockFetch({ five_hour: { utilization: 0.4, resets_at: null } });
|
|
const windows = await fetchClaudeQuota("token");
|
|
expect(windows[0]).toMatchObject({
|
|
label: "Current session",
|
|
usedPercent: 40,
|
|
});
|
|
});
|
|
|
|
it("parses seven_day_sonnet and seven_day_opus windows", async () => {
|
|
mockFetch({
|
|
seven_day_sonnet: { utilization: 23.0, resets_at: null },
|
|
seven_day_opus: { utilization: 85.0, resets_at: null },
|
|
});
|
|
const windows = await fetchClaudeQuota("token");
|
|
expect(windows).toHaveLength(2);
|
|
expect(windows[0]!.label).toBe("Current week (Sonnet only)");
|
|
expect(windows[0]!.usedPercent).toBe(23);
|
|
expect(windows[1]!.label).toBe("Current week (Opus only)");
|
|
expect(windows[1]!.usedPercent).toBe(85);
|
|
});
|
|
|
|
it("sets usedPercent to null when utilization is absent", async () => {
|
|
mockFetch({ five_hour: { resets_at: null } });
|
|
const windows = await fetchClaudeQuota("token");
|
|
expect(windows[0]!.usedPercent).toBe(null);
|
|
});
|
|
|
|
it("includes all four windows when all are present", async () => {
|
|
mockFetch({
|
|
five_hour: { utilization: 10.0, resets_at: null },
|
|
seven_day: { utilization: 20.0, resets_at: null },
|
|
seven_day_sonnet: { utilization: 30.0, resets_at: null },
|
|
seven_day_opus: { utilization: 40.0, resets_at: null },
|
|
});
|
|
const windows = await fetchClaudeQuota("token");
|
|
expect(windows).toHaveLength(4);
|
|
const labels = windows.map((w: QuotaWindow) => w.label);
|
|
expect(labels).toEqual([
|
|
"Current session",
|
|
"Current week (all models)",
|
|
"Current week (Sonnet only)",
|
|
"Current week (Opus only)",
|
|
]);
|
|
expect(windows.map((w: QuotaWindow) => w.usedPercent)).toEqual([10, 20, 30, 40]);
|
|
});
|
|
|
|
it("parses extra usage when the OAuth response includes it", async () => {
|
|
mockFetch({
|
|
extra_usage: {
|
|
is_enabled: false,
|
|
utilization: null,
|
|
},
|
|
});
|
|
const windows = await fetchClaudeQuota("token");
|
|
expect(windows).toEqual([
|
|
{
|
|
label: "Extra usage",
|
|
usedPercent: null,
|
|
resetsAt: null,
|
|
valueLabel: "Not enabled",
|
|
detail: "Extra usage not enabled",
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("formats extra usage credits from cents to dollars", async () => {
|
|
mockFetch({
|
|
extra_usage: {
|
|
is_enabled: true,
|
|
monthly_limit: 14000,
|
|
used_credits: 6793,
|
|
utilization: 48.52,
|
|
},
|
|
});
|
|
const windows = await fetchClaudeQuota("token");
|
|
expect(windows).toHaveLength(1);
|
|
expect(windows[0]).toMatchObject({
|
|
label: "Extra usage",
|
|
usedPercent: 49,
|
|
valueLabel: "$67.93 / $140.00",
|
|
detail: "Monthly extra usage pool",
|
|
});
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// fetchCodexQuota — response parsing (credits, windows)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("fetchCodexQuota", () => {
|
|
beforeEach(() => {
|
|
vi.stubGlobal("fetch", vi.fn());
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
function mockFetch(body: unknown, ok = true, status = 200) {
|
|
(fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
|
ok,
|
|
status,
|
|
text: async () => JSON.stringify(body),
|
|
json: async () => body,
|
|
} as Response);
|
|
}
|
|
|
|
it("throws when the WHAM API returns a non-200 status", async () => {
|
|
mockFetch({}, false, 403);
|
|
await expect(fetchCodexQuota("token", null)).rejects.toThrow("chatgpt wham api returned 403");
|
|
});
|
|
|
|
it("passes ChatGPT-Account-Id header when accountId is provided", async () => {
|
|
mockFetch({});
|
|
await fetchCodexQuota("token", "acc-xyz");
|
|
const callInit = (fetch as ReturnType<typeof vi.fn>).mock.calls[0][1] as RequestInit;
|
|
expect((callInit.headers as Record<string, string>)["ChatGPT-Account-Id"]).toBe("acc-xyz");
|
|
});
|
|
|
|
it("omits ChatGPT-Account-Id header when accountId is null", async () => {
|
|
mockFetch({});
|
|
await fetchCodexQuota("token", null);
|
|
const callInit = (fetch as ReturnType<typeof vi.fn>).mock.calls[0][1] as RequestInit;
|
|
expect((callInit.headers as Record<string, string>)["ChatGPT-Account-Id"]).toBeUndefined();
|
|
});
|
|
|
|
it("returns empty array when response body is empty", async () => {
|
|
mockFetch({});
|
|
const windows = await fetchCodexQuota("token", null);
|
|
expect(windows).toEqual([]);
|
|
});
|
|
|
|
it("normalizes numeric reset timestamps from WHAM", async () => {
|
|
mockFetch({
|
|
rate_limit: {
|
|
primary_window: { used_percent: 30, limit_window_seconds: 86400, reset_at: 1_767_312_000 },
|
|
},
|
|
});
|
|
const windows = await fetchCodexQuota("token", null);
|
|
expect(windows).toHaveLength(1);
|
|
expect(windows[0]).toMatchObject({ label: "5h limit", usedPercent: 30, resetsAt: "2026-01-02T00:00:00.000Z" });
|
|
});
|
|
|
|
it("parses secondary_window alongside primary_window", async () => {
|
|
mockFetch({
|
|
rate_limit: {
|
|
primary_window: { used_percent: 10, limit_window_seconds: 18000 },
|
|
secondary_window: { used_percent: 60, limit_window_seconds: 604800 },
|
|
},
|
|
});
|
|
const windows = await fetchCodexQuota("token", null);
|
|
expect(windows).toHaveLength(2);
|
|
expect(windows[0]!.label).toBe("5h limit");
|
|
expect(windows[1]!.label).toBe("Weekly limit");
|
|
});
|
|
|
|
it("includes Credits window when credits present and not unlimited", async () => {
|
|
mockFetch({
|
|
credits: { balance: 420, unlimited: false },
|
|
});
|
|
const windows = await fetchCodexQuota("token", null);
|
|
expect(windows).toHaveLength(1);
|
|
expect(windows[0]).toMatchObject({ label: "Credits", valueLabel: "$4.20 remaining", usedPercent: null });
|
|
});
|
|
|
|
it("omits Credits window when unlimited is true", async () => {
|
|
mockFetch({
|
|
credits: { balance: 9999, unlimited: true },
|
|
});
|
|
const windows = await fetchCodexQuota("token", null);
|
|
expect(windows).toEqual([]);
|
|
});
|
|
|
|
it("shows 'N/A' valueLabel when credits balance is null", async () => {
|
|
mockFetch({
|
|
credits: { balance: null, unlimited: false },
|
|
});
|
|
const windows = await fetchCodexQuota("token", null);
|
|
expect(windows[0]!.valueLabel).toBe("N/A");
|
|
});
|
|
});
|
|
|
|
describe("mapCodexRpcQuota", () => {
|
|
it("maps account and model-specific Codex limits into quota windows", () => {
|
|
const snapshot = mapCodexRpcQuota(
|
|
{
|
|
rateLimits: {
|
|
limitId: "codex",
|
|
primary: { usedPercent: 1, windowDurationMins: 300, resetsAt: 1_763_500_000 },
|
|
secondary: { usedPercent: 27, windowDurationMins: 10_080 },
|
|
planType: "pro",
|
|
},
|
|
rateLimitsByLimitId: {
|
|
codex_bengalfox: {
|
|
limitId: "codex_bengalfox",
|
|
limitName: "GPT-5.3-Codex-Spark",
|
|
primary: { usedPercent: 8, windowDurationMins: 300 },
|
|
secondary: { usedPercent: 20, windowDurationMins: 10_080 },
|
|
},
|
|
},
|
|
},
|
|
{
|
|
account: {
|
|
email: "codex@example.com",
|
|
planType: "pro",
|
|
},
|
|
},
|
|
);
|
|
|
|
expect(snapshot.email).toBe("codex@example.com");
|
|
expect(snapshot.planType).toBe("pro");
|
|
expect(snapshot.windows).toEqual([
|
|
{
|
|
label: "5h limit",
|
|
usedPercent: 1,
|
|
resetsAt: "2025-11-18T21:06:40.000Z",
|
|
valueLabel: null,
|
|
detail: null,
|
|
},
|
|
{
|
|
label: "Weekly limit",
|
|
usedPercent: 27,
|
|
resetsAt: null,
|
|
valueLabel: null,
|
|
detail: null,
|
|
},
|
|
{
|
|
label: "GPT-5.3-Codex-Spark · 5h limit",
|
|
usedPercent: 8,
|
|
resetsAt: null,
|
|
valueLabel: null,
|
|
detail: null,
|
|
},
|
|
{
|
|
label: "GPT-5.3-Codex-Spark · Weekly limit",
|
|
usedPercent: 20,
|
|
resetsAt: null,
|
|
valueLabel: null,
|
|
detail: null,
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("includes a credits row when the root Codex limit reports finite credits", () => {
|
|
const snapshot = mapCodexRpcQuota({
|
|
rateLimits: {
|
|
limitId: "codex",
|
|
credits: {
|
|
unlimited: false,
|
|
balance: "12.34",
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(snapshot.windows).toEqual([
|
|
{
|
|
label: "Credits",
|
|
usedPercent: null,
|
|
resetsAt: null,
|
|
valueLabel: "$12.34 remaining",
|
|
detail: null,
|
|
},
|
|
]);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// fetchWithTimeout — abort on timeout
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("fetchWithTimeout", () => {
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("resolves normally when fetch completes before timeout", async () => {
|
|
const mockResponse = { ok: true, status: 200, json: async () => ({}) } as Response;
|
|
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(mockResponse));
|
|
|
|
const result = await fetchWithTimeout("https://example.com", {}, 5000);
|
|
expect(result.ok).toBe(true);
|
|
});
|
|
|
|
it("rejects with abort error when fetch takes too long", async () => {
|
|
vi.useFakeTimers();
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn().mockImplementation(
|
|
(_url: string, init: RequestInit) =>
|
|
new Promise((_resolve, reject) => {
|
|
init.signal?.addEventListener("abort", () => {
|
|
reject(new DOMException("The operation was aborted.", "AbortError"));
|
|
});
|
|
}),
|
|
),
|
|
);
|
|
|
|
const promise = fetchWithTimeout("https://example.com", {}, 1000);
|
|
vi.advanceTimersByTime(1001);
|
|
await expect(promise).rejects.toThrow("aborted");
|
|
});
|
|
});
|