feat(agent): claude-usage agent — plan limits, local cache, push (#50) #53
6 changed files with 1744 additions and 0 deletions
|
|
@ -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: <tmp>/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(`<html>error ${OAUTH_TOKEN} please retry</html>`, { 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/);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
53
__tests__/fixtures/claude-usage/oauth-usage.json
Normal file
53
__tests__/fixtures/claude-usage/oauth-usage.json
Normal file
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
276
agent/claude-usage/limits.js
Normal file
276
agent/claude-usage/limits.js
Normal file
|
|
@ -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,
|
||||
};
|
||||
238
agent/claude-usage/push-claude-usage.js
Executable file
238
agent/claude-usage/push-claude-usage.js
Executable file
|
|
@ -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<number>} 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,
|
||||
};
|
||||
85
agent/claude-usage/run-claude-usage.sh
Executable file
85
agent/claude-usage/run-claude-usage.sh
Executable file
|
|
@ -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" "$@"
|
||||
163
agent/claude-usage/state.js
Normal file
163
agent/claude-usage/state.js
Normal file
|
|
@ -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,
|
||||
};
|
||||
Loading…
Reference in a new issue