anthropic model-access modules: manager (refreshable-grant) and consumer
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
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
// 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();
|
||||
Reference in New Issue
Block a user