Phase C of vendor-agnostic model-access (ADR 0050/0054). Two TypeScript runtime modules: - anthropic-manager: the refresh token is sealed at rest to the manager node's own key (atrest.ts, envelope encryption over X25519) and opened ONLY on the manager node. adopt seals the first envelope; refresh opens it, calls the Anthropic OAuth token endpoint, re-seals a rotated refresh token, and hands the control plane only the access token plus the opaque envelope. Also polls licence-grain usage (ADR 0054). - anthropic-consumer: writes the delivered access token to ~/.claude/.credentials.json, access-token-only, atomically (the refresh token is never delivered); reports session-grain usage from the CLI transcripts; a fail-closed identity guard (expected-uuid plumbing is a flagged TODO). Both run as scheduled containers (ADR 0053). Pure logic covered by node --test fixtures (at-rest round-trip, credential strip, transcript sum, refresh merge). Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
93 lines
3.3 KiB
TypeScript
93 lines
3.3 KiB
TypeScript
// Session-grain usage emission (novox/hq ADR 0054). On a schedule, read every transcript under
|
|
// `~/.claude/projects/*/<sessionId>.jsonl`, sum its tokens, and emit one session-grain usage event
|
|
// per session. The consumer IS the (node,module) session's fixed binding, so no per-message account
|
|
// attribution is done — just the totals (port map "don't-map" #3).
|
|
//
|
|
// Runs as `mesh-tools run` (no broker), so events are emitted best-effort via the sibling mesh-tools
|
|
// `emit` primitive; the totals are also written to a file so the reading is observable without one.
|
|
|
|
import { readdirSync, statSync, writeFileSync, renameSync, mkdirSync } from "node:fs";
|
|
import { join, dirname } from "node:path";
|
|
|
|
import { readSessionFile, type SessionUsage } from "../transcript.js";
|
|
|
|
function projectsDir(): string {
|
|
return process.env.MESH_CLAUDE_PROJECTS_DIR ?? `${process.env.HOME ?? "/root"}/.claude/projects`;
|
|
}
|
|
|
|
/** Every `<sessionId>.jsonl` under the projects tree, with the project directory it sits in. */
|
|
function transcripts(root: string): { path: string; sessionId: string }[] {
|
|
const found: { path: string; sessionId: string }[] = [];
|
|
let projects: string[];
|
|
try {
|
|
projects = readdirSync(root);
|
|
} catch {
|
|
return found; // no projects yet is not a failure — there is simply nothing to report.
|
|
}
|
|
for (const proj of projects) {
|
|
const dir = join(root, proj);
|
|
let entries: string[];
|
|
try {
|
|
if (!statSync(dir).isDirectory()) continue;
|
|
entries = readdirSync(dir);
|
|
} catch {
|
|
continue;
|
|
}
|
|
for (const file of entries) {
|
|
if (!file.endsWith(".jsonl")) continue;
|
|
found.push({ path: join(dir, file), sessionId: file.replace(/\.jsonl$/, "") });
|
|
}
|
|
}
|
|
return found;
|
|
}
|
|
|
|
async function main(): Promise<void> {
|
|
const module = process.env.MESH_MODULE ?? "anthropic-consumer";
|
|
const node = process.env.MESH_NODE ?? "unknown";
|
|
|
|
const readings: SessionUsage[] = [];
|
|
for (const t of transcripts(projectsDir())) {
|
|
try {
|
|
readings.push(await readSessionFile(t.path, t.sessionId));
|
|
} catch (err) {
|
|
console.error(`[anthropic-consumer] could not read ${t.path}: ${err}`);
|
|
}
|
|
}
|
|
|
|
for (const r of readings) {
|
|
await emitUsage({ grain: "session", node, module, ...r });
|
|
}
|
|
|
|
if (process.env.MESH_ANTHROPIC_USAGE_OUT) {
|
|
atomicWrite(process.env.MESH_ANTHROPIC_USAGE_OUT, JSON.stringify(readings, null, 2));
|
|
}
|
|
console.error(`[anthropic-consumer] reported ${readings.length} session(s)`);
|
|
}
|
|
|
|
function atomicWrite(path: string, content: string): void {
|
|
mkdirSync(dirname(path), { recursive: true });
|
|
const tmp = `${path}.tmp`;
|
|
writeFileSync(tmp, content, { mode: 0o600 });
|
|
renameSync(tmp, path);
|
|
}
|
|
|
|
/** Emit best-effort via the sibling mesh-tools `emit`, which wires a broker a run step has none. */
|
|
async function emitUsage(body: Record<string, unknown>): Promise<void> {
|
|
const main = process.env.MESH_TOOLS_MAIN ?? "/app/dist/main.js";
|
|
const { spawn } = await import("node:child_process");
|
|
await new Promise<void>((resolve) => {
|
|
const child = spawn(
|
|
process.execPath,
|
|
[main, "emit", "module.anthropic-consumer.usage.session", JSON.stringify(body)],
|
|
{ stdio: "inherit" },
|
|
);
|
|
child.on("exit", () => resolve());
|
|
child.on("error", (err) => {
|
|
console.error(`[anthropic-consumer] could not emit usage: ${err}`);
|
|
resolve();
|
|
});
|
|
});
|
|
}
|
|
|
|
await main();
|