The module owns /etc/claude-code: managed-mcp.json lists the console as `mesh` over HTTP on loopback plus the servers in its mcp_servers setting (exclusive, by the operator's choice — the https rule of managedMcpServers refuses a loopback console); managed-settings.json carries the attribution convention, keeps claude.ai connectors, and adds the key-helper only for an API-key licence; CLAUDE.md says how a session here works. Rendered whenever the runtime collects the tools, written only on change, through the operator account's sudo. Under the home, only the credentials file, only on a hand-over. Nothing declared under a home or /etc; the console's port comes from node-tools' mcp-endpoint (mesh-tools #34).
219 lines
9.7 KiB
TypeScript
219 lines
9.7 KiB
TypeScript
// claude-code's tools (novox/hq design 36, ADR 0183). Served by the node's tool runtime, which runs as
|
|
// the operator account; this bundle is given its state directory and two files the mesh renders into it
|
|
// (ADR 0192), and the runtime's own words — the operator's account and home among them.
|
|
//
|
|
// Every time the runtime collects these tools, the managed directory is rendered: written only when its
|
|
// content changed, through the account's escalation, because /etc is root's. The credentials file under
|
|
// the home is written only when the licence manager hands this node a token (`claude_code_apply`); this
|
|
// module calls nothing, the manager starts every exchange (ADR 0183's dated note).
|
|
|
|
import { chmodSync, existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
import { createHash } from "node:crypto";
|
|
import { spawnSync } from "node:child_process";
|
|
import { join } from "node:path";
|
|
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
|
|
|
|
import { MANAGED_DIR, render, type Binding, type Facts, type Settings } from "../render.js";
|
|
import { generateKeyPair, open, seal, type SealedBox } from "../seal.js";
|
|
import { decideApply, grantOf, holdsLogin, readCredentials, withGrant, writeCredentials, type Grant } from "../grant.js";
|
|
import { readIdentity } from "../identity.js";
|
|
|
|
interface Paths {
|
|
state: string;
|
|
facts: string;
|
|
settings: string;
|
|
home: string;
|
|
account: string;
|
|
}
|
|
|
|
function pathsFrom(env: NodeJS.ProcessEnv): Paths | null {
|
|
const state = env.MESH_CLAUDE_CODE_STATE;
|
|
const facts = env.MESH_CLAUDE_CODE_FACTS;
|
|
const settings = env.MESH_CLAUDE_CODE_SETTINGS;
|
|
const home = env.MESH_OPERATOR_HOME;
|
|
if (!state || !facts || !settings || !home) return null;
|
|
return { state, facts, settings, home, account: env.MESH_OPERATOR_ACCOUNT ?? "" };
|
|
}
|
|
|
|
const readJson = <T>(p: string, fallback: T): T => {
|
|
try {
|
|
return JSON.parse(readFileSync(p, "utf8")) as T;
|
|
} catch {
|
|
return fallback;
|
|
}
|
|
};
|
|
|
|
const credentialsPath = (p: Paths) => join(p.home, ".claude", ".credentials.json");
|
|
const identityPath = (p: Paths) => join(p.home, ".claude.json");
|
|
const bindingPath = (p: Paths) => join(p.state, "binding.json");
|
|
const apiKeyPath = (p: Paths) => join(p.state, "api-key");
|
|
const helperPath = (p: Paths) => join(p.state, "api-key-helper");
|
|
const keyPath = (p: Paths) => join(p.state, "key.pem");
|
|
const pubPath = (p: Paths) => join(p.state, "key.pub.pem");
|
|
const fingerprint = (s: string) => "sha256:" + createHash("sha256").update(s).digest("hex").slice(0, 16);
|
|
|
|
function keypair(p: Paths): { publicKey: string; privateKey: string } {
|
|
if (!existsSync(keyPath(p))) {
|
|
const k = generateKeyPair();
|
|
writeFileSync(keyPath(p), k.privateKey, { mode: 0o600 });
|
|
writeFileSync(pubPath(p), k.publicKey, { mode: 0o644 });
|
|
}
|
|
return { privateKey: readFileSync(keyPath(p), "utf8"), publicKey: readFileSync(pubPath(p), "utf8") };
|
|
}
|
|
|
|
/** Write one managed file as root when its content changed. Returns what happened, in words. */
|
|
function writeManaged(name: string, content: string, asRoot: boolean): string {
|
|
const path = join(MANAGED_DIR, name);
|
|
let current: string | null = null;
|
|
try {
|
|
current = readFileSync(path, "utf8");
|
|
} catch {
|
|
/* absent */
|
|
}
|
|
if (current === content) return `${name}: unchanged`;
|
|
const cmd = asRoot ? ["install", "-D", "-m", "0644", "/dev/stdin", path] : ["sudo", "-n", "install", "-D", "-m", "0644", "/dev/stdin", path];
|
|
const r = spawnSync(cmd[0], cmd.slice(1), { input: content, encoding: "utf8" });
|
|
if (r.status !== 0) {
|
|
throw new Error(
|
|
`${name}: could not be written to ${MANAGED_DIR} (${(r.stderr || r.error?.message || "").trim()}). ` +
|
|
`The module writes there through the operator account's passwordless sudo; this machine does not give it.`,
|
|
);
|
|
}
|
|
return `${name}: written`;
|
|
}
|
|
|
|
function renderNow(p: Paths): string[] {
|
|
const facts = readJson<Facts | null>(p.facts, null);
|
|
if (!facts?.console) throw new Error(`the mesh has not rendered ${p.facts} yet; nothing to write`);
|
|
const settings = readJson<Settings>(p.settings, {});
|
|
const binding = readJson<Binding | null>(bindingPath(p), null);
|
|
const files = render(facts, settings, binding, helperPath(p));
|
|
const asRoot = process.getuid?.() === 0;
|
|
return Object.entries(files).map(([name, content]) => writeManaged(name, content, asRoot));
|
|
}
|
|
|
|
interface Handed {
|
|
licence: string;
|
|
kind: "subscription" | "api-key";
|
|
source: "rotation" | "switch";
|
|
sealed: SealedBox;
|
|
}
|
|
|
|
function apply(p: Paths, args: Record<string, unknown>): Record<string, unknown> {
|
|
const handed = args as unknown as Handed;
|
|
if (!handed?.licence || !handed.sealed || (handed.kind !== "subscription" && handed.kind !== "api-key")) {
|
|
return { applied: false, reason: "a hand-over names a licence, its kind and a sealed token" };
|
|
}
|
|
const plain = open(handed.sealed, keypair(p).privateKey);
|
|
const previous = readJson<Binding | null>(bindingPath(p), null);
|
|
const source = previous?.licence === handed.licence ? (handed.source ?? "rotation") : "switch";
|
|
if (handed.kind === "api-key") {
|
|
writeFileSync(apiKeyPath(p), plain.trim() + "\n", { mode: 0o600 });
|
|
writeFileSync(helperPath(p), `#!/bin/sh\nexec cat '${apiKeyPath(p)}'\n`, { mode: 0o700 });
|
|
chmodSync(helperPath(p), 0o700);
|
|
} else {
|
|
const grant = JSON.parse(plain) as Grant;
|
|
const local = readCredentials(credentialsPath(p));
|
|
const d = decideApply(grantOf(local), grant, source);
|
|
if (!d.apply) {
|
|
writeFileSync(bindingPath(p), JSON.stringify({ licence: handed.licence, kind: handed.kind }) + "\n", { mode: 0o600 });
|
|
return { applied: false, licence: handed.licence, reason: d.reason };
|
|
}
|
|
writeCredentials(credentialsPath(p), withGrant(local, grant));
|
|
}
|
|
writeFileSync(bindingPath(p), JSON.stringify({ licence: handed.licence, kind: handed.kind }) + "\n", { mode: 0o600 });
|
|
// An API-key binding adds the key-helper to the managed settings; a subscription takes it away.
|
|
const rendered = renderNow(p);
|
|
return { applied: true, licence: handed.licence, kind: handed.kind, source, rendered };
|
|
}
|
|
|
|
function status(p: Paths): Record<string, unknown> {
|
|
const binding = readJson<Binding | null>(bindingPath(p), null);
|
|
const creds = readCredentials(credentialsPath(p));
|
|
const grant = grantOf(creds);
|
|
const managed = ["managed-mcp.json", "managed-settings.json", "CLAUDE.md"].map((f) => {
|
|
try {
|
|
return { file: join(MANAGED_DIR, f), fingerprint: fingerprint(readFileSync(join(MANAGED_DIR, f), "utf8")) };
|
|
} catch {
|
|
return { file: join(MANAGED_DIR, f), fingerprint: null };
|
|
}
|
|
});
|
|
return {
|
|
node: readJson<Facts | null>(p.facts, null)?.node ?? null,
|
|
licence: binding,
|
|
token: grant
|
|
? { fingerprint: fingerprint(grant.accessToken), expiresAt: new Date(grant.expiresAt).toISOString(), refreshTokenOnDisk: holdsLogin(creds) }
|
|
: null,
|
|
managed,
|
|
publicKey: existsSync(pubPath(p)) ? fingerprint(readFileSync(pubPath(p), "utf8")) : null,
|
|
};
|
|
}
|
|
|
|
function pendingLogin(p: Paths, args: Record<string, unknown>): Record<string, unknown> {
|
|
const managerKey = typeof args.public_key === "string" ? args.public_key : "";
|
|
if (!managerKey) return { waiting: false, reason: "the caller names the public key to seal a login to" };
|
|
const creds = readCredentials(credentialsPath(p));
|
|
if (!holdsLogin(creds)) return { waiting: false };
|
|
const identity = readIdentity(identityPath(p));
|
|
return { waiting: true, identity, sealed: seal(JSON.stringify(creds!.claudeAiOauth), managerKey) };
|
|
}
|
|
|
|
export function getClaudeCodeTools(p: Paths): ToolDefinition[] {
|
|
return [
|
|
{
|
|
name: "claude_code_status",
|
|
description:
|
|
"This machine's agent as the mesh configured it: the node, the licence it holds and when its token expires, " +
|
|
"and the managed files it rendered. Fingerprints only — never a token.",
|
|
input: {},
|
|
run: async () => status(p),
|
|
},
|
|
{
|
|
name: "claude_code_render",
|
|
description: "Write the agent's managed directory now from the mesh's facts and this module's settings; says which files changed.",
|
|
input: {},
|
|
run: async () => ({ rendered: renderNow(p) }),
|
|
},
|
|
{
|
|
name: "claude_code_public_key",
|
|
description: "The public half of this node's key, which the licence manager seals a token to.",
|
|
input: {},
|
|
run: async () => ({ public_key: keypair(p).publicKey }),
|
|
},
|
|
{
|
|
name: "claude_code_apply",
|
|
description:
|
|
"The licence manager's hand-over: a token sealed to this node's key, with its licence and kind. Applied by the " +
|
|
"lineage rule; the answer says applied or refused and why, never the token.",
|
|
input: {
|
|
licence: { type: "string", description: "the licence's name" },
|
|
kind: { type: "string", description: "subscription or api-key" },
|
|
source: { type: "string", description: "rotation or switch" },
|
|
sealed: { type: "object", description: "the sealed box" },
|
|
},
|
|
run: async (args) => apply(p, args),
|
|
},
|
|
{
|
|
name: "claude_code_pending_login",
|
|
description:
|
|
"A login a person made on this machine, waiting to be adopted: the grant sealed to the key the caller gives, and " +
|
|
"the account it belongs to. Nothing when no login is waiting.",
|
|
input: { public_key: { type: "string", description: "the caller's public key, PEM" } },
|
|
run: async (args) => pendingLogin(p, args),
|
|
},
|
|
];
|
|
}
|
|
|
|
registerModuleTools("claude-code", (env) => {
|
|
const p = pathsFrom(env);
|
|
if (!p) return [];
|
|
try {
|
|
keypair(p);
|
|
for (const line of renderNow(p)) if (!line.endsWith("unchanged")) console.log(`[claude-code] ${line}`);
|
|
} catch (err) {
|
|
// Said, and the tools still served: claude_code_status and claude_code_render say what is wrong.
|
|
console.log(`[claude-code] ${err instanceof Error ? err.message : String(err)}`);
|
|
}
|
|
return getClaudeCodeTools(p);
|
|
});
|