Events carry what happened and no secret; tokens travel on requests (design 32 §10). The manager's licence.rotated/switched events make the module ask anthropic-licence-manager.current; at start it asks once to catch up. A refresh token appearing in the credentials file is a login: it is pushed to the manager's adopt at once, sealed to the manager's key — the one time a refresh token travels. A switch replaces the old licence's grant whole, removes the API key and its helper, and rewrites oauthAccount in ~/.claude.json. New tools register and unregister MCP servers on this node, or with nodes: all / a list via an mcp.registered event every node consumes; called for one node, the answer names the other nodes running claude-code. 26 tests.
136 lines
6.4 KiB
TypeScript
136 lines
6.4 KiB
TypeScript
// What the module writes into the agent's machine-wide managed directory (novox/hq design 36 §1–§4).
|
|
// Pure: composed from the facts the mesh rendered, the settings the operator set and the licence the
|
|
// node holds, so what lands under /etc is tested without a machine.
|
|
//
|
|
// Three files, owned whole by this module:
|
|
// managed-mcp.json the tool servers every session loads: the mesh's console as `mesh`, and the
|
|
// servers the operator declared for the mesh or this node. Exclusive by the
|
|
// vendor's rule — a server not listed here does not load — which is why the
|
|
// list is the module's settings and nothing else (operator's choice, 2026-10-03).
|
|
// managed-settings.json the mesh's keys only: the repositories' attribution convention, the
|
|
// claude.ai connectors kept beside the managed servers, and — for an API-key
|
|
// licence only — the key-helper. A person's preferences are theirs.
|
|
// CLAUDE.md how a session on this mesh works, who this node is, the conventions.
|
|
|
|
export const MANAGED_DIR = "/etc/claude-code";
|
|
|
|
export interface Facts {
|
|
readonly node: string;
|
|
readonly console: string;
|
|
}
|
|
|
|
export interface Settings {
|
|
readonly role?: string;
|
|
/** Extra tool servers, in the vendor's `.mcp.json` entry shape, keyed by name. */
|
|
readonly mcp_servers?: Readonly<Record<string, Record<string, unknown>>>;
|
|
}
|
|
|
|
export interface Binding {
|
|
readonly licence: string;
|
|
readonly kind: "subscription" | "api-key";
|
|
}
|
|
|
|
export interface Rendered {
|
|
readonly [file: string]: string;
|
|
}
|
|
|
|
const MESH_ENTRY = "mesh";
|
|
|
|
export type Servers = Readonly<Record<string, Record<string, unknown>>>;
|
|
|
|
/** Whether an entry is one the vendor's managed file takes: a name of letters, digits, `-` and `_`, and
|
|
* an http/sse server with a url or a stdio server with a command. Returns why not, or null. */
|
|
export function entryProblem(name: string, entry: Record<string, unknown>): string | null {
|
|
if (!/^[A-Za-z0-9_-]+$/.test(name)) return `"${name}" is not a name the agent takes: letters, digits, - and _`;
|
|
if (name === MESH_ENTRY) return `"${MESH_ENTRY}" is the mesh's own entry`;
|
|
const type = entry?.type ?? "stdio";
|
|
if (type === "http" || type === "sse" || type === "streamable-http") {
|
|
return typeof entry.url === "string" && entry.url ? null : `an ${type} server needs a url`;
|
|
}
|
|
if (type === "stdio") return typeof entry.command === "string" && entry.command ? null : "a stdio server needs a command";
|
|
return `"${String(type)}" is not a server type the agent knows (http, sse, stdio)`;
|
|
}
|
|
|
|
/**
|
|
* Compose the three files. `registered` is the module's own list on this node — what was registered
|
|
* through its tools — laid over the servers the operator set in its settings.
|
|
*/
|
|
export function render(facts: Facts, settings: Settings, binding: Binding | null, helperPath: string,
|
|
registered: Servers = {}): Rendered {
|
|
const servers: Record<string, unknown> = {};
|
|
for (const [name, entry] of Object.entries({ ...(settings.mcp_servers ?? {}), ...registered })) {
|
|
if (entryProblem(name, entry) !== null) continue; // the mesh's own entry, or one the agent would refuse
|
|
servers[name] = entry;
|
|
}
|
|
servers[MESH_ENTRY] = { type: "http", url: facts.console };
|
|
|
|
const managed: Record<string, unknown> = {
|
|
attribution: { commit: "", pr: "" },
|
|
allowAllClaudeAiMcps: true,
|
|
};
|
|
if (binding?.kind === "api-key") managed.apiKeyHelper = helperPath;
|
|
|
|
return {
|
|
"managed-mcp.json": json({ mcpServers: sortKeys(servers) }),
|
|
"managed-settings.json": json(managed),
|
|
"CLAUDE.md": instructions(facts, settings),
|
|
};
|
|
}
|
|
|
|
function json(v: unknown): string {
|
|
return JSON.stringify(v, null, 2) + "\n";
|
|
}
|
|
|
|
function sortKeys(o: Record<string, unknown>): Record<string, unknown> {
|
|
return Object.fromEntries(Object.keys(o).sort().map((k) => [k, o[k]]));
|
|
}
|
|
|
|
export function instructions(facts: Facts, settings: Settings): string {
|
|
const role = settings.role?.trim() ? settings.role.trim() : "not stated — set it in this module's settings for the node";
|
|
return `# This machine is a node of a Novox mesh
|
|
|
|
Written by the mesh's \`claude-code\` module. Edit the module's settings or the catalogue, never this file:
|
|
it is rewritten whenever the module renders.
|
|
|
|
## Who this node is
|
|
|
|
- **Node:** \`${facts.node}\`
|
|
- **Role:** ${role}
|
|
- The other nodes, their roles and what runs where: ask the controller (\`mesh-controller.nodes\`,
|
|
\`mesh-controller.node\`). Nothing here lists them, because a copy drifts.
|
|
|
|
## How a session on this mesh works
|
|
|
|
The console is the only way to the mesh: the MCP server named \`mesh\`. It offers five tools, and
|
|
everything else is an address you find and call through them:
|
|
|
|
- \`mesh_search\` — words in, matching addresses out. \`mesh_describe\` — one address's arguments.
|
|
- \`mesh_call\` — call an address. A seat the mesh holds once is \`<seat>.<verb>\` (the mesh's own verbs
|
|
are \`mesh-controller.<verb>\`: \`status\`, \`plan\`, \`node\`, \`assign\`, \`push\`, \`settings\`);
|
|
a module on a machine is \`<node>/<module>.<tool>\`.
|
|
- \`mesh_overview\` and \`mesh_machine\` — the mesh's seats and machines, and what one machine runs.
|
|
|
|
- **Symptom first.** For an error, a failing service or anything unexpected, search the record with the
|
|
literal text before forming a hypothesis: the records module's \`records_search\`, then
|
|
\`records_read\`.
|
|
- **Ask the mesh before changing it**, and change it through the controller's verbs or the catalogue.
|
|
- **A licence** through the \`anthropic-licence-manager\` seat's verbs. Never edit the agent's credentials
|
|
file by hand, never print or ask for a token.
|
|
|
|
## Hard rules
|
|
|
|
- A file the mesh manages is changed through the verb or the catalogue that owns it, never on disk. If
|
|
unsure, \`mesh-controller.plan\` for the node says what the mesh writes there.
|
|
- Never write to a store's database by hand; schema changes are numbered migrations.
|
|
- Never push to a main branch: a branch, a pull request, and a human approval for every merge.
|
|
- The mesh creates no symlinks, and nobody else does either.
|
|
- A package is declared in a module, never installed by hand.
|
|
|
|
## Conventions
|
|
|
|
- Commit messages are concise, in the imperative, about why.
|
|
- Test before pushing: nodes update unattended.
|
|
- The playbooks in the record say how research, decisions, designs, issues and hand-offs are done.
|
|
`;
|
|
}
|