From e274ca7ed522616b912ce98456c870935336faba Mon Sep 17 00:00:00 2001 From: le king fu Date: Sun, 4 Oct 2026 09:28:33 -0400 Subject: [PATCH 1/3] feat(agent): add the claude-usage agent: plan limits, local cache, push limits.js reads the Claude Code OAuth credentials read-only and calls the pinned Anthropic usage endpoint once (redirect refused, 10 s timeout, no retry, never a refresh); detail codes come from a fixed enum only. state.js freezes every Toronto day before yesterday, empty ones included, prunes past 35 days, keeps the last good limits, and writes atomically in mode 600. push-claude-usage.js writes the cache before pushing, refuses a non-https URL (exit 1) and a body over 64 KiB (exit 2, nothing sent), and prints no token. run-claude-usage.sh takes a non-blocking flock so a beat that overlaps the previous one exits 0. Co-Authored-By: Claude Opus 5.5 --- __tests__/agent-claude-usage.test.js | 929 ++++++++++++++++++ .../fixtures/claude-usage/oauth-usage.json | 53 + agent/claude-usage/limits.js | 276 ++++++ agent/claude-usage/push-claude-usage.js | 238 +++++ agent/claude-usage/run-claude-usage.sh | 85 ++ agent/claude-usage/state.js | 163 +++ 6 files changed, 1744 insertions(+) create mode 100644 __tests__/fixtures/claude-usage/oauth-usage.json create mode 100644 agent/claude-usage/limits.js create mode 100755 agent/claude-usage/push-claude-usage.js create mode 100755 agent/claude-usage/run-claude-usage.sh create mode 100644 agent/claude-usage/state.js diff --git a/__tests__/agent-claude-usage.test.js b/__tests__/agent-claude-usage.test.js index dc8391f..5ad23fb 100644 --- a/__tests__/agent-claude-usage.test.js +++ b/__tests__/agent-claude-usage.test.js @@ -753,3 +753,932 @@ describe("prototype-shaped names", () => { ); }); }); + +// ============================================================================ +// The agent around the aggregation (issue #50): plan limits, local cache, +// snapshot assembly and delivery, cron wrapper. +// +// Nothing here touches the real ~/.claude or ~/.local, nor the network: every +// path (home, credentials, state dir, transcripts root) and `fetch` itself are +// injected, and the spawned processes run with HOME pointed at a temp dir. +// The two tokens below are checked, fragment by fragment, against every output +// that would reach `logger -t claude-usage`, as in agent.test.js. +// ============================================================================ + +const { execFile, spawn } = require("node:child_process"); +const { promisify } = require("node:util"); +const execFileAsync = promisify(execFile); + +const { + fetchLimits, + parseUsage, + USAGE_URL, + DETAILS, +} = require("../agent/claude-usage/limits.js"); +const { + loadState, + saveState, + nextState, + limitsForSnapshot, + emptyState, + stateFilePath, + defaultStateDir, + RETENTION_DAYS, +} = require("../agent/claude-usage/state.js"); +const { + main: agentMain, + readConfig: agentReadConfig, + MAX_BODY_BYTES, + EXIT_OK, + EXIT_CONFIG, + EXIT_PUSH, +} = require("../agent/claude-usage/push-claude-usage.js"); +const { sanitizeClaudeUsage } = require("../claude-usage.js"); + +const AGENT_DIR = path.join(__dirname, "..", "agent", "claude-usage"); +const PUSH_PATH = path.join(AGENT_DIR, "push-claude-usage.js"); +const WRAPPER_PATH = path.join(AGENT_DIR, "run-claude-usage.sh"); +const USAGE_FIXTURE = path.join(__dirname, "fixtures", "claude-usage", "oauth-usage.json"); + +// Mixed letters and digits, no run of five digits, so neither a port nor a +// timestamp can ever look like a leak. +const OAUTH_TOKEN = "sk9ant8oat2q7w4e1r6t3y5u"; +const INGEST_TOKEN = "c4u9g2i7n3g8e5s1t6k0z"; +const API_URL = "https://health.example.test"; +const NOW = Date.parse("2026-10-04T16:00:00.000Z"); // Toronto 2026-10-04 12:00 +const FUTURE = NOW + 3600 * 1000; +const LIMIT_STATUS_ENUM = ["ok", "token_expired", "http_error", "unavailable", "disabled"]; +const DETAIL_ENUM = Object.values(DETAILS); + +function usageFixture() { + return JSON.parse(fs.readFileSync(USAGE_FIXTURE, "utf-8")); +} + +function fragments(token, min = 5) { + const out = []; + for (let len = min; len <= token.length; len++) { + for (let i = 0; i + len <= token.length; i++) out.push(token.slice(i, i + len)); + } + return out; +} + +function expectNoLeak(text) { + const haystack = String(text); + for (const token of [OAUTH_TOKEN, INGEST_TOKEN]) { + const leaked = fragments(token).filter((f) => haystack.includes(f)); + const worst = leaked.reduce((a, b) => (b.length > a.length ? b : a), ""); + expect(leaked.length, `token leaked — longest fragment found: "${worst}"`).toBe(0); + } +} + +function expectDetailInEnum(limits) { + expect(LIMIT_STATUS_ENUM).toContain(limits.status); + if (limits.detail !== undefined) { + const ok = DETAIL_ENUM.includes(limits.detail) || /^http_[1-5]\d\d$/.test(limits.detail); + expect(ok, `detail outside the enum: ${limits.detail}`).toBe(true); + } +} + +// A fetch double that records every call. `handler(url, init)` returns a +// Response (or a promise of one) or throws. +function makeFetch(handler) { + const calls = []; + const fn = async (url, init = {}) => { + calls.push({ url: String(url), init }); + return handler(String(url), init); + }; + fn.calls = calls; + return fn; +} + +function jsonResponse(body, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +// Anthropic answers with `usageBody`, the health API with `pushStatus`. +function routedFetch({ usage = () => jsonResponse(usageFixture()), push = () => new Response(null, { status: 204 }) } = {}) { + return makeFetch((url, init) => (url === USAGE_URL ? usage(url, init) : push(url, init))); +} + +// A fake home: /home with .claude/projects (optionally the fixture tree) +// and .claude/.credentials.json (optionally). +function makeHome({ fixtures = false, credentials } = {}) { + const home = path.join(tmpDir, "home"); + fs.mkdirSync(path.join(home, ".claude", "projects"), { recursive: true }); + if (fixtures) { + fs.cpSync(FIXTURES, path.join(home, ".claude", "projects"), { recursive: true }); + } + const credsPath = path.join(home, ".claude", ".credentials.json"); + if (credentials !== undefined) { + fs.writeFileSync( + credsPath, + typeof credentials === "string" ? credentials : JSON.stringify(credentials), + { mode: 0o600 }, + ); + } + return { home, credsPath, stateFile: stateFilePath(defaultStateDir(home)) }; +} + +function goodCredentials(expiresAt = FUTURE) { + return { + claudeAiOauth: { + accessToken: OAUTH_TOKEN, + refreshToken: "rt9single8use7only6", + expiresAt, + scopes: ["user:inference"], + }, + }; +} + +function agentEnv(extra = {}) { + return { CLAUDE_USAGE_API_URL: API_URL, CLAUDE_USAGE_INGEST_TOKEN: INGEST_TOKEN, ...extra }; +} + +// Runs main() in process with everything injected; collects the output. +async function runAgent({ home, fetch, env = agentEnv(), argv = [], now = NOW, ...deps }) { + const out = []; + const err = []; + const code = await agentMain(argv, env, { + home, + fetch, + now, + log: (l) => out.push(l), + logError: (l) => err.push(l), + ...deps, + }); + return { code, stdout: out.join("\n"), stderr: err.join("\n") }; +} + +function pushCalls(fetch) { + return fetch.calls.filter((c) => c.url !== USAGE_URL); +} + +// --- limits: parsing ---------------------------------------------------------- + +describe("limits — the endpoint's answer", () => { + const limitsOpts = (extra = {}) => ({ + env: {}, + credentialsPath: "/unused", + readFile: () => JSON.stringify(goodCredentials()), + claudeCodeVersion: "2.1.283", + now: NOW, + ...extra, + }); + + it("maps the anonymized limits[] to items, weekly_scoped carrying model Fable", async () => { + const fetch = makeFetch(() => jsonResponse(usageFixture())); + const r = await fetchLimits(limitsOpts({ fetch })); + expect(r).toEqual({ + status: "ok", + fetchedAt: new Date(NOW).toISOString(), + items: [ + { kind: "session", group: "session", percent: 1, severity: "normal", resetsAt: "2026-09-27T21:30:00Z", model: null }, + { kind: "weekly_all", group: "weekly", percent: 50, severity: "normal", resetsAt: "2026-10-01T07:00:00Z", model: null }, + { kind: "weekly_scoped", group: "weekly", percent: 63, severity: "normal", resetsAt: "2026-10-01T07:00:00Z", model: "Fable" }, + ], + }); + expect(fetch.calls).toHaveLength(1); + }); + + it.each([ + ["absent", (b) => delete b.limits], + ["empty", (b) => (b.limits = [])], + ["made of junk entries", (b) => (b.limits = [null, 3, "x", { percent: 9 }])], + ])("falls back to five_hour / seven_day when limits[] is %s", (_label, mutate) => { + const body = usageFixture(); + mutate(body); + expect(parseUsage(body)).toEqual([ + { kind: "session", group: "session", percent: 1, resetsAt: "2026-09-27T21:30:00.512345+00:00", model: null }, + { kind: "weekly_all", group: "weekly", percent: 50, resetsAt: "2026-10-01T07:00:00.512345+00:00", model: null }, + ]); + }); + + it("an all-null answer is unavailable/bad_response", async () => { + const body = Object.fromEntries(Object.keys(usageFixture()).map((k) => [k, null])); + const fetch = makeFetch(() => jsonResponse(body)); + const r = await fetchLimits(limitsOpts({ fetch })); + expect(r).toEqual({ status: "unavailable", detail: "bad_response", fetchedAt: null, items: [] }); + }); + + it("rounds and clamps percent to an integer 0-100, drops a non-number, normalizes resetsAt", () => { + const items = parseUsage({ + limits: [ + { kind: "a", group: "g", percent: 62.6, resets_at: 1790000000 }, + { kind: "b", group: "g", percent: 130, resets_at: "not a date" }, + { kind: "c", group: "g", percent: -3, resets_at: "Thu, 01 Oct 2026 07:00:00 GMT" }, + { kind: "d", percent: "50", scope: { model: { display_name: 42 } } }, + ], + }); + expect(items.map((i) => i.percent)).toEqual([63, 100, 0, undefined]); + expect("percent" in items[3]).toBe(false); + expect(items[3].group).toBe("d"); + expect(items[3].model).toBeNull(); + expect(items[0].resetsAt).toBe(new Date(1790000000 * 1000).toISOString()); + expect(items[1].resetsAt).toBeNull(); + expect(items[2].resetsAt).toBe("2026-10-01T07:00:00.000Z"); + }); + + it("keeps at most 12 items and no upstream key outside the whitelist", () => { + const limits = Array.from({ length: 20 }, (_, i) => ({ + kind: `k${i}`, group: "g", percent: 1, severity: "normal", resets_at: null, + scope: null, is_active: true, __proto__x: "junk", token: OAUTH_TOKEN, + })); + const items = parseUsage({ limits }); + expect(items).toHaveLength(12); + for (const it of items) { + expect(Object.keys(it).sort()).toEqual(["group", "kind", "model", "percent", "resetsAt", "severity"]); + } + expectNoLeak(JSON.stringify(items)); + }); +}); + +// --- limits: when no call is made ---------------------------------------------- + +describe("limits — cases that never call the endpoint", () => { + it.each([ + ["CLAUDE_USAGE_LIMITS=off", { env: { CLAUDE_USAGE_LIMITS: "off" } }, { status: "disabled" }], + ["CLAUDE_USAGE_LIMITS=OFF", { env: { CLAUDE_USAGE_LIMITS: " OFF " } }, { status: "disabled" }], + ["an expired token", { creds: JSON.stringify(goodCredentials(NOW - 1)) }, { status: "token_expired" }], + ["truncated credentials (rewrite in progress)", { creds: '{"claudeAiOauth":{"accessToken":"sk9ant8o' }, { status: "unavailable", detail: "parse_error" }], + ["absent credentials", { missing: true }, { status: "unavailable", detail: "no_credentials" }], + ["credentials without an access token", { creds: '{"claudeAiOauth":{}}' }, { status: "unavailable", detail: "no_credentials" }], + ["an unreadable credentials file", { unreadable: true }, { status: "unavailable", detail: "parse_error" }], + ["no Claude Code version", { version: null }, { status: "unavailable", detail: "no_version" }], + ])("%s", async (_label, c, expected) => { + const fetch = makeFetch(() => jsonResponse(usageFixture())); + const readFile = () => { + if (c.missing) throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }); + if (c.unreadable) throw Object.assign(new Error("EACCES"), { code: "EACCES" }); + return c.creds || JSON.stringify(goodCredentials()); + }; + const r = await fetchLimits({ + env: c.env || {}, + credentialsPath: "/unused", + readFile, + fetch, + claudeCodeVersion: c.version === null ? null : "2.1.283", + now: NOW, + }); + expect(fetch.calls).toHaveLength(0); + expect(r).toEqual({ ...expected, fetchedAt: null, items: [] }); + expectNoLeak(JSON.stringify(r)); + }); + + it("CLAUDE_USAGE_LIMITS=off does not even read the credentials", async () => { + const readFile = vi.fn(() => JSON.stringify(goodCredentials())); + const fetch = makeFetch(() => jsonResponse(usageFixture())); + await fetchLimits({ env: { CLAUDE_USAGE_LIMITS: "off" }, readFile, fetch, claudeCodeVersion: "2.1.283" }); + expect(readFile).not.toHaveBeenCalled(); + expect(fetch.calls).toHaveLength(0); + }); +}); + +// --- limits: the request and its failures -------------------------------------- + +describe("limits — the request", () => { + const base = (fetch, env = {}) => ({ + env, + credentialsPath: "/unused", + readFile: () => JSON.stringify(goodCredentials()), + fetch, + claudeCodeVersion: "2.1.283", + now: NOW, + }); + + it("sends the OAuth bearer to the pinned Anthropic URL only, redirects refused, whatever the environment says", async () => { + expect(USAGE_URL).toBe("https://api.anthropic.com/api/oauth/usage"); + const fetch = makeFetch(() => jsonResponse(usageFixture())); + await fetchLimits( + base(fetch, { + ANTHROPIC_BASE_URL: "https://evil.example", + ANTHROPIC_API_URL: "https://evil.example", + CLAUDE_USAGE_API_URL: "https://evil.example", + CLAUDE_USAGE_LIMITS_URL: "https://evil.example", + CLAUDE_USAGE_LIMITS: "on", + }), + ); + expect(fetch.calls).toHaveLength(1); + const { url, init } = fetch.calls[0]; + expect(url).toBe("https://api.anthropic.com/api/oauth/usage"); + expect(init.method).toBe("GET"); + expect(init.redirect).toBe("error"); + expect(init.signal).toBeInstanceOf(AbortSignal); + expect(init.headers).toMatchObject({ + Authorization: `Bearer ${OAUTH_TOKEN}`, + "anthropic-beta": "oauth-2025-04-20", + "User-Agent": "claude-code/2.1.283", + }); + }); + + it("limits.js reads no environment variable but CLAUDE_USAGE_LIMITS, and writes the URL once", () => { + const src = fs.readFileSync(path.join(AGENT_DIR, "limits.js"), "utf-8"); + const code = src + .split("\n") + .filter((l) => !l.trim().startsWith("//") && !l.trim().startsWith("*")) + .join("\n"); + expect([...code.matchAll(/\benv\.([A-Za-z_]+)/g)].map((m) => m[1])).toEqual(["CLAUDE_USAGE_LIMITS"]); + // process.env appears once, as the default of the injectable `env`. + expect(code.match(/process\.env/g)).toEqual(["process.env"]); + expect(code).toContain("env = process.env,"); + expect(code.match(/https:\/\//g)).toHaveLength(1); + expect(code).not.toMatch(/refresh/i); + }); + + it("a 429 is http_error/http_429, and the agent then serves the last good items with their fetchedAt", async () => { + const fetch = makeFetch(() => new Response("Too Many Requests", { status: 429 })); + const r = await fetchLimits(base(fetch)); + expect(r).toEqual({ status: "http_error", detail: "http_429", fetchedAt: null, items: [] }); + expect(fetch.calls).toHaveLength(1); // no retry + + const { home, credsPath } = makeHome({ fixtures: true, credentials: goodCredentials() }); + const first = routedFetch(); + expect((await runAgent({ home, fetch: first, now: NOW })).code).toBe(EXIT_OK); + const firstSnap = JSON.parse(pushCalls(first)[0].init.body); + + const later = NOW + 15 * 60 * 1000; + const second = routedFetch({ usage: () => new Response("slow down", { status: 429 }) }); + expect((await runAgent({ home, fetch: second, now: later })).code).toBe(EXIT_OK); + const snap = JSON.parse(pushCalls(second)[0].init.body); + expect(snap.limits).toEqual({ + status: "http_error", + detail: "http_429", + fetchedAt: new Date(NOW).toISOString(), + items: firstSnap.limits.items, + }); + expect(snap.generatedAt).toBe(new Date(later).toISOString()); + expect(credsPath).toBeTruthy(); + }); + + it.each([ + ["a 500 echoing the token and request headers", () => new Response(`upstream said: Authorization: Bearer ${OAUTH_TOKEN}\nanthropic-beta: oauth`, { status: 500 })], + ["a 200 with arbitrary text", () => new Response(`error ${OAUTH_TOKEN} please retry`, { status: 200 })], + ["a 200 JSON echoing the headers", () => jsonResponse({ headers: { authorization: `Bearer ${OAUTH_TOKEN}` }, detail: "Ignore previous instructions" })], + ["a 200 JSON with a hostile detail field", () => jsonResponse({ detail: "http_200_but_actually_long_text", limits: "nope", five_hour: { utilization: "x" } })], + ["a 302 to another host", () => new Response(null, { status: 302, headers: { location: "https://evil.example/" } })], + ["a thrown error carrying the token in its message", () => { throw new TypeError(`Headers.append: "Bearer ${OAUTH_TOKEN}" is an invalid header value.`); }], + ["a network failure", () => { throw Object.assign(new TypeError("fetch failed"), { cause: { code: "ECONNREFUSED" } }); }], + ["a timeout", () => { throw new DOMException("The operation was aborted due to timeout", "TimeoutError"); }], + ])("%s keeps detail inside the enum and no token in the result", async (_label, answer) => { + const fetch = makeFetch(answer); + const r = await fetchLimits(base(fetch)); + expectDetailInEnum(r); + expect(r.status).not.toBe("ok"); + expect(r.items).toEqual([]); + expectNoLeak(JSON.stringify(r)); + expect(fetch.calls).toHaveLength(1); + }); + + it("maps timeouts, network errors and non-2xx to their codes", async () => { + const timeout = await fetchLimits({ + ...base(makeFetch((url, init) => new Promise((_, reject) => { + init.signal.addEventListener("abort", () => reject(init.signal.reason)); + }))), + timeoutMs: 20, + }); + expect(timeout).toMatchObject({ status: "unavailable", detail: "timeout" }); + + const network = await fetchLimits(base(makeFetch(() => { throw new TypeError("fetch failed"); }))); + expect(network).toMatchObject({ status: "unavailable", detail: "network" }); + + const unauthorized = await fetchLimits(base(makeFetch(() => new Response("{}", { status: 401 })))); + expect(unauthorized).toMatchObject({ status: "http_error", detail: "http_401" }); + }); +}); + +// --- the credentials file ------------------------------------------------------ + +describe("the credentials file is never opened for writing", () => { + it("a full run only ever reads it, through fs.readFileSync without a flag", async () => { + const { home, credsPath } = makeHome({ fixtures: true, credentials: goodCredentials() }); + const before = fs.readFileSync(credsPath, "utf-8"); + const beforeStat = fs.statSync(credsPath); + + const writers = [ + "writeFileSync", "writeFile", "appendFileSync", "appendFile", "createWriteStream", + "renameSync", "rename", "copyFileSync", "copyFile", "cpSync", "rmSync", "rm", + "unlinkSync", "unlink", "truncateSync", "truncate", "chmodSync", "chmod", + "chownSync", "utimesSync", "symlinkSync", "linkSync", "openSync", "open", + ]; + const spies = writers.map((name) => vi.spyOn(fs, name)); + const promiseWriters = ["writeFile", "appendFile", "open", "rename", "copyFile", "rm", "unlink", "truncate", "chmod"]; + const promiseSpies = promiseWriters.map((name) => vi.spyOn(fs.promises, name)); + const readSpy = vi.spyOn(fs, "readFileSync"); + + const fetch = routedFetch(); + const { code } = await runAgent({ home, fetch }); + expect(code).toBe(EXIT_OK); + expect(fetch.calls.filter((c) => c.url === USAGE_URL)).toHaveLength(1); + + const credReads = readSpy.mock.calls.filter((c) => c[0] === credsPath); + expect(credReads).toHaveLength(1); + expect(credReads[0][1]).toBe("utf-8"); + + for (const spy of [...spies, ...promiseSpies]) { + for (const args of spy.mock.calls) { + const touches = args.some((a) => String(a) === credsPath || String(a).startsWith(`${credsPath}.`)); + expect(touches, `${spy.getMockName()} touched the credentials file`).toBe(false); + } + } + // The state file, on the other hand, was written: the spies were live. + expect(spies[0].mock.calls.length + spies[6].mock.calls.length).toBeGreaterThan(0); + + expect(fs.readFileSync(credsPath, "utf-8")).toBe(before); + expect(fs.statSync(credsPath).mtimeMs).toBe(beforeStat.mtimeMs); + }); +}); + +// --- state ----------------------------------------------------------------------- + +describe("state — local cache", () => { + it.each([ + ["missing", null], + ["truncated", '{"version":1,"frozenDays":{"2026-09-'], + ["not an object", "[1,2,3]"], + ["binary junk", "\u0000\u0001ÿ"], + ])("a %s state.json gives the empty state", (_label, content) => { + const file = path.join(tmpDir, "state.json"); + if (content !== null) fs.writeFileSync(file, content); + expect(loadState(file)).toEqual(emptyState()); + }); + + it("keeps only well-formed frozen days and last good limits on reload", () => { + const file = path.join(tmpDir, "state.json"); + fs.writeFileSync( + file, + JSON.stringify({ + frozenDays: { + "2026-10-01": { models: { m: { input: 1 } }, projects: {} }, + "2026-02-30": { models: {}, projects: {} }, + "not-a-day": { models: {}, projects: {} }, + "2026-09-30": "junk", + "2026-09-29": { models: [], projects: null }, + }, + lastGoodLimits: { fetchedAt: "garbage", items: [] }, + }), + ); + const s = loadState(file); + expect(Object.keys(s.frozenDays).sort()).toEqual(["2026-09-29", "2026-10-01"]); + expect(s.frozenDays["2026-09-29"]).toEqual({ models: {}, projects: {} }); + expect(s.lastGoodLimits).toBeNull(); + }); + + it("freezes every open day strictly before yesterday, empty ones included; yesterday and today stay open", () => { + const openDays = { + "2026-09-05": { models: {}, projects: {} }, + "2026-10-01": { models: { m: { input: 1 } }, projects: { p: { work: 1 } } }, + "2026-10-02": { models: {}, projects: {} }, + "2026-10-03": { models: { m: { input: 2 } }, projects: {} }, + "2026-10-04": { models: {}, projects: {} }, + }; + const s = nextState(emptyState(), { openDays, today: "2026-10-04", limits: { status: "disabled" } }); + expect(Object.keys(s.frozenDays).sort()).toEqual(["2026-09-05", "2026-10-01", "2026-10-02"]); + expect(s.frozenDays["2026-10-02"]).toEqual({ models: {}, projects: {} }); + expect(s.frozenDays["2026-10-01"]).toEqual(openDays["2026-10-01"]); + }); + + it(`prunes frozen days older than ${RETENTION_DAYS} days`, () => { + const state = { + ...emptyState(), + frozenDays: { + "2026-08-29": { models: {}, projects: {} }, // today - 36 + "2026-08-30": { models: {}, projects: {} }, // today - 35 + "2026-09-20": { models: {}, projects: {} }, + }, + }; + const s = nextState(state, { openDays: {}, today: "2026-10-04", limits: { status: "disabled" } }); + expect(Object.keys(s.frozenDays).sort()).toEqual(["2026-08-30", "2026-09-20"]); + }); + + it("updates lastGoodLimits on `ok` only", () => { + const ok = { status: "ok", fetchedAt: "2026-10-04T16:00:00.000Z", items: [{ kind: "session" }] }; + const s1 = nextState(emptyState(), { openDays: {}, today: "2026-10-04", limits: ok }); + expect(s1.lastGoodLimits).toEqual({ fetchedAt: ok.fetchedAt, items: ok.items }); + for (const failed of [ + { status: "http_error", detail: "http_429", fetchedAt: null, items: [] }, + { status: "token_expired", fetchedAt: null, items: [] }, + { status: "unavailable", detail: "timeout", fetchedAt: null, items: [] }, + { status: "disabled", fetchedAt: null, items: [] }, + ]) { + const s2 = nextState(s1, { openDays: {}, today: "2026-10-04", limits: failed }); + expect(s2.lastGoodLimits).toEqual(s1.lastGoodLimits); + expect(limitsForSnapshot(failed, s2)).toEqual({ + status: failed.status, + ...(failed.detail ? { detail: failed.detail } : {}), + fetchedAt: ok.fetchedAt, + items: ok.items, + }); + } + expect(limitsForSnapshot({ status: "token_expired", fetchedAt: null, items: [] }, emptyState())).toEqual({ + status: "token_expired", + fetchedAt: null, + items: [], + }); + }); + + it("writes atomically: mode 600, temp file suffixed with the pid in the same directory, then rename", () => { + const file = path.join(tmpDir, "deep", "state", "state.json"); + const writeSpy = vi.spyOn(fs, "writeFileSync"); + const renameSpy = vi.spyOn(fs, "renameSync"); + saveState(file, { ...emptyState(), frozenDays: { "2026-10-01": { models: {}, projects: {} } } }); + + const tmp = `${file}.${process.pid}.tmp`; + expect(writeSpy.mock.calls.map((c) => c[0])).toEqual([tmp]); + expect(renameSpy.mock.calls).toEqual([[tmp, file]]); + expect(fs.existsSync(tmp)).toBe(false); + expect(fs.statSync(file).mode & 0o777).toBe(0o600); + expect(loadState(file).frozenDays).toEqual({ "2026-10-01": { models: {}, projects: {} } }); + }); + + it("the default state dir is ~/.local/state/maximus-claude-usage", () => { + expect(stateFilePath(defaultStateDir("/home/u"))).toBe("/home/u/.local/state/maximus-claude-usage/state.json"); + }); +}); + +// --- push-claude-usage: one beat ---------------------------------------------- + +describe("push-claude-usage — one beat", () => { + it("two consecutive beats around an empty day: the empty day is frozen, the second beat opens no file before the threshold", async () => { + const { home, stateFile } = makeHome({ credentials: undefined }); + const root = path.join(home, ".claude", "projects"); + const projectDir = path.join(root, "-home-tester-claude-code-alpha"); + fs.mkdirSync(projectDir, { recursive: true }); + const write = (name, lines, mtimeIso) => { + const file = path.join(projectDir, name); + fs.writeFileSync(file, `${lines.map((l) => JSON.stringify(l)).join("\n")}\n`); + setMtime(file, mtimeIso); + return file; + }; + // Lines on Oct 1 (Toronto 23:00), file last touched Oct 2 00:30 Toronto: + // Oct 2 itself has no line at all. Unfrozen, that empty day would hold the + // threshold on Oct 2 and this file would be re-read on every beat. + const old = write("old.jsonl", [line({ ts: "2026-10-02T03:00:00Z", output: 40 })], "2026-10-02T04:30:00Z"); + const recent = write( + "recent.jsonl", + [line({ ts: "2026-10-03T15:00:00Z", output: 7 }), line({ ts: "2026-10-04T15:00:00Z", output: 9 })], + "2026-10-04T15:30:00Z", + ); + const env = agentEnv({ CLAUDE_USAGE_LIMITS: "off" }); + + const firstOpened = []; + const fetch1 = routedFetch(); + const r1 = await runAgent({ + home, fetch: fetch1, env, + openFile: (f, o) => { firstOpened.push(f); return fs.createReadStream(f, o); }, + }); + expect(r1.code).toBe(EXIT_OK); + expect(firstOpened.sort()).toEqual([old, recent].sort()); + + const frozen = JSON.parse(fs.readFileSync(stateFile, "utf-8")).frozenDays; + expect(frozen["2026-10-02"]).toEqual({ models: {}, projects: {} }); + expect(frozen["2026-10-01"].models["claude-opus-5-5"].output).toBe(40); + expect(frozen["2026-10-03"]).toBeUndefined(); // yesterday stays open + expect(frozen["2026-10-04"]).toBeUndefined(); + expect(Object.keys(frozen)).toHaveLength(28); // the window minus yesterday and today + + const secondOpened = []; + const fetch2 = routedFetch(); + const r2 = await runAgent({ + home, fetch: fetch2, env, now: NOW + 15 * 60 * 1000, + openFile: (f, o) => { secondOpened.push(f); return fs.createReadStream(f, o); }, + }); + expect(r2.code).toBe(EXIT_OK); + expect(secondOpened).toEqual([recent]); + + const snap1 = JSON.parse(pushCalls(fetch1)[0].init.body); + const snap2 = JSON.parse(pushCalls(fetch2)[0].init.body); + expect(snap2.days).toEqual(snap1.days); // frozen days served as they were counted + expect(snap2.projects).toEqual(snap1.projects); + }); + + it("a corrupt state.json gives a full beat without failing, and is rewritten", async () => { + const { home, stateFile } = makeHome({ fixtures: true }); + fs.mkdirSync(path.dirname(stateFile), { recursive: true }); + fs.writeFileSync(stateFile, '{"frozenDays":{"2026-10-0'); + const opened = []; + const fetch = routedFetch(); + const r = await runAgent({ + home, fetch, env: agentEnv({ CLAUDE_USAGE_LIMITS: "off" }), + openFile: (f, o) => { opened.push(f); return fs.createReadStream(f, o); }, + }); + expect(r.code).toBe(EXIT_OK); + expect(opened).toHaveLength(2); // the whole fixture tree + expect(Object.keys(loadState(stateFile).frozenDays).length).toBeGreaterThan(0); + expect(fs.statSync(stateFile).mode & 0o777).toBe(0o600); + }); + + it("writes the cache before the push: a failed push still leaves the frozen days on disk", async () => { + const { home, stateFile } = makeHome({ fixtures: true }); + const fetch = routedFetch({ push: () => new Response("nope", { status: 503 }) }); + const r = await runAgent({ home, fetch, env: agentEnv({ CLAUDE_USAGE_LIMITS: "off" }) }); + expect(r.code).toBe(EXIT_PUSH); + expect(r.stderr).toContain("push failed: HTTP 503"); + expect(Object.keys(loadState(stateFile).frozenDays).length).toBe(28); + }); + + it("the snapshot built on the fixtures goes through sanitizeClaudeUsage unchanged", async () => { + const { home } = makeHome({ fixtures: true, credentials: goodCredentials() }); + const fetch = routedFetch(); + const r = await runAgent({ home, fetch }); + expect(r.code).toBe(EXIT_OK); + const sent = JSON.parse(pushCalls(fetch)[0].init.body); + + expect(sent.schemaVersion).toBe(1); + expect(sent.timeZone).toBe("America/Toronto"); + expect(sent.claudeCodeVersion).toBe("2.1.283"); + expect(sent.days).toHaveLength(30); + expect(sent.days[29].date).toBe("2026-10-04"); + expect(sent.limits.status).toBe("ok"); + expect(sent.limits.items.find((i) => i.kind === "weekly_scoped").model).toBe("Fable"); + expect(sent.projects.length).toBeGreaterThan(0); + + const { snapshot, error } = sanitizeClaudeUsage(sent); + expect(error).toBeUndefined(); + expect(snapshot).toEqual(sent); + }); + + it.each([ + ["ok limits", { credentials: goodCredentials() }, {}], + ["expired token", { credentials: goodCredentials(NOW - 1) }, {}], + ["no credentials", {}, {}], + ["limits off", { credentials: goodCredentials() }, { CLAUDE_USAGE_LIMITS: "off" }], + ])("with %s, the snapshot still round-trips through the sanitizer", async (_label, homeOpts, envExtra) => { + const { home } = makeHome({ fixtures: true, ...homeOpts }); + const fetch = routedFetch(); + expect((await runAgent({ home, fetch, env: agentEnv(envExtra) })).code).toBe(EXIT_OK); + const sent = JSON.parse(pushCalls(fetch)[0].init.body); + expect(sanitizeClaudeUsage(sent).snapshot).toEqual(sent); + }); + + it("sends the OAuth bearer only to api.anthropic.com and the ingest token only to the health API", async () => { + const { home } = makeHome({ fixtures: true, credentials: goodCredentials() }); + const fetch = routedFetch(); + await runAgent({ home, fetch }); + expect(fetch.calls.map((c) => c.url)).toEqual([USAGE_URL, `${API_URL}/claude-usage`]); + + const [usage, push] = fetch.calls; + expect(usage.init.headers.Authorization).toBe(`Bearer ${OAUTH_TOKEN}`); + expect(JSON.stringify(usage)).not.toContain(INGEST_TOKEN); + expect(push.init.headers.Authorization).toBe(`Bearer ${INGEST_TOKEN}`); + expect(push.init.method).toBe("POST"); + expect(push.init.redirect).toBe("error"); + expect(push.init.signal).toBeInstanceOf(AbortSignal); + expect(JSON.stringify(push.init.headers)).not.toContain(OAUTH_TOKEN); + expect(push.init.body).not.toContain(OAUTH_TOKEN); + }); + + it("neither token appears in the snapshot nor in the output, on success or on hostile failures", async () => { + const scenarios = [ + routedFetch(), + routedFetch({ push: () => new Response(`denied ${INGEST_TOKEN} ${OAUTH_TOKEN}`, { status: 401 }) }), + routedFetch({ push: () => { throw new TypeError(`Headers.append: "Bearer ${INGEST_TOKEN}" is an invalid header value.`); } }), + routedFetch({ usage: () => new Response(`Authorization: Bearer ${OAUTH_TOKEN}`, { status: 500 }) }), + routedFetch({ push: () => { throw Object.assign(new Error(`connect to ${INGEST_TOKEN}`), { code: `E${INGEST_TOKEN}` }); } }), + ]; + for (const fetch of scenarios) { + fs.rmSync(path.join(tmpDir, "home"), { recursive: true, force: true }); + const { home } = makeHome({ fixtures: true, credentials: goodCredentials() }); + const r = await runAgent({ home, fetch }); + expectNoLeak(`${r.stdout}\n${r.stderr}`); + expectNoLeak(pushCalls(fetch)[0].init.body); + expectNoLeak(fs.readFileSync(stateFilePath(defaultStateDir(home)), "utf-8")); + } + }); + + it("the real process output contains no fragment of either token", async () => { + const { home } = makeHome({ fixtures: true, credentials: goodCredentials(Date.now() - 1000) }); + // Expired credentials (no Anthropic call), and a push target that refuses + // the connection: a real failing run, nothing leaves the machine. + const result = await execFileAsync(process.execPath, [PUSH_PATH], { + env: { + PATH: process.env.PATH, + HOME: home, + CLAUDE_USAGE_API_URL: "https://127.0.0.1:1", + CLAUDE_USAGE_INGEST_TOKEN: INGEST_TOKEN, + NO_PROXY: "*", + no_proxy: "*", + }, + }).catch((e) => e); + expect(result.code).toBe(EXIT_PUSH); + expect(result.stderr).toMatch(/claude-usage: push failed: request error \(code=[A-Za-z0-9_]+\) limits=token_expired/); + expectNoLeak(`${result.stdout}${result.stderr}`); + }); + + it.each([ + ["http:", { CLAUDE_USAGE_API_URL: "http://health.example.test" }, "must be https://"], + ["unset URL", { CLAUDE_USAGE_API_URL: "" }, "CLAUDE_USAGE_API_URL is not set"], + ["garbage URL", { CLAUDE_USAGE_API_URL: "::not a url::" }, "not a valid URL"], + ["unset token", { CLAUDE_USAGE_INGEST_TOKEN: "" }, "CLAUDE_USAGE_INGEST_TOKEN is not set"], + ["token with a trailing newline", { CLAUDE_USAGE_INGEST_TOKEN: `${INGEST_TOKEN}\n` }, "invalid character"], + ])("configuration error (%s) exits 1 before any scan or call", async (_label, envExtra, message) => { + const { home, stateFile } = makeHome({ fixtures: true, credentials: goodCredentials() }); + const fetch = routedFetch(); + const openFile = vi.fn(); + const r = await runAgent({ home, fetch, env: agentEnv(envExtra), openFile }); + expect(r.code).toBe(EXIT_CONFIG); + expect(r.stderr).toContain(message); + expect(fetch.calls).toHaveLength(0); + expect(openFile).not.toHaveBeenCalled(); + expect(fs.existsSync(stateFile)).toBe(false); + expectNoLeak(`${r.stdout}${r.stderr}`); + }); + + it("readConfig appends /claude-usage to the base URL", () => { + expect(agentReadConfig(agentEnv({ CLAUDE_USAGE_API_URL: "https://h.example/" })).config.target).toBe( + "https://h.example/claude-usage", + ); + }); + + it(`a snapshot over ${MAX_BODY_BYTES} bytes logs payload_too_large, exits 2, sends nothing`, async () => { + const { home } = makeHome(); + const projectDir = path.join(home, ".claude", "projects", "big"); + fs.mkdirSync(projectDir, { recursive: true }); + // 30 days x 12 models with 64-character names and maximal counters: about + // 70 KB once serialized, every value still inside the contract. + const big = Number.MAX_SAFE_INTEGER; + const lines = []; + for (const date of windowDays("2026-10-04")) { + for (let m = 0; m < 12; m++) { + lines.push( + line({ + ts: `${date}T15:00:00Z`, + model: `m${String(m).padStart(2, "0")}-${"x".repeat(60)}`, + input: big, output: big, cacheCreation: big, cacheRead: big, + }), + ); + } + } + const file = path.join(projectDir, "s.jsonl"); + fs.writeFileSync(file, `${lines.map((l) => JSON.stringify(l)).join("\n")}\n`); + setMtime(file, "2026-10-04T15:30:00Z"); + + const fetch = routedFetch(); + const r = await runAgent({ home, fetch, env: agentEnv({ CLAUDE_USAGE_LIMITS: "off" }) }); + expect(r.code).toBe(EXIT_PUSH); + expect(r.stderr).toMatch(/payload_too_large bytes=\d+ max=65536/); + expect(Number(/bytes=(\d+)/.exec(r.stderr)[1])).toBeGreaterThan(MAX_BODY_BYTES); + expect(fetch.calls).toHaveLength(0); + }); + + it("one push attempt only: a failure is not retried", async () => { + const { home } = makeHome({ fixtures: true }); + const fetch = routedFetch({ push: () => { throw Object.assign(new TypeError("fetch failed"), { cause: { code: "ECONNRESET" } }); } }); + const r = await runAgent({ home, fetch, env: agentEnv({ CLAUDE_USAGE_LIMITS: "off" }) }); + expect(r.code).toBe(EXIT_PUSH); + expect(r.stderr).toContain("push failed: request error (code=ECONNRESET)"); + expect(pushCalls(fetch)).toHaveLength(1); + }); + + it("a 2xx answer exits 0 with a one-line summary", async () => { + const { home } = makeHome({ fixtures: true, credentials: goodCredentials() }); + const r = await runAgent({ home, fetch: routedFetch() }); + expect(r.code).toBe(EXIT_OK); + expect(r.stdout).toMatch(/^claude-usage: ok status=204 limits=ok bytes=\d+$/); + expect(r.stderr).toBe(""); + }); + + it("--dry-run prints the snapshot, needs no configuration, pushes nothing and leaves the cache alone", async () => { + const { home, stateFile } = makeHome({ fixtures: true }); + const fetch = routedFetch(); + const r = await runAgent({ home, fetch, env: { CLAUDE_USAGE_LIMITS: "off" }, argv: ["--dry-run"] }); + expect(r.code).toBe(EXIT_OK); + const snap = JSON.parse(r.stdout); + expect(snap.days).toHaveLength(30); + expect(snap.limits).toEqual({ status: "disabled", fetchedAt: null, items: [] }); + expect(fetch.calls).toHaveLength(0); + expect(fs.existsSync(stateFile)).toBe(false); + }); + + it("the agent modules load nothing but node built-ins and their siblings", () => { + for (const name of ["limits.js", "state.js", "push-claude-usage.js"]) { + const src = fs.readFileSync(path.join(AGENT_DIR, name), "utf-8"); + for (const [, mod] of src.matchAll(/require\("([^"]+)"\)/g)) { + expect(mod.startsWith("node:") || mod.startsWith("./"), `${name} requires ${mod}`).toBe(true); + } + } + }); +}); + +// --- run-claude-usage.sh ---------------------------------------------------------- + +describe("run-claude-usage.sh", () => { + function writeEnvFile(home, lines, mode = 0o600) { + const file = path.join(home, ".config", "maximus-claude-usage.env"); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, `${lines.join("\n")}\n`, { mode }); + fs.chmodSync(file, mode); + return file; + } + + // A stand-in for node: records that it ran, then holds the lock a while. + function writeStubNode(home, seconds) { + const stub = path.join(home, "stub-node"); + fs.writeFileSync(stub, `#!/bin/sh\necho run >> "$HOME/runs"\nsleep ${seconds}\n`, { mode: 0o755 }); + return stub; + } + + function runs(home) { + const file = path.join(home, "runs"); + return fs.existsSync(file) ? fs.readFileSync(file, "utf-8").trim().split("\n").length : 0; + } + + function wrapperEnv(home) { + return { PATH: process.env.PATH, HOME: home }; + } + + test("two runs in parallel: the second exits 0 without working", async () => { + const { home } = makeHome(); + const stub = writeStubNode(home, 2); + writeEnvFile(home, [ + `CLAUDE_USAGE_API_URL=${API_URL}`, + `CLAUDE_USAGE_INGEST_TOKEN=${INGEST_TOKEN}`, + `NODE_BIN=${stub}`, + ]); + + const first = spawn(WRAPPER_PATH, [], { env: wrapperEnv(home), stdio: "ignore" }); + const firstDone = new Promise((resolve) => first.on("exit", (code) => resolve(code))); + const deadline = Date.now() + 5000; + while (runs(home) === 0 && Date.now() < deadline) await new Promise((r) => setTimeout(r, 25)); + expect(runs(home)).toBe(1); + + const started = Date.now(); + const second = await execFileAsync(WRAPPER_PATH, [], { env: wrapperEnv(home) }); + expect(Date.now() - started).toBeLessThan(1500); + expect(second.stdout).toContain("claude-usage: a run is already in progress, skipping"); + expect(second.stderr).toBe(""); + expect(runs(home)).toBe(1); + + expect(await firstDone).toBe(0); + // The lock is released with the first run: the next beat works again. + const stub0 = writeStubNode(home, 0); + expect(stub0).toBe(stub); + await execFileAsync(WRAPPER_PATH, [], { env: wrapperEnv(home) }); + expect(runs(home)).toBe(2); + expect(fs.existsSync(path.join(home, ".local", "state", "maximus-claude-usage", "run.lock"))).toBe(true); + }, 15000); + + test("a dry run through the wrapper prints a valid snapshot and no token", async () => { + const { home } = makeHome({ fixtures: true }); + writeEnvFile(home, [ + `CLAUDE_USAGE_API_URL=${API_URL}`, + `CLAUDE_USAGE_INGEST_TOKEN=${INGEST_TOKEN}`, + "CLAUDE_USAGE_LIMITS=off", + `NODE_BIN=${process.execPath}`, + ]); + const result = await execFileAsync(WRAPPER_PATH, ["--dry-run"], { env: wrapperEnv(home) }); + const snap = JSON.parse(result.stdout); + expect(snap.limits.status).toBe("disabled"); // CLAUDE_USAGE_LIMITS was exported + expect(sanitizeClaudeUsage(snap).snapshot).toEqual(snap); + expect(result.stderr).toBe(""); + expectNoLeak(`${result.stdout}${result.stderr}`); + }); + + test.each([ + [["CLAUDE_USAGE_INGEST_TOKEN"], "CLAUDE_USAGE_API_URL is not set"], + [["CLAUDE_USAGE_API_URL"], "CLAUDE_USAGE_INGEST_TOKEN is not set"], + ])("stops when a variable is missing (%j)", async (present, message) => { + const { home } = makeHome(); + const values = { CLAUDE_USAGE_API_URL: API_URL, CLAUDE_USAGE_INGEST_TOKEN: INGEST_TOKEN }; + writeEnvFile(home, present.map((k) => `${k}=${values[k]}`)); + const result = await execFileAsync(WRAPPER_PATH, [], { env: wrapperEnv(home) }).catch((e) => e); + expect(result.code).toBe(1); + expect(result.stderr).toContain(message); + expectNoLeak(`${result.stdout}${result.stderr}`); + }); + + test("stops when the env file is absent, and warns on a mode other than 600", async () => { + const { home } = makeHome(); + const missing = await execFileAsync(WRAPPER_PATH, [], { env: wrapperEnv(home) }).catch((e) => e); + expect(missing.code).toBe(1); + expect(missing.stderr).toContain("claude-usage: env file not found:"); + + const stub = writeStubNode(home, 0); + writeEnvFile( + home, + [`CLAUDE_USAGE_API_URL=${API_URL}`, `CLAUDE_USAGE_INGEST_TOKEN=${INGEST_TOKEN}`, `NODE_BIN=${stub}`], + 0o644, + ); + const loose = await execFileAsync(WRAPPER_PATH, [], { env: wrapperEnv(home) }); + expect(loose.stderr).toContain("is mode 644, expected 600"); + expect(runs(home)).toBe(1); + expectNoLeak(`${loose.stdout}${loose.stderr}`); + }); + + test("never enables shell tracing — `set -x` would echo the token into syslog", () => { + const script = fs.readFileSync(WRAPPER_PATH, "utf-8"); + const traced = script + .split("\n") + .filter((l) => !l.trim().startsWith("#")) + .filter((l) => /\bset\b[^#\n]*-[a-z]*x/.test(l)); + expect(traced).toEqual([]); + }); + + test("is executable, and takes a non-blocking flock under ~/.local/state", () => { + expect(fs.statSync(WRAPPER_PATH).mode & 0o111).toBeGreaterThan(0); + const script = fs.readFileSync(WRAPPER_PATH, "utf-8"); + expect(script).toContain('STATE_DIR="$HOME/.local/state/maximus-claude-usage"'); + expect(script).toMatch(/flock -n 9/); + }); +}); diff --git a/__tests__/fixtures/claude-usage/oauth-usage.json b/__tests__/fixtures/claude-usage/oauth-usage.json new file mode 100644 index 0000000..4168657 --- /dev/null +++ b/__tests__/fixtures/claude-usage/oauth-usage.json @@ -0,0 +1,53 @@ +{ + "five_hour": { "utilization": 1.0, "resets_at": "2026-09-27T21:30:00.512345+00:00" }, + "seven_day": { "utilization": 50.0, "resets_at": "2026-10-01T07:00:00.512345+00:00" }, + "seven_day_oauth_apps": null, + "seven_day_opus": null, + "seven_day_sonnet": null, + "seven_day_fable": null, + "iguana_necktie": null, + "extra_usage": null, + "opaque_a": null, + "opaque_b": null, + "opaque_c": null, + "opaque_d": null, + "opaque_e": null, + "opaque_f": null, + "opaque_g": null, + "opaque_h": null, + "opaque_i": null, + "opaque_j": null, + "opaque_k": null, + "opaque_l": null, + "opaque_m": null, + "opaque_n": null, + "limits": [ + { + "kind": "session", + "group": "session", + "percent": 1, + "severity": "normal", + "resets_at": "2026-09-27T21:30:00Z", + "scope": null, + "is_active": true + }, + { + "kind": "weekly_all", + "group": "weekly", + "percent": 50, + "severity": "normal", + "resets_at": "2026-10-01T07:00:00Z", + "scope": null, + "is_active": true + }, + { + "kind": "weekly_scoped", + "group": "weekly", + "percent": 63, + "severity": "normal", + "resets_at": "2026-10-01T07:00:00Z", + "scope": { "model": { "display_name": "Fable" } }, + "is_active": true + } + ] +} diff --git a/agent/claude-usage/limits.js b/agent/claude-usage/limits.js new file mode 100644 index 0000000..1ac23fc --- /dev/null +++ b/agent/claude-usage/limits.js @@ -0,0 +1,276 @@ +// Claude plan limits for the claude-usage agent (issue #50). +// +// One GET to Anthropic's (undocumented) OAuth usage endpoint, with the access +// token Claude Code already stored in ~/.claude/.credentials.json. Returns the +// `limits` block of the POST /claude-usage contract (schemaVersion 1), minus +// the "last good values" fallback, which state.js owns. +// +// Properties that are load-bearing and easy to break: +// +// * THE CREDENTIALS FILE IS READ, NEVER WRITTEN. Refresh tokens are single +// use: refreshing here would log Claude Code out. An expired token means +// `token_expired` and no call at all. The file is only ever passed to +// fs.readFileSync (no flag), and __tests__/agent-claude-usage.test.js +// spies on every writing function of node:fs to prove it. +// * THE BEARER ONLY GOES TO USAGE_URL. The same process also holds a +// configurable push URL; the OAuth token must never follow it. The URL is +// a constant (no environment variable changes it) and redirects are +// refused (`redirect: "error"`), so the token cannot be bounced elsewhere. +// * `detail` IS AN ENUM. It comes from DETAILS below and nothing else, never +// from an error message or a response body: both can carry upstream text, +// and this value ends up in the admin dashboard. +// * ONE CALL, NO RETRY. The endpoint rate-limits hard and sends no +// Retry-After; a failed call waits for the next cron beat (15 min). + +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); + +const USAGE_URL = "https://api.anthropic.com/api/oauth/usage"; +const ANTHROPIC_BETA = "oauth-2025-04-20"; +const LIMITS_TIMEOUT_MS = 10000; +// Far beyond the ~2 KB the endpoint answers; past it the body is not parsed. +const MAX_RESPONSE_BYTES = 256 * 1024; +const MAX_ITEMS = 12; +const MAX_STRING = 128; + +const DETAILS = { + NO_CREDENTIALS: "no_credentials", + PARSE_ERROR: "parse_error", + NO_VERSION: "no_version", + TIMEOUT: "timeout", + NETWORK: "network", + BAD_RESPONSE: "bad_response", +}; + +// Same shape as the server sanitizer's timestamp check: a value outside it +// would be dropped there, so it is normalized (or nulled) here instead. +const ISO_TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,9})?(Z|[+-]\d{2}:\d{2})$/; + +function defaultCredentialsPath(home = os.homedir()) { + return path.join(home, ".claude", ".credentials.json"); +} + +function isPlainObject(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function failure(status, detail) { + const out = { status, fetchedAt: null, items: [] }; + if (detail) out.detail = detail; + return out; +} + +function cleanString(value) { + return typeof value === "string" ? value.slice(0, MAX_STRING) : null; +} + +// The contract wants an integer 0-100. Upstream sends a number that can be +// fractional (utilization) and could overshoot 100 once a limit is exceeded. +function cleanPercent(value) { + if (typeof value !== "number" || !Number.isFinite(value)) return undefined; + return Math.min(100, Math.max(0, Math.round(value))); +} + +// ISO string kept as sent when it already has the contract's shape; any other +// parseable string, or an epoch number (seconds or milliseconds), is +// re-rendered with toISOString; anything else is null. +function cleanResetsAt(value) { + if (typeof value === "string") { + if (value.length <= MAX_STRING && ISO_TIMESTAMP_RE.test(value) && !Number.isNaN(Date.parse(value))) { + return value; + } + const ms = Date.parse(value); + return Number.isNaN(ms) ? null : new Date(ms).toISOString(); + } + if (typeof value === "number" && Number.isFinite(value) && value > 0) { + const ms = value < 1e11 ? value * 1000 : value; + const date = new Date(ms); + return Number.isNaN(date.getTime()) ? null : date.toISOString(); + } + return null; +} + +function item({ kind, group, percent, severity, resetsAt, model }) { + const out = { kind, group }; + if (percent !== undefined) out.percent = percent; + if (typeof severity === "string") out.severity = severity; + out.resetsAt = resetsAt; + out.model = model; + return out; +} + +// `limits[]` entries: { kind, group, percent, severity, resets_at, +// scope: { model: { display_name } } | null, is_active }. Built field by field, +// so no upstream key reaches the snapshot unless it is listed here. +function itemsFromLimits(list) { + const items = []; + for (const entry of list) { + if (items.length >= MAX_ITEMS) break; + if (!isPlainObject(entry) || typeof entry.kind !== "string" || !entry.kind) continue; + const scope = isPlainObject(entry.scope) ? entry.scope : null; + const model = scope && isPlainObject(scope.model) ? cleanString(scope.model.display_name) : null; + items.push( + item({ + kind: entry.kind.slice(0, MAX_STRING), + group: typeof entry.group === "string" ? entry.group.slice(0, MAX_STRING) : entry.kind.slice(0, MAX_STRING), + percent: cleanPercent(entry.percent), + severity: cleanString(entry.severity), + resetsAt: cleanResetsAt(entry.resets_at), + model, + }), + ); + } + return items; +} + +// Older shape: five_hour / seven_day { utilization, resets_at }. +function itemsFromWindows(body) { + const items = []; + const windows = [ + ["five_hour", "session", "session"], + ["seven_day", "weekly_all", "weekly"], + ]; + for (const [key, kind, group] of windows) { + const w = body[key]; + if (!isPlainObject(w)) continue; + const percent = cleanPercent(w.utilization); + if (percent === undefined) continue; + items.push(item({ kind, group, percent, severity: null, resetsAt: cleanResetsAt(w.resets_at), model: null })); + } + return items; +} + +function parseUsage(body) { + if (!isPlainObject(body)) return []; + if (Array.isArray(body.limits)) { + const items = itemsFromLimits(body.limits); + if (items.length) return items; + } + return itemsFromWindows(body); +} + +// Read the OAuth credentials. Never throws, never logs the content. +function readCredentials(credentialsPath, readFile) { + let raw; + try { + raw = readFile(credentialsPath); + } catch (err) { + if (err && err.code === "ENOENT") return { detail: DETAILS.NO_CREDENTIALS }; + return { detail: DETAILS.PARSE_ERROR }; + } + let parsed; + try { + // Claude Code rewrites this file in place: a half-written JSON is a normal + // occurrence, and the next beat will read the finished one. + parsed = JSON.parse(String(raw)); + } catch { + return { detail: DETAILS.PARSE_ERROR }; + } + const oauth = isPlainObject(parsed) && isPlainObject(parsed.claudeAiOauth) ? parsed.claudeAiOauth : null; + if (!oauth || typeof oauth.accessToken !== "string" || !oauth.accessToken) { + return { detail: DETAILS.NO_CREDENTIALS }; + } + return { accessToken: oauth.accessToken, expiresAt: oauth.expiresAt }; +} + +async function readBody(res) { + const text = await res.text(); + if (text.length > MAX_RESPONSE_BYTES) throw new Error("too large"); + return JSON.parse(text); +} + +function discardBody(res) { + try { + if (res && res.body && typeof res.body.cancel === "function") res.body.cancel().catch(() => {}); + } catch { + // nothing to release + } +} + +/** + * Fetch the plan limits once. + * + * @param {object} opts + * @param {object} [opts.env] process environment (only CLAUDE_USAGE_LIMITS is read) + * @param {string} [opts.credentialsPath] ~/.claude/.credentials.json by default + * @param {Function} [opts.readFile] (path) => string, read-only — injectable for tests + * @param {Function} [opts.fetch] fetch implementation — injectable for tests + * @param {string|null} opts.claudeCodeVersion version for the User-Agent (from the transcripts) + * @param {number} [opts.now] epoch ms + * @param {number} [opts.timeoutMs] + * @returns {Promise<{status: string, detail?: string, fetchedAt: string|null, items: object[]}>} + */ +async function fetchLimits({ + env = process.env, + credentialsPath = defaultCredentialsPath(), + readFile = (file) => fs.readFileSync(file, "utf-8"), + fetch: fetchImpl = globalThis.fetch, + claudeCodeVersion = null, + now = Date.now(), + timeoutMs = LIMITS_TIMEOUT_MS, +} = {}) { + if (String(env.CLAUDE_USAGE_LIMITS || "").trim().toLowerCase() === "off") { + return failure("disabled"); + } + + const creds = readCredentials(credentialsPath, readFile); + if (creds.detail) return failure("unavailable", creds.detail); + + // Never refreshed: the refresh token is single use and belongs to Claude + // Code. A missing expiresAt is left to the server to judge (a 401 then). + if (typeof creds.expiresAt === "number" && Number.isFinite(creds.expiresAt) && creds.expiresAt <= now) { + return failure("token_expired"); + } + + if (typeof claudeCodeVersion !== "string" || !claudeCodeVersion) { + return failure("unavailable", DETAILS.NO_VERSION); + } + + let res; + try { + res = await fetchImpl(USAGE_URL, { + method: "GET", + headers: { + Authorization: `Bearer ${creds.accessToken}`, + "anthropic-beta": ANTHROPIC_BETA, + "User-Agent": `claude-code/${claudeCodeVersion}`, + Accept: "application/json", + }, + redirect: "error", + signal: AbortSignal.timeout(timeoutMs), + }); + } catch (err) { + const name = err && err.name; + return failure("unavailable", name === "TimeoutError" || name === "AbortError" ? DETAILS.TIMEOUT : DETAILS.NETWORK); + } + + const status = res && Number.isInteger(res.status) ? res.status : 0; + if (status < 200 || status > 299) { + discardBody(res); + return failure("http_error", status >= 100 && status <= 599 ? `http_${status}` : DETAILS.BAD_RESPONSE); + } + + let body; + try { + body = await readBody(res); + } catch (err) { + const name = err && err.name; + if (name === "TimeoutError" || name === "AbortError") return failure("unavailable", DETAILS.TIMEOUT); + return failure("unavailable", DETAILS.BAD_RESPONSE); + } + + const items = parseUsage(body); + if (!items.length) return failure("unavailable", DETAILS.BAD_RESPONSE); + return { status: "ok", fetchedAt: new Date(now).toISOString(), items }; +} + +module.exports = { + fetchLimits, + parseUsage, + defaultCredentialsPath, + USAGE_URL, + ANTHROPIC_BETA, + LIMITS_TIMEOUT_MS, + DETAILS, +}; diff --git a/agent/claude-usage/push-claude-usage.js b/agent/claude-usage/push-claude-usage.js new file mode 100755 index 0000000..87756c0 --- /dev/null +++ b/agent/claude-usage/push-claude-usage.js @@ -0,0 +1,238 @@ +#!/usr/bin/env node + +// claude-usage agent entry point (issue #50). Runs from the ThinkPad's +// crontab every 15 minutes through run-claude-usage.sh: +// +// 1. aggregate the local transcripts (aggregate.js), frozen days excluded; +// 2. read the plan limits once (limits.js); +// 3. update and write the local cache (state.js) BEFORE pushing, so a failed +// push never costs the next beat a full rescan; +// 4. assemble the schemaVersion 1 snapshot, measure it, push it once. +// +// Zero runtime dependencies (Node 22 global fetch). Same two rules as +// agent/push-metrics.js, for the same reason — the output goes to +// `logger -t claude-usage`, so into syslog and journald for good: +// +// * NO QUEUE, NO REPLAY. One beat = one attempt; a failure exits non-zero. +// * THE OUTPUT NEVER CARRIES A SECRET. Neither the ingest token nor the +// OAuth token is printed. Failures are rebuilt from fixed templates plus an +// HTTP status or a sanitized error code — never an error object, a message +// from the HTTP stack (undici quotes an invalid header value in it), the +// request options or a response body. + +const os = require("node:os"); +const path = require("node:path"); + +const { aggregate, torontoDay, TIME_ZONE } = require("./aggregate.js"); +const { fetchLimits, defaultCredentialsPath } = require("./limits.js"); +const { + loadState, + saveState, + nextState, + limitsForSnapshot, + defaultStateDir, + stateFilePath, +} = require("./state.js"); + +const SCHEMA_VERSION = 1; +// Same ceiling as the server (CLAUDE_USAGE_MAX_BODY_BYTES): a larger body +// would be refused with a 413, so it is not even sent. +const MAX_BODY_BYTES = 65536; +const PUSH_TIMEOUT_MS = 10000; + +const EXIT_OK = 0; +const EXIT_CONFIG = 1; +const EXIT_PUSH = 2; + +const PREFIX = "claude-usage:"; + +// A token is printable ASCII without spaces. Checked up front: fetch rejects +// an invalid header value with a message that QUOTES it. +const TOKEN_RE = /^[\x21-\x7e]+$/; +// What may be printed of an error: a system or undici code (ECONNREFUSED, +// UND_ERR_CONNECT_TIMEOUT: upper case only) or an error class name +// (TimeoutError, TypeError). Anything else, whatever field it came from, is +// replaced by UNKNOWN rather than trusted. +const ERROR_CODE_RE = /^[A-Z][A-Z0-9_]{1,39}$/; +const ERROR_NAME_RE = /^[A-Z][A-Za-z]{0,30}Error$/; + +function safeCode(err) { + const codes = [err && err.cause && err.cause.code, err && err.code]; + for (const c of codes) { + if (typeof c === "string" && ERROR_CODE_RE.test(c)) return c; + } + const name = err && err.name; + return typeof name === "string" && ERROR_NAME_RE.test(name) ? name : "UNKNOWN"; +} + +function readConfig(env) { + const apiUrl = String(env.CLAUDE_USAGE_API_URL || "").trim(); + const token = env.CLAUDE_USAGE_INGEST_TOKEN || ""; + if (!apiUrl) return { error: "CLAUDE_USAGE_API_URL is not set" }; + if (!token) return { error: "CLAUDE_USAGE_INGEST_TOKEN is not set" }; + if (!TOKEN_RE.test(token)) { + return { + error: + "CLAUDE_USAGE_INGEST_TOKEN contains an invalid character (a trailing newline or space in the env file is the usual cause)", + }; + } + let target; + try { + target = new URL(`${apiUrl.replace(/\/+$/, "")}/claude-usage`); + } catch { + return { error: "CLAUDE_USAGE_API_URL is not a valid URL" }; + } + // The ingest token travels in this request: https only, no exception. + if (target.protocol !== "https:") return { error: "CLAUDE_USAGE_API_URL must be https://" }; + return { config: { target: target.href, token } }; +} + +function buildSnapshot({ now, aggregated, limits }) { + const snapshot = { + schemaVersion: SCHEMA_VERSION, + generatedAt: new Date(now).toISOString(), + }; + // Left out rather than null: the contract types it as a string, and the + // server drops a null there anyway. + if (aggregated.claudeCodeVersion) snapshot.claudeCodeVersion = aggregated.claudeCodeVersion; + snapshot.timeZone = TIME_ZONE; + snapshot.limits = limits; + snapshot.days = aggregated.days; + snapshot.projects = aggregated.projects; + return snapshot; +} + +async function pushSnapshot({ target, token, body, fetchImpl }) { + let res; + try { + res = await fetchImpl(target, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body, + redirect: "error", + signal: AbortSignal.timeout(PUSH_TIMEOUT_MS), + }); + } catch (err) { + return { error: `push failed: request error (code=${safeCode(err)})` }; + } + // The response body is never read, never logged. + try { + if (res.body && typeof res.body.cancel === "function") res.body.cancel().catch(() => {}); + } catch { + // nothing to release + } + const status = Number.isInteger(res.status) ? res.status : 0; + if (status >= 200 && status <= 299) return { status }; + return { error: `push failed: HTTP ${status}` }; +} + +/** + * @param {string[]} argv + * @param {object} env + * @param {object} deps injectable for tests: home, fetch, now, readFile, openFile, log, logError + * @returns {Promise} exit code + */ +async function main(argv = process.argv.slice(2), env = process.env, deps = {}) { + const home = deps.home || os.homedir(); + const fetchImpl = deps.fetch || globalThis.fetch; + const now = deps.now !== undefined ? deps.now : Date.now(); + const log = deps.log || ((line) => console.log(line)); + const logError = deps.logError || ((line) => console.error(line)); + + const dryRun = argv.includes("--dry-run"); + + // Configuration first: a full scan costs seconds, no point spending them to + // then discover a typo in the env file. A dry run needs no configuration. + let config = null; + if (!dryRun) { + const result = readConfig(env); + if (result.error) { + logError(`${PREFIX} configuration error: ${result.error}`); + return EXIT_CONFIG; + } + config = result.config; + } + + const stateFile = stateFilePath(deps.stateDir || defaultStateDir(home)); + const state = loadState(stateFile); + const today = torontoDay(now); + + const aggregated = await aggregate({ + root: deps.transcriptsRoot || path.join(home, ".claude", "projects"), + frozenDays: state.frozenDays, + today, + homedir: home, + ...(deps.openFile ? { openFile: deps.openFile } : {}), + }); + + const limits = await fetchLimits({ + env, + credentialsPath: deps.credentialsPath || defaultCredentialsPath(home), + ...(deps.readFile ? { readFile: deps.readFile } : {}), + fetch: fetchImpl, + claudeCodeVersion: aggregated.claudeCodeVersion, + now, + }); + + const updated = nextState(state, { openDays: aggregated.openDays, today, limits }); + // Cache first. A dry run is read-only: it shows what would be sent and + // leaves the cache exactly as the cron left it. + if (!dryRun) { + try { + saveState(stateFile, updated); + } catch (err) { + // Not fatal: the next beat rescans, which is slow but correct. + logError(`${PREFIX} warning: state not saved (code=${safeCode(err)})`); + } + } + + const snapshot = buildSnapshot({ now, aggregated, limits: limitsForSnapshot(limits, updated) }); + const body = JSON.stringify(snapshot); + const bytes = Buffer.byteLength(body, "utf-8"); + + if (dryRun) log(JSON.stringify(snapshot, null, 2)); + + if (bytes > MAX_BODY_BYTES) { + logError(`${PREFIX} payload_too_large bytes=${bytes} max=${MAX_BODY_BYTES}`); + return EXIT_PUSH; + } + if (dryRun) return EXIT_OK; + + const pushed = await pushSnapshot({ target: config.target, token: config.token, body, fetchImpl }); + const summary = `limits=${snapshot.limits.status}${snapshot.limits.detail ? ` detail=${snapshot.limits.detail}` : ""} bytes=${bytes}`; + if (pushed.error) { + logError(`${PREFIX} ${pushed.error} ${summary}`); + return EXIT_PUSH; + } + log(`${PREFIX} ok status=${pushed.status} ${summary}`); + return EXIT_OK; +} + +if (require.main === module) { + main().then( + (code) => { + process.exitCode = code; + }, + (err) => { + // Nothing should land here. The code only, never the object or its + // message: either could carry a request option. + console.error(`${PREFIX} unexpected failure (code=${safeCode(err)})`); + process.exitCode = EXIT_PUSH; + }, + ); +} + +module.exports = { + main, + readConfig, + buildSnapshot, + pushSnapshot, + MAX_BODY_BYTES, + PUSH_TIMEOUT_MS, + EXIT_OK, + EXIT_CONFIG, + EXIT_PUSH, +}; diff --git a/agent/claude-usage/run-claude-usage.sh b/agent/claude-usage/run-claude-usage.sh new file mode 100755 index 0000000..86c0767 --- /dev/null +++ b/agent/claude-usage/run-claude-usage.sh @@ -0,0 +1,85 @@ +#!/bin/sh +# +# Cron wrapper for push-claude-usage.js: take the lock, load the secrets, check +# the install, hand over to node. Every fifteen minutes, unattended. +# +# NEVER add `set -x`. This script is meant to be piped into `logger`, and the +# shell would echo the `. "$ENV_FILE"` expansion — the ingest token — straight +# into /var/log/syslog and journald, where it would stay. Same reason nothing +# below ever echoes a variable that holds a secret: the checks print the NAME of +# what is missing, never its value. +# +# Suggested crontab line (see agent/README.md): +# */15 * * * * $HOME/.local/share/maximus-claude-usage/run-claude-usage.sh 2>&1 | logger -t claude-usage + +set -eu + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +AGENT="$SCRIPT_DIR/push-claude-usage.js" + +# Overridable for testing only; cron uses the default. +ENV_FILE="${CLAUDE_USAGE_ENV_FILE:-$HOME/.config/maximus-claude-usage.env}" +STATE_DIR="$HOME/.local/state/maximus-claude-usage" +LOCK_FILE="$STATE_DIR/run.lock" + +if ! command -v flock >/dev/null 2>&1; then + echo "claude-usage: flock not found (util-linux)" >&2 + exit 1 +fi + +mkdir -p "$STATE_DIR" +chmod 700 "$STATE_DIR" + +# One run at a time. The first full scan of ~210 MB of transcripts can outlast +# the 15-minute beat; a second run would only redo the same work and race the +# state file. fd 9 stays open through `exec`, so node holds the lock until it +# exits. A beat that finds the lock taken is not an error: exit 0. +exec 9>"$LOCK_FILE" +if ! flock -n 9; then + echo "claude-usage: a run is already in progress, skipping" + exit 0 +fi + +if [ ! -f "$ENV_FILE" ]; then + echo "claude-usage: env file not found: $ENV_FILE" >&2 + exit 1 +fi + +# A warning, not a refusal: a wrong mode bit is worth shouting about, but it is +# not worth silencing the whole agent over. +FILE_MODE=$(stat -c "%a" "$ENV_FILE" 2>/dev/null || echo "") +if [ -n "$FILE_MODE" ] && [ "$FILE_MODE" != "600" ]; then + echo "claude-usage: warning: $ENV_FILE is mode $FILE_MODE, expected 600" >&2 +fi + +# shellcheck source=/dev/null +. "$ENV_FILE" + +if [ -z "${CLAUDE_USAGE_API_URL:-}" ]; then + echo "claude-usage: CLAUDE_USAGE_API_URL is not set in $ENV_FILE" >&2 + exit 1 +fi +if [ -z "${CLAUDE_USAGE_INGEST_TOKEN:-}" ]; then + echo "claude-usage: CLAUDE_USAGE_INGEST_TOKEN is not set in $ENV_FILE" >&2 + exit 1 +fi + +if [ ! -f "$AGENT" ]; then + echo "claude-usage: agent not found: $AGENT" >&2 + exit 1 +fi + +# cron's PATH is famously short (/usr/bin:/bin), and a node installed through +# nvm or /usr/local lives outside it. Set NODE_BIN in the env file when +# `command -v node` comes up empty under cron but works in a login shell. +NODE_BIN="${NODE_BIN:-node}" +if ! command -v "$NODE_BIN" >/dev/null 2>&1; then + echo "claude-usage: node not found (NODE_BIN=$NODE_BIN); set NODE_BIN in $ENV_FILE" >&2 + exit 1 +fi + +# CLAUDE_USAGE_LIMITS is optional (`off` skips the Anthropic call). Exported +# too, otherwise setting it in the env file silently does nothing. +export CLAUDE_USAGE_API_URL CLAUDE_USAGE_INGEST_TOKEN CLAUDE_USAGE_LIMITS + +exec "$NODE_BIN" "$AGENT" "$@" diff --git a/agent/claude-usage/state.js b/agent/claude-usage/state.js new file mode 100644 index 0000000..7a83ddb --- /dev/null +++ b/agent/claude-usage/state.js @@ -0,0 +1,163 @@ +// Local cache of the claude-usage agent (issue #50): +// ~/.local/state/maximus-claude-usage/state.json. +// +// Two things live here, both rebuilt from scratch when the file is lost: +// +// * frozenDays — every Toronto day strictly before yesterday, empty days +// included, as { models, projects } (the shape of an aggregate() open +// day). A frozen day is never recomputed, and aggregate() skips every +// transcript last written before the day after the newest frozen day: this +// is what keeps a beat from re-reading the whole 30-day window. An empty +// day left unfrozen would hold that threshold back forever. +// Yesterday stays open: a session running past midnight still writes +// yesterday's lines into a transcript touched today. +// * lastGoodLimits — the last `ok` limits with their fetchedAt, served when +// the current call fails, so the dashboard shows the last known values and +// how old they are instead of nothing. +// +// The file holds no secret. It is still written mode 600, atomically (temp file +// suffixed with the pid in the same directory, then rename), so a concurrent +// reader sees the old state or the new one, never a truncated JSON. + +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); + +const { shiftDay } = require("./aggregate.js"); + +const STATE_VERSION = 1; +const RETENTION_DAYS = 35; +const DAY_RE = /^\d{4}-\d{2}-\d{2}$/; + +function defaultStateDir(home = os.homedir()) { + return path.join(home, ".local", "state", "maximus-claude-usage"); +} + +function stateFilePath(stateDir) { + return path.join(stateDir, "state.json"); +} + +function isPlainObject(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isDay(value) { + return typeof value === "string" && DAY_RE.test(value) && shiftDay(value, 0) === value; +} + +function emptyState() { + return { version: STATE_VERSION, frozenDays: {}, lastGoodLimits: null }; +} + +// Any problem — missing file, unreadable, truncated JSON, wrong shape — gives +// the empty state: the beat then does a full scan and rebuilds the cache. +// Entries are kept only in their expected shape; their counters are re-read +// defensively by aggregate() anyway. +function loadState(file, { readFile = (f) => fs.readFileSync(f, "utf-8") } = {}) { + let parsed; + try { + parsed = JSON.parse(String(readFile(file))); + } catch { + return emptyState(); + } + if (!isPlainObject(parsed)) return emptyState(); + + const state = emptyState(); + if (isPlainObject(parsed.frozenDays)) { + for (const [day, value] of Object.entries(parsed.frozenDays)) { + if (!isDay(day) || !isPlainObject(value)) continue; + state.frozenDays[day] = { + models: isPlainObject(value.models) ? value.models : {}, + projects: isPlainObject(value.projects) ? value.projects : {}, + }; + } + } + const last = parsed.lastGoodLimits; + if ( + isPlainObject(last) && + typeof last.fetchedAt === "string" && + !Number.isNaN(Date.parse(last.fetchedAt)) && + Array.isArray(last.items) + ) { + state.lastGoodLimits = { fetchedAt: last.fetchedAt, items: last.items.filter(isPlainObject) }; + } + return state; +} + +/** + * Next state after one beat. Pure. + * + * @param {object} state current state (loadState) + * @param {object} opts + * @param {object} opts.openDays aggregate().openDays + * @param {string} opts.today Toronto "YYYY-MM-DD" + * @param {object} opts.limits fetchLimits() result + */ +function nextState(state, { openDays = {}, today, limits = null }) { + const yesterday = shiftDay(today, -1); + const oldest = shiftDay(today, -RETENTION_DAYS); + + const frozenDays = {}; + for (const [day, value] of Object.entries(state.frozenDays || {})) { + if (day >= oldest) frozenDays[day] = value; + } + for (const [day, value] of Object.entries(openDays || {})) { + if (!isDay(day) || day >= yesterday || day < oldest) continue; + frozenDays[day] = { models: value.models || {}, projects: value.projects || {} }; + } + + const lastGoodLimits = + limits && limits.status === "ok" + ? { fetchedAt: limits.fetchedAt, items: limits.items } + : state.lastGoodLimits || null; + + return { version: STATE_VERSION, frozenDays, lastGoodLimits }; +} + +// The `limits` block of the snapshot: the current result when it is `ok`, +// otherwise its status and detail with the last good items and their date. +function limitsForSnapshot(limits, state) { + if (limits.status === "ok") { + return { status: "ok", fetchedAt: limits.fetchedAt, items: limits.items }; + } + const out = { status: limits.status }; + if (limits.detail) out.detail = limits.detail; + const last = state && state.lastGoodLimits; + out.fetchedAt = last ? last.fetchedAt : null; + out.items = last ? last.items : []; + return out; +} + +// Atomic write: temp file in the same directory (rename is only atomic within +// one file system), mode 600, then rename over the old file. +function saveState(file, state) { + const dir = path.dirname(file); + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + const tmp = `${file}.${process.pid}.tmp`; + try { + fs.writeFileSync(tmp, `${JSON.stringify(state)}\n`, { mode: 0o600 }); + // writeFileSync only applies `mode` when it creates the file: a temp file + // left behind by a killed run would keep its old bits otherwise. + fs.chmodSync(tmp, 0o600); + fs.renameSync(tmp, file); + } catch (err) { + try { + fs.unlinkSync(tmp); + } catch { + // already gone + } + throw err; + } +} + +module.exports = { + loadState, + saveState, + nextState, + limitsForSnapshot, + emptyState, + defaultStateDir, + stateFilePath, + RETENTION_DAYS, + STATE_VERSION, +}; -- 2.45.2 From 7f01e6afe55e7b4ab88fe9be50e7548cc855c26e Mon Sep 17 00:00:00 2001 From: le king fu Date: Sun, 4 Oct 2026 09:28:33 -0400 Subject: [PATCH 2/3] docs(agent): document the claude-usage agent install and its guarantees Resolves #50 Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 4 +- agent/README.md | 120 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 123 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index eacf117..427dacf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -68,7 +68,7 @@ curl -H "Authorization: Bearer $(cat ~/.coolify-token)" \ ## Tests - **CI** : `.forgejo/workflows/ci.yml` (issue #24), sur toute PR (pas de filtre `branches:`, pour que les maillons d'une pile soient batis) et sur `push` vers `main`. Un seul job en `container: node:22-alpine`, la base du Dockerfile : `npm ci`, `npm test`, `bash test-curl.sh`, puis `sh scripts/boot-from-copy.sh`. Verdict a lire sur le statut de commit (`GET /repos/maximus/vps-health-api/commits//status`), l'API Actions repondant 404 sur cette instance. -- `npm test` (vitest) — 500 cas +- `npm test` (vitest) — 606 cas - `__tests__/claude-usage.test.js` — `POST`/`GET /claude-usage` et `sanitizeClaudeUsage()` (119 cas, issue #48 : contrat garde tel quel et idempotence ; corps entier refuse seulement hors objet ou `schemaVersion` != 1 ; valeurs invalides **jetees, pas forcees** (champ inconnu a chaque niveau, `__proto__`, nombre negatif, au-dela de 2^53, fractionnaire, `percent` hors 0-100, `status` hors enum, `detail` hors regex, dates ISO, jour inexistant, cle de modele) ; bornes de liste ; cloisonnement des trois tokens dans les deux sens ; 503 nommant chacun sa variable ; espion `timingSafeEqual` ; 413 a 65 537 octets et 204 a 65 536, `/hosts/` toujours 413 a 4 097 ; ecriture atomique en mode 600 ; aller-retour POST puis GET ; peremption a la seconde pres sous horloge figee ; re-sanitisation et degradation en `no_data` a la lecture ; journalisation des rejets ; `claude-usage.js` dans le `COPY`) - `__tests__/findings.test.js` — `/defenseurs/findings` (14 cas : auth, validation, filtres severity/category, asymetrie INFO, scan clean vs no_data, JSON corrompu) - `__tests__/state.test.js` — `/defenseurs/state` + non-regression `/defenseurs` apres extraction de `serveJsonFile()` (9 cas : corps identique octet pour octet sur un JSON non canonique, no_data, isolation des deux chemins, 401 sans token / mauvais token / token d'ingestion, 404 sur POST) @@ -76,6 +76,7 @@ curl -H "Authorization: Bearer $(cat ~/.coolify-token)" \ - `__tests__/auth.test.js` — filet de securite auth sur les routes de lecture (67 cas : matrice route x mode d'echec, en-tetes malformes (dont le separateur `Bearer` autre qu'un espace), quasi-rates du token sur les 6 routes de lecture, fail-closed, **2 cas `404 (not 401)` qui figent l'ordre routage -> auth**, **un espion sur `timingSafeEqual`** qui exige deux empreintes de 32 octets a chaque requete, et **des epingles sur le source** : aucune egalite contre une chaine `Bearer`, un seul `tokenMatches()` et un seul `return null` dans `checkAuth()`, egalites limitees a une liste blanche, `tokenMatches()` qui hache les deux cotes sans test de longueur) - `__tests__/hosts.test.js` — matrice exhaustive des endpoints postes (173 cas : regex de route comme controle anti-traversee, 401 avant 403, cloisonnement des deux tokens, 503 fail-closed, reconstruction par liste blanche, `loadAvg` a exactement 3 elements, 413, `neverSeen`, fraicheur serveur sous horloge figee, degradation de lecture, **balayage des `.tmp` orphelins** au demarrage, prouve sur le vrai point d'entree, symlink ni suivi ni supprime, ancre `^` de `TEMP_FILE_RE`) - `__tests__/agent.test.js` — agent poste (50 cas : contrat de payload verifie contre `sanitizeSnapshot()`, une seule tentative sans rejeu, timeout, codes de sortie, wrapper shell, et **le token absent de toute sortie d'echec, fragments de 5 caracteres compris**) + - `__tests__/agent-claude-usage.test.js` — agent claude-usage du ThinkPad (106 cas). Agregation des transcripts, #49 (38 cas) : jours Toronto et changement d'heure, regles de projet, deduplication des requetes, 30 jours, top 8 + `(autres)`, 12 modeles, seuil de scan des jours figes. Agent, #50 (68 cas) : reponse anonymisee de l'endpoint (`__tests__/fixtures/claude-usage/oauth-usage.json`) vers les items, repli `five_hour`/`seven_day`, tout `null` -> `unavailable` ; `fetch` (double) jamais appele sur jeton expire, `off`, credentials tronques, absents ou sans jeton, version absente ; 429 -> `http_error`/`http_429` avec les dernieres limites valides et leur `fetchedAt` ; corps hostiles -> `detail` dans l'enum ; bearer OAuth envoye a la seule URL Anthropic epinglee, aucune variable d'env ne la change (epingle sur le source), `redirect: "error"` ; **espions sur toutes les ecritures de `node:fs`** : le fichier de credentials n'est que lu ; aucun fragment de 5 caracteres des deux jetons dans le snapshot, l'etat ni la sortie (dont un vrai processus) ; deux passages autour d'un jour vide (fige, puis aucun fichier sous le seuil ouvert) ; `state.json` corrompu, elagage a 35 jours, ecriture atomique en 600 ; snapshot sur fixtures inchange par `sanitizeClaudeUsage` ; `http:` -> 1, > 64 Kio -> 2 sans envoi ; `run-claude-usage.sh` lance deux fois en parallele (faux `node`, HOME temporaire) : le second sort 0 sans travailler - `__tests__/ci.test.js` — epingles du workflow et des scripts de CI (48 cas : image du `container:` identique au `FROM` du Dockerfile, les quatre etapes presentes, aucun filtre `branches:` sous `pull_request`, `push` limite a `main`, ni `defaults:` ni `working-directory` sur une etape `uses:`, **aucun `continue-on-error`, sous toute ecriture litterale** (une etape rouge passerait au vert en silence ; detecteur sur le mot nu, sans ancrage ni deux-points exiges, lui-meme verifie sur la cle en bloc, en element de liste, en style flow, entre guillemets et via une ancre YAML ; seules les lignes de commentaire entieres en sont exemptees ; une cle ecrite avec une sequence d'echappement, `"continue\u002don-error"`, passe encore : l'epingle lit du texte, pas du YAML), ce que le boot check suppose du Dockerfile : `USER node`, `CMD` en forme exec, le **refus d'un port deja occupe** (voisin HTTP reel, script lance en `spawn` asynchrone), **aucune requete par un proxy de l'environnement** (boot complet derriere un faux proxy qui repond a tout, sa vivacite prouvee d'abord par un `curl` nu, avec un `no_proxy` qui laisse la boucle locale hors liste : exporter `NO_PROXY` seul ne suffit pas), **`test-curl.sh` lance en entier** (#41 et #46, 22 cas, chacun sur des ports libres qui lui sont propres, passes par `TEST_CURL_PORT` et `TEST_CURL_NOINGEST_PORT` : derriere le meme faux proxy avec `FORCE_COLOR=3`, avec `BASE_URL` et `NOINGEST_URL` pointes sur un port mort, et avec `TOKEN` et `INGEST_TOKEN` herites d'une meme valeur, rien n'atteint le proxy, les 45 cas passent, le nombre etant ecrit en dur dans le test, et les deux ports sont refuses des la premiere tentative a la sortie du script ; **deux passages au meme instant** sur quatre ports libres, 45 cas chacun ; les **refus de port** : valeur non numerique, port 0, port au-dela de 65535, nombre suivi de lettres (`80abc`), chacun sur chacune des deux variables, puis deux ports identiques, et les **deux bornes acceptees**, 1 et 65535, donnees aux deux variables a la fois pour ne rien faire ecouter sur un port fixe (atteindre le refus « must differ » montre que la valeur a passe le controle de plage), message sur stderr et code 1 avant tout lancement ; un **voisin HTTP reel** sur le port principal, puis sur le second : refus avant tout lancement, le voisin n'ayant vu que la sonde `/` ; un **occupant non HTTP** sur le port principal, qui ferme aussitot (code curl 52 ou 56) puis qui accepte sans repondre (28) : meme refus, la regle etant « tout code autre que 7 » ; un **serveur vivant qui n'ecoute jamais** : message d'echeance et code 1 apres les 5 s d'attente ; puis un faux `node` en tete de `PATH` qui sort en 1 pour le port principal, puis le second : message sur stderr, code 1 en moins de 3 s, aucun serveur laisse sur son port ; et deux **epingles sur le source** du script : defauts 3099 et 3098, seules occurrences de ces nombres hors commentaires, et URLs derivees des ports sans lecture de l'environnement. Plus aucun cas n'ecoute sur 3099 ni 3098 : les defauts ne sont exerces que par l'etape CI « Socket-level pass » et par un `bash test-curl.sh` a la main. Les ports viennent de `freePorts()`, qui ouvre ses sondes ensemble puis les ferme avant le lancement : un port repris dans l'intervalle fait echouer le cas avec le message de `require_free`, sans cas lance contre un mauvais serveur), et **des `require` en colonne 0 seulement, sans `import()`**, dans chaque `.js` du `COPY`. Cette derniere epingle est une heuristique sur les appels litteraux, espace avant la parenthese comprise (`require (`, `import (`). Son detecteur, `loadsLate()`, est lui-meme verifie sur six lignes : `require` indente et `import` a toute colonne, avec ou sans espace, signales ; `require` de premier niveau et ligne de commentaire laisses passer. Trous connus, entre autres, laisses ouverts (#35) : une fonction ecrite entiere sur une ligne en colonne 0 (`const lazy = () => require("x");`), un `require(` renvoye a la ligne en colonne 0 dans une fonction (de meme forme que le `} = require("node:fs");` legitime d'`index.js`), et toute indirection, comme un alias de `require`) - `__tests__/writes.test.js` — surfaces d'ecriture : `HOSTS_DIR`, et le dossier de `CLAUDE_USAGE_PATH` pour le seul `POST /claude-usage` (#48 : `violations()` prend le dossier autorise du cas, `HOSTS_DIR` par defaut ; deux cas dedies, `POST /claude-usage` n'ecrit que dans son dossier, et ses rejets 401/400/413 et la lecture d'un fichier corrompu n'ecrivent rien du tout ; `GET /claude-usage` entre dans les routes de lecture) (14 cas : espions sur toutes les fonctions d'ecriture de `node:fs`, formes callback, Sync et `fs.promises`, et sur `child_process`, poses avant un `require` frais apres purge du cache de **tous** les modules runtime (un `metrics.js` reste en cache garderait les espions restaures d'un test precedent) ; demarrage par le balayage des `.tmp` ; les six routes GET n'ecrivent rien, meme une macrotache apres la reponse, contenu reel exige ; `POST /hosts/` n'ecrit que sous `HOSTS_DIR` ; **chemins degrades** : rejets 401/403/404, entrees 400/413, `agents-map.json` corrompu, fichiers Defenseurs absents, snapshot corrompu, avec le contenu degrade exige (`no_data`, `neverSeen`) ; **commandes shell** figees sur `head -1 /proc/stat` et `df -k /`, `/health` servant de temoin ; chaque cas prouve que ses espions sont vivants ; **tout appel est compte, sans attribution par pile** (#40 : une fonction `node:fs` passee telle quelle a `process.nextTick`, a un ecouteur ou a un `.then` s'execute sans frame runtime sur sa pile, et un filtre par pile la laissait passer) ; les retouches de fixtures du cas degrade passent par les fonctions reelles capturees avant la pose des espions, jamais par un `rmSync` recursif, qui appelle en interne le `fs.rmdirSync` public, donc l'espion ; **epingle sur le bloc `require.main`**, ensemble exact des appels, puisqu'il ne tourne jamais dans le processus de test. Limites : un chemin qu'aucun cas n'emprunte, et une ecriture differee au-dela de 20 ms. Contrepartie assumee : une ecriture du lanceur de tests lui-meme pendant un cas le ferait rougir) - Runtime reste 0-dep ; vitest en devDep uniquement @@ -87,6 +88,7 @@ curl -H "Authorization: Bearer $(cat ~/.coolify-token)" \ - Pas d'Express — HTTP natif Node.js uniquement - Le `COPY` du Dockerfile liste les fichiers un par un (`package.json index.js metrics.js claude-usage.js`) : tout nouveau module runtime doit y etre ajoute, sinon le conteneur plante au demarrage sur un `MODULE_NOT_FOUND` que `npm test` ne voit pas (seul `scripts/boot-from-copy.sh`, en CI, le rattrape). Un `require` place dans un handler de route echapperait au boot check (qui n'appelle que `/health`) : garder tous les `require` au niveau module, une epingle de `ci.test.js` l'exige. Corollaire : `agent/` n'entre PAS dans l'image — c'est du code de poste, livre par copie de fichiers (voir `agent/README.md`), et un test epingle que le `COPY` ne le mentionne jamais. - `agent/` tourne sur le ThinkPad, pas sur le VPS, et sa sortie part dans `logger -t host-agent` : tout ce qu'il imprime finit dans `/var/log/syslog` et journald pour de bon. Le chemin d'erreur ne rend donc que `err.code` et le code HTTP — jamais un objet d'erreur, jamais les options de requete, jamais un en-tete. Et jamais de `set -x` dans `run-push.sh` : le shell echoerait le token en sourcant le fichier d'env. +- `agent/claude-usage/` (#50, `logger -t claude-usage`, cron 15 min) tient deux secrets dans le meme processus : le jeton OAuth de Claude Code et `CLAUDE_USAGE_INGEST_TOKEN`. Il ne rafraichit **jamais** le jeton OAuth et n'ecrit **jamais** `~/.claude/.credentials.json` (refresh token a usage unique : Claude Code serait deconnecte) ; jeton expire -> `token_expired`, aucun appel. L'URL Anthropic est une constante de `limits.js` (aucune variable d'env, `redirect: "error"`) pour que le bearer OAuth ne suive jamais l'URL de push configurable ; `detail` ne sort que de l'enum, jamais d'un message d'erreur ou d'un corps de reponse. Memes regles de sortie que l'agent poste (codes seulement, pas de `set -x` dans `run-claude-usage.sh`). - `getHealth()` garde `Promise.all([collectMetrics(), getLogtoHealth()])` : l'echantillon CPU de 500 ms et le check Logto (timeout 3 s) sont deliberement concurrents. Les serialiser ferait monter le p99 de `/health` a ~3,5 s. Garde de non-regression : le test de latence dans `__tests__/health.test.js`. - L'API n'est plus read-only depuis l'issue #13. `status.json`, `agents-map.json` et les rapports de scan restent ecrits par le Sergent defenseurs et lus seulement ici ; en revanche `POST /hosts/` ecrit dans `HOSTS_DIR`, et `POST /claude-usage` (#48) dans le dossier de `CLAUDE_USAGE_PATH`. Consequence : ces deux montages-la doivent etre en lecture-ecriture pour l'uid 1000, alors que les deux montages Defenseurs sont en `:ro` (#36). Une ecriture ajoutee un jour sous `REPORTS_DIR` ou `/data/defenseurs` passerait tous les autres tests (fixtures inscriptibles, CI en root) et tomberait en prod en 500 `EROFS` : `__tests__/writes.test.js` exige que toute ecriture par `node:fs` vise `HOSTS_DIR` (ou, pour le seul cas `POST /claude-usage`, le dossier de `CLAUDE_USAGE_PATH`), et que les seules commandes shell soient les deux lectures de `metrics.js`, sur chaque chemin qu'il exerce (demarrage, lectures et ingestion nominales, rejets, entrees invalides, fichier corrompu, fichiers absents). Un nouveau chemin qui ecrit doit y entrer comme cas. - L'ecriture des snapshots passe par un fichier temporaire dans le **meme** repertoire puis `renameSync` — `rename` est atomique dans un systeme de fichiers, donc un `GET /hosts` concurrent voit l'ancien snapshot ou le nouveau, jamais un JSON tronque. Ne pas « simplifier » en `writeFileSync` direct. Un processus tue entre les deux laisse un `....tmp` : `sweepStaleTempFiles()` les supprime au demarrage, avant `listen`, s'ils ont plus d'une heure (plus jeunes, un autre conteneur peut etre en train d'ecrire pendant un redeploy). Il ne touche que ce format de nom exact et que des fichiers reguliers (`lstat`) ; changer le nom du fichier temporaire impose de changer `TEMP_FILE_RE`. `writeClaudeUsage()` suit le meme patron (temporaire `.claude-usage.json...tmp`), mais **aucun balayage** ne couvre son dossier : un orphelin y resterait, inoffensif (`GET /claude-usage` ne lit que le fichier final) ; un seul fichier ecrit toutes les quelques minutes ne justifiait pas d'elargir `sweepStaleTempFiles()` et le bloc `require.main` epingle. diff --git a/agent/README.md b/agent/README.md index ec4adb7..4e6ace7 100644 --- a/agent/README.md +++ b/agent/README.md @@ -178,3 +178,123 @@ Sharing `metrics.js` with the server keeps the two in step, but it is not the guarantee — the frozen copy can lag behind a deployed server. The real contract is the server's own validation, and `__tests__/agent.test.js` checks the agent's payload against it (including a real snapshot through the real handler). + +## claude-usage agent (`agent/claude-usage/`) + +A second, independent agent on the ThinkPad. Every 15 minutes it reads the +Claude plan limits, aggregates the local Claude Code transcripts over the last +30 Toronto days, and pushes one `schemaVersion: 1` snapshot to +`POST /claude-usage`; the admin dashboard reads it through `GET /claude-usage`. + +It lives in this repo, next to the server, because the snapshot contract and +its sanitizer (`claude-usage.js`) are tested together: +`__tests__/agent-claude-usage.test.js` builds a real snapshot from fixtures and +checks that `sanitizeClaudeUsage()` keeps it unchanged. Like the host agent, it +never enters the server image. + +| File | Role | +|------|------| +| `aggregate.js` | Read `~/.claude/projects/**/*.jsonl`, build `days` and `projects` | +| `limits.js` | One GET to Anthropic's OAuth usage endpoint, mapped to `limits.items` | +| `state.js` | Local cache: frozen days and last good limits | +| `push-claude-usage.js` | Entry point: aggregate, limits, write the cache, push once, exit | +| `run-claude-usage.sh` | Cron wrapper: lock, load the env file, check the install, exec node | + +### What it deliberately does not do + +**It never refreshes the OAuth token and never writes +`~/.claude/.credentials.json`.** Refresh tokens are single use: refreshing from +here would log Claude Code out. The file is only read; an expired token gives +`limits.status: "token_expired"` and no call at all, until Claude Code itself +refreshes it. + +**The Anthropic URL is pinned.** It is a constant in `limits.js`, no +environment variable changes it, and redirects are refused: the same process +holds the OAuth bearer and a configurable push URL, and the bearer must only +ever reach `https://api.anthropic.com`. `CLAUDE_USAGE_API_URL` must be +`https://` (the agent exits 1 otherwise). + +**One call, one push, no retry.** The usage endpoint rate-limits hard and sends +no `Retry-After`. When it fails, the snapshot carries the failure status +(`http_error`, `unavailable`, ...) with the last good items and their +`fetchedAt`, so the dashboard can show how old they are. + +**No secret in the logs.** Same rule as the host agent: the output goes to +`logger -t claude-usage`, so failures print fixed templates plus an HTTP status +or an error code — never an error object, a response body, or a header — and +`run-claude-usage.sh` must never gain a `set -x`. + +### Install + +Freeze a copy — never point the cron at the git working tree: + +``` +mkdir -p ~/.local/share/maximus-claude-usage +cp agent/claude-usage/aggregate.js agent/claude-usage/limits.js agent/claude-usage/state.js agent/claude-usage/push-claude-usage.js agent/claude-usage/run-claude-usage.sh ~/.local/share/maximus-claude-usage/ +chmod +x ~/.local/share/maximus-claude-usage/run-claude-usage.sh +``` + +Re-run that `cp` after any change to the agent: the copy step *is* the +deployment. The code lives in `~/.local/share/`, the cache in +`~/.local/state/maximus-claude-usage/` (`state.json` and `run.lock`, created on +the first run): wiping the copy never loses the cache, and vice versa. + +Env file `~/.config/maximus-claude-usage.env`, mode `600` (the wrapper warns on +any other mode). Write it with `printf`, not a heredoc: a trailing newline in +the token is refused with exit 1. + +``` +CLAUDE_USAGE_API_URL=https://health.lacompagniemaximus.com +CLAUDE_USAGE_INGEST_TOKEN= +# CLAUDE_USAGE_LIMITS=off # skip the Anthropic call entirely +# NODE_BIN=/usr/bin/node # only if cron cannot find node +``` + +``` +chmod 600 ~/.config/maximus-claude-usage.env +``` + +`CLAUDE_USAGE_INGEST_TOKEN` is its own token: neither `HOSTS_INGEST_TOKEN` nor +`HEALTH_TOKEN` works on `POST /claude-usage`, and it opens nothing else. + +Dry run — prints the snapshot, pushes nothing, leaves the cache untouched, needs +no env file (it still calls the Anthropic endpoint once unless +`CLAUDE_USAGE_LIMITS=off`): + +``` +CLAUDE_USAGE_LIMITS=off node ~/.local/share/maximus-claude-usage/push-claude-usage.js --dry-run +``` + +Then one real run by hand, before installing the cron (the first one scans every +transcript of the window and can take a while; the next ones only read files +written since yesterday): + +``` +~/.local/share/maximus-claude-usage/run-claude-usage.sh +# claude-usage: ok status=204 limits=ok bytes=... +``` + +Crontab line: + +``` +*/15 * * * * $HOME/.local/share/maximus-claude-usage/run-claude-usage.sh 2>&1 | logger -t claude-usage +``` + +``` +journalctl -t claude-usage --since -1h +``` + +A beat that finds the previous one still running (`flock` on +`~/.local/state/maximus-claude-usage/run.lock`) logs +`a run is already in progress, skipping` and exits 0. + +### Exit codes + +| Code | Meaning | +|------|---------| +| 0 | Snapshot accepted (2xx), or a skipped beat (lock held) | +| 1 | Install problem: env file missing, a variable unset, URL not `https://`, token with an invalid character, node or flock not found | +| 2 | The push did not land (network or non-2xx), or the snapshot exceeded 64 KiB (`payload_too_large`, nothing sent) | + +The limits failing is **not** an exit code: the snapshot is still pushed, with +`limits.status` saying why. -- 2.45.2 From 719d65129a8b8abdd2f0c1108f8c6d0d9803a27e Mon Sep 17 00:00:00 2001 From: le king fu Date: Sun, 4 Oct 2026 15:26:20 -0400 Subject: [PATCH 3/3] docs(claude-md): count the threshold and prototype cases of the rebased #49 fix Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 427dacf..4be7f43 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -68,7 +68,7 @@ curl -H "Authorization: Bearer $(cat ~/.coolify-token)" \ ## Tests - **CI** : `.forgejo/workflows/ci.yml` (issue #24), sur toute PR (pas de filtre `branches:`, pour que les maillons d'une pile soient batis) et sur `push` vers `main`. Un seul job en `container: node:22-alpine`, la base du Dockerfile : `npm ci`, `npm test`, `bash test-curl.sh`, puis `sh scripts/boot-from-copy.sh`. Verdict a lire sur le statut de commit (`GET /repos/maximus/vps-health-api/commits//status`), l'API Actions repondant 404 sur cette instance. -- `npm test` (vitest) — 606 cas +- `npm test` (vitest) — 611 cas - `__tests__/claude-usage.test.js` — `POST`/`GET /claude-usage` et `sanitizeClaudeUsage()` (119 cas, issue #48 : contrat garde tel quel et idempotence ; corps entier refuse seulement hors objet ou `schemaVersion` != 1 ; valeurs invalides **jetees, pas forcees** (champ inconnu a chaque niveau, `__proto__`, nombre negatif, au-dela de 2^53, fractionnaire, `percent` hors 0-100, `status` hors enum, `detail` hors regex, dates ISO, jour inexistant, cle de modele) ; bornes de liste ; cloisonnement des trois tokens dans les deux sens ; 503 nommant chacun sa variable ; espion `timingSafeEqual` ; 413 a 65 537 octets et 204 a 65 536, `/hosts/` toujours 413 a 4 097 ; ecriture atomique en mode 600 ; aller-retour POST puis GET ; peremption a la seconde pres sous horloge figee ; re-sanitisation et degradation en `no_data` a la lecture ; journalisation des rejets ; `claude-usage.js` dans le `COPY`) - `__tests__/findings.test.js` — `/defenseurs/findings` (14 cas : auth, validation, filtres severity/category, asymetrie INFO, scan clean vs no_data, JSON corrompu) - `__tests__/state.test.js` — `/defenseurs/state` + non-regression `/defenseurs` apres extraction de `serveJsonFile()` (9 cas : corps identique octet pour octet sur un JSON non canonique, no_data, isolation des deux chemins, 401 sans token / mauvais token / token d'ingestion, 404 sur POST) @@ -76,7 +76,7 @@ curl -H "Authorization: Bearer $(cat ~/.coolify-token)" \ - `__tests__/auth.test.js` — filet de securite auth sur les routes de lecture (67 cas : matrice route x mode d'echec, en-tetes malformes (dont le separateur `Bearer` autre qu'un espace), quasi-rates du token sur les 6 routes de lecture, fail-closed, **2 cas `404 (not 401)` qui figent l'ordre routage -> auth**, **un espion sur `timingSafeEqual`** qui exige deux empreintes de 32 octets a chaque requete, et **des epingles sur le source** : aucune egalite contre une chaine `Bearer`, un seul `tokenMatches()` et un seul `return null` dans `checkAuth()`, egalites limitees a une liste blanche, `tokenMatches()` qui hache les deux cotes sans test de longueur) - `__tests__/hosts.test.js` — matrice exhaustive des endpoints postes (173 cas : regex de route comme controle anti-traversee, 401 avant 403, cloisonnement des deux tokens, 503 fail-closed, reconstruction par liste blanche, `loadAvg` a exactement 3 elements, 413, `neverSeen`, fraicheur serveur sous horloge figee, degradation de lecture, **balayage des `.tmp` orphelins** au demarrage, prouve sur le vrai point d'entree, symlink ni suivi ni supprime, ancre `^` de `TEMP_FILE_RE`) - `__tests__/agent.test.js` — agent poste (50 cas : contrat de payload verifie contre `sanitizeSnapshot()`, une seule tentative sans rejeu, timeout, codes de sortie, wrapper shell, et **le token absent de toute sortie d'echec, fragments de 5 caracteres compris**) - - `__tests__/agent-claude-usage.test.js` — agent claude-usage du ThinkPad (106 cas). Agregation des transcripts, #49 (38 cas) : jours Toronto et changement d'heure, regles de projet, deduplication des requetes, 30 jours, top 8 + `(autres)`, 12 modeles, seuil de scan des jours figes. Agent, #50 (68 cas) : reponse anonymisee de l'endpoint (`__tests__/fixtures/claude-usage/oauth-usage.json`) vers les items, repli `five_hour`/`seven_day`, tout `null` -> `unavailable` ; `fetch` (double) jamais appele sur jeton expire, `off`, credentials tronques, absents ou sans jeton, version absente ; 429 -> `http_error`/`http_429` avec les dernieres limites valides et leur `fetchedAt` ; corps hostiles -> `detail` dans l'enum ; bearer OAuth envoye a la seule URL Anthropic epinglee, aucune variable d'env ne la change (epingle sur le source), `redirect: "error"` ; **espions sur toutes les ecritures de `node:fs`** : le fichier de credentials n'est que lu ; aucun fragment de 5 caracteres des deux jetons dans le snapshot, l'etat ni la sortie (dont un vrai processus) ; deux passages autour d'un jour vide (fige, puis aucun fichier sous le seuil ouvert) ; `state.json` corrompu, elagage a 35 jours, ecriture atomique en 600 ; snapshot sur fixtures inchange par `sanitizeClaudeUsage` ; `http:` -> 1, > 64 Kio -> 2 sans envoi ; `run-claude-usage.sh` lance deux fois en parallele (faux `node`, HOME temporaire) : le second sort 0 sans travailler + - `__tests__/agent-claude-usage.test.js` — agent claude-usage du ThinkPad (111 cas). Agregation des transcripts, #49 (43 cas) : jours Toronto et changement d'heure, regles de projet, deduplication des requetes, 30 jours, top 8 + `(autres)`, 12 modeles, seuil de scan pris sur le plus ancien jour ouvert (jour fige futur, trou dans la serie figee, tout fige), noms `__proto__` et `constructor` sans ecriture sur `Object.prototype`. Agent, #50 (68 cas) : reponse anonymisee de l'endpoint (`__tests__/fixtures/claude-usage/oauth-usage.json`) vers les items, repli `five_hour`/`seven_day`, tout `null` -> `unavailable` ; `fetch` (double) jamais appele sur jeton expire, `off`, credentials tronques, absents ou sans jeton, version absente ; 429 -> `http_error`/`http_429` avec les dernieres limites valides et leur `fetchedAt` ; corps hostiles -> `detail` dans l'enum ; bearer OAuth envoye a la seule URL Anthropic epinglee, aucune variable d'env ne la change (epingle sur le source), `redirect: "error"` ; **espions sur toutes les ecritures de `node:fs`** : le fichier de credentials n'est que lu ; aucun fragment de 5 caracteres des deux jetons dans le snapshot, l'etat ni la sortie (dont un vrai processus) ; deux passages autour d'un jour vide (fige, puis aucun fichier sous le seuil ouvert) ; `state.json` corrompu, elagage a 35 jours, ecriture atomique en 600 ; snapshot sur fixtures inchange par `sanitizeClaudeUsage` ; `http:` -> 1, > 64 Kio -> 2 sans envoi ; `run-claude-usage.sh` lance deux fois en parallele (faux `node`, HOME temporaire) : le second sort 0 sans travailler - `__tests__/ci.test.js` — epingles du workflow et des scripts de CI (48 cas : image du `container:` identique au `FROM` du Dockerfile, les quatre etapes presentes, aucun filtre `branches:` sous `pull_request`, `push` limite a `main`, ni `defaults:` ni `working-directory` sur une etape `uses:`, **aucun `continue-on-error`, sous toute ecriture litterale** (une etape rouge passerait au vert en silence ; detecteur sur le mot nu, sans ancrage ni deux-points exiges, lui-meme verifie sur la cle en bloc, en element de liste, en style flow, entre guillemets et via une ancre YAML ; seules les lignes de commentaire entieres en sont exemptees ; une cle ecrite avec une sequence d'echappement, `"continue\u002don-error"`, passe encore : l'epingle lit du texte, pas du YAML), ce que le boot check suppose du Dockerfile : `USER node`, `CMD` en forme exec, le **refus d'un port deja occupe** (voisin HTTP reel, script lance en `spawn` asynchrone), **aucune requete par un proxy de l'environnement** (boot complet derriere un faux proxy qui repond a tout, sa vivacite prouvee d'abord par un `curl` nu, avec un `no_proxy` qui laisse la boucle locale hors liste : exporter `NO_PROXY` seul ne suffit pas), **`test-curl.sh` lance en entier** (#41 et #46, 22 cas, chacun sur des ports libres qui lui sont propres, passes par `TEST_CURL_PORT` et `TEST_CURL_NOINGEST_PORT` : derriere le meme faux proxy avec `FORCE_COLOR=3`, avec `BASE_URL` et `NOINGEST_URL` pointes sur un port mort, et avec `TOKEN` et `INGEST_TOKEN` herites d'une meme valeur, rien n'atteint le proxy, les 45 cas passent, le nombre etant ecrit en dur dans le test, et les deux ports sont refuses des la premiere tentative a la sortie du script ; **deux passages au meme instant** sur quatre ports libres, 45 cas chacun ; les **refus de port** : valeur non numerique, port 0, port au-dela de 65535, nombre suivi de lettres (`80abc`), chacun sur chacune des deux variables, puis deux ports identiques, et les **deux bornes acceptees**, 1 et 65535, donnees aux deux variables a la fois pour ne rien faire ecouter sur un port fixe (atteindre le refus « must differ » montre que la valeur a passe le controle de plage), message sur stderr et code 1 avant tout lancement ; un **voisin HTTP reel** sur le port principal, puis sur le second : refus avant tout lancement, le voisin n'ayant vu que la sonde `/` ; un **occupant non HTTP** sur le port principal, qui ferme aussitot (code curl 52 ou 56) puis qui accepte sans repondre (28) : meme refus, la regle etant « tout code autre que 7 » ; un **serveur vivant qui n'ecoute jamais** : message d'echeance et code 1 apres les 5 s d'attente ; puis un faux `node` en tete de `PATH` qui sort en 1 pour le port principal, puis le second : message sur stderr, code 1 en moins de 3 s, aucun serveur laisse sur son port ; et deux **epingles sur le source** du script : defauts 3099 et 3098, seules occurrences de ces nombres hors commentaires, et URLs derivees des ports sans lecture de l'environnement. Plus aucun cas n'ecoute sur 3099 ni 3098 : les defauts ne sont exerces que par l'etape CI « Socket-level pass » et par un `bash test-curl.sh` a la main. Les ports viennent de `freePorts()`, qui ouvre ses sondes ensemble puis les ferme avant le lancement : un port repris dans l'intervalle fait echouer le cas avec le message de `require_free`, sans cas lance contre un mauvais serveur), et **des `require` en colonne 0 seulement, sans `import()`**, dans chaque `.js` du `COPY`. Cette derniere epingle est une heuristique sur les appels litteraux, espace avant la parenthese comprise (`require (`, `import (`). Son detecteur, `loadsLate()`, est lui-meme verifie sur six lignes : `require` indente et `import` a toute colonne, avec ou sans espace, signales ; `require` de premier niveau et ligne de commentaire laisses passer. Trous connus, entre autres, laisses ouverts (#35) : une fonction ecrite entiere sur une ligne en colonne 0 (`const lazy = () => require("x");`), un `require(` renvoye a la ligne en colonne 0 dans une fonction (de meme forme que le `} = require("node:fs");` legitime d'`index.js`), et toute indirection, comme un alias de `require`) - `__tests__/writes.test.js` — surfaces d'ecriture : `HOSTS_DIR`, et le dossier de `CLAUDE_USAGE_PATH` pour le seul `POST /claude-usage` (#48 : `violations()` prend le dossier autorise du cas, `HOSTS_DIR` par defaut ; deux cas dedies, `POST /claude-usage` n'ecrit que dans son dossier, et ses rejets 401/400/413 et la lecture d'un fichier corrompu n'ecrivent rien du tout ; `GET /claude-usage` entre dans les routes de lecture) (14 cas : espions sur toutes les fonctions d'ecriture de `node:fs`, formes callback, Sync et `fs.promises`, et sur `child_process`, poses avant un `require` frais apres purge du cache de **tous** les modules runtime (un `metrics.js` reste en cache garderait les espions restaures d'un test precedent) ; demarrage par le balayage des `.tmp` ; les six routes GET n'ecrivent rien, meme une macrotache apres la reponse, contenu reel exige ; `POST /hosts/` n'ecrit que sous `HOSTS_DIR` ; **chemins degrades** : rejets 401/403/404, entrees 400/413, `agents-map.json` corrompu, fichiers Defenseurs absents, snapshot corrompu, avec le contenu degrade exige (`no_data`, `neverSeen`) ; **commandes shell** figees sur `head -1 /proc/stat` et `df -k /`, `/health` servant de temoin ; chaque cas prouve que ses espions sont vivants ; **tout appel est compte, sans attribution par pile** (#40 : une fonction `node:fs` passee telle quelle a `process.nextTick`, a un ecouteur ou a un `.then` s'execute sans frame runtime sur sa pile, et un filtre par pile la laissait passer) ; les retouches de fixtures du cas degrade passent par les fonctions reelles capturees avant la pose des espions, jamais par un `rmSync` recursif, qui appelle en interne le `fs.rmdirSync` public, donc l'espion ; **epingle sur le bloc `require.main`**, ensemble exact des appels, puisqu'il ne tourne jamais dans le processus de test. Limites : un chemin qu'aucun cas n'emprunte, et une ecriture differee au-dela de 20 ms. Contrepartie assumee : une ecriture du lanceur de tests lui-meme pendant un cas le ferait rougir) - Runtime reste 0-dep ; vitest en devDep uniquement -- 2.45.2