Compare commits

..
Author SHA1 Message Date
jochen add923c74a ssh-client: the mesh's region first in ~/.ssh/config, its hosts in config.d, tools in Go
The region at the end let earlier Host lines win over the mesh's (research 027/03). A
roster fact cannot be placed at the start, so the region holds one Include of config.d,
and the hosts are config.d/00-mesh, read first. Eight tools; authorized_keys and
known_hosts stay found until the controller holds those facts.
2026-10-04 12:37:44 +02:00
jochen d4a6008bd6 docker: the container runtime as a module, with its tools in Go
Claims node-container-runtime (ADR 0207). Owns the packages, the socket and a weekly
prune of dangling images and unused build cache. Serves 18 tools over every container,
marking the mesh's. daemon.json, docker.service and the docker group are left to a
proposed change: dnsmasq and zsh declare them today, and the controller refuses a
second declaration (README).
2026-10-04 12:37:44 +02:00
mesh-admin 83a51832d7 Merge pull request 'claude-code writes its managed files from a staged file, not /dev/stdin' (#258) from fix/claude-code-writes-managed-from-a-file into main 2026-10-04 10:01:17 +00:00
mesh-admin 9e63a258d0 Merge pull request 'zsh: keep each PATH directory once' (#260) from fix/zsh-unique-path into main 2026-10-04 09:34:43 +00:00
jochen 328d90fb88 zsh: keep each PATH directory once
Every nested shell, and every sourced file that prepends, added the same
directories again; a workstation's PATH carried each of several entries three
times. typeset -U in the .zshenv block applies to every zsh.
2026-10-04 11:34:36 +02:00
mesh-admin ca5ab288f6 Merge pull request 'zsh: save history and initialise completion' (#259) from fix/zsh-completion-and-history into main 2026-10-04 09:33:28 +00:00
jochen 5fd0f72221 zsh: save history and initialise completion
zsh saves no history by default (SAVEHIST=0) and nothing called compinit, so
every machine had 30 lines of unsaved history and only basic completion.
Found reviewing the shell on its first machine (hq to-be 41).
2026-10-04 11:33:17 +02:00
jochen 5003dc0377 claude-code writes its managed files from a staged file, not /dev/stdin
Node hands a child its input over a socket, which /dev/stdin cannot open
(ENXIO): on the first assignment nothing under /etc/claude-code was written.
2026-10-04 11:31:44 +02:00
mesh-admin 0a78d130e5 Merge pull request 'claude-code watches its MCP servers beside the handshake, and retries' (#257) from fix/claude-code-watches-without-blocking into main 2026-10-04 09:21:26 +00:00
jochen 4295aad88e claude-code watches its MCP servers beside the handshake, and asks again until the state answers (novox/hq ADR 0201)
Awaited at import, a bucket not yet on the bus — or a grant the bus had not
reloaded — answered after the runtime's 10s handshake, and the module was left
unserved on its first assignment. Also cites module state as ADR 0201, as hq
main numbers it (folds #256).
2026-10-04 11:13:33 +02:00
mesh-admin 9dfd3b1105 Merge pull request 'claude-code: the operator's agent, its managed configuration and the licence consumer side (hq design 36, to-be 40 WP2)' (#244) from feat/claude-code-agent into main 2026-10-04 09:01:31 +00:00
mesh-admin 22e8714040 Merge pull request 'The shell and the account's environment as modules: node-env, zsh, powerlevel10k, two plugins, and systemd finished (hq to-be 41 WP3, WP4)' (#255) from feat/the-shell-and-its-environment into main 2026-10-04 08:55:11 +00:00
jochen 11e7ede8a4 Merge remote-tracking branch 'origin/main' into feat/the-shell-and-its-environment 2026-10-04 10:31:03 +02:00
mesh-admin 27315d35cf Merge pull request 'The store does not collect until every controller composes the window (hq ADR 0189)' (#254) from fix/the-store-collects-once-the-window-is-understood into main 2026-10-04 02:26:57 +00:00
jschoubben 525c639041 The store does not collect until every controller composes the window (hq ADR 0189)
mesh-controller#259 fixes while-stopped to name the container as the machine
knows it — `distribution.store`, not `store`. Until that controller is the
one composing, novox refuses its whole declaration and takes nothing at all.

The step comes out; deletion stays on, already applied and harmless on its
own. A collect step without its window would be worse than none: garbage
collection against a live registry can sweep a blob a build is pushing.

Put back once the fixed controller is deployed and stays.
2026-10-04 04:26:38 +02:00
jochen 42c80fa9e1 zsh: no doubled blank line in the block when the first slot is empty 2026-10-04 04:05:30 +02:00
jochen 56e0830700 zsh-autosuggestions, zsh-syntax-highlighting: the plugins as packages and one line each
The distribution packages both, so they are installed as packages rather than cloned or
vendored (novox/hq ADR 0205). Each contributes the line that loads the package's own
copy, from the path the Arch package installs, to a slot of the login shell's block
(ADR 0204). Syntax highlighting goes in last, as its upstream asks.
2026-10-04 04:04:35 +02:00
jochen 0844b35ebb powerlevel10k: the prompt as a pinned archive of the module's own, loaded from a slot
The distribution does not package the theme, and the predecessor cloned whatever
upstream's default branch held the day a hook ran (novox/hq ADR 0205). So upstream's
v1.20.0 release is vendored verbatim, with its licence, and shipped as an archive the
host unpacks under the account's home and checks by digest.

The prompt's configuration is today's ~/.p10k.zsh byte for byte, as a second archive.
Inline, its 86 KB would ride in every declaration and be unreviewable JSON. The zsh code
that loads both is a contribution to the normal slot (ADR 0204). Instant prompt stays off,
as it is today.
2026-10-04 04:04:10 +02:00
jochen 566739e02c zsh: hold the mesh's login-shell seat, source the environment, and leave the rest to slots
The seat is now the mesh's node-login-shell, which a shell module claims rather than
declares (novox/hq ADR 0204), and the environment is one module's that every module
contributes to (ADR 0203). Per hq to-be 41 WP3:

- no seat declaration; the claim is node-login-shell serving execute;
- EDITOR, VISUAL, XDG_CONFIG_HOME and the three PATH entries are an environment
  contribution, not exports in the block;
- a ~/.zshenv block sources ~/.config/mesh/environment.sh, so a script, a login and
  execute all see the environment;
- the ~/.zshrc block goes at the start, so the operator's lines run after it, and holds
  today's shared defaults between the first, normal and last slots. The prompt, the
  plugins and the operator's own lines are no longer in it;
- execute is bounded below the runtime's call limit (20 s default, 25 s at most), kills its
  whole process group on timeout, cuts each stream at 256 KiB and says so, runs in the
  account's home without the mesh's words, with the account's session words. The dead
  runuser branch is gone, because the runtime is the account;
- zsh_config shows both files with their block line counts;
- the README lists the one-off migration (ADR 0182).
2026-10-04 04:02:54 +02:00
jochen 38b56a7877 node-env: the account's environment as one module's two files
Every module contributes variables and PATH entries as facts, and one holder of
node-environment places them (novox/hq ADR 0203, to-be 41 WP3). This is that holder: no
package, no process, no tools — the directories it owns under the home and two files the
controller fills, the POSIX file at the path the seat fixes (~/.config/mesh/environment.sh,
sourced by the login shell) and environment.d's 50-mesh.conf for the account's service
manager and graphical session.
2026-10-04 03:59:50 +02:00
jochen 0596503db5 systemd: act as the runtime's account can, and never read a failure as an answer
The node tools runtime runs as the operator account, not root, and gives its bundles no
session words (novox/hq ADR 0175, 0188, 0193). So, per hq to-be 41 WP4:

- system-scope start/stop/restart/enable/disable go through sudo -n when not root, as the
  packet filter and intrusion prevention do, and a refusal is named by how it failed;
- user scope is plain --user with XDG_RUNTIME_DIR and the session bus of /run/user/<uid>;
  the dead --machine branches are gone;
- a failed systemctl or journalctl is an error, and an unreachable user manager is said
  even when systemctl exits 0; systemd_failed reports it beside the other manager's answer
  instead of claiming nothing failed;
- status says whether the mesh declares the unit: its loaded unit file begins with the
  header the host writes for a module's process. Only such a unit carries the restore note;
- the package resource goes: the service manager is always present, and it collided with
  systemd-networkd's identical declaration;
- calls are bounded below the runtime's call limit, a unit name is never an option, and
  the runner is injected so the tests use a fake one.
2026-10-04 03:58:19 +02:00
jochen 3668b02b94 claude-code keeps its MCP servers in state, not events (novox/hq ADR 0202)
One key per registration in the module's servers bucket — all.<server> or
<node>.<server> — watched by every node, so a node assigned after a
registration takes it at start, which the mcp.registered event could not do.
Also narrows apply()'s refusal by hand: the builder compiles without strict,
where the discriminated union does not narrow and the build failed.
2026-10-04 03:48:41 +02:00
mesh-admin c0159ca0a1 Merge pull request 'Group 8: minio declares the bucket it derives (hq ADR 0201), and the store collects nightly (hq ADR 0189)' (#229) from feat/the-store-keeps-what-the-records-name into main 2026-10-04 01:47:48 +00:00
jschoubben 159ed53103 Rebased onto main: ADR 0188 renumbered to 0201, and minio takes the sdk at 0.1.7
The bundles refactor took ADR 0188 on main, so minio's comments cite 0201.
The sdk is 0.1.7 after the same rebase, and minio needs the `derived` field
it carries.
2026-10-04 02:45:13 +02:00
jschoubben 723e676b75 The store enables deletion and collects nightly (hq ADR 0189)
REGISTRY_STORAGE_DELETE_ENABLED on the server — the door already accepts a
push — and a scheduled step running the registry's own collector over the
volume at 03:30 with the server held still. Plain garbage-collect: what the
mesh keeps is still a manifest, so --delete-untagged is not needed and would
delete images machines are running.
2026-10-04 02:34:06 +02:00
jschoubben 661114370f minio declares the bucket it derives; its consumers stop transcribing it (hq ADR 0188)
serves.s3-bucket.bucket is ${consumer:as:dns}; the provisioner uses what it
is given. nextcloud, invoicing and photos ask for ${bound:s3-bucket:bucket}
instead of naming mesh-novox-* literals, which also named this node.
bucketFor and the long-dead accessKeyFor are gone.
2026-10-04 02:34:06 +02:00
jochen a1d7b9ad5a systemd: the service manager as a module — holds node-service-manager and answers for the units in both scopes
The holder of the seat the controller seeds under novox/hq ADR 0177. Eight
verbs under the seat's name — units, status, start, stop, restart, enable,
disable, journal — each taking an optional scope, "system" by default or
"user" for the operator account's own manager, reached as
`systemctl --user --machine=<account>@` when the runtime is not that account.
One tool of its own, systemd_failed, for every failed unit in both scopes.
A package, a claim and a bundle; no container, no process: served by the node
tools runtime (ADR 0175) once it exists. `module check` passes against a
controller that carries the seat; the tools type-check against the SDK.
2026-10-04 02:32:34 +02:00
jochen 5548b0f4e9 zsh: the shell as a module — package, the mesh's ~/.zshrc block, the login-shell seat and execute
The first module of the operator's environment (novox/hq to-be 37 §1, ADR 0173,
0176). A package, the mesh's default configuration as a block inside the
account's ~/.zshrc so the operator's own lines around it survive every push
(ADR 0174 as the host's `into: block` realises it), a `user` shape that makes
zsh the account's login shell, the `login-shell` seat declared with its one
verb, and a tools bundle: `execute` under the seat's name, `zsh_config` under
the module's. No container, no process: the tools are served by the node tools
runtime (ADR 0175), which does not exist yet — the bundle builds and the
manifest registers ahead of it. `module check` passes; the tools type-check
against the SDK.

Two things the manifest cannot yet say, left for the controller: the `user`
shape applies wherever the module is assigned, not only where it holds the
seat; and the runtime learns the account from MESH_OPERATOR_ACCOUNT, which
nothing sets yet.
2026-10-04 02:32:34 +02:00
jochen 295cc59e1e claude-code: declare using the licence manager's seat when that module exists; until then the mesh refuses a seat no module declares 2026-10-04 02:23:34 +02:00
jochen 6a7e4ebd5e claude-code over NATS: licence events, a token by request, a login pushed to the manager, MCP servers registered per node or mesh-wide
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.
2026-10-04 02:23:23 +02:00
mesh-admin 9e8146192c Merge pull request 'mssql: give TLS a host name when the server is an address' (#252) from fix/mssql-tls-names-the-host into main 2026-10-03 23:31:40 +00:00
jochen 6171d747db mssql: give TLS a host name when the server is an address
Node 25 refuses an IP address as the TLS server name, and the module reaches its server on
loopback, so every connection failed on the live machines. The certificate is trusted either way.
2026-10-04 01:31:31 +02:00
mesh-admin 84609c0373 Merge pull request 'The last three: mesh-catalog, mongodb and mssql code moves into bundles the node's runtime serves (hq ADR 0198, to-be 38 WP4c)' (#250) from feat/0198-the-last-three-module-code-moves into main 2026-10-03 23:28:58 +00:00
mesh-admin 28d5e7f939 Merge pull request 'audit-logger: the test subscribes as the module does' (#251) from fix/audit-logger-test-hears-every-event into main 2026-10-03 23:21:52 +00:00
jochen 4128380a3d audit-logger: its test subscribes as the module does and expects local event names
The test subscribed '**', which its in-memory broker never matched, while the module subscribes
'#'; and it still expected the module-qualified type from before event names became local.
2026-10-04 01:21:40 +02:00
jochen cf57d3fd8f mssql: its handlers, tools and provisioner run in the node's runtime, through the driver in its bundle (hq ADR 0198)
The mesh-mssql container goes with its Dockerfile (and the sqlcmd it fetched), build bases and bus credential. The client speaks TDS through the mssql driver its package.json names, inlined into the bundle by the builder (ADR 0198 §4): one session per call as one sqlcmd invocation was, FOR JSON rendering rows exactly as before, the consumer's password checked as a bound parameter. A caller's statement still runs only as the reader login (issue 193); the one-line rule and -x guarded against sqlcmd's own commands and variable substitution, which no longer stand between the caller and the server. The server is reached on loopback at the port the machine published (${port:1433}). The reader test drives a fake session in place of a fake sqlcmd.
2026-10-04 01:17:41 +02:00
jochen da8a46cfe8 mongodb: its handlers, tools and provisioner run in the node's runtime, through the driver in its bundle (hq ADR 0198)
The mesh-mongodb container goes with its Dockerfile, build bases and bus credential. Its client shelled out to mongosh, which no machine's system carries, so it now speaks to the server through the official mongodb driver its package.json names, inlined into the bundle by the builder (ADR 0198 §4); the tools answer exactly as before (relaxed Extended JSON). The server is reached on loopback at the port the machine published (${port:27017}). The root secret was owned by the mongo image's user (secrets-owner 999:999), which the runtime's account cannot read; the module's own copy is now the runtime's, and the server is given its own 999-owned copy rendered from the same secret.
2026-10-04 01:17:41 +02:00
jochen ed50130a6a mesh-catalog: its consumer and tools run in the node's runtime, and its preparation is a run-once process (hq ADR 0198)
The mesh-catalog container goes with its Dockerfile, build bases, bus credential and mesh-state directory. Its words are the database URL file where the mesh writes it. `pg` is a dependency in its package.json, which the builder now installs and inlines into the bundle (mesh-controller: a TypeScript bundle installs its module's own packages). `prepares: true` needs a container running the module's own artifact, so it becomes what ADR 0198 §3 says it is: prepare/index.js run by node as a run-once process, with the same words and no bus, before the runtime is started with the version that needs it, and again when the database URL changes.
2026-10-04 01:17:41 +02:00
mesh-admin bd2123166f Merge pull request 'Waves 2-3: nine modules' code moves into bundles the node's runtime serves (hq ADR 0198, to-be 38 WP4c)' (#248) from feat/0198-waves-2-3-module-code-moves into main 2026-10-03 23:01:18 +00:00
mesh-admin d9db931bd0 Merge pull request 'mosquitto: run mosquitto_ctrl inside the broker's container' (#249) from fix/mosquitto-ctrl-from-its-container into main 2026-10-03 23:01:03 +00:00
jochen 69f2efb591 mosquitto: run mosquitto_ctrl inside the broker's container
The module's code moved out of its container and took mosquitto_ctrl from a host package. A
machine whose package index is stale cannot install it (hq issue 205), so the tools failed. The
broker's own image carries the tool at the broker's version: the tools exec into the running
broker, and the bootstrap seeds from a throwaway container of the same image.
2026-10-04 01:00:51 +02:00
jochen 568674fef7 anthropic-consumer: its usage runs in the node's runtime, and its apply is a scheduled process (hq ADR 0198)
Both containers go with the Dockerfile, build bases, bus credential and state directory. apply needs no bus and runs every five minutes as a process on the machine at the host paths the container mounted. usage emitted by spawning the runtime image's own emit command with the module's credential, which exists nowhere now, so it is loaded by the node's runtime instead: it emits through the SDK as this module and reads on the cadence the schedule gave it, once at start and every five minutes. That is the one code change.
2026-10-04 00:52:31 +02:00
jochen 3c6b70845c openai-consumer: its apply is a scheduled process (hq ADR 0198)
The mesh-openai-consumer-apply container goes with its Dockerfile and build bases. The same entrypoint runs every five minutes as a process on the machine, reading the binding and writing the credentials at the host paths the container used to mount.
2026-10-04 00:52:31 +02:00
jochen 35ef72081f route-adapter: its step is a run-once process (hq ADR 0198)
The mesh-route-adapter container goes with its Dockerfile and build bases. The step runs node on the bundle as a run-once process, reading what the mesh contributed and its config where the mesh writes them and writing the proxy's dynamic directory at the path the container used to mount; it still runs again when a route or its config changes.
2026-10-04 00:52:31 +02:00
jochen 59c42b2086 lab: its tools run in the node's runtime (hq ADR 0198)
The mesh-lab container goes with its Dockerfile, build bases, bus credential and state directory. What the image installed — git, make, python, file, iproute2, sudo, npm, go and the incus client — are packages of the machine, and docker and incus are reached through their sockets as the runtime's account. The forge is an operator's setting, which reaches a file and never a bundle's words, so the tools read it from the env-file the mesh already fills, at each call; that is the one code change.
2026-10-04 00:52:31 +02:00
jochen 3b8164f1c0 mailu: its handlers, tools and provisioner run in the node's runtime (hq ADR 0198)
The mesh-mailu container goes with its Dockerfile, the mesh-tools build bases and its bus credential; automx keeps its own image. The code reached the admin API by its name on the mailu network, which a process on the machine cannot, so the admin container publishes 8080 to this machine only and the bundle reaches it on loopback at that port. Mail is still read through docker exec into mailu-imap, so the runtime's account needs the docker socket as nextcloud's does.
2026-10-04 00:52:31 +02:00
jochen 8877f893e5 records: its consumer and tools run in the node's runtime (hq ADR 0198)
The records container goes with its Dockerfile, build bases and bus credential. The checkout, the config file and the origin file are read where the mesh writes them, and git comes from the machine's git package instead of the image's apt layer.
2026-10-04 00:52:31 +02:00
jochen 043ae17fbf mesh-vault: its handlers, tools and provisioner run in the node's runtime (hq ADR 0198)
The mesh-vault container goes with its Dockerfile, build bases, bus credential and state directory; its env was already host paths, so it becomes the bundle's words unchanged.
2026-10-04 00:52:31 +02:00
jochen 944f086ec7 gitea: its watcher, tools and provisioner run in the node's runtime (hq ADR 0198)
The mesh-gitea container goes with its Dockerfile, build bases and bus credential; its env becomes the bundle's words with mount targets folded back to host paths: the config file, the admin password and the kept-token state directory are read where the mesh writes them.
2026-10-04 00:52:31 +02:00
jochen dd93cfd613 audit-logger: its handler runs in the node's runtime (hq ADR 0198)
The mesh-audit-logger container goes with its Dockerfile, build bases and bus credential: its one entrypoint is a load of one bundle, which subscribes to every event through the runtime and writes the trail at the host path the container used to mount.
2026-10-04 00:52:31 +02:00
mesh-admin 64cc292d7e Merge pull request 'Wave 1: thirteen modules' code moves into bundles the node's runtime serves (hq ADR 0198, to-be 38 WP4c)' (#245) from feat/0198-wave-1-module-code-moves into main 2026-10-03 22:52:08 +00:00
mesh-admin 7e889adf71 Merge pull request 'netcheck: one module, a Go tools bundle and a TypeScript one (hq ADR 0188, 0193)' (#246) from feat/netcheck-a-module-in-two-languages into main 2026-10-03 22:35:32 +00:00
jochen 7e9ef899c1 netcheck: one module, a Go tools bundle and a TypeScript one (hq ADR 0188, 0193)
ADR 0193 says the node's runtime launches every served bundle over MCP stdio and knows no
language, and ADR 0188 says one module may carry several bundles in any language. Nothing in
the catalogue shows both at once: every tools bundle is TypeScript, and the only Go bundle is
the runtime itself. netcheck is the smallest real module that does — read-only checks from a
machine, worth having on their own:

- tools-go (Go SDK go/v0.1.6): netcheck_tcp (one connect, nothing sent) and netcheck_dns
  (A/AAAA/CNAME/TXT/MX through the machine's resolver).
- tools-typescript (@novox/mesh-sdk): netcheck_http (HEAD or GET, body neither sent nor read,
  redirects reported not followed, anything but http(s) refused).

Both say loads; the module lists its tools. No container, no image, no env: nothing to be
given, so the runtime's own words suffice.
2026-10-04 00:34:31 +02:00
jochen 03e729d103 claude-code owns /etc/claude-code and ~/.claude as declared directories
So the controller's ownership check refuses a second module owning either. ~/.claude is the
operator's at 0700 (it was 0755 on the workstations); of what is inside, the module owns only what it
writes, and the host keeps a directory that is not empty when the module goes (hq ADR 0182).
2026-10-03 23:49:16 +02:00
jochen eab335b755 claude-code: launched over stdio (ADR 0193), the console's five tools in its instructions (ADR 0195)
Every bundle is now a child speaking MCP over stdio, so stdout is the channel: the module logs on
stderr. The managed CLAUDE.md teaches mesh_search, mesh_describe, mesh_call, mesh_overview and
mesh_machine with addresses (<seat>.<verb>, <node>/<module>.<tool>) instead of flat tool names.
A hand-over is applied whatever the trailing render says; a failed render is reported beside it.
Proven over stdio as the runtime drives it: five tools listed, a key made on first use, a sealed
switch writing an access-token-only 0600 credentials file that keeps unknown keys.
2026-10-03 23:41:01 +02:00
jochen f42b58f789 claude-code: the manifest, the managed directory and the tools (hq design 36, to-be 40 WP2)
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).
2026-10-03 23:40:21 +02:00
jochen 5737752744 claude-code: the sealed hand-over, the credentials write with the lineage rule, the identity read (hq to-be 40 WP2, in progress)
The parts of the agent module that hold whichever way the console is registered: X25519 +
HKDF + AES-GCM from Node's own library so the bundle carries no dependency; the predecessor's
lineage rule (rotation only if newer, a re-issue adopted, a switch regardless) with its
incidents as tests; an atomic 0600 write that strips any refresh token and keeps keys it does not
know; the account read from the agent's own state file. Manifest and renderer follow.
2026-10-03 23:40:21 +02:00
jochen f79199777d minio: its tools and provisioner run in the node's runtime (hq ADR 0198)
The mesh-minio container goes with its Dockerfile, build bases, bus credential and state directory. The client reaches minio on the published port, runs the minio-client package's mcli instead of the image's mc, and keeps mc's config, which holds the root alias, in the module's own state directory rather than a shared /tmp.
2026-10-03 23:33:51 +02:00
jochen b9d0884335 nextcloud: its handlers and tools run in the node's runtime (hq ADR 0198)
The mesh-nextcloud container goes with its Dockerfile, build bases and bus credential. occ still runs through docker exec, now with the host's own docker CLI and socket.
2026-10-03 23:33:51 +02:00
jochen 6a6d5747a3 nodered: the runtime serves its tools, and its mqtt step is a run-once process (hq ADR 0198)
The mesh-nodered container goes with its Dockerfile, build bases and bus credential. The mqtt step runs node on the bundle and reads the binding and settings files where the mesh writes them, from an env-file the mesh fills because a process's env is not given ${port:…}.
2026-10-03 23:33:51 +02:00
jochen b19c4a2593 home-assistant: the runtime serves its code, and its provisions step is a run-once process (hq ADR 0198)
The mesh-home-assistant container goes with its Dockerfile, build bases and bus credential. The provisions step runs node on the bundle and reads the binding files where the mesh writes them, from an env-file the mesh fills because a process's env is not given ${port:…}. It still runs again when a binding it reads changes.
2026-10-03 23:33:51 +02:00
jochen a934e2a69f icecast: its handlers and tools run in the node's runtime (hq ADR 0198)
The mesh-icecast container goes with its Dockerfile, build bases and bus credential. The bundle reaches icecast on the port this machine published rather than the container network's name.
2026-10-03 23:33:51 +02:00
jochen af346f6066 grafana: its handlers and tools run in the node's runtime (hq ADR 0198)
The mesh-grafana container goes with its Dockerfile, build bases and bus credential; its env becomes the bundle's words with mount targets folded back to host paths.
2026-10-03 23:33:51 +02:00
jochen 7b0cfceb68 cloudflare-dns: its tools and provisioner run in the node's runtime (hq ADR 0198)
The mesh-cloudflare-dns container goes with its Dockerfile, build bases, bus credential and state directory. MESH_RECEIVES now names the grants directory itself; the container's value pointed at a path nothing was mounted on.
2026-10-03 23:33:51 +02:00
jochen 26021865c1 umami: its tools and provisioner run in the node's runtime (hq ADR 0198)
The mesh-umami container goes with its Dockerfile, build bases, bus credential and state directory. The provisioner's env-file only told it umami's container-network address, so it becomes a word on the published port and the file goes.
2026-10-03 23:33:51 +02:00
jochen 7440b8d009 keycloak: its handlers, tools and provisioner run in the node's runtime (hq ADR 0198)
The mesh-keycloak container goes with its Dockerfile, build bases and bus credential; its env becomes the bundle's words with mount targets folded back to host paths.
2026-10-03 23:33:51 +02:00
jochen 0cb67e856f influxdb: its tools and provisioner run in the node's runtime (hq ADR 0198)
The mesh-influxdb container goes with its Dockerfile, build bases and bus credential; its env becomes the bundle's words with mount targets folded back to host paths.
2026-10-03 23:33:51 +02:00
jochen 038a0a25ce mosquitto: the runtime serves its code, and its bootstrap is a run-once process (hq ADR 0198)
The mesh-mosquitto container goes with its Dockerfile, build bases and bus credential. mosquitto_ctrl comes from the mosquitto package, and the bootstrap step runs node on the bundle, reading the broker's published port from an env-file the mesh fills, because a process's env is not given ${port:…}.
2026-10-03 23:33:50 +02:00
jochen 1b27ce319a redis: its handlers, tools and provisioner run in the node's runtime (hq ADR 0198)
The mesh-redis container goes with its Dockerfile, build bases, bus credential and the state directory only that credential lived in. The bundle reaches redis on the port this machine published rather than the container network's name.
2026-10-03 23:33:50 +02:00
jochen 7563569c8a postgres: its handlers, tools and provisioner run in the node's runtime (hq ADR 0198)
The mesh-postgres container goes with its Dockerfile, build bases and bus credential: its three entrypoints are loads of one bundle, given their words as host paths, and psql comes from postgresql-libs instead of the image's apt layer. The seat word is dropped, since a bundle's words cannot carry one and the client treats it as optional.
2026-10-03 23:33:50 +02:00
mesh-admin efff54157b Merge pull request 'The seven say what the runtime loads from their tools bundle (hq ADR 0192)' (#243) from fix/0192-the-seven-say-what-the-runtime-loads into main 2026-10-03 13:58:27 +00:00
jochen 2000ec3f48 The seven say what the runtime loads from their tools bundle (hq ADR 0192)
None declares a tools list, so the composer had nothing saying the runtime loads from the bundle,
and delivered it nowhere: built, recorded, never sent. loads names tools/index.js.
2026-10-03 15:58:19 +02:00
mesh-admin 7c800705bf Merge pull request 'Seven tool containers move to bundles the node's runtime serves, given their words (hq ADR 0192)' (#242) from feat/0192-seven-tool-containers-move into main 2026-10-03 13:45:46 +00:00
jochen 19511012f8 Seven tool containers move to bundles the node's runtime serves, given their words (hq ADR 0192)
baserow, confluence, gitlab, jira, letta, searxng and unifi: each runtime container's
environment becomes its tools bundle's env, mount targets folded back into the host paths they
came from; baserow and letta reach their service on the published port rather than a container
network name. The container, base images, Dockerfile and the module's own bus credential go,
and the state directory where only that credential lived. Tool code is unchanged: every client
is built from the environment the contributor is handed.
2026-10-03 15:35:00 +02:00
jschoubben 0e8ad3a8e1 Merge pull request 'dnsmasq: restart on the mesh hosts region, which it reads only at start' (#241) from fix/dnsmasq-rereads-the-names-region into main 2026-10-03 13:29:29 +00:00
jschoubben b43e405947 dnsmasq: restart on the mesh's hosts region, which it reads only at start
The names region of /etc/hosts is written by mesh-wireguard and dnsmasq answers from it, but read
it once at start: after the mesh stopped publishing public names (hq ADR 0191) every machine's
hosts file was right and every resolver still answered mail.novox.be with a tunnel address. The
config comment that says so changes the file, which restarts dnsmasq once everywhere.
2026-10-03 15:29:28 +02:00
mesh-admin d5269c8662 Merge pull request 'fail2ban's tools are a bundle the node's runtime serves; its container goes (hq to-be 38 WP4)' (#240) from feat/fail2ban-tools-as-a-bundle into main 2026-10-03 13:07:35 +00:00
jochen aa5bf7d5ef fail2ban's tools are a bundle the node's runtime serves; its container goes (hq to-be 38 WP4)
The second holder follows the packet filter: the container, its base images, the Dockerfile,
and the bus credential and state directory only the container read are gone; the tools are a
TypeScript bundle node-tools loads. The daemon's socket answers only to root, so the client
runs through sudo without a prompt where the runtime's account is not root, naming sudo's
absence or refusal by how it failed; client and daemon are the one package the module declares.
2026-10-03 15:07:21 +02:00
mesh-admin 510de183b1 Merge pull request 'The packet filter's tools are a bundle the node's runtime serves; its container goes (hq to-be 38 WP4)' (#239) from feat/wp4-the-packet-filter-moves into main 2026-10-03 11:14:33 +00:00
jochen db5e7c80cf The packet filter's tools are a bundle the node's runtime serves; its container goes (hq to-be 38 WP4)
nftables drops its container, NET_ADMIN, the container-runtime capability, the runtime base
images, the Dockerfile, and the bus credential and state directory only the container read;
its tools are declared as a TypeScript bundle the toolchain compiles and node-tools loads on
every node, and the iptables package the image used to carry is declared on the host. The
runtime runs as the operator's account, so the tool runs the filter's commands through sudo
without a prompt when it is not root (ADR 0175 §4, to-be 38 WP4), naming sudo's absence or
refusal by how it failed; the filter file is the path the manifest's filtering names, held to
it by a test; a found firewall that is present but will not answer stops a removal rather
than passing for inactive; a legacy tool that is present but fails is said, not swallowed.
2026-10-03 12:59:07 +02:00
jschoubben 38be3ba7f0 Merge pull request 'ssh-client: own ~/.ssh, not the openssh package' (#238) from fix/ssh-client-owns-no-package into main 2026-10-03 10:43:46 +00:00
jschoubben a19638d112 ssh-client: own ~/.ssh, not the openssh package
A package has one owning module, and sshd already owns openssh on every node — assigning
ssh-client was refused for both declaring it, and the refusal left every node unresolvable
until it was unassigned. Owning it was wrong besides: unassigning ssh-client would have
removed the package sshd serves from. The client binary ships in the package sshd holds.
2026-10-03 12:43:35 +02:00
jschoubben 1cbddeb104 Merge pull request 'ssh-client module: the mesh owns ~/.ssh, config from the hub (to-be 29)' (#237) from feat/ssh-client-module into main 2026-10-03 10:40:13 +00:00
jschoubben 301aeda5f3 ssh-client module: the mesh owns ~/.ssh, config from the hub (to-be 29)
Requires openssh; creates ~/.ssh (0700, owned by the operator account via
${machine:account}); writes every other node's Host block (HostName + User
<account>) into a marked region of ~/.ssh/config (home-scoped, into:block), so
`ssh <node>` reaches each peer as the right account and the operator's own
config is kept. Universal-tier: assigned wherever a person logs in; a node with
no account gets no config.
2026-10-03 11:58:05 +02:00
mesh-admin 853ace3828 Merge pull request 'Retire builder: the build machine is the build-agent on every machine (hq ADR 0190)' (#236) from feat/retire-builder into main 2026-10-03 09:44:09 +00:00
jochen 8ccc6762d4 Retire builder: the build machine is the build-agent on every machine (hq ADR 0190)
build-agent holds node-build-agent on all four machines and the controller asks that seat; the one-holder
builder is unassigned and forgotten. Proven live before this: a catalogue module built on a workstation's
agent (step-ca, 2026-10-03 09:35).
2026-10-03 11:43:01 +02:00
mesh-admin 6d01007ea6 Merge pull request 'build-agent: a short slug, so its identifier on a machine fits a backend's 20-character key' (#235) from fix/build-agent-slug into main 2026-10-03 09:13:57 +00:00
jochen 02463ba55d build-agent: a short slug, so its identifier on a machine fits a backend's 20-character key
"mesh_novox_build_agent" is 22 characters; the mesh refused to send the control node its declaration
for it. "agent" keeps the identifier within what an S3 access key allows on every machine.
2026-10-03 11:09:24 +02:00
jschoubben 85af10ebad Merge pull request 'step-ca: stop offering acme-ca, so public names are certified by public-acme' (#234) from fix/step-ca-is-not-the-public-acme-ca into main 2026-10-03 08:57:25 +00:00
jschoubben 0778f8f0ae step-ca: stop offering acme-ca, so public names are certified by public-acme
step-ca and public-acme both offered acme-ca on novox, and route-proxy's pin names a node, not
a module — so which one certified the public names depended on provider order. After the
controller restart on 2026-10-03 it came out as step-ca, and every public site served a
certificate no browser trusts. step-ca's own names are already certified through
internal-acme-ca; acme-ca is the public authority's alone.
2026-10-03 10:54:43 +02:00
mesh-admin 6afc1160b6 Merge pull request 'build-agent: the build machine as a node role every machine can hold (hq ADR 0190)' (#231) from feat/build-agent into main 2026-10-03 01:52:04 +00:00
mesh-admin c32edcfc6f Merge pull request 'Retire mesh-console: the console is the node-tools runtime's serving mode (hq ADR 0175 §6, to-be 38 WP3)' (#230) from feat/retire-mesh-console into main 2026-10-03 00:40:37 +00:00
mesh-admin 740359ffd9 Merge pull request 'Remove portainer: deprecated, and unassigned everywhere' (#233) from jschoubben/remove-portainer into main 2026-10-02 21:26:03 +00:00
jschoubben a309deb479 Remove portainer: deprecated, and unassigned everywhere
It held the docker socket behind a public name. Nothing depends on it;
it is off both machines that ran it, with its data.
2026-10-02 23:25:45 +02:00
mesh-admin 0fd722e829 Merge pull request 'gitea: the jail also bans what gitea's sshd refuses' (#232) from jschoubben/gitea-ssh-jail into main 2026-10-02 21:23:44 +00:00
jschoubben 2e6cc71f7a gitea: the jail also bans what gitea's sshd refuses
The jail read gitea's container journal, which carries its sshd's lines,
but matched only the web login. 167 ssh attempts an hour from the
internet went unbanned. Two patterns, one per attempt: an unknown user,
and a user sshd refuses; tested against a day of the real log, 946
matches and none on an accepted login.
2026-10-02 23:23:35 +02:00
jochen 84403aec0c build-agent: the build machine as a node role every machine can hold (hq ADR 0190)
The builder's manifest with one change that matters: it claims node-build-agent, a node seat, so it
is assignable to every machine with a container runtime, and every holder pulls one build at a time
from the role's one work queue. A tier of many images is then built by as many machines as hold the
seat and are online. The builder module stays until this is assigned where it was; then it goes.
2026-10-02 22:28:48 +02:00
jochen b28b1b9f24 Retire mesh-console: the console is the node-tools runtime's serving mode (hq ADR 0175 §6, to-be 38 WP3)
The console was a container per node built on the runtime image, calling everything and serving
nothing. node-tools — the runtime as a module, in the mesh-tools repository — answers MCP on the
same loopback port from the same process that serves every module's tools, so the module that was
only that is gone. Merged once node-tools is assigned where mesh-console was, on every machine.
2026-10-02 21:58:21 +02:00
mesh-admin 810c7fbac3 Merge pull request 'A ban list never holds a neighbour (hq ADR 0186)' (#227) from fix/a-ban-list-never-holds-a-neighbour into main 2026-10-02 16:43:26 +00:00
jschoubben 304044da40 A ban list never holds a neighbour (hq ADR 0186)
The home server banned the house's own router within an hour of the first public jail: the router
reflects local traffic, so every client in the building arrives as the gateway's address. Every
private range joins the mesh's own in the never-ban list.
2026-10-02 18:42:16 +02:00
mesh-admin 419d92e810 Merge pull request 'The proxy's jail reads a refused name as well as a refused certificate (hq ADR 0179)' (#226) from fix/the-proxys-jail-reads-both-refusals into main 2026-10-02 15:23:48 +00:00
jschoubben 23112b111c The proxy's jail reads both refusals, each pattern naming the host once
fail2ban expands <HOST> to a named group, so two in one pattern is a duplicate group name and
the daemon refuses to start at all -- every jail on the machine, not just this one. Two patterns,
one <HOST> each: the certificate refused for an unserved name, and the request refused for one.
Caught live on the control node (hq ADR 0179).
2026-10-02 17:23:42 +02:00
jschoubben b547308e05 The proxy's jail reads a refused name as well as a refused certificate
The pattern ended at the line's end, which only the certificate refusal does; a request for
an unserved name carries trailing text and never matched. Caught against the live lines
before the jail counted anything (hq ADR 0179).
2026-10-02 17:20:54 +02:00
mesh-admin 3c3c5c6e03 Merge pull request 'fail2ban holds the intrusion seat's verbs and composes the jails; mail, forge and proxy declare theirs (hq ADR 0179, to-be 31)' (#225) from feat/the-intrusion-seat-serves-its-verbs into main 2026-10-02 15:19:20 +00:00
jschoubben 1601d5a335 fail2ban holds the intrusion seat's verbs and composes the jails; mail, forge and proxy declare theirs (hq ADR 0179, to-be 31)
The module gains a runtime carrying only the fail2ban client with the daemon's socket shared in,
serving status/banned/ban/unban and its own fail2ban_settings. It declares jailing, so the
controller's composition lands in jail.d/mesh.conf and filter.d; mailu, route-proxy and gitea log to
the journal and declare a jail reading it by container name. The base is strict: three in a day for
a day, twice banned in two weeks for four; the mesh's range stays never banned.
2026-10-02 17:02:49 +02:00
mesh-admin 96b3d60a4a Merge pull request 'nftables declares the ufw front end absent once its filter is loaded (hq ADR 0175)' (#223) from feat/the-found-front-end-is-uninstalled into main 2026-10-02 14:38:00 +00:00
jschoubben 3dfbad6f03 nftables declares the ufw front end absent once its filter is loaded (hq ADR 0175) 2026-10-02 16:27:34 +02:00
mesh-admin d5c5415756 Merge pull request 'lab: the image carries python3, file, iproute2 and sudo' (#222) from jschoubben/lab-image-tools into main 2026-10-02 13:24:00 +00:00
jschoubben 6dfd2401c9 lab: the image carries what the builds and the lab call: python3, file, iproute2, sudo 2026-10-02 15:23:52 +02:00
mesh-admin 31923f70e7 Merge pull request 'lab: a run resolves the @novox scope from the forge's package registry' (#221) from jschoubben/lab-npm-scope into main 2026-10-02 13:13:29 +00:00
jschoubben 8cd4f199f1 lab: a run resolves the @novox scope from the forge's package registry 2026-10-02 15:13:22 +02:00
mesh-admin 1b9b298827 Merge pull request 'lab: compile from the module's root, so the runtime finds its tools' (#220) from jschoubben/lab-tools-path into main 2026-10-02 13:08:14 +00:00
jschoubben bce7b3a551 lab: compile from the module's root, so the runtime finds its tools
Both sources sit in tools/, so tsc took tools/ as the root and wrote
dist/index.js, while the runtime loads dist/tools/index.js: the module
started and served nothing.
2026-10-02 15:08:01 +02:00
mesh-admin 17d3d3e63a Merge pull request 'lab: declares the virtualisation capability (hq ADR 0172)' (#218) from jschoubben/the-lab-is-a-module-2 into main 2026-10-02 12:53:43 +00:00
mesh-admin 5c961c446f Merge pull request 'Cite hq ADR 0170, not 0169: the firewall seat's record was renumbered' (#219) from fix/adr-0170-cited into main 2026-10-02 12:53:10 +00:00
jschoubben b77582f6a6 Cite hq ADR 0170, not 0169: the firewall seat's record was renumbered after a collision on hq main 2026-10-02 14:52:26 +02:00
jschoubben b3865d240f lab: declares the virtualisation capability, which grants its daemon's socket 2026-10-02 14:48:10 +02:00
mesh-admin a81b94d4ab Merge pull request 'lab: the lab as a module, running beds when the mesh asks (hq ADR 0172)' (#217) from jschoubben/the-lab-is-a-module into main 2026-10-02 12:17:14 +00:00
jschoubben 67d1a400e8 lab: the lab as a module, running beds when the mesh asks
Five tools on the machine the lab runs on: check, run beds against
branches on the forge, a run's status, its log, and stop. A run checks
out every repository the lab builds, side by side, and runs the suite;
one at a time, answered at once with an id (novox/hq ADR 0172).
2026-10-02 14:15:42 +02:00
mesh-admin 1c201d59c9 Merge pull request 'nftables holds the node-packet-filter seat: rules, reload and remove, from a runtime with NET_ADMIN (hq ADR 0169)' (#216) from feat/the-firewall-seat-serves-its-verbs into main 2026-10-02 11:33:47 +00:00
jschoubben 663e8143d4 nftables holds the node-packet-filter seat: rules, reload and remove, from a runtime with NET_ADMIN (hq ADR 0169)
The seat's three verbs over the machine's own tools: the filter as enforced
(nftables and the legacy filter), the mesh's own table reloaded from its file,
and one rule set the mesh did not write removed by the name the host reports
it under (ADR 0168) — a predecessor's chain loses its jumps and goes, the
runtime's user chain is emptied back to its return, a table of the machine's
own goes whole; the mesh's tables, the runtime's chains, a built-in chain and
an active found firewall's chains are refused. Tested over the shapes two
machines of the first mesh reported live. The module's own tool stays.
2026-10-02 13:28:33 +02:00
mesh-admin 8ce4935132 Merge pull request 'unifi: list networks and set the DNS their DHCP hands out (hq issue 198)' (#215) from jschoubben/unifi-network-dns into main 2026-10-02 09:53:22 +00:00
jschoubben d1f8ab86d1 unifi: list networks and set the DNS their DHCP hands out
Which DNS server the home network's DHCP hands out could be changed only
in the controller's own interface or by hand against its API (novox/hq
issue 198).
2026-10-02 11:53:16 +02:00
mesh-admin 3020cd2312 Merge pull request 'dnsmasq: listen addresses are a setting, and docker's file takes none (hq issue 198)' (#214) from jschoubben/the-lans-dns-is-the-mesh-2 into main 2026-10-02 09:50:09 +00:00
jschoubben b72213261a dnsmasq: listen addresses are a setting, and docker's file takes none
The addresses dnsmasq listens on beside the machine's are a setting, so a
machine that answers its own LAN can say so (novox/hq issue 198). Docker's
daemon.json no longer merges the module's settings: it needs none, and a
setting reaching it is a key dockerd refuses. The host still merges it
into the existing file.
2026-10-02 11:49:57 +02:00
mesh-admin 7e5c98920e Merge pull request 'Revert dnsmasq's listen addresses as a setting (hq issue 198)' (#213) from jschoubben/revert-dnsmasq-listen into main 2026-10-02 09:48:49 +00:00
jschoubben 67f5236b01 Revert dnsmasq's listen addresses as a setting
A module's settings merge into every mergeable file it owns, so the
setting reached docker's daemon.json beside dnsmasq's config, where
dockerd would refuse it (novox/hq issue 198). Back to the fixed
loopback line until settings can be kept out of files they are not for.
2026-10-02 11:48:37 +02:00
mesh-admin e9876858a8 Merge pull request 'dnsmasq: the addresses it listens on beside the machine's are a setting (hq issue 198)' (#212) from jschoubben/the-lans-dns-is-the-mesh into main 2026-10-02 09:46:32 +00:00
jschoubben 56a22847f5 dnsmasq: the addresses it listens on beside the machine's are a setting
Loopback by default, as before. A machine that answers its own LAN adds
its LAN address, and its DNS endpoints' reach opens the filter (novox/hq
issue 198). The mesh-wide default must be set before this lands.
2026-10-02 11:42:41 +02:00
mesh-admin 1271f797e9 Merge pull request 'route-proxy: a bus account, to read its membership (hq ADR 0167, issue 191)' (#211) from jschoubben/an-internal-only-route into main 2026-10-01 23:49:50 +00:00
jschoubben 4f952ce771 route-proxy: a bus account, to read its membership
The proxy reads its routes and the mesh's addresses from its membership
on the bus rather than from a file alone (novox/hq ADR 0167, issue 191).
2026-10-02 01:46:18 +02:00
mesh-admin fec6d76fb3 Merge pull request 'mssql: the query runs as a read-only login, one line, no variables; sqlcmd is installed (hq #193)' (#210) from fix/193-mssql-reads-as-a-reader into main 2026-10-01 22:30:02 +00:00
mesh-admin da7355dce0 Merge pull request 'postgres: the store's query runs as a read-only login, never as the admin (hq #193)' (#209) from fix/193-the-store-reads-as-a-reader into main 2026-10-01 22:29:57 +00:00
jschoubben 400b2f9696 mssql: the query runs as a read-only login, one line, no variables; sqlcmd is installed (hq #193)
Proven on a throwaway server: as the administrator a caller's $(SQLCMDPASSWORD) returned
the sa password, and a line beginning ':!!' ran a program in the tools container. The
statement now runs as mesh_mssql_reader (CONNECT ANY DATABASE, SELECT ALL USER SECURABLES),
with substitution off (-x), after the module's own text on the first line, and a line break
is refused. go-sqlcmd v1.10.0 is installed at a pinned digest: the image never had sqlcmd,
so every mssql tool failed with spawn sqlcmd ENOENT.
2026-10-02 00:25:22 +02:00
jschoubben 160b5ad65a postgres: the store's query runs as a read-only login, never as the admin (hq #193)
The verb wrapped the caller's text in BEGIN READ ONLY ... ROLLBACK as the superuser, so
'COMMIT; ...' left the transaction and, proven on a throwaway server, COPY TO PROGRAM ran a
shell command on the database host. The statement now runs as mesh_store_reader:
pg_read_all_data, no other grant, read-only transactions by role and session, its password
an own-secret the mesh mints. Without that password the call is refused. -q drops the
command tags that came back as rows keyed by BEGIN.
2026-10-02 00:09:09 +02:00
mesh-admin ef44c502db Merge pull request 'route-proxy README: the node that runs a proxy carries public-acme (hq #258)' (#208) from docs/route-proxy-carries-public-acme into main 2026-10-01 15:32:05 +00:00
jschoubben 0651b63926 route-proxy README: the node that runs a proxy carries public-acme (hq #258) 2026-10-01 17:31:58 +02:00
mesh-admin 0c521ffa20 Merge pull request 'The vault's claim, its own event names, and the uplink holders' capabilities return' (#207) from fix/the-vaults-claim-and-events-return into main 2026-10-01 15:19:55 +00:00
jschoubben 01d68bda88 And the uplink holders' capabilities return
The same split lost them the other way round: the merge base held both changes, each branch had reset
the other's files, and the three-way merge kept neither. Both halves of hq ADR 0161 are on main again
with this.
2026-10-01 17:19:41 +02:00
jschoubben 89e0dde9e0 The vault's claim and its own event names return
The uplink branch was split from the vault's with the vault's files reset to a main that did not yet
hold #205; merging it afterwards took the older vault definition along (no claim, the refused event
names), and the vault could not be built. Restored to #205's state.
2026-10-01 17:19:05 +02:00
mesh-admin 5ebc89d89d Merge pull request 'Each uplink holder declares the manager it speaks for (hq ADR 0161)' (#206) from feat/each-uplink-holder-declares-the-manager-it-speaks-for into main 2026-10-01 15:11:31 +00:00
mesh-admin 32fa76fccb Merge pull request 'The vault claims mesh-vault, and each uplink holder declares the manager it speaks for (hq ADR 0161)' (#205) from feat/the-vault-claims-its-seat-and-the-uplinks-say-their-dialect into main 2026-10-01 14:53:15 +00:00
jschoubben 2e96d2f67d This branch carries the uplink capabilities alone (hq ADR 0161 rule 3); merges once every machine running a holder has reported uplink-<manager> 2026-10-01 16:52:47 +02:00
jschoubben 932efb5186 This branch carries the vault's claim alone; the uplink capabilities wait for every machine to report its profile 2026-10-01 16:52:46 +02:00
jschoubben 6ba61a8b4b The provisioner announces the vault's events by their new names 2026-10-01 16:51:13 +02:00
jschoubben 8368697744 The vault's events are its own: provisioned, rotated, deprovisioned
A module publishes under its own name only; secret.provisioned read as another module's event and the
builder refused the vault's definition today, so the seat claim could not be built. The three events lose
the prefix; nothing outside the vault listens for the old names.
2026-10-01 16:50:56 +02:00
jschoubben 966fed1829 The vault claims mesh-vault, and each uplink holder declares the manager it speaks for (hq ADR 0161)
The vault's claim makes a second provider of secret a second claimant, refused by name. The three
uplink definitions declare uplink-networkmanager, uplink-systemd-networkd and uplink-dhcpcd, which the
host reports for the manager it finds active, so the holder for a manager the machine does not run
is refused the way any missing capability is. Merges after the controller holds the seat and the host
reports the capability.
2026-10-01 15:58:05 +02:00
mesh-admin 67c834ac65 Merge pull request 'postgres serves the store seat's verbs, databases and query, and lists its tools (ADR 0159)' (#203) from feat/postgres-serves-the-stores-verbs into main 2026-10-01 13:53:05 +00:00
jschoubben c00dd04494 postgres implements the store seat's verbs under the seat's name, and its claim says so (hq ADR 0160)
The store's databases and query are registered under mesh-store, so the runtime serves them on the
seat's subjects wherever postgres holds the seat and never lists them as postgres's own; the claim
names them, so the mesh can judge the holder without postgres listing the seat's verbs among its
tools. Scoped to what the store enables: creating a database stays postgres's tool.
2026-10-01 15:19:35 +02:00
jschoubben abf5859415 Merge remote-tracking branch 'origin/main' into feat/postgres-serves-the-stores-verbs 2026-10-01 15:19:08 +02:00
mesh-admin fe8d6a25c0 Merge pull request 'The media chain's stale copies leave the catalogue' (#204) from chore/remove-stale-media-duplicates into main 2026-10-01 12:54:00 +00:00
jschoubben 738415710c The media chain's stale copies leave the catalogue
bazarr, bookshelf, lidarr, nzbget, ombi, plex, qbittorrent, radarr,
sonarr and tautulli live in novox/mesh-media-catalog (#195 moved jackett
and left these behind). A build of this repository at a commit today
registered plex and nzbget from these copies, which carry no provides,
and ace's plan stopped resolving. Nothing here depends on the directories:
home-assistant consumes their provisions by name.
2026-10-01 14:53:35 +02:00
jschoubben 4c7e438ea3 postgres serves the store seat's verbs, databases and query, and lists its tools (hq ADR 0159)
Named as the seat names them so the runtime finds them by name; the same calls as its own tools.
Its definition now lists its tools, which is what holding a seat with verbs demands at registration.
Merge before the controller declares the verbs on the mesh-store seat.
2026-10-01 14:02:41 +02:00
mesh-admin fd0fa75cc2 Merge pull request 'searxng says it reads its secret key at start, so the mesh may rotate it (hq 180)' (#202) from feat/searxng-says-how-its-secret-is-taken into main 2026-10-01 10:10:05 +00:00
jschoubben 50c08818d6 searxng says it reads its secret key at start, so the mesh may rotate it (hq 180)
The key signs sessions and nothing else holds it; it lands in the settings file the server
restarts on, so a rotation is a new value and a restart.
2026-10-01 12:09:47 +02:00
mesh-admin 1712670610 Merge pull request 'nodered says it reads its API token and admin password at start, so the mesh may rotate them (hq 180)' (#201) from feat/nodered-says-how-its-secrets-are-taken into main 2026-10-01 09:45:32 +00:00
jschoubben 6b0164c2ba nodered says it reads its API token and admin password at start, so the mesh may rotate them (hq 180)
Both land in settings.js and the runtime's config file, and the containers that read them restart
on those files; a rotation is a new value and a restart. The broker credential says nothing yet: its
other party is the bus, and that rotation is the two-party form.
2026-10-01 11:44:29 +02:00
mesh-admin 0966599c8a Merge pull request 'The forge's tools close and read pull requests, read files and branches, and delete a branch' (#200) from feat/the-forges-tools-close-and-read-pull-requests into main 2026-10-01 09:25:51 +00:00
jschoubben 171f8a03f6 The forge's tools close and read pull requests, read files and branches, and delete a branch
Ten tools the console lacked for the actions a review and a merge leave behind: close or reopen a
pull request whose work landed elsewhere, change its title or body, read its files, its diff and its
comments, reopen an issue, read one file at a ref, list branches, delete the branch a closed pull
request leaves. Each is the client's own call; `gitea_api` stays the escape hatch for the rest.
Tested against the fake forge through the compiled tools, the way the console calls them (13/13).
2026-10-01 11:25:34 +02:00
mesh-admin cb48c882a0 Merge pull request 'postgres: its server container is not named after the seat' (#177) from fix/postgres-is-not-named-after-the-seat into main 2026-10-01 09:25:05 +00:00
mesh-admin 3cbd98b14f Merge pull request 'n8n: its media library is an access placed by the assignment' (#199) from fix/n8n-media-access-by-id into main 2026-10-01 00:02:44 +00:00
jschoubben 9fc0d675cd n8n: its media library is an access placed by the assignment
The container mounted /services/media literally — one installation's path
(ADR 0112). The access is now declared by id and mounted as ${access:media};
the assignment says where the library is (ace: /storage/media, hq 153).
2026-10-01 02:02:30 +02:00
mesh-admin 1b0e3841e4 Merge pull request 'n8n: its own image built from source, placed data, and what its workflows use' (#172) from feat/n8n-for-ace into main 2026-09-30 23:59:29 +00:00
jschoubben 5c4364e462 n8n: its own image built from source, placed data, and what its workflows use
The module named /var/lib/n8n, /services/n8n/n8n-data and n8n.novox.be -
paths and a domain no definition may carry (ADR 0112). State and data are
placed directories; the public name is ${bound:route:name} (depends on
mesh-controller #149), for N8N_HOST and WEBHOOK_URL alike.

The endpoint said 5682 while the container publishes 5678. 5682 was one
machine's host port; the endpoint is the software's port and the mesh
assigns the machine's (ADR 0038).

n8n had been run from an image in a registry that no longer exists: the
upstream image plus shadow, a `media` group (2000) with `node` in it, and
a global `uuid`. That recipe is now this module's Dockerfile, built on the
upstream 1.71.3 image named in build.on by digest, with uuid pinned to the
version the running image carries (14.0.1) - Code nodes require() it. The
media group is how the container writes into the shared media library, a
read-write `access` (ADR 0051), mounted where workflows expect it,
/media-library.

The workflows also use a redis (the Redis nodes of the chat workflows) and a
Selenium Chrome (the scraper), which the previous deployment ran beside n8n.
Both are containers on the module's own network, publishing nothing, pinned
to the digests in use; redis keeps its append-only file in a placed
directory.

The basic-auth secret is gone: N8N_BASIC_AUTH_* was removed in n8n 1.0 and
did nothing. The grant's password is a 0400 file owned by `node`, read
through DB_POSTGRESDB_PASSWORD_FILE, so nothing secret is in the
environment. The credentials' encryption key is n8n's own, in the data
directory (config), and moves with it - nothing to mint or accept.

Verified: catalogue tests with MESH_CATALOGUE set; the Dockerfile built
against the pinned base gives n8n 1.71.3, uid 1000 in group 2000, uuid
14.0.1 - the running image's shape. Throwaway containers: an instance on
PostgreSQL 15 with an owner, a workflow and an encrypted credential;
stopped, copied, dumped from the copy, restored (--no-owner --role, the
uuid-ossp extension pre-made by the superuser) into a grant-shaped
database on the postgres module's pgvector image (PG17); the new shape
(password from the file, data dir copied) serves /healthz, the owner logs
in, the workflow is listed, and the credential decrypts with the carried
key. The node user writes into a root:2000 0775 library through the media
group; redis and Selenium resolve by name on the module network and
Selenium reports ready. Test containers and data removed.
2026-10-01 01:52:23 +02:00
mesh-admin a3d1c9b9ee Merge pull request 'An access has an id, and its mounts name it (issue 153)' (#198) from feat/153-an-access-has-an-id into main 2026-09-30 22:07:15 +00:00
jschoubben 684b9853ad An access has an id, and its mounts name it (issue 153)
Ten definitions name each access by id; the path stays as the default an assignment may replace,
and the host side of every mount says ${access:<id>}. Resolved with no placement, every definition
names exactly the paths it named before (TestPlacedDirectoriesKeepTheirPaths, extended). On an
adopted machine the assignment now says `accesses: {<id>: <path>}` and the mount follows.

Needs the controller that knows an access id (mesh-controller #176); the running one refuses the
field at registration.
2026-10-01 00:01:42 +02:00
mesh-admin 34ccc457fa Merge pull request 'The mesh's own files for a module are placed by the mesh, not the definition (issue 174)' (#197) from feat/the-mesh-places-its-own-files into main 2026-09-30 21:49:11 +00:00
jschoubben a724c0d82e Merge pull request 'A provider declares what it serves: mail's domain, the identity provider's issuer (issue 173)' (#196) from feat/a-provider-declares-what-it-serves into main
Reviewed-on: http://git.novox.be/novox/mesh-catalog/pulls/196
2026-09-30 20:49:06 +00:00
jschoubben e3246fa11e A provider declares what it serves: mail's domain, the identity provider's issuer (issue 173)
Consumers read `${bound:smtp:domain}` and `${bound:oidc-client:issuer}`, and both keys reached
them only because a module's settings were laid over everything it served. Issue 173 stops that: a
setting overrides a key a served fact declares and adds none. So the two providers declare the keys
their consumers read, as the operator's value (`${setting:…}`, ADR 0155), and the setting that
already carries each fills it. Nothing a consumer reads changes.

Merges first: under the controller that still merges settings over served facts this is the same
value, and the controller that stops merging (mesh-controller, feat/the-mesh-places-its-own-files)
needs these declared before it rolls out.
2026-09-30 22:33:19 +02:00
jschoubben 48850ebf90 The mesh's own files for a module are placed by the mesh, not the definition (issue 174)
48 definitions stop naming /var/lib/mesh/<module>: the directory says `place: "mesh"`, the two
subdirectories beneath it (gitea's runtime state, anthropic-manager's output) state their path
beneath it, and every credential, binding, merged file and mount names it as ${dir:mesh-state}.
Resolved on the default root, every definition names exactly the paths it named before —
TestPlacedDirectoriesKeepTheirPaths in mesh-controller, run over both checkouts. Needs the
controller that knows the word (mesh-controller, same branch) one release ahead.
2026-09-30 22:29:28 +02:00
mesh-admin 8bc4b7c389 Merge pull request 'The media chain moves to novox/mesh-media-catalog; home-assistant and searxng keep their parts' (#195) from chore/media-chain-moves-out into main 2026-09-30 19:42:56 +00:00
jschoubben 7d721051f8 The media chain moves to novox/mesh-media-catalog; home-assistant and searxng keep their parts of that stack
jackett leaves: it is registered from novox/mesh-media-catalog with sonarr,
radarr, lidarr, bazarr, nzbget, qbittorrent, bookshelf, plex, tautulli,
kometa and ombi (PRs 145-168 consolidated there). What those branches
changed outside the chain stays here: home-assistant's provisions
(sonarr-api, radarr-api, mqtt-topic — from #147) and searxng's sidecar
dialling the port it was given (#154).
2026-09-30 21:42:42 +02:00
jschoubben b2df040896 Merge pull request 'distribution claims mesh-artifact-store' (#194) from feat/the-artifact-store-seat-is-named-for-its-scope into main
Reviewed-on: http://git.novox.be/novox/mesh-catalog/pulls/194
2026-09-30 19:17:47 +00:00
jschoubben af069dd667 Merge pull request 'A definition names no host path for its own data' (#193) from feat/definitions-place-their-directories into main
Reviewed-on: http://git.novox.be/novox/mesh-catalog/pulls/193
2026-09-30 19:17:00 +00:00
jschoubben 13e13734c5 distribution claims mesh-artifact-store, the seat's name for its scope (novox/hq ADR 0156) 2026-09-30 21:14:40 +02:00
jschoubben eed5e8958a A definition names no host path for its own data
Twenty-eight modules' data directories are placed: the root as place ".", a sub-directory named by
its id, and every host-side reference — binds, secrets, own secrets, grants, receives, file paths,
mounts, env-files — as ${dir:<id>}. Resolved on the default root every path is the one the manifest
named before, which the controller's TestPlacedDirectoriesKeepTheirPaths proves over both checkouts;
so no data moves and no machine sees a change. Five directories whose id is not their last segment
keep their path as a placement (novox/hq issue 119, ADR 0112, design 27).
2026-09-30 21:10:18 +02:00
mesh-admin 12bbcafacf Merge pull request 'gitea: the tools' token carries write:admin; a kept token is re-minted when it lacks a scope' (#192) from feat/gitea-token-write-admin into main 2026-09-30 19:06:05 +00:00
jschoubben d58ed21367 gitea: the tools' token carries write:admin, and a kept token is re-minted when it lacks a scope
The forge's own users are the mesh's to settle — making the builder's login
a site admin so private repos build (hq 229) — and the tools' token had no
write:admin. A token kept from before a scope was added lacks it, so the
client now treats the forge's 403 "required scope" like a 401: the source
re-mints by name with the whole list and retries once. The fake forge in the
tests learns /repos/search, which the client has used since 2026-09-28 and
which had left 9 of the 11 token tests failing on main.
2026-09-30 21:05:52 +02:00
jschoubben 20d8487515 Merge pull request 'website: it listens on the port its container publishes' (#191) from fix/website-listens-its-own-port into main 2026-09-30 19:01:49 +00:00
jschoubben aab40c6e9d website: it listens on the port its container publishes
listens said 4000 while the container publishes 8080; the old assignment's port setting hid it, and
the rename lost the setting, so the proxy dialled a port nothing answered (2026-09-30).
2026-09-30 21:01:47 +02:00
jschoubben ad2aac7bb9 Merge pull request 'website: the container joins the network the module declares' (#190) from fix/website-network into main 2026-09-30 18:56:26 +00:00
jschoubben 202672ee7d website: the container joins the network the module declares
The rename changed the network resource's name and not the container's network, so the container
looked for a network that no longer exists and the site answered 502 (2026-09-30).
2026-09-30 20:56:23 +02:00
mesh-admin ac7a9f2ca8 Merge pull request 'portainer: publish its software ports; the machine side is the mesh's to assign' (#189) from fix/portainer-software-ports into main 2026-09-30 18:54:41 +00:00
jschoubben 80d9e9a7e7 portainer: publish its software ports; the machine side is the mesh's to assign
9090:9000 and 9443:9443 were the predecessor's machine numbers written into the
definition. The manifest now says 9000 and 9443 and the mesh assigns the
machine ports on each node (the portainer slice of #173, which is stale).
2026-09-30 20:54:29 +02:00
jschoubben e0c5acd547 Merge pull request 'No definition names this installation' (#188) from feat/a-definition-names-no-installation into main 2026-09-30 18:49:38 +00:00
jschoubben 3476f1ebec No definition names this installation
keycloak, minio and n8n are told their names from their route bindings; mailu takes its domain, site
name, website and proxy address as settings and its front's name from its route, and the provisioner
reads the domain from the merged config; builder and route-proxy package the controller from the git
seat; the applications built outside the mesh say so per container; matrix says which of the world's
servers it means; the site module is named website, and the why prose no longer names a name (novox/hq
ADR 0155, issues 122 and 134). module check passes over all 77.
2026-09-30 20:49:21 +02:00
jschoubben 76443ec06d postgres: its server container is not named after the seat
The module's server container was called mesh-store and its data directory
/var/lib/mesh-store — the seat's name reused for the module's own resources,
a leftover from the first migration. On a machine whose postgres holds no
seat (ace, as a database provider only) that produced a container called
mesh-store holding nothing of the kind. The container is now named
postgres. The data directory keeps its path: a path change recreates a
running container on an empty directory (hq 126), and novox's store lives
there.

Rolling this out recreates novox's store container once (a restart on its
bind mount, no data moves). The two catalogue-test failures on this branch
(resolver_manifests_test) fail identically on main today.
2026-09-30 14:56:31 +02:00
381 changed files with 47459 additions and 8157 deletions
-22
View File
@@ -1,22 +0,0 @@
# anthropic-consumer's runtime: the tool runtime, carrying this module's compiled code.
#
# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in
# the base images, published like any other artifact — which is what makes this buildable by the
# mesh from a repository and a path (novox/hq ADR 0069) rather than only on a workstation that
# happens to have the siblings.
#
# Two bases, named rather than pinned (novox/hq issue 044): the image this is COMPILED in and the
# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`.
ARG BUILD_BASE
ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build
WORKDIR /app/modules/anthropic-consumer
COPY . .
RUN node /app/node_modules/typescript/bin/tsc apply/index.ts usage/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE}
COPY --from=build /app/modules/anthropic-consumer/dist /app/modules/anthropic-consumer/dist
# No serve-time entrypoints: every container of this module names its command (`run` on a
# schedule), so nothing here serves — deliberately no MESH_TOOL_MODULES.
+30 -67
View File
@@ -9,29 +9,20 @@
"model-access" "model-access"
], ],
"binds": { "binds": {
"model-access": "/var/lib/anthropic-consumer/model.json" "model-access": "${dir:state}/model.json"
}, },
"secrets": { "secrets": {
"model-access": "/var/lib/anthropic-consumer/access-token" "model-access": "${dir:state}/access-token"
},
"own-secrets": {
"broker": "/var/lib/mesh/anthropic-consumer/broker"
}, },
"emits": [ "emits": [
"usage.session" "usage.session"
], ],
"resources": [ "resources": [
{
"id": "mesh-state",
"type": "directory",
"path": "/var/lib/mesh/anthropic-consumer",
"mode": "0700"
},
{ {
"id": "state", "id": "state",
"type": "directory", "type": "directory",
"path": "/var/lib/anthropic-consumer", "mode": "0700",
"mode": "0700" "place": "."
}, },
{ {
"id": "claude-home", "id": "claude-home",
@@ -42,71 +33,43 @@
{ {
"id": "out", "id": "out",
"type": "directory", "type": "directory",
"path": "/var/lib/anthropic-consumer/out",
"mode": "0700" "mode": "0700"
}, },
{ {
"id": "apply", "id": "apply",
"type": "container", "type": "process",
"name": "mesh-anthropic-consumer-apply", "name": "anthropic-consumer-apply",
"network": "host", "artifact": "code",
"run": [
"node",
"apply/index.js"
],
"schedule": "*/5 * * * *", "schedule": "*/5 * * * *",
"args": [
"run",
"/app/modules/anthropic-consumer/dist/apply/index.js"
],
"volumes": [
"/var/lib/anthropic-consumer:/run/state"
],
"env": { "env": {
"MESH_MODEL_ACCESS_SECRET_FILE": "/run/state/access-token", "MESH_MODEL_ACCESS_SECRET_FILE": "${dir:state}/access-token",
"MESH_MODEL_ACCESS_BIND_FILE": "/run/state/model.json", "MESH_MODEL_ACCESS_BIND_FILE": "${dir:state}/model.json",
"MESH_CLAUDE_CREDENTIALS_FILE": "/run/state/claude/.credentials.json", "MESH_CLAUDE_CREDENTIALS_FILE": "${dir:state}/claude/.credentials.json",
"MESH_CLAUDE_IDENTITY_FILE": "/run/state/claude/.claude.json" "MESH_CLAUDE_IDENTITY_FILE": "${dir:state}/claude/.claude.json"
}, }
"artifact": "runtime"
},
{
"id": "usage",
"type": "container",
"name": "mesh-anthropic-consumer-usage",
"network": "host",
"schedule": "*/5 * * * *",
"args": [
"run",
"/app/modules/anthropic-consumer/dist/usage/index.js"
],
"volumes": [
"/var/lib/mesh/anthropic-consumer/broker:/run/secrets/broker:ro",
"/var/lib/anthropic-consumer:/run/state"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_CLAUDE_PROJECTS_DIR": "/run/state/claude/projects",
"MESH_ANTHROPIC_USAGE_OUT": "/run/state/out/session-usage.json",
"MESH_TOOLS_MAIN": "/app/dist/main.js"
},
"artifact": "runtime"
} }
], ],
"build": { "build": {
"on": [
{
"arg": "BUILD_BASE",
"module": "mesh-tools",
"artifact": "build"
},
{
"arg": "RUNTIME_BASE",
"module": "mesh-tools",
"artifact": "runtime"
}
],
"artifacts": [ "artifacts": [
{ {
"name": "runtime", "name": "code",
"kind": "image", "kind": "bundle",
"from": "Dockerfile" "language": "typescript",
"entrypoints": [
"apply/index.js",
"usage/index.js"
],
"loads": [
"usage/index.js"
],
"env": {
"MESH_CLAUDE_PROJECTS_DIR": "${dir:state}/claude/projects",
"MESH_ANTHROPIC_USAGE_OUT": "${dir:state}/out/session-usage.json"
}
} }
] ]
} }
+19 -18
View File
@@ -3,12 +3,15 @@
// per session. The consumer IS the (node,module) session's fixed binding, so no per-message account // 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). // 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 // Runs in the node's runtime (novox/hq ADR 0198), every five minutes, so events are emitted through
// `emit` primitive; the totals are also written to a file so the reading is observable without one. // the runtime as this module; the totals are also written to a file so the reading is observable
// without one.
import { readdirSync, statSync, readFileSync, writeFileSync, renameSync, mkdirSync } from "node:fs"; import { readdirSync, statSync, readFileSync, writeFileSync, renameSync, mkdirSync } from "node:fs";
import { join, dirname } from "node:path"; import { join, dirname } from "node:path";
import { emit } from "@novox/mesh-sdk/events";
import { readSessionFile, type SessionUsage } from "../transcript.js"; import { readSessionFile, type SessionUsage } from "../transcript.js";
/** The vendor-neutral usage row ADR 0054 fixes — the shape the model-usage store upserts. Kept local /** The vendor-neutral usage row ADR 0054 fixes — the shape the model-usage store upserts. Kept local
@@ -116,22 +119,20 @@ function atomicWrite(path: string, content: string): void {
renameSync(tmp, path); renameSync(tmp, path);
} }
/** Emit best-effort via the sibling mesh-tools `emit`, which wires a broker a run step has none. */ /** Emit best-effort through the runtime: a reading that could not be announced is still in the file. */
async function emitUsage(body: Record<string, unknown>): Promise<void> { async function emitUsage(body: Record<string, unknown>): Promise<void> {
const main = process.env.MESH_TOOLS_MAIN ?? "/app/dist/main.js"; try {
const { spawn } = await import("node:child_process"); await emit("usage.session", body);
await new Promise<void>((resolve) => { } catch (err) {
const child = spawn( console.error(`[anthropic-consumer] could not emit usage: ${err}`);
process.execPath, }
[main, "emit", "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(); // The cadence the scheduled container had: once at start, then every five minutes. Not awaited, so the
// runtime's handshake is answered while a long first reading is still under way.
const EVERY_MS = 5 * 60 * 1000;
const tick = (): void => {
void main().catch((err) => console.error(`[anthropic-consumer] usage reading failed: ${err}`));
};
tick();
setInterval(tick, EVERY_MS);
+8 -8
View File
@@ -9,13 +9,13 @@
"model-access" "model-access"
], ],
"binds": { "binds": {
"model-access": "/var/lib/mesh/anthropic-manager/model.json" "model-access": "${dir:mesh-state}/model.json"
}, },
"secrets": { "secrets": {
"model-access": "/var/lib/mesh/anthropic-manager/refresh-token" "model-access": "${dir:mesh-state}/refresh-token"
}, },
"own-secrets": { "own-secrets": {
"broker": "/var/lib/mesh/anthropic-manager/broker" "broker": "${dir:mesh-state}/broker"
}, },
"emits": [ "emits": [
"usage.read" "usage.read"
@@ -24,13 +24,13 @@
{ {
"id": "mesh-state", "id": "mesh-state",
"type": "directory", "type": "directory",
"path": "/var/lib/mesh/anthropic-manager", "mode": "0700",
"mode": "0700" "place": "mesh"
}, },
{ {
"id": "out", "id": "out",
"type": "directory", "type": "directory",
"path": "/var/lib/mesh/anthropic-manager/out", "path": "${dir:mesh-state}/out",
"mode": "0700" "mode": "0700"
}, },
{ {
@@ -45,8 +45,8 @@
"/app/modules/anthropic-manager/dist/refresh/index.js" "/app/modules/anthropic-manager/dist/refresh/index.js"
], ],
"volumes": [ "volumes": [
"/var/lib/mesh/anthropic-manager/broker:/run/secrets/broker:ro", "${dir:mesh-state}/broker:/run/secrets/broker:ro",
"/var/lib/mesh/anthropic-manager:/run/state" "${dir:mesh-state}:/run/state"
], ],
"env": { "env": {
"MESH_BROKER_FILE": "/run/secrets/broker", "MESH_BROKER_FILE": "/run/secrets/broker",
-33
View File
@@ -1,33 +0,0 @@
# audit-logger's runtime: the shared runtime image, carrying this module's compiled code.
#
# **Built from this module's own directory and nothing else.** The toolkit is in the base image, so
# nothing is copied out of a neighbouring checkout — which is what lets the mesh build this from a
# repository and a path (novox/hq ADR 0069) rather than only on a workstation that happens to have
# the siblings laid out beside it.
# Two bases, named rather than pinned: the image this is COMPILED in, and the image it RUNS in.
# They are different images on purpose — the first carries a compiler and the second must not, or
# every running container would carry one it never invokes. The mesh answers both with the copies it
# holds, because a fingerprint written here would name one particular copy and no other mesh has it
# (novox/hq issue 044). Declared in module.json's `build.on`; deliberately no defaults, so a build
# nobody told stops here and says which module to build first.
ARG BUILD_BASE
ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build
# Compiled under /app/modules so `@novox/mesh-sdk` resolves upward into the base's own
# node_modules — the module is compiled against exactly the toolkit it will run against.
WORKDIR /app/modules/audit-logger
COPY . .
# The compiler is invoked by its real path rather than through node_modules/.bin, whose entries are
# symlinks to a launcher that requires its library relatively — resolved away when the base image
# was assembled.
RUN node /app/node_modules/typescript/bin/tsc audit.ts index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE}
COPY --from=build /app/modules/audit-logger/dist /app/modules/audit-logger/dist
# **Served, not run.** This subscribes on import, and the serve mode binds the broker before it
# imports anything — `run` exists for a step that works offline and exits, and would leave this
# with nothing to subscribe to.
ENV MESH_TOOL_MODULES=/app/modules/audit-logger/dist/index.js
+14 -36
View File
@@ -5,27 +5,21 @@
"consumes": [ "consumes": [
"**" "**"
], ],
"own-secrets": {
"broker": "/var/lib/audit-logger/broker"
},
"build": { "build": {
"on": [
{
"arg": "BUILD_BASE",
"module": "mesh-tools",
"artifact": "build"
},
{
"arg": "RUNTIME_BASE",
"module": "mesh-tools",
"artifact": "runtime"
}
],
"artifacts": [ "artifacts": [
{ {
"name": "runtime", "name": "code",
"kind": "image", "kind": "bundle",
"from": "Dockerfile" "language": "typescript",
"entrypoints": [
"index.js"
],
"loads": [
"index.js"
],
"env": {
"AUDIT_LOG": "${dir:trail}/audit.log"
}
} }
] ]
}, },
@@ -33,29 +27,13 @@
{ {
"id": "state", "id": "state",
"type": "directory", "type": "directory",
"path": "/var/lib/audit-logger", "mode": "0700",
"mode": "0700" "place": "."
}, },
{ {
"id": "trail", "id": "trail",
"type": "directory", "type": "directory",
"path": "/var/lib/audit-logger/trail",
"mode": "0700" "mode": "0700"
},
{
"id": "run",
"type": "container",
"name": "mesh-audit-logger",
"network": "host",
"volumes": [
"/var/lib/audit-logger/broker:/run/secrets/broker:ro",
"/var/lib/audit-logger/trail:/trail"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"AUDIT_LOG": "/trail/audit.log"
},
"artifact": "runtime"
} }
], ],
"capabilities": [ "capabilities": [
+4 -2
View File
@@ -15,7 +15,7 @@ test("audit-logger records every event to the trail as one line each", async ()
const path = join(dir, "audit.log"); const path = join(dir, "audit.log");
// The audit-logger's whole behaviour: consume everything, record it. // The audit-logger's whole behaviour: consume everything, record it.
await on("**", async (event) => record(event, path)); await on("#", async (event) => record(event, path)); // the pattern index.ts subscribes
process.env.MESH_MODULE = "umami"; process.env.MESH_MODULE = "umami";
process.env.MESH_NODE = "anchor"; process.env.MESH_NODE = "anchor";
@@ -24,7 +24,9 @@ test("audit-logger records every event to the trail as one line each", async ()
const lines = (await readFile(path, "utf8")).trim().split("\n").map((l) => JSON.parse(l)); const lines = (await readFile(path, "utf8")).trim().split("\n").map((l) => JSON.parse(l));
assert.equal(lines.length, 2); assert.equal(lines.length, 2);
assert.deepEqual(lines.map((l) => l.type), ["umami.site.created", "node.anchor.joined"]); // A module names its events locally (design 29); the module is the `source`, which together with
// the type says whose event it was. This broker does no namespacing, so the type is as emitted.
assert.deepEqual(lines.map((l) => l.type), ["site.created", "node.anchor.joined"]);
assert.equal(lines[0].source, "umami"); assert.equal(lines[0].source, "umami");
assert.equal(lines[0].node, "anchor"); assert.equal(lines[0].node, "anchor");
assert.equal(lines[0].body.domain, "my-app"); assert.equal(lines[0].body.domain, "my-app");
-24
View File
@@ -1,24 +0,0 @@
# baserow's runtime: the tool runtime, carrying this module's compiled code.
#
# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in
# the base images, published like any other artifact — which is what makes this buildable by the
# mesh from a repository and a path (novox/hq ADR 0069) rather than only on a workstation that
# happens to have the siblings.
#
# Two bases, named rather than pinned (novox/hq issue 044): the image this is COMPILED in and the
# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`.
ARG BUILD_BASE
ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build
WORKDIR /app/modules/baserow
COPY . .
RUN node /app/node_modules/typescript/bin/tsc client.ts tools/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE}
COPY --from=build /app/modules/baserow/dist /app/modules/baserow/dist
# Every serve-time entrypoint, loaded by the runtime in serve mode: tools and events serve, and a
# provider's provisioner runs its reconcile loop in the same process, with the broker connected —
# the convention novox/hq issues 060/061 settled.
ENV MESH_TOOL_MODULES=/app/modules/baserow/dist/tools/index.js
+17 -39
View File
@@ -25,8 +25,7 @@
"postgres-database": "${dir:state}/database.secret" "postgres-database": "${dir:state}/database.secret"
}, },
"own-secrets": { "own-secrets": {
"admin": "${dir:state}/admin.secret", "admin": "${dir:state}/admin.secret"
"broker": "/var/lib/mesh/baserow/broker"
}, },
"listens": [ "listens": [
{ {
@@ -41,8 +40,8 @@
{ {
"id": "mesh-state", "id": "mesh-state",
"type": "directory", "type": "directory",
"path": "/var/lib/mesh/baserow", "mode": "0700",
"mode": "0700" "place": "mesh"
}, },
{ {
"id": "state", "id": "state",
@@ -88,49 +87,28 @@
{ {
"id": "runtime-config", "id": "runtime-config",
"type": "file", "type": "file",
"path": "/var/lib/mesh/baserow/config.json", "path": "${dir:mesh-state}/config.json",
"mode": "0600", "mode": "0600",
"content": "{\n \"password\": \"${secret:admin}\",\n \"host\": \"${bound:route:name}\"\n}\n", "content": "{\n \"password\": \"${secret:admin}\",\n \"host\": \"${bound:route:name}\"\n}\n",
"merge": "json" "merge": "json"
},
{
"id": "runtime",
"type": "container",
"name": "mesh-baserow",
"network": "baserow",
"volumes": [
"/var/lib/mesh/baserow/broker:/run/secrets/broker:ro",
"/var/lib/mesh/baserow/config.json:/run/config/config.json:ro"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_BASEROW_URL": "http://baserow:80",
"MESH_BASEROW_CONFIG_FILE": "/run/config/config.json"
},
"restart-on": [
"runtime-config"
],
"artifact": "runtime"
} }
], ],
"build": { "build": {
"on": [
{
"arg": "BUILD_BASE",
"module": "mesh-tools",
"artifact": "build"
},
{
"arg": "RUNTIME_BASE",
"module": "mesh-tools",
"artifact": "runtime"
}
],
"artifacts": [ "artifacts": [
{ {
"name": "runtime", "name": "tools",
"kind": "image", "kind": "bundle",
"from": "Dockerfile" "language": "typescript",
"entrypoints": [
"tools/index.js"
],
"loads": [
"tools/index.js"
],
"env": {
"MESH_BASEROW_URL": "http://127.0.0.1:${port:80}",
"MESH_BASEROW_CONFIG_FILE": "${dir:mesh-state}/config.json"
}
} }
] ]
} }
-24
View File
@@ -1,24 +0,0 @@
# bazarr's runtime: the tool runtime, carrying this module's compiled code.
#
# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in
# the base images, published like any other artifact — which is what makes this buildable by the
# mesh from a repository and a path (novox/hq ADR 0069) rather than only on a workstation that
# happens to have the siblings.
#
# Two bases, named rather than pinned (novox/hq issue 044): the image this is COMPILED in and the
# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`.
ARG BUILD_BASE
ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build
WORKDIR /app/modules/bazarr
COPY . .
RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts tools/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE}
COPY --from=build /app/modules/bazarr/dist /app/modules/bazarr/dist
# Every serve-time entrypoint, loaded by the runtime in serve mode: tools and events serve, and a
# provider's provisioner runs its reconcile loop in the same process, with the broker connected —
# the convention novox/hq issues 060/061 settled.
ENV MESH_TOOL_MODULES=/app/modules/bazarr/dist/index.js,/app/modules/bazarr/dist/tools/index.js
-173
View File
@@ -1,173 +0,0 @@
// The Bazarr API client — bazarr's own code, living in the module (novox/hq ADR 0039). Bazarr
// manages subtitles for a Sonarr/Radarr library: it tracks which episodes and movies are still
// missing subtitles, searches providers for them, and records what it downloaded. This client
// talks its /api surface (keyed by an X-API-KEY header); bazarr's tools and events import it.
import { readFileSync } from "node:fs";
export interface WantedSubtitle {
kind: "episode" | "movie";
title: string; // series + episode, or movie title
path?: string;
seriesId?: number; // sonarr series id (episodes)
episodeId?: number; // sonarr episode id (episodes)
radarrId?: number; // radarr movie id (movies)
missing: string[]; // language names still missing
}
export interface ProviderSubtitle {
provider: string;
language: string;
hearingImpaired: boolean;
forced: boolean;
score?: number;
release?: string;
subtitle: string; // the opaque token Bazarr uses to download this exact result
}
export interface HistoryEntry {
kind: "episode" | "movie";
id: string; // stable dedup key across polls
title: string;
language?: string;
provider?: string;
path?: string;
timestamp?: string;
description?: string;
}
/** The settings-merged config the mesh delivers (novox/hq ADR 0046): { url, apiKey, token, password, user, ... }. */
function meshConfig(file?: string): Record<string, string> {
if (!file) return {};
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
catch { return {}; }
}
/** Read a secret the mesh mounted at a file path (an own-secret delivered by `secret accept`);
* absent or unreadable yields undefined so callers fall back rather than crash. */
function readSecret(file?: string): string | undefined {
if (!file) return undefined;
try { return readFileSync(file, "utf8").trim(); }
catch { return undefined; }
}
export class BazarrClient {
readonly baseUrl: string;
constructor(
url: string,
private readonly apiKey: string,
) {
this.baseUrl = url.replace(/\/$/, "");
}
/** Build from the module's resolved environment. Bazarr's API is keyed; without URL and key
* there is nothing to talk to, so this throws rather than run half-configured. */
static fromEnv(env: NodeJS.ProcessEnv = process.env): BazarrClient {
const cfg = meshConfig(env.MESH_BAZARR_CONFIG_FILE);
const url = cfg.url ?? env.MESH_BAZARR_URL;
const apiKey = cfg.apiKey ?? readSecret(env.MESH_BAZARR_API_KEY_FILE) ?? env.MESH_BAZARR_API_KEY;
if (!url) throw new Error("no Bazarr URL — set MESH_BAZARR_URL");
if (!apiKey) throw new Error("no Bazarr API key — set MESH_BAZARR_API_KEY");
return new BazarrClient(url, apiKey);
}
private async request(method: string, path: string, params: Record<string, string> = {}): Promise<any> {
const url = new URL(`${this.baseUrl}/api${path}`);
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
const res = await fetch(url.toString(), { method, headers: { "X-API-KEY": this.apiKey, Accept: "application/json" } });
if (!res.ok) throw new Error(`Bazarr API ${method} ${path}: ${res.status} ${await res.text()}`);
// Downloads/patches return an empty body; only GETs carry JSON.
const text = await res.text();
return text ? JSON.parse(text) : {};
}
private get(path: string, params?: Record<string, string>): Promise<any> {
return this.request("GET", path, params);
}
private languageNames(missing: any[]): string[] {
return (missing ?? []).map((m: any) => m?.name ?? m?.code2 ?? m?.code3).filter(Boolean);
}
/** Episodes and movies still missing subtitles — Bazarr's core "what's left to do" list. */
async getWanted(limit = 50): Promise<WantedSubtitle[]> {
const [eps, movies] = await Promise.all([
this.get("/episodes/wanted", { start: "0", length: String(limit) }),
this.get("/movies/wanted", { start: "0", length: String(limit) }),
]);
const episodes: WantedSubtitle[] = (eps?.data ?? []).map((e: any) => ({
kind: "episode" as const,
title: `${e.seriesTitle ?? e.series ?? "Unknown"} — ${e.episodeTitle ?? e.episode_title ?? ""}`.trim(),
path: e.path,
seriesId: e.sonarrSeriesId,
episodeId: e.sonarrEpisodeId,
missing: this.languageNames(e.missing_subtitles),
}));
const films: WantedSubtitle[] = (movies?.data ?? []).map((m: any) => ({
kind: "movie" as const,
title: m.title ?? "Unknown",
path: m.path,
radarrId: m.radarrId,
missing: this.languageNames(m.missing_subtitles),
}));
return [...episodes, ...films];
}
/** Ask providers what subtitles are available for one wanted episode — a manual search. */
async searchEpisode(episodeId: number): Promise<ProviderSubtitle[]> {
const raw = await this.get("/providers/episodes", { episodeid: String(episodeId) });
return this.mapProviderResults(raw);
}
/** Ask providers what subtitles are available for one movie — a manual search. */
async searchMovie(radarrId: number): Promise<ProviderSubtitle[]> {
const raw = await this.get("/providers/movies", { radarrid: String(radarrId) });
return this.mapProviderResults(raw);
}
private mapProviderResults(raw: any): ProviderSubtitle[] {
const list = Array.isArray(raw) ? raw : (raw?.data ?? []);
return list.map((r: any) => ({
provider: r.provider,
language: r.language?.name ?? r.language ?? "unknown",
hearingImpaired: Boolean(r.hearing_impaired ?? r.hi),
forced: Boolean(r.forced),
score: r.score,
release: r.release_info?.[0] ?? r.release_info,
subtitle: r.subtitle,
}));
}
/** Recent subtitle-download history, episodes and movies together, newest first. Each entry
* carries a stable id so the events poller can tell a fresh download from one already seen. */
async getHistory(limit = 40): Promise<HistoryEntry[]> {
const [eps, movies] = await Promise.all([
this.get("/episodes/history", { start: "0", length: String(limit) }),
this.get("/movies/history", { start: "0", length: String(limit) }),
]);
const key = (kind: string, r: any): string =>
`${kind}:${r.timestamp ?? r.parsed_timestamp ?? ""}:${r.subtitles_path ?? r.path ?? ""}:${r.language?.code3 ?? r.language ?? ""}`;
const episodes: HistoryEntry[] = (eps?.data ?? []).map((r: any) => ({
kind: "episode" as const,
id: key("episode", r),
title: `${r.seriesTitle ?? "Unknown"} — ${r.episodeTitle ?? ""}`.trim(),
language: r.language?.name ?? r.language,
provider: r.provider,
path: r.subtitles_path,
timestamp: r.timestamp,
description: r.description,
}));
const films: HistoryEntry[] = (movies?.data ?? []).map((r: any) => ({
kind: "movie" as const,
id: key("movie", r),
title: r.title ?? "Unknown",
language: r.language?.name ?? r.language,
provider: r.provider,
path: r.subtitles_path,
timestamp: r.timestamp,
description: r.description,
}));
return [...episodes, ...films];
}
}
-47
View File
@@ -1,47 +0,0 @@
// bazarr's events. The tool runtime imports this once the broker is bound. Bazarr's one genuinely
// observable thing is a subtitle arriving: it works away in the background, searching providers for
// the missing-subtitle list, and when it succeeds a subtitle appears in its history. That is worth
// announcing to the mesh.
//
// Emits (novox/hq ADR 0041/0042):
// module.bazarr.subtitle.downloaded — a subtitle was fetched for an episode or movie
//
// Bazarr has nothing on the mesh it usefully reacts to (a download completing is Sonarr/Radarr's
// business, and they trigger Bazarr directly), so it consumes nothing — a pure emitter.
//
// The event is observation-based: poll history and diff. Primed silently on the first look, or a
// restart would re-announce the whole recent history as freshly downloaded.
import { emit } from "@novox/mesh-sdk/events";
import { BazarrClient } from "./client.js";
const bazarr = BazarrClient.fromEnv();
const seen = new Set<string>();
let primed = false;
async function pollHistory(): Promise<void> {
const entries = await bazarr.getHistory(40);
for (const entry of entries) {
if (seen.has(entry.id)) continue;
if (primed) {
await emit("subtitle.downloaded", {
kind: entry.kind,
title: entry.title,
language: entry.language,
provider: entry.provider,
path: entry.path,
});
}
seen.add(entry.id);
}
primed = true;
}
const tick = (fn: () => Promise<void>, everyMs: number): void => {
const run = (): void => void fn().catch((err) => console.error(`[bazarr] ${err}`));
setInterval(run, everyMs);
run();
};
tick(pollHistory, 60_000);
console.log("[bazarr] watching subtitle-download history");
-141
View File
@@ -1,141 +0,0 @@
{
"module": "bazarr",
"version": "1",
"capabilities": [
"container-runtime"
],
"emits": [
"subtitle.downloaded"
],
"own-secrets": {
"broker": "/var/lib/mesh/bazarr/broker",
"api-key": "/var/lib/mesh/bazarr/api-key"
},
"listens": [
{
"name": "web",
"port": 6767,
"protocol": "tcp",
"from": "mesh",
"why": "managing subtitles"
}
],
"accesses": [
{
"path": "/services/media/movies",
"mode": "read-write"
},
{
"path": "/services/media/series",
"mode": "read-write"
},
{
"path": "/services/media/anime",
"mode": "read-write"
},
{
"path": "/services/media/downloads",
"mode": "read"
}
],
"resources": [
{
"id": "mesh-state",
"type": "directory",
"path": "/var/lib/mesh/bazarr",
"mode": "0700"
},
{
"id": "config",
"type": "directory",
"path": "/services/bazarr/config",
"mode": "0700",
"owner": "1000:1000"
},
{
"id": "server",
"type": "container",
"name": "bazarr",
"image": "lscr.io/linuxserver/bazarr@sha256:3a820372f19fcb2981ea19fe4b5382934d67414afaba974bce831ddda0a64a02",
"env": {
"PUID": "1000",
"PGID": "1000",
"TZ": "Etc/UTC"
},
"ports": [
"6767"
],
"volumes": [
"/services/bazarr/config:/config",
"/services/media/movies:/movies",
"/services/media/series:/series",
"/services/media/anime:/anime",
"/services/media/downloads:/downloads"
]
},
{
"id": "runtime-config",
"type": "file",
"path": "/var/lib/mesh/bazarr/config.json",
"mode": "0600",
"content": "{}\n",
"merge": "json"
},
{
"id": "runtime",
"type": "container",
"name": "mesh-bazarr",
"network": "host",
"volumes": [
"/var/lib/mesh/bazarr/broker:/run/secrets/broker:ro",
"/var/lib/mesh/bazarr/api-key:/run/secrets/api-key:ro",
"/var/lib/mesh/bazarr/config.json:/run/config/config.json:ro",
"/services/bazarr/config:/var/lib/bazarr/config:ro"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_BAZARR_URL": "http://127.0.0.1:6767",
"MESH_BAZARR_API_KEY_FILE": "/run/secrets/api-key",
"MESH_BAZARR_CONFIG_FILE": "/run/config/config.json",
"MESH_BAZARR_CONFIG_DIR": "/var/lib/bazarr/config"
},
"restart-on": [
"runtime-config"
],
"artifact": "runtime"
}
],
"requires": [
"route"
],
"contributes": {
"route": {
"label": "subs",
"endpoint": "web"
}
},
"binds": {
"route": "/var/lib/mesh/bazarr/route.json"
},
"build": {
"on": [
{
"arg": "BUILD_BASE",
"module": "mesh-tools",
"artifact": "build"
},
{
"arg": "RUNTIME_BASE",
"module": "mesh-tools",
"artifact": "runtime"
}
],
"artifacts": [
{
"name": "runtime",
"kind": "image",
"from": "Dockerfile"
}
]
}
}
-14
View File
@@ -1,14 +0,0 @@
{
"name": "@novox/module-bazarr",
"version": "0.1.0",
"description": "bazarr — subtitle management. Its API client, tools and events live here (novox/hq ADR 0039).",
"type": "module",
"private": true,
"dependencies": {
"@novox/mesh-sdk": "^0.1.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.6.0"
}
}
-57
View File
@@ -1,57 +0,0 @@
// bazarr's tools — its own code (novox/hq ADR 0039), importing bazarr's client. They return
// structured data; the mesh serves them through the sdk's tool harness.
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
import { BazarrClient } from "../client.js";
export function getBazarrTools(bazarr: BazarrClient): ToolDefinition[] {
return [
{
name: "bazarr_wanted",
description: "Episodes and movies still missing subtitles, with the languages each still needs.",
input: { limit: { type: "number", description: "max items per kind (default 50)" } },
run: async (args) => {
const wanted = await bazarr.getWanted(args.limit ? Number(args.limit) : 50);
return { count: wanted.length, wanted };
},
},
{
name: "bazarr_search_subtitles",
description: "Manually search subtitle providers for one wanted item — pass an episodeId or a radarrId.",
input: {
episodeId: { type: "number", description: "a Sonarr episode id (from bazarr_wanted)" },
radarrId: { type: "number", description: "a Radarr movie id (from bazarr_wanted)" },
},
run: async (args) => {
if (args.episodeId !== undefined) {
const results = await bazarr.searchEpisode(Number(args.episodeId));
return { kind: "episode", episodeId: Number(args.episodeId), count: results.length, results };
}
if (args.radarrId !== undefined) {
const results = await bazarr.searchMovie(Number(args.radarrId));
return { kind: "movie", radarrId: Number(args.radarrId), count: results.length, results };
}
throw new Error("pass either episodeId or radarrId");
},
},
{
name: "bazarr_history",
description: "Recent subtitle-download history — what was downloaded, for which title, from which provider.",
input: { limit: { type: "number", description: "max entries per kind (default 40)" } },
run: async (args) => {
const history = await bazarr.getHistory(args.limit ? Number(args.limit) : 40);
return { count: history.length, history };
},
},
];
}
// Exposed only when Bazarr is configured; otherwise bazarr contributes no tools rather than
// failing the whole runtime.
registerModuleTools("bazarr", (env) => {
try {
return getBazarrTools(BazarrClient.fromEnv(env));
} catch {
return [];
}
});
-24
View File
@@ -1,24 +0,0 @@
# bookshelf's runtime: the tool runtime, carrying this module's compiled code.
#
# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in
# the base images, published like any other artifact — which is what makes this buildable by the
# mesh from a repository and a path (novox/hq ADR 0069) rather than only on a workstation that
# happens to have the siblings.
#
# Two bases, named rather than pinned (novox/hq issue 044): the image this is COMPILED in and the
# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`.
ARG BUILD_BASE
ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build
WORKDIR /app/modules/bookshelf
COPY . .
RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts tools/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE}
COPY --from=build /app/modules/bookshelf/dist /app/modules/bookshelf/dist
# Every serve-time entrypoint, loaded by the runtime in serve mode: tools and events serve, and a
# provider's provisioner runs its reconcile loop in the same process, with the broker connected —
# the convention novox/hq issues 060/061 settled.
ENV MESH_TOOL_MODULES=/app/modules/bookshelf/dist/index.js,/app/modules/bookshelf/dist/tools/index.js
-136
View File
@@ -1,136 +0,0 @@
// The Bookshelf API client — bookshelf's own code, living in the module (novox/hq ADR 0039).
// Ported from the shared hal `arr` client, but self-contained: in nox each Servarr app owns its own
// copy, so a change to Bookshelf's API rebuilds only bookshelf and nothing else. Both this module's
// tools and its events entrypoint import it, and nothing outside bookshelf does.
//
// Bookshelf is a Readarr fork (ghcr.io/pennydreadful/bookshelf). It speaks the Servarr v1 API; its
// content is "book". Unlike Sonarr/Radarr it exposes no calendar endpoint, so there is no calendar
// tool here — matching hal, which excluded bookshelf from its calendar-capable apps.
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
// Bookshelf speaks the v1 API; its content is "book".
const API_VERSION = "v1";
const CONTENT_ENDPOINT = "book";
const APP_NAME = "Bookshelf";
export interface BookshelfQueueItem {
/** The queue record id — stable while the item is in the queue, so events can diff on it. */
id: number;
title: string;
status: string;
size: string;
sizeleft: string;
timeleft?: string;
}
export interface BookshelfContentItem {
title: string;
author?: string;
year?: number;
status?: string;
monitored: boolean;
}
export class BookshelfClient {
readonly baseUrl: string;
constructor(
url: string,
private readonly apiKey: string,
) {
this.baseUrl = url.replace(/\/$/, "");
}
/**
* Build from the module's resolved environment. The URL defaults to the server on this node (the
* runtime shares its network), and the API key is read from MESH_BOOKSHELF_API_KEY or, failing
* that, discovered from the server's own config.xml under MESH_BOOKSHELF_CONFIG_DIR — the same
* file Bookshelf writes it to, so a running server needs nothing configured by hand. Throws when
* no key can be found, so the tools/events simply do not load (the harness treats the throw as
* "exposes nothing").
*/
static fromEnv(env: NodeJS.ProcessEnv = process.env): BookshelfClient {
const url = env.MESH_BOOKSHELF_URL ?? `http://127.0.0.1:${env.MESH_BOOKSHELF_PORT ?? "8787"}`;
const configDir = env.MESH_BOOKSHELF_CONFIG_DIR ?? "/config";
const apiKey = env.MESH_BOOKSHELF_API_KEY ?? BookshelfClient.detectApiKey(configDir);
if (!apiKey) {
throw new Error("Bookshelf not configured — set MESH_BOOKSHELF_API_KEY or make the config dir readable");
}
return new BookshelfClient(url, apiKey);
}
/** Discover the API key from the server's config.xml, falling back to null. Every Servarr app
* writes <ApiKey> into config.xml at the root of its config directory. */
static detectApiKey(configDir: string): string | null {
const config = join(configDir, "config.xml");
if (existsSync(config)) {
const match = readFileSync(config, "utf8").match(/<ApiKey>([^<]+)<\/ApiKey>/);
if (match) return match[1];
}
return null;
}
private async get(endpoint: string, params?: Record<string, string>): Promise<unknown> {
const url = new URL(`${this.baseUrl}/api/${API_VERSION}/${endpoint}`);
if (params) {
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
}
const res = await fetch(url.toString(), { headers: { "X-Api-Key": this.apiKey } });
if (!res.ok) throw new Error(`${APP_NAME} API /${endpoint}: ${res.status} ${await res.text()}`);
return res.json();
}
async getStatus(): Promise<{ appName: string; version: string }> {
const data = (await this.get("system/status")) as { appName?: string; version?: string };
return { appName: data.appName || APP_NAME, version: data.version ?? "unknown" };
}
async getContent(limit?: number): Promise<BookshelfContentItem[]> {
const data = await this.get(CONTENT_ENDPOINT);
const items: any[] = Array.isArray(data) ? data : ((data as any)?.records ?? []);
const mapped = items.map((item) => ({
title: item.title ?? "Unknown",
author: item.author?.authorName ?? item.authorName,
year: item.releaseDate ? new Date(item.releaseDate).getFullYear() : item.year,
status: item.status,
monitored: item.monitored ?? true,
}));
return limit ? mapped.slice(0, limit) : mapped;
}
/** Library search is a filter over existing content, not an indexer lookup — same as hal's. */
async searchContent(term: string): Promise<BookshelfContentItem[]> {
const all = await this.getContent();
const lower = term.toLowerCase();
return all.filter(
(item) =>
item.title.toLowerCase().includes(lower) ||
(item.author?.toLowerCase().includes(lower) ?? false),
);
}
async getQueue(): Promise<{ totalRecords: number; items: BookshelfQueueItem[] }> {
const data = (await this.get("queue", { pageSize: "50" })) as { totalRecords?: number; records?: any[] };
const records = data.records ?? [];
return {
totalRecords: data.totalRecords ?? records.length,
items: records.map((r) => ({
id: r.id,
title: r.title ?? r.book?.title ?? r.author?.authorName ?? "Unknown",
status: r.status ?? "unknown",
size: formatBytes(r.size ?? 0),
sizeleft: formatBytes(r.sizeleft ?? 0),
timeleft: r.timeleft,
})),
};
}
}
function formatBytes(bytes: number): string {
if (bytes === 0) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`;
}
-74
View File
@@ -1,74 +0,0 @@
// bookshelf's events. The tool runtime imports this once the broker is bound. It watches the
// download queue and turns its comings and goings into mesh events — the same mechanism radarr uses,
// applied to a Servarr book manager.
//
// Emits (novox/hq ADR 0041/0042):
// module.bookshelf.book.grabbed — a release entered the queue (Bookshelf grabbed it)
// module.bookshelf.download.completed — a release left the queue, imported. This routing key is
// what the plex module consumes (module.*.download.completed)
// to rescan, so a new audiobook becomes a visible item.
// Consumes: none.
//
// NOTE: the hal bookshelf module emitted no events (its hooks only did install-time provisioning).
// This queue watcher is new in nox, modelled exactly on radarr's — bookshelf is a Servarr app with
// the same queue semantics, so the diff-and-emit pattern carries over unchanged.
//
// The queue is polled and diffed, primed silently on the first look (like plex's index.ts) so a
// restart mid-download does not re-announce everything already in flight as freshly grabbed.
import { emit } from "@novox/mesh-sdk/events";
import { BookshelfClient, type BookshelfQueueItem } from "./client.js";
// Building the client throws when Bookshelf has no URL/key yet. Like the tools (see tools/index.ts),
// the events entrypoint must not crash the runtime for that — it stays idle until configured.
function buildClient(): BookshelfClient | null {
try {
return BookshelfClient.fromEnv();
} catch {
return null;
}
}
const bookshelf = buildClient();
// Bookshelf removes an item from the queue once it has been imported; a "warning"/"failed" status is
// how a stuck or broken grab shows itself, so we do not call those a completion when they vanish.
const FAILED_STATUSES = new Set(["failed", "warning"]);
const inQueue = new Map<number, BookshelfQueueItem>();
let primed = false;
async function pollQueue(bookshelf: BookshelfClient): Promise<void> {
const { items } = await bookshelf.getQueue();
const now = new Map(items.map((i) => [i.id, i]));
if (primed) {
// Entered the queue since last look — Bookshelf grabbed a release.
for (const [id, item] of now) {
if (!inQueue.has(id)) await emit("book.grabbed", { title: item.title, status: item.status });
}
// Left the queue — imported and done, unless it was last seen failing.
for (const [id, item] of inQueue) {
if (!now.has(id) && !FAILED_STATUSES.has(item.status)) {
await emit("download.completed", { title: item.title });
}
}
}
inQueue.clear();
for (const [id, item] of now) inQueue.set(id, item);
primed = true;
}
const tick = (fn: () => Promise<void>, everyMs: number): void => {
const run = (): void => void fn().catch((err) => console.error(`[bookshelf] ${err}`));
setInterval(run, everyMs);
run();
};
if (bookshelf) {
tick(() => pollQueue(bookshelf), 30_000);
console.log("[bookshelf] watching the download queue, emitting grabs and completions");
} else {
console.log("[bookshelf] not configured — events idle until an API key is available");
}
-118
View File
@@ -1,118 +0,0 @@
{
"module": "bookshelf",
"version": "1",
"slug": "books",
"capabilities": [
"container-runtime"
],
"emits": [
"book.grabbed",
"download.completed"
],
"consumes": [],
"own-secrets": {
"broker": "/var/lib/mesh/bookshelf/broker"
},
"listens": [
{
"name": "web",
"port": 8787,
"protocol": "tcp",
"from": "mesh",
"why": "managing the ebook/audiobook library"
}
],
"accesses": [
{
"path": "/services/media/books",
"mode": "read-write"
},
{
"path": "/services/media/downloads",
"mode": "read-write"
}
],
"resources": [
{
"id": "mesh-state",
"type": "directory",
"path": "/var/lib/mesh/bookshelf",
"mode": "0700"
},
{
"id": "config",
"type": "directory",
"path": "/services/bookshelf/config",
"mode": "0700",
"owner": "1000:1000"
},
{
"id": "server",
"type": "container",
"name": "bookshelf",
"image": "ghcr.io/pennydreadful/bookshelf@sha256:388eecc94362580eae31ee0a454be6af516f8a311f8432a521c202fb475f4359",
"env": {
"PUID": "1000",
"PGID": "1000",
"TZ": "Etc/UTC"
},
"ports": [
"8787"
],
"volumes": [
"/services/bookshelf/config:/config",
"/services/media/books:/books",
"/services/media/downloads:/downloads"
]
},
{
"id": "runtime",
"type": "container",
"name": "mesh-bookshelf",
"network": "host",
"volumes": [
"/var/lib/mesh/bookshelf/broker:/run/secrets/broker:ro",
"/services/bookshelf/config:/var/lib/bookshelf/config:ro"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_BOOKSHELF_URL": "http://127.0.0.1:8787",
"MESH_BOOKSHELF_CONFIG_DIR": "/var/lib/bookshelf/config"
},
"artifact": "runtime"
}
],
"requires": [
"route"
],
"contributes": {
"route": {
"label": "books",
"endpoint": "web"
}
},
"binds": {
"route": "/var/lib/mesh/bookshelf/route.json"
},
"build": {
"on": [
{
"arg": "BUILD_BASE",
"module": "mesh-tools",
"artifact": "build"
},
{
"arg": "RUNTIME_BASE",
"module": "mesh-tools",
"artifact": "runtime"
}
],
"artifacts": [
{
"name": "runtime",
"kind": "image",
"from": "Dockerfile"
}
]
}
}
-14
View File
@@ -1,14 +0,0 @@
{
"name": "@novox/module-bookshelf",
"version": "0.1.0",
"description": "bookshelf — ebook/audiobook management (Readarr fork). Its API client, tools and events live here (novox/hq ADR 0039).",
"type": "module",
"private": true,
"dependencies": {
"@novox/mesh-sdk": "^0.1.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.6.0"
}
}
-69
View File
@@ -1,69 +0,0 @@
// bookshelf's tools — ported from the shared hal `arr` sdk (novox/hq ADR 0039), importing
// bookshelf's own client. They return structured data (not the pre-formatted text hal returned); the
// mesh serves them through the sdk's tool harness. Bookshelf has no calendar endpoint, so there is
// no calendar tool — matching hal, which excluded it from its calendar-capable apps.
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
import { BookshelfClient } from "../client.js";
export function getBookshelfTools(bookshelf: BookshelfClient): ToolDefinition[] {
return [
{
name: "bookshelf_status",
description: "Bookshelf status overview: version, book count, monitored count, queue size.",
input: {},
run: async () => {
const [status, content, queue] = await Promise.all([
bookshelf.getStatus(),
bookshelf.getContent(),
bookshelf.getQueue(),
]);
return {
app: status.appName,
version: status.version,
books: content.length,
monitored: content.filter((c) => c.monitored).length,
queue: queue.totalRecords,
};
},
},
{
name: "bookshelf_library",
description: "List books from the Bookshelf library.",
input: { limit: { type: "number", description: "max items to return (default 50)" } },
run: async (args) => {
const items = await bookshelf.getContent(args.limit ? Number(args.limit) : 50);
return { count: items.length, books: items };
},
},
{
name: "bookshelf_search",
description:
"Search the Bookshelf library for books by title or author (filters existing content, not indexers).",
input: { query: { type: "string", description: "the search term" } },
run: async (args) => {
const query = String(args.query);
return { query, results: await bookshelf.searchContent(query) };
},
},
{
name: "bookshelf_queue",
description: "Show the Bookshelf download queue — what is downloading and how far along.",
input: {},
run: async () => {
const queue = await bookshelf.getQueue();
return { count: queue.totalRecords, items: queue.items };
},
},
];
}
// The tools exist only when Bookshelf is configured; without a URL and key, bookshelf contributes
// none rather than failing the whole runtime.
registerModuleTools("bookshelf", (env) => {
try {
return getBookshelfTools(BookshelfClient.fromEnv(env));
} catch {
return [];
}
});
@@ -1,6 +1,6 @@
ARG GO_BASE ARG GO_BASE
ARG ALPINE_BASE ARG ALPINE_BASE
# builder's own image: the build machine itself, compiled into a container. # build-agent's image: the build machine itself, compiled into a container (novox/hq ADR 0190).
# #
# **The source is not vendored here.** builder's actual code — cmd/mesh-builder, internal/builder, # **The source is not vendored here.** builder's actual code — cmd/mesh-builder, internal/builder,
# internal/catalogue — lives in the mesh-controller repository, the same control plane it is one # internal/catalogue — lives in the mesh-controller repository, the same control plane it is one
+15
View File
@@ -0,0 +1,15 @@
# build-agent
The mesh's build machine as a role every machine can hold (novox/hq ADR 0190). It holds the node seat
`node-build-agent`: every holder pulls one build at a time from the role's one work queue when it is
idle, so a tier of many images is built by as many machines as hold the seat and are online, and a
machine that is off builds nothing and blocks nothing. The controller asks the role, never a machine;
the outcome names the machine that built it.
What a holding machine needs is what the builder always needed, said here once: a container runtime
(the socket is mounted), the artifact store and the package registry as provisions, a workspace, and
the bus credential. The code is `cmd/mesh-builder` in the mesh-controller repository, compiled from
that repository's main (`build.artifacts[].context`); this module ships the packaging.
Assign it to every machine with a container runtime. It replaces `builder`, the one-holder form of the
same thing; retire that once this is assigned where it was.
@@ -1,13 +1,14 @@
{ {
"module": "builder", "module": "build-agent",
"version": "1", "version": "1",
"slug": "agent",
"capabilities": [ "capabilities": [
"container-runtime" "container-runtime"
], ],
"claims": [ "claims": [
{ {
"name": "mesh-build-machine", "name": "node-build-agent",
"scope": "mesh" "scope": "node"
} }
], ],
"requires": [ "requires": [
@@ -15,49 +16,48 @@
"npm-package-registry" "npm-package-registry"
], ],
"binds": { "binds": {
"npm-package-registry": "/var/lib/mesh/builder/package-registry.json" "npm-package-registry": "${dir:mesh-state}/package-registry.json"
}, },
"secrets": { "secrets": {
"npm-package-registry": "/var/lib/mesh/builder/package-registry.secret" "npm-package-registry": "${dir:mesh-state}/package-registry.secret"
}, },
"own-secrets": { "own-secrets": {
"broker": "/var/lib/mesh/builder/broker" "broker": "${dir:mesh-state}/broker"
}, },
"resources": [ "resources": [
{ {
"id": "mesh-state", "id": "mesh-state",
"type": "directory", "type": "directory",
"path": "/var/lib/mesh/builder", "mode": "0700",
"mode": "0700" "place": "mesh"
}, },
{ {
"id": "workspace", "id": "workspace",
"type": "directory", "type": "directory",
"path": "/var/lib/builder/workspace",
"mode": "0700" "mode": "0700"
}, },
{ {
"id": "builder-env", "id": "agent-env",
"type": "file", "type": "file",
"path": "/var/lib/mesh/builder/builder.env", "path": "${dir:mesh-state}/build-agent.env",
"mode": "0600", "mode": "0600",
"content": "MESH_BROKER_FILE=/run/mesh/broker\nMESH_NODE=${machine:name}\nMESH_REGISTRY=${bound:artifact-store:at}:${bound:artifact-store:port}\nMESH_PACKAGE_BINDING=/run/mesh/package-registry.json\nMESH_NPM_TOKEN_FILE=/run/mesh/package-registry.secret\nMESH_WORKSPACE=/var/lib/builder/workspace\n" "content": "MESH_BROKER_FILE=/run/mesh/broker\nMESH_NODE=${machine:name}\nMESH_REGISTRY=${bound:artifact-store:at}:${bound:artifact-store:port}\nMESH_PACKAGE_BINDING=/run/mesh/package-registry.json\nMESH_NPM_TOKEN_FILE=/run/mesh/package-registry.secret\nMESH_WORKSPACE=${dir:workspace}\n"
}, },
{ {
"id": "server", "id": "server",
"type": "container", "type": "container",
"name": "mesh-builder", "name": "mesh-build-agent",
"artifact": "server", "artifact": "server",
"env-file": [ "env-file": [
"/var/lib/mesh/builder/builder.env" "${dir:mesh-state}/build-agent.env"
], ],
"volumes": [ "volumes": [
"/var/lib/mesh/builder:/run/mesh:ro", "${dir:mesh-state}:/run/mesh:ro",
"/var/lib/builder/workspace:/var/lib/builder/workspace", "${dir:workspace}:${dir:workspace}",
"/var/run/docker.sock:/var/run/docker.sock" "/var/run/docker.sock:/var/run/docker.sock"
], ],
"restart-on": [ "restart-on": [
"builder-env" "agent-env"
], ],
"network": "host" "network": "host"
} }
@@ -69,7 +69,8 @@
"kind": "image", "kind": "image",
"from": "Dockerfile", "from": "Dockerfile",
"context": { "context": {
"repository": "https://git.novox.be/novox/mesh-controller.git", "seat": "git",
"repository": "novox/mesh-controller",
"ref": "main" "ref": "main"
} }
} }
+74
View File
@@ -0,0 +1,74 @@
# claude-code
The operator's agent on a machine (novox/hq design 36): its package, its machine-wide managed
configuration, and the consumer side of the Anthropic licence manager (design 39, ADR 0183).
## What it owns
Two directories, declared, so the mesh refuses a second module owning either:
- `/etc/claude-code`, the agent's machine-wide managed directory, root's, `0755`.
- `~/.claude` under the operator account's home, the operator's, `0700`. The module owns the directory —
that it exists, who owns it, its mode — and of what is inside only what it writes. Everything else
in it (memory, history, projects, local settings, a person's own rules and skills) is the person's
and is never read or written (hq ADR 0182). Unassigned, the module leaves the directory: the host
removes a directory only when it is empty.
## What it writes
Under the agent's managed directory, `/etc/claude-code`, owned whole by this module and rewritten
whenever the node's tool runtime collects the module's tools:
| file | holds |
|---|---|
| `managed-mcp.json` | the tool servers every session loads: the mesh's console as `mesh`, and the servers set in this module's `mcp_servers` setting. **Exclusive**: a server not listed here does not load — not one added with `claude mcp add`, not a project's `.mcp.json`, not a plugin's |
| `managed-settings.json` | the repositories' attribution convention, the claude.ai connectors kept beside the managed servers, and the key-helper while the node holds an API-key licence |
| `CLAUDE.md` | how a session on this mesh works, this node's name and role, the conventions |
Under the operator's home, only `~/.claude/.credentials.json`, and only when the licence manager hands
this node a subscription token. Nothing else under the home is read or written.
## Over NATS
Everything between this module and the rest of the mesh is NATS, in three kinds: an **event** says that
something happened and carries no secret, because a stream keeps it; a **request** carries a token,
because nothing keeps it (hq design 32 §10); and **state** is the current value of something every node
must see, a node that joins later included — kept, so it carries no secret either (hq ADR 0201).
| what | how |
|---|---|
| the licence manager rotated a licence, or switched this node | its `licence.rotated` / `licence.switched` event; this module then asks `anthropic-licence-manager.current` for its token, sealed to the key it sends |
| this node starts | it asks `current` once, so a node that was off catches up |
| a person ran `/login` here | the credentials file gains a refresh token this module never writes; it asks `anthropic-licence-manager.adopt` at once with the grant sealed to the manager's key — the one time a refresh token travels, because the login made the manager's stale |
| an MCP server registered through this module | a key in the module's `servers` state — `all.<server>` for every node, `<node>.<server>` for one; every node watches it and renders what applies to it, a node's own entry over the one for every node. A node that joins later, or was off, reads the whole current set at start; unregistering is a delete. An entry with a secret in its `env` or `headers` is refused by the runtime |
## Tools
`claude_code_status`, `claude_code_render`, `claude_code_pull`, `claude_code_mcp_list`,
`claude_code_mcp_register` (this node by default; `nodes: "all"` or a list for more — called for this
node alone, its answer names the other nodes running claude-code), `claude_code_mcp_unregister`.
## Settings
Per node or for the whole mesh, through `mesh-controller.settings module=claude-code`:
- `role` — what this node is, in a few words; shown to every session.
- `mcp_servers` — extra tool servers, set by the operator for the mesh or a node, beside the ones
registered through the tools; keyed by name, in the vendor's `.mcp.json` entry shape
(`{"type":"http","url":…}` or `{"type":"stdio","command":…,"args":[…]}`). The name `mesh` is the
module's own and cannot be set. Put a person's own servers here, or they stop loading.
## On a machine that carried the predecessor
Remove these by hand, once; the mesh removes nothing it did not make (ADR 0182):
- `~/.claude/CLAUDE.md`
- `~/.claude/rules/00-hal-mesh.md`, `~/.claude/rules/conventions.md`
- `~/.claude/skills/cleanup/`, `~/.claude/skills/hal-switch-license/`
- the hand-made console entry in `~/.claude.json` under `mcpServers` — it is ignored now anyway
## Escalation
Writing `/etc/claude-code` needs root. The runtime runs as the operator account, and the module uses
that account's passwordless `sudo`; on a machine without it, `claude_code_render` says so and nothing
is written.
+112
View File
@@ -0,0 +1,112 @@
// The agent's credentials file, and whether an offered grant may replace what it holds (novox/hq
// ADR 0183, design 36 §5). Pure where it decides, so the rules are tested without a file.
//
// The file is the vendor's: `{ claudeAiOauth: { accessToken, expiresAt, refreshTokenExpiresAt?,
// scopes?, subscriptionType?, rateLimitTier? }, ... }`. A node never holds a refresh token, so the
// one this module writes never carries one, and a full grant a login left behind is stripped the
// moment the manager hands the node its own.
//
// The lineage rule is the predecessor's, with the incidents that earned it: a rotation of the same
// licence is applied only if newer; a grant re-issued by a login is adopted whatever its expiry; a
// switch to another licence is applied regardless, because across licences the expiries are
// unrelated numbers.
import { readFileSync, renameSync, writeFileSync, mkdirSync } from "node:fs";
import { dirname } from "node:path";
export interface Grant {
readonly accessToken: string;
readonly expiresAt: number;
readonly refreshTokenExpiresAt?: number | null;
readonly scopes?: readonly string[] | null;
readonly subscriptionType?: string | null;
readonly rateLimitTier?: string | null;
}
export type ApplySource = "rotation" | "switch";
export type ApplyDecision =
| { apply: true; reissued?: boolean }
| { apply: false; reason: "already-current" }
| { apply: false; reason: "not-newer"; localExpiresAt: number };
/** Two refresh-token expiries within a day are one lineage; a login starts a fresh window weeks away. */
export const GENERATION_TOLERANCE_MS = 24 * 60 * 60 * 1000;
export function sameGeneration(a?: number | null, b?: number | null): boolean {
if (a == null || b == null) return true;
return Math.abs(Number(a) - Number(b)) <= GENERATION_TOLERANCE_MS;
}
export function decideApply(local: Grant | null | undefined, offered: Grant, source: ApplySource): ApplyDecision {
if (!local?.accessToken) return { apply: true };
if (local.accessToken === offered.accessToken) return { apply: false, reason: "already-current" };
const reissued = !sameGeneration(local.refreshTokenExpiresAt, offered.refreshTokenExpiresAt);
if (source === "rotation" && !reissued && Number(local.expiresAt) >= Number(offered.expiresAt)) {
return { apply: false, reason: "not-newer", localExpiresAt: Number(local.expiresAt) };
}
return reissued ? { apply: true, reissued: true } : { apply: true };
}
type Oauth = Record<string, unknown> & { accessToken?: string; refreshToken?: string; expiresAt?: number };
type Credentials = Record<string, unknown> & { claudeAiOauth?: Oauth };
export function readCredentials(path: string): Credentials | null {
try {
const parsed = JSON.parse(readFileSync(path, "utf8")) as Credentials;
return parsed && typeof parsed === "object" ? parsed : null;
} catch {
return null;
}
}
/** The grant the file holds, or null. */
export function grantOf(creds: Credentials | null): Grant | null {
const o = creds?.claudeAiOauth;
if (!o?.accessToken) return null;
return {
accessToken: o.accessToken,
expiresAt: Number(o.expiresAt ?? 0),
refreshTokenExpiresAt: o.refreshTokenExpiresAt == null ? null : Number(o.refreshTokenExpiresAt),
};
}
/** Does the file hold a full grant — a refresh token this module never writes, so a person's login? */
export function holdsLogin(creds: Credentials | null): boolean {
return typeof creds?.claudeAiOauth?.refreshToken === "string" && creds.claudeAiOauth.refreshToken.length > 0;
}
/**
* The handed grant laid over what is there — a rotation of the licence the node already holds — or,
* for a switch, in place of it: the old licence's grant goes whole, scopes and subscription included,
* and only keys outside the grant (another kind of credential the vendor keeps in the file) stay.
* Either way, no refresh token survives.
*/
export function replacedBy(local: Credentials | null, grant: Grant): Credentials {
const next: Credentials = { ...(local ?? {}) };
delete next.claudeAiOauth;
return withGrant(next, grant);
}
/** Overlay the handed grant on what is there, and delete any refresh token. */
export function withGrant(local: Credentials | null, grant: Grant): Credentials {
const next: Credentials = { ...(local ?? {}) };
const oauth: Oauth = { ...(local?.claudeAiOauth ?? {}) };
oauth.accessToken = grant.accessToken;
oauth.expiresAt = grant.expiresAt;
for (const k of ["refreshTokenExpiresAt", "scopes", "subscriptionType", "rateLimitTier"] as const) {
const v = grant[k];
if (v != null) oauth[k] = v as unknown;
}
delete oauth.refreshToken;
next.claudeAiOauth = oauth;
return next;
}
/** Write atomically at 0600: a partial credentials file must never be read as a whole one. */
export function writeCredentials(path: string, creds: Credentials): void {
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
const tmp = `${path}.mesh-tmp`;
writeFileSync(tmp, JSON.stringify(creds, null, 2) + "\n", { mode: 0o600 });
renameSync(tmp, path);
}
+50
View File
@@ -0,0 +1,50 @@
// Which account the agent is logged in as (novox/hq ADR 0183): not in the token, but in the agent's
// own state file beside the home, `~/.claude.json` → `oauthAccount`. Read to attribute a login; written,
// three keys and nothing else, when a licence is switched, so the file Claude Code shows the account from
// names the account whose token it now holds (as the predecessor learned: two files that disagree make
// a later login look like the wrong account).
import { readFileSync, renameSync, writeFileSync } from "node:fs";
export interface Identity {
readonly accountUuid: string;
readonly emailAddress?: string;
readonly organizationUuid?: string;
}
export function readIdentity(stateFile: string): Identity | null {
try {
const raw = JSON.parse(readFileSync(stateFile, "utf8")) as { oauthAccount?: Record<string, unknown> };
const a = raw.oauthAccount;
if (!a || typeof a.accountUuid !== "string") return null;
return {
accountUuid: a.accountUuid,
emailAddress: typeof a.emailAddress === "string" ? a.emailAddress : undefined,
organizationUuid: typeof a.organizationUuid === "string" ? a.organizationUuid : undefined,
};
} catch {
return null;
}
}
/**
* Point the state file's account at `id`, keeping every other key as found. Returns whether the file
* changed; a file that cannot be read as an object is left alone rather than replaced.
*/
export function writeIdentity(stateFile: string, id: Identity): boolean {
let raw: Record<string, unknown>;
try {
raw = JSON.parse(readFileSync(stateFile, "utf8")) as Record<string, unknown>;
if (!raw || typeof raw !== "object") return false;
} catch {
raw = {};
}
const current = (raw.oauthAccount ?? {}) as Record<string, unknown>;
if (current.accountUuid === id.accountUuid && current.emailAddress === id.emailAddress
&& current.organizationUuid === id.organizationUuid) return false;
raw.oauthAccount = { ...current, accountUuid: id.accountUuid, emailAddress: id.emailAddress, organizationUuid: id.organizationUuid };
const tmp = `${stateFile}.mesh-tmp`;
writeFileSync(tmp, JSON.stringify(raw, null, 2), { mode: 0o600 });
renameSync(tmp, stateFile);
return true;
}
+90
View File
@@ -0,0 +1,90 @@
{
"module": "claude-code",
"version": "1",
"slug": "agent",
"capabilities": [
"package-manager"
],
"requires": [
"mcp-endpoint"
],
"binds": {
"mcp-endpoint": "${dir:state}/mcp-endpoint.json"
},
"consumes": [
"claude-licence-manager.licence.rotated",
"claude-licence-manager.licence.switched"
],
"state": [
"servers"
],
"tools": [
"claude_code_status",
"claude_code_render",
"claude_code_pull",
"claude_code_mcp_list",
"claude_code_mcp_register",
"claude_code_mcp_unregister"
],
"resources": [
{
"id": "package",
"type": "package",
"package": "claude-code"
},
{
"id": "managed",
"type": "directory",
"path": "/etc/claude-code",
"mode": "0755"
},
{
"id": "agent-home",
"type": "directory",
"path": "${machine:account-home}/.claude",
"mode": "0700",
"owner": "${machine:account}"
},
{
"id": "state",
"type": "directory",
"mode": "0700",
"owner": "${machine:account}",
"place": "."
},
{
"id": "facts",
"type": "file",
"path": "${dir:state}/facts.json",
"mode": "0600",
"owner": "${machine:account}",
"content": "{\n \"node\": \"${machine:name}\",\n \"console\": \"http://127.0.0.1:${bound:mcp-endpoint:port}/mcp\"\n}\n"
},
{
"id": "settings",
"type": "file",
"path": "${dir:state}/settings.json",
"mode": "0600",
"owner": "${machine:account}",
"merge": "json",
"content": "{\n \"role\": \"\",\n \"mcp_servers\": {}\n}\n"
}
],
"build": {
"artifacts": [
{
"name": "tools",
"kind": "bundle",
"language": "typescript",
"entrypoints": [
"tools/index.js"
],
"env": {
"MESH_CLAUDE_CODE_STATE": "${dir:state}",
"MESH_CLAUDE_CODE_FACTS": "${dir:state}/facts.json",
"MESH_CLAUDE_CODE_SETTINGS": "${dir:state}/settings.json"
}
}
]
}
}
+271
View File
@@ -0,0 +1,271 @@
// What claude-code does on a node, written against two things it is handed — a way to ask a tool on the
// bus and a way to emit an event — so every path is tested without a bus (novox/hq design 36 §4–§5,
// ADR 0183, ADR 0198).
//
// **Over NATS, in two kinds** (design 32 §10): an event says that something happened and carries no
// secret, because a stream keeps it; a token travels on a request, which nothing keeps. So:
// - the licence manager's `licence.rotated` and `licence.switched` events tell this module to ask the
// seat for its current token, sealed to the key it sends with the request;
// - a login a person made here — a refresh token this module never writes — is offered to the seat at
// once, sealed to the seat's key: the one moment a refresh token travels, because the login made the
// manager's stale;
// - an MCP server registered through this module is **state, not an event** (novox/hq ADR 0201): one
// key per server in the module's `servers` bucket — `all.<server>` for every node, `<node>.<server>`
// for one — which every node watches. A node that joins later, or was off, reads the whole current set
// at start; unregistering is a delete. A secret never goes in an entry: the runtime refuses one.
import { chmodSync, existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { render, entryProblem, MANAGED_DIR, type Binding, type Facts, type Settings, type Servers } from "./render.js";
import { generateKeyPair, open, seal, type SealedBox } from "./seal.js";
import { decideApply, grantOf, holdsLogin, readCredentials, replacedBy, withGrant, writeCredentials, type Grant } from "./grant.js";
import { readIdentity, writeIdentity, type Identity } from "./identity.js";
export const SEAT = "anthropic-licence-manager";
export interface Paths {
state: string;
facts: string;
settings: string;
home: string;
node: string;
}
/** A tool on the bus: its address and arguments in, its JSON answer out. */
export type Ask = (address: string, args: Record<string, unknown>) => Promise<unknown>;
/** An event of this module's, by its local name. */
export type Emit = (type: string, body: unknown) => Promise<void>;
/** Write one managed file; answers what happened. */
export type WriteManaged = (name: string, content: string) => string;
export 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 accountPath = (p: Paths) => join(p.home, ".claude.json");
const bindingPath = (p: Paths) => join(p.state, "licence.json");
const apiKeyPath = (p: Paths) => join(p.state, "api-key");
export 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 registryPath = (p: Paths) => join(p.state, "mcp-servers.json");
export 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") };
}
export function registered(p: Paths): Servers {
return readJson<Servers>(registryPath(p), {});
}
export function renderNow(p: Paths, write: WriteManaged): 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 files = render(facts, readJson<Settings>(p.settings, {}), readJson<Binding | null>(bindingPath(p), null),
helperPath(p), registered(p));
return Object.entries(files).map(([name, content]) => write(name, content));
}
// ---- the licence ----------------------------------------------------------------------------------
/** What the seat answers to `current`: the licence this node is bound to and its token, sealed. */
export interface Current {
licence: string;
kind: "subscription" | "api-key";
sealed: SealedBox;
identity?: Identity | null;
}
/** Ask the seat for this node's current token and apply it. */
export async function pull(p: Paths, ask: Ask, write: WriteManaged): Promise<Record<string, unknown>> {
const answer = (await ask(`${SEAT}.current`, { node: p.node, public_key: keypair(p).publicKey })) as Current | null;
if (!answer?.sealed) return { applied: false, reason: "the seat holds no licence for this node" };
return apply(p, answer, write);
}
/** Apply what the seat handed over. A switch replaces the grant whole and cleans up after the old licence. */
export function apply(p: Paths, handed: Current, write: WriteManaged): Record<string, unknown> {
const plain = open(handed.sealed, keypair(p).privateKey);
const previous = readJson<Binding | null>(bindingPath(p), null);
const switched = previous?.licence !== handed.licence;
let outcome: Record<string, unknown> = { applied: true, licence: handed.licence, kind: handed.kind, switched };
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, switched ? "switch" : "rotation");
if (d.apply) writeCredentials(credentialsPath(p), switched ? replacedBy(local, grant) : withGrant(local, grant));
else outcome = { applied: false, licence: handed.licence, reason: "reason" in d ? d.reason : undefined }; // narrowed by hand: the build compiles without strict
// Away from the API key: it goes, with its helper.
rmSync(apiKeyPath(p), { force: true });
rmSync(helperPath(p), { force: true });
}
if (switched && handed.identity?.accountUuid) {
outcome.account = writeIdentity(accountPath(p), handed.identity) ? "updated" : "unchanged";
}
writeFileSync(bindingPath(p), JSON.stringify({ licence: handed.licence, kind: handed.kind }) + "\n", { mode: 0o600 });
try {
outcome.rendered = renderNow(p, write); // the key-helper comes or goes with the licence's kind
} catch (err) {
outcome.rendered = { failed: err instanceof Error ? err.message : String(err) };
}
return outcome;
}
/** A licence event from the manager: is it for this node? */
export function concerns(p: Paths, type: string, body: { licence?: string; node?: string }): boolean {
if (type.endsWith("licence.switched")) return body.node === p.node;
if (type.endsWith("licence.rotated")) return body.licence === readJson<Binding | null>(bindingPath(p), null)?.licence;
return false;
}
/** A refresh token in the credentials file is a login: this module never writes one. Offer it to the seat. */
export async function offerLogin(p: Paths, ask: Ask): Promise<Record<string, unknown> | null> {
const creds = readCredentials(credentialsPath(p));
if (!holdsLogin(creds)) return null;
const key = (await ask(`${SEAT}.public_key`, {})) as { public_key?: string } | null;
if (!key?.public_key) throw new Error("the licence manager did not say what key to seal a login to");
return (await ask(`${SEAT}.adopt`, {
node: p.node,
identity: readIdentity(accountPath(p)),
sealed: seal(JSON.stringify(creds!.claudeAiOauth), key.public_key),
})) as Record<string, unknown>;
}
// ---- MCP servers ----------------------------------------------------------------------------------
export interface Registration {
name: string;
entry?: Record<string, unknown>;
/** Which nodes: this one (absent), every node running the module ("all"), or a list. */
nodes?: "all" | string[];
}
/** The `servers` state, as this module reaches it through the runtime (`state("servers")` in the SDK). */
export interface ServerState {
put(key: string, value: Record<string, unknown>): Promise<number>;
delete(key: string): Promise<void>;
keys(): Promise<string[]>;
}
/** One change to the `servers` state, as a watch hands it over. */
export interface ServerChange {
key: string;
op: "put" | "delete";
value?: Record<string, unknown>;
}
/** The key a registration lives at: `all.<server>` for every node, `<node>.<server>` for one. */
export const keyOf = (scope: string, name: string) => `${scope}.${name}`;
/**
* What this node takes from the `servers` state: the entries for every node and for this one, by key —
* kept in memory from the watch, and written through to the module's own file whenever what applies here
* changes, so the managed directory can be rendered without the bus.
*/
export class ServerView {
private readonly entries = new Map<string, Record<string, unknown>>();
constructor(private readonly p: Paths) {}
/** Take one change; answers whether what applies to this node changed. */
take(c: ServerChange): boolean {
const dot = c.key.indexOf(".");
const scope = c.key.slice(0, dot), name = c.key.slice(dot + 1);
if (dot <= 0 || (scope !== "all" && scope !== this.p.node)) return false;
if (c.op === "put" && c.value && entryProblem(name, c.value) === null) this.entries.set(c.key, c.value);
else this.entries.delete(c.key);
return this.writeThrough();
}
/** What applies here: every node's entries, with this node's own laid over them by server name. */
effective(): Servers {
const out: Record<string, Record<string, unknown>> = {};
for (const scope of ["all", this.p.node]) {
for (const [key, entry] of [...this.entries].sort(([a], [b]) => a.localeCompare(b))) {
if (key.startsWith(scope + ".")) out[key.slice(scope.length + 1)] = entry;
}
}
return out;
}
private writeThrough(): boolean {
const now = JSON.stringify(this.effective(), null, 2) + "\n";
let before = "";
try {
before = readFileSync(registryPath(this.p), "utf8");
} catch {
/* none yet */
}
if (now === before) return false;
writeFileSync(registryPath(this.p), now, { mode: 0o600 });
return true;
}
}
/** A change from the watch: take it, and render when what applies here changed. */
export function onServerChange(view: ServerView, c: ServerChange, p: Paths, write: WriteManaged): string | null {
if (!view.take(c)) return null;
renderNow(p, write);
return `${c.op === "put" ? "registered" : "unregistered"} ${c.key}`;
}
const scopesOf = (p: Paths, nodes: Registration["nodes"]): string[] =>
nodes === undefined ? [p.node] : nodes === "all" ? ["all"] : nodes;
/**
* Register (or with no entry, unregister) a server: a put (or delete) per scope in the `servers` state.
* Taken into this node's view at once, so the answer says what it did here; every other node takes it
* from its watch, and a node that joins later from the current state.
*/
export async function registerServer(p: Paths, r: Registration, servers: ServerState, view: ServerView,
write: WriteManaged, others: () => Promise<string[]>): Promise<Record<string, unknown>> {
if (r.entry) {
const problem = entryProblem(r.name, r.entry);
if (problem) return { registered: false, reason: problem };
}
const scopes = scopesOf(p, r.nodes);
// Compared before and after rather than read from take(): this node's own watch may hand the view the
// same change first, and then take() here finds nothing new although this call made it.
const before = JSON.stringify(view.effective());
for (const scope of scopes) {
const key = keyOf(scope, r.name);
if (r.entry) await servers.put(key, r.entry);
else await servers.delete(key);
view.take({ key, op: r.entry ? "put" : "delete", value: r.entry });
}
const changedHere = JSON.stringify(view.effective()) !== before;
const here = scopes.includes("all") || scopes.includes(p.node);
const answer: Record<string, unknown> = {
[r.entry ? "registered" : "unregistered"]: r.name,
on: r.nodes === undefined ? [p.node] : r.nodes,
here: here ? (changedHere ? "changed" : "already so") : "not this node",
rendered: changedHere ? renderNow(p, write) : [],
};
if (!r.entry && view.effective()[r.name]) {
answer.still = `${r.name} still applies here from another registration (for every node, or for this one); unregister that too`;
}
if (r.nodes === undefined) {
// The question the operator wanted asked: here only, or more?
const elsewhere = (await others().catch(() => [] as string[])).filter((n) => n !== p.node);
answer.also = elsewhere.length
? `claude-code also runs on ${elsewhere.join(", ")}. To ${r.entry ? "register" : "unregister"} it there too, call again with nodes: "all" or a list of those nodes.`
: `To do the same on every node running claude-code, call again with nodes: "all".`;
}
return answer;
}
export { MANAGED_DIR };
+18
View File
@@ -0,0 +1,18 @@
{
"name": "@novox/module-claude-code",
"version": "0.1.0",
"description": "claude-code — the operator's agent on a machine: its managed configuration, and the consumer side of the Anthropic licence manager (novox/hq design 36).",
"type": "module",
"private": true,
"scripts": {
"build": "tsc seal.ts grant.ts identity.ts render.ts node.ts tools/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --rootDir . --outDir dist",
"test": "npm run build && node --test --experimental-strip-types 'test/*.test.ts'"
},
"dependencies": {
"@novox/mesh-sdk": "^0.1.7"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.6.0"
}
}
+135
View File
@@ -0,0 +1,135 @@
// 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.
`;
}
+77
View File
@@ -0,0 +1,77 @@
// Sealing a token to one recipient (novox/hq ADR 0183): the manager seals what it hands a node to that
// node's agent module key, and a node seals a waiting login to the key the manager names. X25519 for
// the agreement, HKDF-SHA256 for the key, AES-256-GCM for the box — all from Node's own library, so a
// bundle carries no dependency and no secret ever crosses the bus in the clear.
//
// A sealed box is `{ v: 1, eph, iv, tag, ct }`, every field base64. `eph` is a one-time public key, so
// two boxes of one value to one recipient share nothing, and only the recipient's private key opens it.
import {
createCipheriv, createDecipheriv, createPrivateKey, createPublicKey, diffieHellman,
generateKeyPairSync, hkdfSync, randomBytes, type KeyObject,
} from "node:crypto";
export interface SealedBox {
readonly v: 1;
readonly eph: string;
readonly iv: string;
readonly tag: string;
readonly ct: string;
}
/** A recipient's keypair, as the two PEM strings it is kept and published as. */
export interface KeyPairPem {
readonly publicKey: string;
readonly privateKey: string;
}
const INFO = Buffer.from("novox-mesh sealed box v1");
export function generateKeyPair(): KeyPairPem {
const { publicKey, privateKey } = generateKeyPairSync("x25519");
return {
publicKey: publicKey.export({ type: "spki", format: "pem" }).toString(),
privateKey: privateKey.export({ type: "pkcs8", format: "pem" }).toString(),
};
}
function keyFor(secret: Buffer, eph: Buffer, recipient: Buffer): Buffer {
// The ephemeral and the recipient's public halves are bound into the key, so a box cannot be
// re-addressed to another recipient by swapping its `eph`.
return Buffer.from(hkdfSync("sha256", secret, Buffer.concat([eph, recipient]), INFO, 32));
}
function rawPublic(key: KeyObject): Buffer {
return key.export({ type: "spki", format: "der" }).subarray(-32);
}
export function seal(plaintext: string, recipientPublicPem: string): SealedBox {
const recipient = createPublicKey(recipientPublicPem);
const eph = generateKeyPairSync("x25519");
const secret = diffieHellman({ privateKey: eph.privateKey, publicKey: recipient });
const ephRaw = eph.publicKey.export({ type: "spki", format: "der" });
const key = keyFor(secret, ephRaw, rawPublic(recipient));
const iv = randomBytes(12);
const cipher = createCipheriv("aes-256-gcm", key, iv);
const ct = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
return {
v: 1,
eph: ephRaw.toString("base64"),
iv: iv.toString("base64"),
tag: cipher.getAuthTag().toString("base64"),
ct: ct.toString("base64"),
};
}
/** Open a box with the recipient's private key. Throws on a box for another key or one tampered with. */
export function open(box: SealedBox, privateKeyPem: string): string {
if (!box || box.v !== 1) throw new Error("not a sealed box this module can open");
const priv = createPrivateKey(privateKeyPem);
const ephRaw = Buffer.from(box.eph, "base64");
const eph = createPublicKey({ key: ephRaw, format: "der", type: "spki" });
const secret = diffieHellman({ privateKey: priv, publicKey: eph });
const key = keyFor(secret, ephRaw, rawPublic(createPublicKey(priv)));
const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(box.iv, "base64"));
decipher.setAuthTag(Buffer.from(box.tag, "base64"));
return Buffer.concat([decipher.update(Buffer.from(box.ct, "base64")), decipher.final()]).toString("utf8");
}
+54
View File
@@ -0,0 +1,54 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, readFileSync, statSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
decideApply, grantOf, holdsLogin, readCredentials, withGrant, writeCredentials, type Grant,
} from "../dist/grant.js";
const NOW = 1_700_000_000_000;
const HOUR = 3_600_000;
const g = (over: Partial<Grant> = {}): Grant => ({
accessToken: "tok-A", expiresAt: NOW + HOUR, refreshTokenExpiresAt: NOW + 30 * 24 * HOUR, ...over,
});
test("a rotation applies a newer grant of the same licence", () => {
assert.deepEqual(decideApply(g(), g({ accessToken: "tok-B", expiresAt: NOW + 2 * HOUR }), "rotation"), { apply: true });
});
test("a rotation refuses a grant that arrived late and is older", () => {
const d = decideApply(g({ accessToken: "new", expiresAt: NOW + 2 * HOUR }), g({ accessToken: "old" }), "rotation");
assert.equal(d.apply === false && d.reason, "not-newer");
});
test("a grant re-issued by a login is adopted even though it expires sooner (2026-09-05)", () => {
const local = g({ expiresAt: NOW + 8 * HOUR, refreshTokenExpiresAt: NOW + 30 * 24 * HOUR });
const offered = g({ accessToken: "reissued", expiresAt: NOW + HOUR, refreshTokenExpiresAt: NOW + 5 * 24 * HOUR });
assert.deepEqual(decideApply(local, offered, "rotation"), { apply: true, reissued: true });
});
test("a switch to another licence applies whatever the expiries say", () => {
const local = g({ expiresAt: NOW + 8 * HOUR });
assert.equal(decideApply(local, g({ accessToken: "other", expiresAt: NOW + HOUR }), "switch").apply, true);
});
test("the same token is not rewritten", () => {
assert.deepEqual(decideApply(g(), g(), "switch"), { apply: false, reason: "already-current" });
});
test("a full grant left by a login is seen as a login, and stripped when the node's own is written", () => {
const dir = mkdtempSync(join(tmpdir(), "claude-code-"));
const path = join(dir, ".claude", ".credentials.json");
writeFileSync(join(dir, "x"), "");
const login = { claudeAiOauth: { accessToken: "at-login", refreshToken: "rt-login", expiresAt: NOW }, other: 1 };
assert.equal(holdsLogin(login), true);
writeCredentials(path, withGrant(login, g({ accessToken: "at-mesh", scopes: ["user:inference"] })));
const back = readCredentials(path)!;
assert.equal(holdsLogin(back), false);
assert.equal(grantOf(back)!.accessToken, "at-mesh");
assert.deepEqual(back.claudeAiOauth!.scopes, ["user:inference"]);
assert.equal(back.other, 1, "a key the module does not know was lost");
assert.ok(!readFileSync(path, "utf8").includes("rt-login"));
assert.equal(statSync(path).mode & 0o777, 0o600);
});
+19
View File
@@ -0,0 +1,19 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { readIdentity } from "../dist/identity.js";
test("the account is read from the agent's state file", () => {
const p = join(mkdtempSync(join(tmpdir(), "cc-id-")), ".claude.json");
writeFileSync(p, JSON.stringify({ oauthAccount: { accountUuid: "u-1", emailAddress: "a@example.org" }, other: 2 }));
assert.deepEqual(readIdentity(p), { accountUuid: "u-1", emailAddress: "a@example.org", organizationUuid: undefined });
});
test("no state file, or no account in it, is no identity rather than a guess", () => {
assert.equal(readIdentity("/nonexistent/.claude.json"), null);
const p = join(mkdtempSync(join(tmpdir(), "cc-id-")), ".claude.json");
writeFileSync(p, "{}");
assert.equal(readIdentity(p), null);
});
+173
View File
@@ -0,0 +1,173 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
apply, concerns, keypair, offerLogin, onServerChange, pull, registerServer, registered, ServerView, type Paths,
type ServerChange, type ServerState,
} from "../dist/node.js";
import { generateKeyPair, open, seal } from "../dist/seal.js";
const NOW = Date.now();
function node(name = "laptop"): { p: Paths; written: Record<string, string> } {
const root = mkdtempSync(join(tmpdir(), "cc-node-"));
const p = { state: join(root, "state"), facts: join(root, "state", "facts.json"), settings: join(root, "state", "settings.json"), home: join(root, "home"), node: name };
mkdirSync(p.state, { recursive: true });
mkdirSync(join(p.home, ".claude"), { recursive: true });
writeFileSync(p.facts, JSON.stringify({ node: name, console: "http://127.0.0.1:4270/mcp" }));
writeFileSync(p.settings, JSON.stringify({ role: "", mcp_servers: {} }));
return { p, written: {} };
}
const writer = (w: Record<string, string>) => (name: string, content: string) => { w[name] = content; return `${name}: written`; };
const creds = (p: Paths) => JSON.parse(readFileSync(join(p.home, ".claude", ".credentials.json"), "utf8"));
const grantFor = (p: Paths, licence: string, token: string, kind: "subscription" | "api-key" = "subscription", identity?: object) => ({
licence, kind, identity,
sealed: seal(kind === "api-key" ? token : JSON.stringify({ accessToken: token, expiresAt: NOW + 3_600_000, refreshTokenExpiresAt: NOW + 86_400_000, subscriptionType: licence }), keypair(p).publicKey),
});
test("a pull asks the seat with this node's key and applies what it answers", async () => {
const { p, written } = node();
let asked: [string, Record<string, unknown>] | null = null;
const r = await pull(p, async (address, args) => { asked = [address, args]; return grantFor(p, "personal", "at-1"); }, writer(written));
assert.equal(asked![0], "anthropic-licence-manager.current");
assert.equal(asked![1].node, "laptop");
assert.match(String(asked![1].public_key), /BEGIN PUBLIC KEY/);
assert.equal(r.applied, true);
assert.equal(creds(p).claudeAiOauth.accessToken, "at-1");
assert.ok(written["managed-mcp.json"]);
});
test("a switch replaces the old licence's grant whole and points the account at the new one", () => {
const { p, written } = node();
writeFileSync(join(p.home, ".claude.json"), JSON.stringify({ oauthAccount: { accountUuid: "old" }, projects: { keep: 1 } }));
apply(p, grantFor(p, "personal", "at-1"), writer(written));
const r = apply(p, grantFor(p, "work", "at-2", "subscription", { accountUuid: "new", emailAddress: "w@example.org" }), writer(written));
assert.equal(r.switched, true);
assert.equal(creds(p).claudeAiOauth.accessToken, "at-2");
assert.equal(creds(p).claudeAiOauth.subscriptionType, "work", "the old licence's subscription type survived the switch");
const account = JSON.parse(readFileSync(join(p.home, ".claude.json"), "utf8"));
assert.equal(account.oauthAccount.accountUuid, "new");
assert.deepEqual(account.projects, { keep: 1 });
});
test("switching to the API key adds the key-helper; switching away removes the key and the helper", () => {
const { p, written } = node();
apply(p, grantFor(p, "api", "sk-key", "api-key"), writer(written));
assert.ok(JSON.parse(written["managed-settings.json"]).apiKeyHelper);
assert.ok(existsSync(join(p.state, "api-key")));
apply(p, grantFor(p, "personal", "at-1"), writer(written));
assert.ok(!("apiKeyHelper" in JSON.parse(written["managed-settings.json"])));
assert.ok(!existsSync(join(p.state, "api-key")) && !existsSync(join(p.state, "api-key-helper")));
});
test("a rotation event concerns the node bound to that licence; a switch event the node it names", () => {
const { p, written } = node();
apply(p, grantFor(p, "personal", "at-1"), writer(written));
assert.equal(concerns(p, "claude-licence-manager.licence.rotated", { licence: "personal" }), true);
assert.equal(concerns(p, "claude-licence-manager.licence.rotated", { licence: "work" }), false);
assert.equal(concerns(p, "claude-licence-manager.licence.switched", { node: "laptop", licence: "work" }), true);
assert.equal(concerns(p, "claude-licence-manager.licence.switched", { node: "server" }), false);
});
test("a login is offered to the seat sealed to the seat's key, with the account it belongs to", async () => {
const { p } = node();
const manager = generateKeyPair();
writeFileSync(join(p.home, ".claude", ".credentials.json"), JSON.stringify({ claudeAiOauth: { accessToken: "at-login", refreshToken: "rt-login", expiresAt: NOW } }));
writeFileSync(join(p.home, ".claude.json"), JSON.stringify({ oauthAccount: { accountUuid: "u-9" } }));
const calls: [string, Record<string, unknown>][] = [];
await offerLogin(p, async (address, args) => { calls.push([address, args]); return address.endsWith("public_key") ? { public_key: manager.publicKey } : { adopted: true }; });
assert.deepEqual(calls.map((c) => c[0]), ["anthropic-licence-manager.public_key", "anthropic-licence-manager.adopt"]);
const adopt = calls[1][1] as { identity: { accountUuid: string }; sealed: never };
assert.equal(adopt.identity.accountUuid, "u-9");
assert.equal(JSON.parse(open(adopt.sealed, manager.privateKey)).refreshToken, "rt-login");
assert.ok(!JSON.stringify(adopt).includes("rt-login"), "the refresh token crossed in the clear");
});
test("no refresh token in the file is no login, and nothing is asked", async () => {
const { p } = node();
writeFileSync(join(p.home, ".claude", ".credentials.json"), JSON.stringify({ claudeAiOauth: { accessToken: "at", expiresAt: NOW } }));
assert.equal(await offerLogin(p, async () => { throw new Error("asked"); }), null);
});
/** The `servers` state as the bus holds it, shared by every node in a test, with each node's watch. */
function bus() {
const kept = new Map<string, Record<string, unknown>>();
const watchers: ((c: ServerChange) => void)[] = [];
const state: ServerState = {
put: async (key, value) => { kept.set(key, value); watchers.forEach((w) => w({ key, op: "put", value })); return kept.size; },
delete: async (key) => { kept.delete(key); watchers.forEach((w) => w({ key, op: "delete" })); },
keys: async () => [...kept.keys()].sort(),
};
/** A node joining: its view takes the current state, then every change. */
const join = (n: { p: Paths; written: Record<string, string> }) => {
const view = new ServerView(n.p);
for (const [key, value] of kept) onServerChange(view, { key, op: "put", value }, n.p, writer(n.written));
watchers.push((c) => onServerChange(view, c, n.p, writer(n.written)));
return view;
};
return { state, join, kept };
}
test("registering a server here puts it under this node's key, renders it, and asks about the other nodes", async () => {
const n = node();
const b = bus();
const view = b.join(n);
const r = await registerServer(n.p, { name: "search", entry: { type: "http", url: "https://s.example/mcp" } },
b.state, view, writer(n.written), async () => ["laptop", "server", "desktop"]);
assert.equal(r.here, "changed");
assert.match(String(r.also), /server, desktop/);
assert.deepEqual([...b.kept.keys()], ["laptop.search"]);
assert.ok(JSON.parse(n.written["managed-mcp.json"]).mcpServers.search);
});
test("registering for every node reaches the others through their watch, and a node joining later reads it", async () => {
const a = node("laptop"), s = node("server");
const b = bus();
const va = b.join(a);
b.join(s);
await registerServer(a.p, { name: "docs", entry: { type: "stdio", command: "docs-mcp" }, nodes: "all" },
b.state, va, writer(a.written), async () => []);
assert.deepEqual([...b.kept.keys()], ["all.docs"]);
assert.deepEqual(registered(s.p).docs, { type: "stdio", command: "docs-mcp" });
assert.ok(JSON.parse(s.written["managed-mcp.json"]).mcpServers.docs);
// The gap events left: a node assigned after the registration takes the whole current set at start.
const late = node("desktop");
b.join(late);
assert.deepEqual(registered(late.p).docs, { type: "stdio", command: "docs-mcp" });
// Unregistering is a delete, and every node's view drops it.
await registerServer(a.p, { name: "docs", nodes: "all" }, b.state, va, writer(a.written), async () => []);
assert.equal(registered(s.p).docs, undefined);
assert.equal(registered(late.p).docs, undefined);
});
test("a node's own registration overrides the one for every node; other nodes' keys leave this one alone", async () => {
const a = node("laptop"), s = node("server");
const b = bus();
const va = b.join(a);
const vs = b.join(s);
await registerServer(a.p, { name: "x", entry: { type: "http", url: "https://all" }, nodes: "all" }, b.state, va, writer(a.written), async () => []);
await registerServer(a.p, { name: "x", entry: { type: "http", url: "https://laptop" } }, b.state, va, writer(a.written), async () => []);
assert.equal(registered(a.p).x.url, "https://laptop");
assert.equal(registered(s.p).x.url, "https://all");
await registerServer(a.p, { name: "only", entry: { type: "http", url: "https://o" }, nodes: ["server"] }, b.state, va, writer(a.written), async () => []);
assert.equal(registered(a.p).only, undefined);
assert.equal(registered(s.p).only.url, "https://o");
// Unregistering here leaves the every-node one applying, and says so.
const r = await registerServer(a.p, { name: "x" }, b.state, va, writer(a.written), async () => []);
assert.match(String(r.still), /still applies here/);
assert.equal(registered(a.p).x.url, "https://all");
assert.equal(vs.effective().x.url, "https://all");
});
test("a bad entry is refused before anything is put; a repeated change changes nothing", async () => {
const n = node();
const b = bus();
const view = b.join(n);
const r = await registerServer(n.p, { name: "mesh", entry: { type: "http", url: "https://x" } }, b.state, view, writer(n.written), async () => []);
assert.equal(r.registered, false);
assert.equal(b.kept.size, 0);
assert.equal(onServerChange(view, { key: "all.a", op: "put", value: { type: "http", url: "https://a" } }, n.p, writer(n.written)), "registered all.a");
assert.equal(onServerChange(view, { key: "all.a", op: "put", value: { type: "http", url: "https://a" } }, n.p, writer(n.written)), null);
assert.equal(onServerChange(view, { key: "server.b", op: "put", value: { type: "http", url: "https://b" } }, n.p, writer(n.written)), null);
});
+40
View File
@@ -0,0 +1,40 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { render } from "../dist/render.js";
const facts = { node: "workstation", console: "http://127.0.0.1:4270/mcp" };
test("the console is the `mesh` server, and an operator's servers are listed beside it", () => {
const out = render(facts, { mcp_servers: { search: { type: "http", url: "https://s.example/mcp" } } }, null, "/h");
const mcp = JSON.parse(out["managed-mcp.json"]);
assert.deepEqual(Object.keys(mcp.mcpServers), ["mesh", "search"]);
assert.deepEqual(mcp.mcpServers.mesh, { type: "http", url: facts.console });
});
test("a setting cannot replace the mesh's own entry, and a name the vendor refuses is left out", () => {
const out = render(facts, { mcp_servers: { mesh: { type: "http", url: "http://evil" }, "bad name": {} } }, null, "/h");
const mcp = JSON.parse(out["managed-mcp.json"]);
assert.equal(mcp.mcpServers.mesh.url, facts.console);
assert.ok(!("bad name" in mcp.mcpServers));
});
test("managed settings carry the mesh's keys only, and the key-helper only for an API-key licence", () => {
const sub = JSON.parse(render(facts, {}, { licence: "personal", kind: "subscription" }, "/h")["managed-settings.json"]);
assert.deepEqual(sub, { attribution: { commit: "", pr: "" }, allowAllClaudeAiMcps: true });
const key = JSON.parse(render(facts, {}, { licence: "api", kind: "api-key" }, "/state/api-key-helper")["managed-settings.json"]);
assert.equal(key.apiKeyHelper, "/state/api-key-helper");
assert.ok(!("model" in key), "a preference is the person's");
});
test("the instruction file names the node and its role, and no other node", () => {
const md = render(facts, { role: "the laptop" }, null, "/h")["CLAUDE.md"];
assert.match(md, /\*\*Node:\*\* `workstation`/);
assert.match(md, /\*\*Role:\*\* the laptop/);
assert.match(md, /mesh_call/);
assert.match(md, /records_search/);
});
test("rendering is deterministic, so an unchanged input writes nothing", () => {
const s = { mcp_servers: { b: { type: "http", url: "https://b" }, a: { type: "http", url: "https://a" } } };
assert.deepEqual(render(facts, s, null, "/h"), render(facts, s, null, "/h"));
});
+31
View File
@@ -0,0 +1,31 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { generateKeyPair, open, seal } from "../dist/seal.js";
test("a box opens with its recipient's key and yields the value", () => {
const k = generateKeyPair();
assert.equal(open(seal("at-secret", k.publicKey), k.privateKey), "at-secret");
});
test("a box sealed for one node does not open with another node's key", () => {
const a = generateKeyPair();
const b = generateKeyPair();
assert.throws(() => open(seal("at-secret", a.publicKey), b.privateKey));
});
test("a tampered box is refused, not opened to garbage", () => {
const k = generateKeyPair();
const box = seal("at-secret", k.publicKey);
const ct = Buffer.from(box.ct, "base64");
ct[0] ^= 0xff;
assert.throws(() => open({ ...box, ct: ct.toString("base64") }, k.privateKey));
});
test("two boxes of one value share nothing a reader could compare", () => {
const k = generateKeyPair();
const x = seal("at-secret", k.publicKey);
const y = seal("at-secret", k.publicKey);
assert.notEqual(x.ct, y.ct);
assert.notEqual(x.eph, y.eph);
assert.ok(!JSON.stringify(x).includes("at-secret"));
});
+224
View File
@@ -0,0 +1,224 @@
// claude-code's bundle (novox/hq design 36, ADR 0183). The node's runtime launches it over stdio, as the
// operator account (ADR 0193), and is its bus (ADR 0198): it asks tools, emits and consumes through the
// runtime. It is given its state directory and two files the mesh renders into it (ADR 0192), beside the
// runtime's own words. **stdout is the MCP channel**: everything this module says, it says on stderr.
//
// At start it renders the agent's managed directory, asks the licence manager for this node's token,
// begins watching the credentials file for a login, takes the manager's licence events, and watches the
// module's `servers` state — every node's MCP server registrations (novox/hq ADR 0201). node.ts holds the
// logic.
import { mkdtempSync, readFileSync, rmSync, watchFile, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { spawnSync } from "node:child_process";
import { join } from "node:path";
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
import { broker } from "@novox/mesh-sdk/messaging";
import { on } from "@novox/mesh-sdk/events";
import { state } from "@novox/mesh-sdk/state";
import {
MANAGED_DIR, SEAT, ServerView, concerns, keypair, offerLogin, onServerChange, pull, readJson, registerServer,
registered, renderNow, type Ask, type Paths, type Registration, type ServerChange, type ServerState, type WriteManaged,
} from "../node.js";
import { grantOf, holdsLogin, readCredentials } from "../grant.js";
import { createHash } from "node:crypto";
const say = (line: string) => console.error(`[claude-code] ${line}`);
const fingerprint = (s: string) => "sha256:" + createHash("sha256").update(s).digest("hex").slice(0, 16);
function pathsFrom(env: NodeJS.ProcessEnv): Paths | null {
const state = env.MESH_CLAUDE_CODE_STATE, facts = env.MESH_CLAUDE_CODE_FACTS;
const settings = env.MESH_CLAUDE_CODE_SETTINGS, home = env.MESH_OPERATOR_HOME, node = env.MESH_NODE;
if (!state || !facts || !settings || !home || !node) return null;
return { state, facts, settings, home, node };
}
/** Write one managed file as root, only when its content changed. */
const writeManaged: WriteManaged = (name, content) => {
const path = join(MANAGED_DIR, name);
try {
if (readFileSync(path, "utf8") === content) return `${name}: unchanged`;
} catch {
/* absent */
}
// From a file, never /dev/stdin: Node hands a child its input over a socket, which /dev/stdin cannot
// open (ENXIO) — found on the first assignment, where nothing under /etc/claude-code was ever written.
const staged = mkdtempSync(join(tmpdir(), "claude-code-"));
const source = join(staged, name);
writeFileSync(source, content, { mode: 0o644 });
const asRoot = process.getuid?.() === 0;
const cmd = asRoot ? ["install", "-D", "-m", "0644", source, path] : ["sudo", "-n", "install", "-D", "-m", "0644", source, path];
const r = spawnSync(cmd[0], cmd.slice(1), { encoding: "utf8" });
rmSync(staged, { recursive: true, force: true });
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`);
}
return `${name}: written`;
};
/** A tool on the bus, through the runtime; its MCP answer read back as JSON where it is JSON. */
const ask: Ask = async (address, args) => {
const answer = (await broker().request<Record<string, unknown>, { content?: { text?: string }[]; isError?: boolean }>(address, args)) ?? {};
const text = answer.content?.map((c) => c.text ?? "").join("") ?? "";
if (answer.isError) throw new Error(`${address}: ${text}`);
try {
return JSON.parse(text);
} catch {
return text;
}
};
/** The nodes claude-code runs on, from the controller's list of modules — for the register tool's question. */
async function nodesRunningMe(): Promise<string[]> {
const out = await ask("mesh-controller.modules", {});
const text = typeof out === "string" ? out : String((out as { output?: string })?.output ?? "");
const line = text.split("\n").find((l) => /^claude-code\s/.test(l)) ?? "";
const on = line.split(" on ")[1] ?? "";
return on.trim() === "nothing" ? [] : on.split(",").map((s) => s.trim()).filter(Boolean);
}
function status(p: Paths): Record<string, unknown> {
const creds = readCredentials(join(p.home, ".claude", ".credentials.json"));
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: p.node,
licence: readJson(join(p.state, "licence.json"), null),
token: grant ? { fingerprint: fingerprint(grant.accessToken), expiresAt: new Date(grant.expiresAt).toISOString(),
loginWaiting: holdsLogin(creds) } : null,
managed,
registered: Object.keys(registered(p)),
};
}
/** The module's MCP servers on the bus (ADR 0201): its own state, which every node of it watches. */
const servers = () => state<Record<string, unknown>>("servers") as unknown as ServerState;
/** What this node takes from that state, kept from the watch. One per process. */
let view: ServerView | null = null;
const viewOf = (p: Paths) => (view ??= new ServerView(p));
function tools(p: Paths): ToolDefinition[] {
const nodesArg = { type: "string", description: 'more nodes: "all" for every node running claude-code, or a comma-separated list; absent is this node only' };
const nodesOf = (v: unknown): Registration["nodes"] =>
v === undefined || v === "" ? undefined : v === "all" ? "all" : String(v).split(",").map((s) => s.trim()).filter(Boolean);
return [
{
name: "claude_code_status",
description: "Claude Code on this machine as the mesh configured it: the licence it holds and when its token expires, the managed files, the MCP servers registered here. Fingerprints only, never a token.",
input: {},
run: async () => status(p),
},
{
name: "claude_code_render",
description: "Write Claude Code's managed directory now, from the mesh's facts, this module's settings and the servers registered here.",
input: {},
run: async () => ({ rendered: renderNow(p, writeManaged) }),
},
{
name: "claude_code_pull",
description: "Ask the licence manager for this node's current token now and apply it, rather than waiting for its next event.",
input: {},
run: async () => pull(p, ask, writeManaged),
},
{
name: "claude_code_mcp_list",
description: "The MCP servers registered through this module: those that apply on this node (beside the console, `mesh`, and those set in the module's settings), and every registration on the mesh, by key — `all.<server>` for every node, `<node>.<server>` for one.",
input: {},
run: async () => ({ here: registered(p), everywhere: await servers().keys() }),
},
{
name: "claude_code_mcp_register",
description: "Register an MCP server with Claude Code on this node, every node, or a list — an http/sse server by url, or a stdio server by command. Kept on the bus, so a node that joins later takes it too. Never put a secret in env or headers: the mesh refuses one.",
input: {
name: { type: "string", description: "the server's name: letters, digits, - and _" },
type: { type: "string", description: "http, sse or stdio (default stdio when a command is given, http when a url is)" },
url: { type: "string", description: "an http or sse server's url" },
command: { type: "string", description: "a stdio server's program" },
args: { type: "array", description: "a stdio server's arguments" },
env: { type: "object", description: "a stdio server's environment" },
headers: { type: "object", description: "an http server's headers" },
nodes: nodesArg,
},
run: async (a) => {
const entry: Record<string, unknown> = { type: a.type ?? (a.url ? "http" : "stdio") };
for (const k of ["url", "command", "args", "env", "headers"]) if (a[k] !== undefined) entry[k] = a[k];
return registerServer(p, { name: String(a.name ?? ""), entry, nodes: nodesOf(a.nodes) }, servers(), viewOf(p), writeManaged, nodesRunningMe);
},
},
{
name: "claude_code_mcp_unregister",
description: "Remove an MCP server registered through this module, on this node or more.",
input: { name: { type: "string", description: "the server's name" }, nodes: nodesArg },
run: async (a) => registerServer(p, { name: String(a.name ?? ""), nodes: nodesOf(a.nodes) }, servers(), viewOf(p), writeManaged, nodesRunningMe),
},
];
}
registerModuleTools("claude-code", (env) => {
const p = pathsFrom(env);
if (!p) return [];
try {
keypair(p);
for (const line of renderNow(p, writeManaged)) if (!line.endsWith("unchanged")) say(line);
} catch (err) {
say(err instanceof Error ? err.message : String(err));
}
return tools(p);
});
// Launched by the runtime: the bus is there from the first line (ADR 0198). Outside it — a test, a
// build — nothing below runs.
const p = process.env.MESH_SERVED_MODULE ? pathsFrom(process.env) : null;
if (p) {
const loud = (what: string) => (err: unknown) => say(`${what}: ${err instanceof Error ? err.message : String(err)}`);
void on<{ licence?: string; node?: string }>("claude-licence-manager.licence.*", async (event) => {
if (!concerns(p, event.type, event.body ?? {})) return;
say(`${event.type} — asking ${SEAT} for this node's token`);
say(JSON.stringify(await pull(p, ask, writeManaged).catch((e) => ({ failed: String(e) }))));
}).catch(loud("the licence events"));
// Every node's MCP servers: the whole current set first, then each change (ADR 0201). **Not awaited
// where the module is imported**: the runtime waits on the handshake, and a bucket that is not on the
// bus yet — or a grant the bus has not reloaded — answers late; awaited here, that left the bundle
// unable to answer `initialize` in time and the module unserved (found on its first assignment). So it
// watches beside the handshake and asks again until the state answers; until then the managed
// directory holds what the file kept from the last run.
const watchServers = (attempt = 0): void => {
state<Record<string, unknown>>("servers").watch((c) => {
try {
const done = onServerChange(viewOf(p), c as ServerChange, p, writeManaged);
if (done) say(done);
} catch (err) {
loud(`taking ${c.op} ${c.key}`)(err); // the view took it; the next render writes it
}
}).then(
() => say(`watching the MCP servers${attempt ? ` (after ${attempt} refusal(s))` : ""}`),
(err) => {
const wait = [2, 5, 10, 30][attempt] ?? 60;
say(`the MCP servers cannot be watched yet (${err instanceof Error ? err.message : String(err)}); asking again in ${wait}s`);
setTimeout(() => watchServers(attempt + 1), wait * 1000);
});
};
watchServers();
// Catch up once at start: a node that was off takes its current token now.
void pull(p, ask, writeManaged).then((r) => say(`at start: ${JSON.stringify(r)}`), loud("asking for this node's token at start"));
// A login: a refresh token appears in the credentials file. Polled, because the file is replaced by
// rename and a watch on the old inode would go quiet.
const credentials = join(p.home, ".claude", ".credentials.json");
watchFile(credentials, { interval: 5000 }, () => {
void offerLogin(p, ask).then((r) => { if (r) say(`a login here was offered to ${SEAT}: ${JSON.stringify(r)}`); },
loud("offering a login to the licence manager"));
});
}
@@ -8,5 +8,5 @@
"skipLibCheck": true, "skipLibCheck": true,
"noEmit": true "noEmit": true
}, },
"include": ["client.ts", "index.ts", "tools/index.ts"] "include": ["seal.ts", "grant.ts", "identity.ts", "render.ts", "node.ts", "tools/index.ts"]
} }
-24
View File
@@ -1,24 +0,0 @@
# cloudflare-dns's runtime: the tool runtime, carrying this module's compiled code.
#
# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in
# the base images, published like any other artifact — which is what makes this buildable by the
# mesh from a repository and a path (novox/hq ADR 0069) rather than only on a workstation that
# happens to have the siblings.
#
# Two bases, named rather than pinned (novox/hq issue 044): the image this is COMPILED in and the
# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`.
ARG BUILD_BASE
ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build
WORKDIR /app/modules/cloudflare-dns
COPY . .
RUN node /app/node_modules/typescript/bin/tsc client.ts tools/index.ts provisioner/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE}
COPY --from=build /app/modules/cloudflare-dns/dist /app/modules/cloudflare-dns/dist
# Every serve-time entrypoint, loaded by the runtime in serve mode: tools and events serve, and a
# provider's provisioner runs its reconcile loop in the same process, with the broker connected —
# the convention novox/hq issues 060/061 settled.
ENV MESH_TOOL_MODULES=/app/modules/cloudflare-dns/dist/tools/index.js,/app/modules/cloudflare-dns/dist/provisioner/index.js
+22 -48
View File
@@ -12,87 +12,61 @@
"public-dns": {} "public-dns": {}
}, },
"grants": { "grants": {
"public-dns": "/var/lib/cloudflare-dns/grants" "public-dns": "${dir:grants}"
}, },
"receives": { "receives": {
"public-dns": "/var/lib/cloudflare-dns/grants/mesh.json" "public-dns": "${dir:grants}/mesh.json"
}, },
"own-secrets": { "own-secrets": {
"token": "/var/lib/cloudflare-dns/token", "token": "${dir:state}/token"
"broker": "/var/lib/mesh/cloudflare-dns/broker"
}, },
"emits": [ "emits": [
"record.created", "record.created",
"record.removed" "record.removed"
], ],
"resources": [ "resources": [
{
"id": "mesh-state",
"type": "directory",
"path": "/var/lib/mesh/cloudflare-dns",
"mode": "0700"
},
{ {
"id": "state", "id": "state",
"type": "directory", "type": "directory",
"path": "/var/lib/cloudflare-dns", "mode": "0700",
"mode": "0700" "place": "."
}, },
{ {
"id": "grants", "id": "grants",
"type": "directory", "type": "directory",
"path": "/var/lib/cloudflare-dns/grants",
"mode": "0700" "mode": "0700"
}, },
{ {
"id": "config", "id": "config",
"type": "file", "type": "file",
"path": "/var/lib/cloudflare-dns/config.json", "path": "${dir:state}/config.json",
"merge": "json", "merge": "json",
"content": "{}", "content": "{}",
"mode": "0600" "mode": "0600"
},
{
"id": "runtime",
"type": "container",
"name": "mesh-cloudflare-dns",
"network": "host",
"volumes": [
"/var/lib/cloudflare-dns/config.json:/run/config/config.json:ro",
"/var/lib/cloudflare-dns/grants:/grants",
"/var/lib/cloudflare-dns/token:/run/secrets/token:ro",
"/var/lib/mesh/cloudflare-dns/broker:/run/secrets/broker:ro"
],
"env": {
"MESH_CLOUDFLARE_TOKEN_FILE": "/run/secrets/token",
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_CLOUDFLARE_CONFIG_FILE": "/run/config/config.json",
"MESH_RECEIVES": "/var/lib/cloudflare-dns/grants/mesh.json"
},
"artifact": "runtime"
} }
], ],
"capabilities": [ "capabilities": [
"container-runtime" "container-runtime"
], ],
"build": { "build": {
"on": [
{
"arg": "BUILD_BASE",
"module": "mesh-tools",
"artifact": "build"
},
{
"arg": "RUNTIME_BASE",
"module": "mesh-tools",
"artifact": "runtime"
}
],
"artifacts": [ "artifacts": [
{ {
"name": "runtime", "name": "code",
"kind": "image", "kind": "bundle",
"from": "Dockerfile" "language": "typescript",
"entrypoints": [
"tools/index.js",
"provisioner/index.js"
],
"loads": [
"tools/index.js",
"provisioner/index.js"
],
"env": {
"MESH_CLOUDFLARE_TOKEN_FILE": "${dir:state}/token",
"MESH_CLOUDFLARE_CONFIG_FILE": "${dir:state}/config.json",
"MESH_RECEIVES": "${dir:grants}/mesh.json"
}
} }
] ]
} }
-24
View File
@@ -1,24 +0,0 @@
# confluence's runtime: the tool runtime, carrying this module's compiled code.
#
# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in
# the base images, published like any other artifact — which is what makes this buildable by the
# mesh from a repository and a path (novox/hq ADR 0069) rather than only on a workstation that
# happens to have the siblings.
#
# Two bases, named rather than pinned (novox/hq issue 044): the image this is COMPILED in and the
# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`.
ARG BUILD_BASE
ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build
WORKDIR /app/modules/confluence
COPY . .
RUN node /app/node_modules/typescript/bin/tsc client.ts tools/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE}
COPY --from=build /app/modules/confluence/dist /app/modules/confluence/dist
# Every serve-time entrypoint, loaded by the runtime in serve mode: tools and events serve, and a
# provider's provisioner runs its reconcile loop in the same process, with the broker connected —
# the convention novox/hq issues 060/061 settled.
ENV MESH_TOOL_MODULES=/app/modules/confluence/dist/tools/index.js
+17 -43
View File
@@ -3,69 +3,43 @@
"version": "1", "version": "1",
"slug": "confl", "slug": "confl",
"own-secrets": { "own-secrets": {
"token": "/var/lib/confluence/token", "token": "${dir:state}/token"
"broker": "/var/lib/mesh/confluence/broker"
}, },
"resources": [ "resources": [
{
"id": "mesh-state",
"type": "directory",
"path": "/var/lib/mesh/confluence",
"mode": "0700"
},
{ {
"id": "state", "id": "state",
"type": "directory", "type": "directory",
"path": "/var/lib/confluence", "mode": "0700",
"mode": "0700" "place": "."
}, },
{ {
"id": "config", "id": "config",
"type": "file", "type": "file",
"path": "/var/lib/confluence/config.json", "path": "${dir:state}/config.json",
"merge": "json", "merge": "json",
"content": "{}", "content": "{}",
"mode": "0600" "mode": "0600"
},
{
"id": "runtime",
"type": "container",
"name": "mesh-runtime-confluence",
"network": "host",
"volumes": [
"/var/lib/confluence/config.json:/run/config/config.json:ro",
"/var/lib/confluence/token:/run/secrets/token:ro",
"/var/lib/mesh/confluence/broker:/run/secrets/broker:ro"
],
"env": {
"MESH_CONFLUENCE_TOKEN_FILE": "/run/secrets/token",
"MESH_CONFLUENCE_CONFIG_FILE": "/run/config/config.json",
"MESH_BROKER_FILE": "/run/secrets/broker"
},
"artifact": "runtime"
} }
], ],
"capabilities": [ "capabilities": [
"container-runtime" "container-runtime"
], ],
"build": { "build": {
"on": [
{
"arg": "BUILD_BASE",
"module": "mesh-tools",
"artifact": "build"
},
{
"arg": "RUNTIME_BASE",
"module": "mesh-tools",
"artifact": "runtime"
}
],
"artifacts": [ "artifacts": [
{ {
"name": "runtime", "name": "tools",
"kind": "image", "kind": "bundle",
"from": "Dockerfile" "language": "typescript",
"entrypoints": [
"tools/index.js"
],
"loads": [
"tools/index.js"
],
"env": {
"MESH_CONFLUENCE_TOKEN_FILE": "${dir:state}/token",
"MESH_CONFLUENCE_CONFIG_FILE": "${dir:state}/config.json"
}
} }
] ]
} }
+12 -9
View File
@@ -15,11 +15,11 @@
} }
}, },
"binds": { "binds": {
"route": "/var/lib/de-spiegel/route.json" "route": "${dir:state}/route.json"
}, },
"own-secrets": { "own-secrets": {
"smtp-user": "/var/lib/de-spiegel/smtp-user.secret", "smtp-user": "${dir:state}/smtp-user.secret",
"smtp-pass": "/var/lib/de-spiegel/smtp-pass.secret" "smtp-pass": "${dir:state}/smtp-pass.secret"
}, },
"listens": [ "listens": [
{ {
@@ -27,20 +27,20 @@
"port": 35621, "port": 35621,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
"why": "the de-spiegel site and its /contact endpoint over http; the public name de-spiegel.novox.be is a route grant, and route-proxy reaches it on this published port" "why": "the de-spiegel site and its /contact endpoint over http; its public name is a route grant, and route-proxy reaches it on this published port"
} }
], ],
"resources": [ "resources": [
{ {
"id": "state", "id": "state",
"type": "directory", "type": "directory",
"path": "/var/lib/de-spiegel", "mode": "0700",
"mode": "0700" "place": "."
}, },
{ {
"id": "server-env", "id": "server-env",
"type": "file", "type": "file",
"path": "/var/lib/de-spiegel/server.env", "path": "${dir:state}/server.env",
"mode": "0600", "mode": "0600",
"content": "SMTP_AUTH_USER=${secret:smtp-user}\nSMTP_AUTH_PASS=${secret:smtp-pass}\n" "content": "SMTP_AUTH_USER=${secret:smtp-user}\nSMTP_AUTH_PASS=${secret:smtp-pass}\n"
}, },
@@ -56,12 +56,15 @@
"image": "registry-api.novox.be/novox/de-spiegel@sha256:e144b72ce9c145870470d765343549f2c60211728cd118b9ff0e4029f36342ba", "image": "registry-api.novox.be/novox/de-spiegel@sha256:e144b72ce9c145870470d765343549f2c60211728cd118b9ff0e4029f36342ba",
"network": "de-spiegel", "network": "de-spiegel",
"env-file": [ "env-file": [
"/var/lib/de-spiegel/server.env" "${dir:state}/server.env"
], ],
"ports": [ "ports": [
"35621" "35621"
], ],
"secrets-in-environment": "the application's own code reads SMTP_AUTH_USER/PASS from the environment (de-spiegel server/index.js); converting is that repository's change" "secrets-in-environment": "the application's own code reads SMTP_AUTH_USER/PASS from the environment (de-spiegel server/index.js); converting is that repository's change",
"names-on-purpose": {
"registry-api.novox.be": "built outside the mesh, from the application's own repository, and pulled from the registry that built it; moves when that repository is a build source on the git seat (novox/hq ADR 0155, issue 122)"
}
} }
] ]
} }
+2 -1
View File
@@ -3,7 +3,8 @@
"version": "1", "version": "1",
"capabilities": [ "capabilities": [
"package-manager", "package-manager",
"service-manager" "service-manager",
"uplink-dhcpcd"
], ],
"claims": [ "claims": [
{ {
+5 -2
View File
@@ -9,7 +9,7 @@
], ],
"claims": [ "claims": [
{ {
"name": "the-artifact-store", "name": "mesh-artifact-store",
"scope": "mesh" "scope": "mesh"
} }
], ],
@@ -59,7 +59,10 @@
], ],
"volumes": [ "volumes": [
"/var/lib/mesh-registry:/var/lib/registry" "/var/lib/mesh-registry:/var/lib/registry"
] ],
"env": {
"REGISTRY_STORAGE_DELETE_ENABLED": "true"
}
} }
] ]
} }
File diff suppressed because one or more lines are too long
+166
View File
@@ -0,0 +1,166 @@
# docker
The container runtime as a module (novox/hq to-be 42 phase 1, item 8; research 027/01–02; ADR 0166,
ADR 0207). It claims the node seat `node-container-runtime`. That seat carries no verbs yet: its verbs,
and the host creating containers through its holder, wait on ADR 0166's acceptance. Until then the
tools below are the module's own.
## What it declares
| resource | what | the host's rule |
|---|---|---|
| `package` | `docker` | installed if absent; never uninstalled when the module goes |
| `buildx` | `docker-buildx` | the same. Only the build machine has it today; `docker build` needs it for BuildKit everywhere |
| `socket` | `docker.socket` running, enabled at boot | given back as found when the module goes (ADR 0118) |
| `prune-service`, `prune-timer` | `/etc/systemd/system/docker-prune.{service,timer}`, written whole | removed with the module |
| `prune` | `docker-prune.timer` running, enabled at boot; restarted when either file changes | stopped and disabled with the module (the mesh made the unit) |
The weekly prune takes **dangling images and build cache unused for a week, and nothing else**. It
takes no volume, no container and no image a container uses, so it never touches a container the mesh
holds. It runs at idle priority, at a random point in the hour after the weekly mark. A run missed
while the machine was off happens at the next boot.
**Capabilities:** `package-manager`, `service-manager`, `privileged`. It does not declare
`container-runtime`: under ADR 0165, which is still proposed, that word means a running daemon, and
the module that installs the daemon cannot require it.
## What it does not declare yet, and why
Three things this module should own are already declared by other modules on every machine. The
controller refuses two modules on one node that declare the same `path`, `unit`, `name` or `package`
(`checkResources`, mesh-controller `internal/catalogue/resolve.go`). Declaring any of them here would
make the module unassignable everywhere. The refusals were checked against the controller's own
check:
```
zsh and docker both declare the name "${machine:account}"
dnsmasq and docker both declare the path "/etc/docker/daemon.json"
dnsmasq and docker both declare the unit "docker.service"
```
### 1. `/etc/docker/daemon.json` and `docker.service` (issue 190)
Today the file has three writers. Each writes into it (`into: json`, ADR 0102) and reloads the
service:
- **`dnsmasq`** writes `dns` and `live-restore`, through `dnsmasq.runtime-dns` and `dnsmasq.runtime`.
- **The private network**, generated by the controller (`internal/overlay/generator.go`), writes
`insecure-registries`. The collision check does not see generated resources.
- **Nobody** writes log rotation. One machine has `log-driver` and `log-opts` by hand.
**The change proposed, in one merge:**
1. `dnsmasq` drops its `runtime-dns` and `runtime` resources.
2. `docker` adds the two resources below:
```json
{"id": "daemon", "type": "file", "path": "/etc/docker/daemon.json", "mode": "0644", "into": "json",
"content": "{\"dns\": [\"${machine:address}\"], \"live-restore\": true, \"log-driver\": \"json-file\", \"log-opts\": {\"max-size\": \"100m\", \"max-file\": \"5\"}}\n"},
{"id": "runtime", "type": "service", "unit": "docker.service", "state": "running", "boot": "enabled", "reload-on": ["daemon"]}
```
The service is **reloaded, never restarted**: a restart stops every container. The daemon reads
`live-restore` on a reload. It reads `dns`, `log-driver` and `log-opts` only at its next start, so
they apply then (to containers created afterwards, for the log keys). With `live-restore` on, that
start keeps every container running.
**Why one merge, and only after this module is on every machine:**
- In one apply, the host first gives back the resources that are no longer declared, then applies
the new ones (mesh-host `apply.go`).
- `dnsmasq` gives back `dns` and `live-restore` to what they held before it, and `docker` sets them
again in the same apply. The daemon is reloaded once, after both steps.
- A machine pushed the new `dnsmasq` *without* this module would keep its pre-mesh values for both
keys. On one machine that is `live-restore: false`, and the next daemon restart there would stop
every container.
**Later:** the controller hands the registry to this module as a value, and the overlay stops
generating its two resources (issue 190, steps 2 and 5). Until then the overlay keeps writing its one
key beside this module's. The host merges disjoint keys correctly; the mesh-host `into.go` record is
per resource.
### 2. The operator account's membership of the `docker` group
The right shape is the host's `user` shape. Its `groups` are additive: the host runs
`usermod --append` and never takes a group away.
```json
{"id": "group", "type": "user", "name": "${machine:account}", "groups": ["docker"]}
```
`zsh` already declares a `user` resource for the same account (its login shell). The controller
compares `name` across modules, so the two collide.
**The change proposed (mesh-controller, `checkResources`):** judge a `user` resource by the fields it
sets, not by its name:
- `shell` and `home` stay single-owner;
- `groups` may be declared by any number of modules, because the host only adds them.
Then this module declares the resource above, and no module has to carry another's group.
Today the operator account is in the group on every machine, by hand. Nothing is lost while it waits.
## The bootstrap's runtime
On the machine the mesh was first installed on, the foundation bundle declared `package docker`
(`container-runtime`) and `docker.service` running and enabled (`container-runtime-running`). ADR 0207
§5 exempts them.
- The host records them under their bare ids, with origin *carried*. A mesh declaration's orphan pass
never sees them (mesh-host `store.go`).
- So `docker.package` here is a **second record of the same package**. The apply says "already
installed", and neither record ever uninstalls it.
- This module does not declare `docker.service` today, so nothing overlaps there. The proposed step
1 would add a second record of that unit. Its found state is *running*, because genesis started
it, so undeclaring this module would leave the daemon running.
## Tools
The tools run as the operator account. If the daemon's socket refuses that account, a call is asked
again through `sudo -n` (a process keeps the groups it started with). Every call has a 20 s bound.
A failure is an error naming how it failed, never an empty answer.
**Every container on the machine is in scope.** A container the mesh holds carries the host's label
`mesh-host.id` (its value names the assignment), and every answer says `mesh_held`.
| tool | | what |
|---|---|---|
| `docker_list` | r | every container: image, state, health, restarts, ports, mounts, compose project, `mesh_held`; filter by owner, state or name |
| `docker_inspect` | r | one container whole, **environment values left out** (names kept) |
| `docker_logs` | r | the last lines of both streams, merged in order, with timestamps (default 200, at most 2000) |
| `docker_stats` | r | CPU, memory, I/O and process count per running container, heaviest first |
| `docker_start` / `docker_stop` / `docker_restart` | a | one container. On a mesh-held one, the answer says the host restores its declared state at its next apply |
| `docker_top` | r | the processes inside one container |
| `docker_images` | r | images, largest first, with the containers using each; `dangling`, `unused` or `used` |
| `docker_prune` | a | dangling images and build cache, and stopped containers the mesh does not hold if `containers` is true. **A dry run unless `dry_run` is false. Never a volume** |
| `docker_disk_usage` | r | `docker system df -v`: total, active and reclaimable per kind, with the largest of each |
| `docker_networks` | r | networks, subnets, and the containers on each |
| `docker_volumes` | r | volumes, who mounts each, whether the mesh holds one of them, anonymous or not, and sizes if asked |
| `docker_events` | r | the runtime's events over a window ending now (default 60 min, at most 24 h), without exec noise |
| `docker_daemon_config` | r | `daemon.json` as on disk, `docker info`'s essentials, and keys the daemon has not taken yet |
| `docker_unlabelled` | r | the containers the mesh does not hold: the cleanup list |
| `docker_problems` | r | unhealthy, restarting, dead, killed for memory, failed, or restarted five times or more |
| `docker_ports` | r | every published port, and the containers on the host's network |
## Tests
```
go test ./...
```
The tests run against a fake runner and cover:
- escalation through `sudo -n` on a refused socket, and never as root;
- each failure named by its cause;
- a name or id never read as an option;
- mesh-held marking;
- the environment left out of `inspect`;
- the restore note on a mesh-held act;
- prune being a dry run by default and never reaching a volume, a mesh container or `--volumes`;
- the log merge;
- size parsing;
- what the daemon has not yet taken;
- event filtering;
- volume ownership;
- that the tools served are exactly the manifest's `tools`.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,360 @@
package main
import (
"context"
"encoding/json"
"errors"
"io/fs"
"os"
"reflect"
"strings"
"testing"
"time"
)
type call struct {
name string
args []string
}
// fake answers each command by the first rule whose prefix matches "name arg arg…".
type fake struct {
rules []rule
calls []call
}
type rule struct {
prefix string
ran Ran
}
func (f *fake) on(prefix string, r Ran) *fake { f.rules = append(f.rules, rule{prefix, r}); return f }
func (f *fake) run(_ context.Context, name string, args ...string) Ran {
f.calls = append(f.calls, call{name, args})
line := strings.Join(append([]string{name}, args...), " ")
for _, r := range f.rules {
if strings.HasPrefix(line, r.prefix) {
return r.ran
}
}
return Ran{Status: 1, Stderr: "unexpected: " + line}
}
func (f *fake) ran(prefix string) bool {
for _, c := range f.calls {
if strings.HasPrefix(strings.Join(append([]string{c.name}, c.args...), " "), prefix) {
return true
}
}
return false
}
func client(f *fake, uid int) *Client {
return &Client{Run: f.run, UID: uid, ReadFile: func(string) ([]byte, error) { return nil, fs.ErrNotExist },
Now: func() time.Time { return time.Date(2026, 10, 4, 12, 0, 0, 0, time.UTC) }}
}
const held = `{"Id":"aaaaaaaaaaaaaaaa","Name":"/mesh-web","Created":"2026-10-01T00:00:00Z","Image":"sha256:img1",
"Config":{"Image":"web:1","Labels":{"mesh-host.id":"hello-web.server","mesh-host.spec":"x"},"Env":["PASSWORD=hunter2","PATH=/bin"]},
"State":{"Status":"running","Running":true,"StartedAt":"2026-10-01T00:00:01Z","FinishedAt":"0001-01-01T00:00:00Z","Health":{"Status":"healthy"}},
"HostConfig":{"RestartPolicy":{"Name":"unless-stopped"},"NetworkMode":"bridge"},
"NetworkSettings":{"Ports":{"80/tcp":[{"HostIp":"0.0.0.0","HostPort":"8080"}]}},
"Mounts":[{"Type":"volume","Name":"webdata","Destination":"/data","RW":true}]}`
const stray = `{"Id":"bbbbbbbbbbbbbbbb","Name":"/dev-db","Created":"2026-09-01T00:00:00Z","Image":"sha256:img2",
"Config":{"Image":"postgres:16","Labels":{"com.docker.compose.project":"dev","com.docker.compose.project.working_dir":"/home/op/dev"}},
"State":{"Status":"exited","ExitCode":1,"FinishedAt":"2026-09-02T00:00:00Z"},
"HostConfig":{"RestartPolicy":{"Name":"no"}},"NetworkSettings":{"Ports":{}},
"Mounts":[{"Type":"volume","Name":"dbdata","Destination":"/var/lib/postgresql/data","RW":true}]}`
func machine() *fake {
return (&fake{}).
on("docker ps --all --quiet --no-trunc", Ran{Stdout: "aaaaaaaaaaaaaaaa\nbbbbbbbbbbbbbbbb\n"}).
on("docker container inspect aaaaaaaaaaaaaaaa bbbbbbbbbbbbbbbb", Ran{Stdout: "[" + held + "," + stray + "]"}).
on("docker container inspect mesh-web", Ran{Stdout: "[" + held + "]"}).
on("docker container inspect dev-db", Ran{Stdout: "[" + stray + "]"})
}
func TestARefusedSocketIsAskedAgainThroughSudoWithoutAPromptUnlessThisIsRoot(t *testing.T) {
denied := Ran{Status: 1, Stderr: "permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock: Get ...: dial unix /var/run/docker.sock: connect: permission denied\n"}
f := (&fake{}).on("docker ", denied).on("sudo -n docker info", Ran{Stdout: "{}"})
if _, err := client(f, 1000).docker(context.Background(), "info", "--format", "{{json .}}"); err != nil {
t.Fatal(err)
}
if !f.ran("sudo -n docker info --format") {
t.Fatalf("not escalated: %+v", f.calls)
}
f = (&fake{}).on("docker ", denied)
if _, err := client(f, 0).docker(context.Background(), "info"); err == nil || f.ran("sudo") {
t.Fatalf("root escalated or answered: %v %+v", err, f.calls)
}
}
func TestFailuresAreNamedByHowTheyFailed(t *testing.T) {
denied := Ran{Status: 1, Stderr: "permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock\n"}
cases := map[string]*fake{
"may not escalate without a prompt": (&fake{}).on("docker ", denied).on("sudo ", Ran{Status: 1, Stderr: "sudo: a password is required\n"}),
"sudo is not installed": (&fake{}).on("docker ", denied).on("sudo ", Ran{Status: 127, Err: "ENOENT"}),
"docker is not installed": (&fake{}).on("docker ", Ran{Status: 127, Err: "ENOENT"}),
"daemon is not answering": (&fake{}).on("docker ", Ran{Status: 1, Stderr: "Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?\n"}),
"did not answer: no answer within": (&fake{}).on("docker ", Ran{Status: 124, Err: "no answer within 20 s"}),
"docker info failed (3): boom": (&fake{}).on("docker ", Ran{Status: 3, Stderr: "boom\n"}),
}
for want, f := range cases {
_, err := client(f, 1000).docker(context.Background(), "info")
if err == nil || !strings.Contains(err.Error(), want) {
t.Errorf("want %q, got %v", want, err)
}
}
}
func TestANameIsNeverAnOption(t *testing.T) {
for _, bad := range []string{"--help", "-v", "", "a b", "x;y"} {
if _, err := Ref(bad); err == nil {
t.Errorf("%q accepted", bad)
}
}
for _, good := range []string{"mesh-web", "aaaaaaaaaaaa", "registry.mesh.internal:5100/x@sha256:abc", "dev_db.1"} {
if _, err := Ref(good); err != nil {
t.Errorf("%q refused: %v", good, err)
}
}
f := machine()
for _, verb := range []string{"start", "stop", "restart"} {
if _, err := client(f, 1000).Act(context.Background(), verb, "--rm"); err == nil {
t.Errorf("%s took an option", verb)
}
}
if len(f.calls) != 0 {
t.Fatalf("docker was called: %+v", f.calls)
}
}
func TestEveryContainerIsListedAndTheMeshsAreMarked(t *testing.T) {
c := client(machine(), 1000)
all, err := c.Containers(context.Background(), "", "", "")
if err != nil || len(all) != 2 {
t.Fatalf("%v %+v", err, all)
}
web, db := all[1], all[0]
if !web.MeshHeld || web.HeldBy != "hello-web.server" || web.Module != "hello-web" || web.Health != "healthy" {
t.Errorf("held: %+v", web)
}
if !reflect.DeepEqual(web.Ports, []string{"0.0.0.0:8080->80/tcp"}) || web.Mounts[0].Name != "webdata" {
t.Errorf("ports/mounts: %+v", web)
}
if db.MeshHeld || db.Compose != "dev" || db.ComposeDir != "/home/op/dev" || db.FinishedAt == "" {
t.Errorf("stray: %+v", db)
}
mesh, _ := c.Containers(context.Background(), "mesh", "", "")
other, _ := c.Containers(context.Background(), "other", "", "")
if len(mesh) != 1 || mesh[0].Name != "mesh-web" || len(other) != 1 || other[0].Name != "dev-db" {
t.Errorf("held filter: %+v / %+v", mesh, other)
}
if _, err := c.Containers(context.Background(), "mine", "", ""); err == nil {
t.Error("an unknown held filter was accepted")
}
}
func TestNoContainersIsAnEmptyListAndAFailureIsAnError(t *testing.T) {
got, err := client((&fake{}).on("docker ps", Ran{}), 1000).Containers(context.Background(), "", "", "")
if err != nil || got == nil || len(got) != 0 {
t.Fatalf("%v %v", got, err)
}
if _, err := client((&fake{}).on("docker ps", Ran{Status: 1, Stderr: "Cannot connect to the Docker daemon\n"}), 1000).Containers(context.Background(), "", "", ""); err == nil {
t.Fatal("a daemon that does not answer read as no containers")
}
}
func TestInspectLeavesTheEnvironmentsValuesOut(t *testing.T) {
got, err := client(machine(), 1000).Inspect(context.Background(), "mesh-web")
if err != nil {
t.Fatal(err)
}
b, _ := json.Marshal(got)
if strings.Contains(string(b), "hunter2") || !strings.Contains(string(b), `"PASSWORD"`) || got["mesh_held"] != true {
t.Fatalf("%s", b)
}
}
func TestActingOnAMeshContainerSaysTheHostRestoresIt(t *testing.T) {
f := machine().on("docker stop", Ran{}).on("docker start", Ran{})
got, err := client(f, 1000).Act(context.Background(), "stop", "mesh-web")
if err != nil {
t.Fatal(err)
}
if !f.ran("docker stop --time 10 mesh-web") || got["mesh_held"] != true || !strings.Contains(got["note"].(string), "host restores") {
t.Fatalf("%v %+v", got, f.calls)
}
got, _ = client(f, 1000).Act(context.Background(), "start", "dev-db")
if _, noted := got["note"]; noted || got["mesh_held"] != false {
t.Fatalf("a stray was noted: %v", got)
}
}
func TestPruneIsADryRunByDefaultAndNeverTouchesAVolumeOrAMeshContainer(t *testing.T) {
f := machine().
on("docker image ls --no-trunc --filter dangling=true", Ran{Stdout: `{"ID":"sha256:dead","Size":"1.5GB"}` + "\n"}).
on("docker system df --format", Ran{Stdout: `{"Type":"Build Cache","TotalCount":"3","Size":"2GB","Reclaimable":"1GB"}` + "\n"}).
on("docker image prune", Ran{Stdout: "Deleted Images:\nx\n\nTotal reclaimed space: 1.5GB\n"}).
on("docker builder prune", Ran{Stdout: "Total:\t1GB\n"}).
on("docker container rm", Ran{})
c := client(f, 1000)
got, err := c.Prune(context.Background(), PruneAsk{Images: true, BuildCache: true, Containers: true, DryRun: true})
if err != nil {
t.Fatal(err)
}
if f.ran("docker image prune") || f.ran("docker builder prune") || f.ran("docker container rm") {
t.Fatalf("a dry run removed something: %+v", f.calls)
}
if got["images"].(map[string]any)["dangling"] != 1 || !reflect.DeepEqual(got["containers"].(map[string]any)["stopped_not_held"], []string{"dev-db"}) {
t.Fatalf("%v", got)
}
got, err = c.Prune(context.Background(), PruneAsk{Images: true, BuildCache: true, Containers: true, OlderThanH: 24})
if err != nil {
t.Fatal(err)
}
if !f.ran("docker image prune --force --filter until=24h") || !f.ran("docker builder prune --force --filter until=24h") || !f.ran("docker container rm dev-db") {
t.Fatalf("not pruned: %+v", f.calls)
}
for _, c := range f.calls {
line := strings.Join(c.args, " ")
if strings.Contains(line, "volume") || strings.Contains(line, "mesh-web") && c.args[0] != "container" || strings.Contains(line, "--volumes") || strings.Contains(line, "--all") && c.args[0] != "ps" {
t.Errorf("prune reached too far: %s", line)
}
}
if got["images"].(map[string]any)["reclaimed"] != "1.5GB" || got["build_cache"].(map[string]any)["reclaimed"] != "1GB" {
t.Errorf("reclaimed: %v", got)
}
}
func TestLogsMergeBothStreamsInOrderAndKeepTheTail(t *testing.T) {
f := (&fake{}).on("docker logs", Ran{Stdout: "2026-10-04T10:00:01Z out one\n2026-10-04T10:00:03Z out two\n", Stderr: "2026-10-04T10:00:02Z err one\n"})
got, err := client(f, 1000).Logs(context.Background(), "web", 2, "30m")
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(got["lines"], []string{"2026-10-04T10:00:02Z err one", "2026-10-04T10:00:03Z out two"}) {
t.Fatalf("%v", got["lines"])
}
if !f.ran("docker logs --timestamps --tail 2 --since 30m web") {
t.Fatalf("%+v", f.calls)
}
if _, err := client(f, 1000).Logs(context.Background(), "web", 2, "--follow"); err == nil {
t.Fatal("since took an option")
}
f = (&fake{}).on("docker logs", Ran{Status: 1, Stderr: "Error response from daemon: No such container: nope\n"})
if _, err := client(f, 1000).Logs(context.Background(), "nope", 2, ""); err == nil {
t.Fatal("a missing container read as no lines")
}
}
func TestSizesAreReadAsDockerPrintsThem(t *testing.T) {
for in, want := range map[string]int64{"0B": 0, "55.63GB": 55630000000, "33.2MiB": 34812723, "1.5kB": 1500, "12MB (34%)": 12000000, "N/A": -1} {
if got := Bytes(in); got != want {
t.Errorf("%s: %d, want %d", in, got, want)
}
}
}
func TestDaemonConfigSaysWhatTheDaemonHasNotTakenYet(t *testing.T) {
f := (&fake{}).on("docker info", Ran{Stdout: `{"ServerVersion":"29.8.2","LiveRestoreEnabled":false,"LoggingDriver":"json-file","RegistryConfig":{"IndexConfigs":{"docker.io":{"Secure":true},"registry.mesh.internal:5100":{"Secure":false}}}}`})
c := client(f, 1000)
c.ReadFile = func(string) ([]byte, error) {
return []byte(`{"live-restore": true, "dns": ["10.0.0.1"], "log-driver": "local"}`), nil
}
got, err := c.DaemonConfig(context.Background())
if err != nil {
t.Fatal(err)
}
pending := strings.Join(got["pending"].([]string), "\n")
if !strings.Contains(pending, "live-restore is true in the file and false") || !strings.Contains(pending, "log-driver is local") {
t.Errorf("pending: %s", pending)
}
if !reflect.DeepEqual(got["daemon"].(map[string]any)["InsecureRegistries"], []string{"registry.mesh.internal:5100"}) {
t.Errorf("registries: %v", got["daemon"])
}
if !reflect.DeepEqual(got["read_only_at_start"], []string{"dns", "log-driver"}) {
t.Errorf("start-only: %v", got["read_only_at_start"])
}
c.ReadFile = func(string) ([]byte, error) { return nil, os.ErrNotExist }
got, _ = c.DaemonConfig(context.Background())
if !strings.HasPrefix(got["file_state"].(string), "absent") {
t.Errorf("absent: %v", got["file_state"])
}
c.ReadFile = func(string) ([]byte, error) { return nil, errors.New("permission denied") }
got, _ = c.DaemonConfig(context.Background())
if !strings.HasPrefix(got["file_state"].(string), "unreadable") {
t.Errorf("unreadable: %v", got["file_state"])
}
}
func TestEventsAreABoundedWindowWithoutExecNoise(t *testing.T) {
out := `{"Type":"container","Action":"exec_start: pg_isready","Actor":{"ID":"aaaaaaaaaaaaaaaa","Attributes":{"name":"db"}},"timeNano":1}
{"Type":"container","Action":"die","Actor":{"ID":"aaaaaaaaaaaaaaaa","Attributes":{"name":"web","mesh-host.id":"hello-web.server","exitCode":"137"}},"timeNano":2}
`
f := (&fake{}).on("docker events", Ran{Stdout: out})
got, err := client(f, 1000).Events(context.Background(), 30, "container", 10, false)
if err != nil {
t.Fatal(err)
}
evs := got["events"].([]map[string]any)
if len(evs) != 1 || evs[0]["action"] != "die" || evs[0]["mesh_held"] != true || evs[0]["exit_code"] != "137" {
t.Fatalf("%v", evs)
}
if !f.ran("docker events --since 30m --until 0s --format {{json .}} --filter type=container") {
t.Fatalf("%+v", f.calls)
}
if _, err := client(f, 1000).Events(context.Background(), 30, "secret", 10, false); err == nil {
t.Fatal("an unknown type was accepted")
}
}
func TestVolumesSayWhoMountsThemAndWhetherTheMeshDoes(t *testing.T) {
f := machine().
on("docker volume ls --quiet", Ran{Stdout: "webdata\ndbdata\nloose\n"}).
on("docker volume inspect", Ran{Stdout: `[{"Name":"webdata","Driver":"local"},{"Name":"dbdata","Driver":"local"},{"Name":"loose","Driver":"local","Labels":{"com.docker.volume.anonymous":""}}]`})
got, err := client(f, 1000).Volumes(context.Background(), false, false)
if err != nil {
t.Fatal(err)
}
vols := got["volumes"].([]map[string]any)
if vols[0]["mesh_held"] != true || vols[1]["mesh_held"] != false || len(vols[2]["mounted_by"].([]map[string]any)) != 0 || vols[2]["anonymous"] != true {
t.Fatalf("%v", vols)
}
got, _ = client(f, 1000).Volumes(context.Background(), true, false)
if got["count"] != 1 {
t.Fatalf("unmounted: %v", got)
}
}
func TestImagesNameTheirUsers(t *testing.T) {
f := machine().on("docker image ls", Ran{Stdout: `{"ID":"sha256:img1","Repository":"web","Tag":"1","Size":"100MB"}
{"ID":"sha256:img3","Repository":"<none>","Tag":"<none>","Size":"2GB"}
`})
got, err := client(f, 1000).Images(context.Background(), "", "", 10)
if err != nil {
t.Fatal(err)
}
imgs := got["images"].([]Image)
if imgs[0].ID != "sha256:img3" || !imgs[0].Dangling || imgs[1].UsedBy[0] != "mesh-web" || !imgs[1].MeshUsed {
t.Fatalf("%+v", imgs)
}
got, _ = client(f, 1000).Images(context.Background(), "unused", "", 10)
if got["count"] != 1 {
t.Fatalf("unused: %v", got)
}
}
func TestProblemsNameWhyAndUnlabelledIsTheCleanupList(t *testing.T) {
c := client(machine(), 1000)
p, err := c.Problems(context.Background())
if err != nil || len(p) != 1 || p[0]["name"] != "dev-db" || p[0]["why"].([]string)[0] != "exited 1" {
t.Fatalf("%v %v", p, err)
}
u, err := c.Unlabelled(context.Background())
if err != nil || u["count"] != 1 {
t.Fatalf("%v %v", u, err)
}
}
+279
View File
@@ -0,0 +1,279 @@
// docker's Go tools bundle (novox/hq ADR 0188, ADR 0193): a process the node's tool runtime launches
// and speaks MCP over stdio to, through the Go SDK. It answers for every container on this machine —
// the mesh's and every other — and for the runtime's images, networks, volumes, events and
// configuration. It runs as the operator account (ADR 0175 §4); docker.go says how it reaches the
// daemon's socket. The host applies the module's resources; these tools answer about the runtime.
package main
import (
"context"
"fmt"
"math"
"os"
"strings"
stdio "git.novox.be/novox/mesh-sdk/go"
)
func main() {
// An empty name serves as the module the runtime names (MESH_SERVED_MODULE): docker.
if err := stdio.Serve("", tools(NewClient())); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
var containerArg = map[string]any{"type": "string", "description": "the container's name or id"}
func tools(c *Client) []stdio.Tool {
ctx := context.Background()
act := func(verb, description string) stdio.Tool {
return stdio.Tool{
Name: "docker_" + verb, Description: description,
Input: map[string]any{"container": containerArg},
Run: func(args map[string]any) (any, error) {
ref, err := text(args, "container")
if err != nil {
return nil, err
}
return c.Act(ctx, verb, ref)
},
}
}
return []stdio.Tool{
{
Name: "docker_list",
Description: "Every container on this machine — the mesh's and every other — with its image, state, health, restarts, " +
"published ports, mounts, compose project, and mesh_held/held_by (the assignment that holds it).",
Input: map[string]any{
"held": map[string]any{"type": "string", "enum": []string{"all", "mesh", "other"}, "description": "whose: all (default), the mesh's, or the others"},
"state": map[string]any{"type": "string", "description": "only containers in this state (running, exited, created, restarting, paused, dead)"},
"match": map[string]any{"type": "string", "description": "only containers whose name or image contains this"},
},
Run: func(args map[string]any) (any, error) {
list, err := c.Containers(ctx, optional(args, "held"), optional(args, "state"), optional(args, "match"))
if err != nil {
return nil, err
}
return map[string]any{"count": len(list), "containers": list}, nil
},
},
{
Name: "docker_inspect",
Description: "One container whole, as docker inspects it, with mesh_held; its environment's values are left out (names kept), because that is where a container's secrets are.",
Input: map[string]any{"container": containerArg},
Run: func(args map[string]any) (any, error) {
ref, err := text(args, "container")
if err != nil {
return nil, err
}
return c.Inspect(ctx, ref)
},
},
{
Name: "docker_logs",
Description: "The last lines one container wrote, both streams merged in order, each with its timestamp (default 200, at most 2000 lines; a line is cut at 4 KiB).",
Input: map[string]any{
"container": containerArg,
"lines": map[string]any{"type": "integer", "description": "how many lines from the end (default 200, at most 2000)"},
"since": map[string]any{"type": "string", "description": "only lines since then: a duration such as 30m or 2h, or a time"},
},
Run: func(args map[string]any) (any, error) {
ref, err := text(args, "container")
if err != nil {
return nil, err
}
n, err := bounded(args, "lines", 200, 2000)
if err != nil {
return nil, err
}
return c.Logs(ctx, ref, n, optional(args, "since"))
},
},
{
Name: "docker_stats",
Description: "What the running containers use now — CPU, memory, network and disk I/O, processes — the heaviest by memory first; or one container's.",
Input: map[string]any{"container": map[string]any{"type": "string", "description": "one container (optional)"}},
Run: func(args map[string]any) (any, error) {
stats, err := c.Stats(ctx, optional(args, "container"))
if err != nil {
return nil, err
}
return map[string]any{"count": len(stats), "containers": stats}, nil
},
},
act("start", "Start one container. A container the mesh holds is started too, and the answer says the host restores what its declaration says at its next apply."),
act("stop", "Stop one container (ten seconds, then killed). For a container the mesh holds, the answer says the host will start it again at its next apply if its declaration says running."),
act("restart", "Restart one container (ten seconds to stop, then killed); the answer says whether the mesh holds it."),
{
Name: "docker_top",
Description: "The processes running inside one container: pid, user, elapsed time, CPU, resident memory and command.",
Input: map[string]any{"container": containerArg},
Run: func(args map[string]any) (any, error) {
ref, err := text(args, "container")
if err != nil {
return nil, err
}
return c.Top(ctx, ref)
},
},
{
Name: "docker_images",
Description: "The images on this machine, the largest first, each with its size and the containers using it (and whether one of them is the mesh's). " +
"filter: all, dangling, unused or used.",
Input: map[string]any{
"filter": map[string]any{"type": "string", "enum": []string{"all", "dangling", "unused", "used"}, "description": "which images (default all)"},
"match": map[string]any{"type": "string", "description": "only images whose repository:tag contains this"},
"limit": map[string]any{"type": "integer", "description": "how many to show (default 100, at most 1000); count says how many matched"},
},
Run: func(args map[string]any) (any, error) {
n, err := bounded(args, "limit", 100, 1000)
if err != nil {
return nil, err
}
return c.Images(ctx, optional(args, "filter"), optional(args, "match"), n)
},
},
{
Name: "docker_prune",
Description: "Reclaim space: dangling images and unused build cache, and — only when containers is true — stopped containers the mesh does not hold. " +
"Never a volume, never a container the mesh holds, never an image a container uses. A dry run by default: it lists what would go; dry_run false removes it.",
Input: map[string]any{
"dry_run": map[string]any{"type": "boolean", "description": "list only (default true)"},
"images": map[string]any{"type": "boolean", "description": "dangling images (default true)"},
"build_cache": map[string]any{"type": "boolean", "description": "build cache nothing refers to (default true)"},
"containers": map[string]any{"type": "boolean", "description": "stopped containers the mesh does not hold (default false); what they mounted is kept"},
"older_than_hours": map[string]any{"type": "integer", "description": "only what is older than this many hours (default 0: any age)"},
},
Run: func(args map[string]any) (any, error) {
older := 0
if v, ok := args["older_than_hours"]; ok && v != nil && v != float64(0) {
n, err := bounded(args, "older_than_hours", 0, 24*365)
if err != nil {
return nil, err
}
older = n
}
return c.Prune(ctx, PruneAsk{
DryRun: flag(args, "dry_run", true), Images: flag(args, "images", true), BuildCache: flag(args, "build_cache", true),
Containers: flag(args, "containers", false), OlderThanH: older,
})
},
},
{
Name: "docker_disk_usage",
Description: "What the runtime takes on disk (docker system df -v): per kind — images, containers, volumes, build cache — the total, the active and the reclaimable, and the largest of each.",
Input: map[string]any{"top": map[string]any{"type": "integer", "description": "how many of the largest per kind (default 10, at most 100)"}},
Run: func(args map[string]any) (any, error) {
n, err := bounded(args, "top", 10, 100)
if err != nil {
return nil, err
}
return c.DiskUsage(ctx, n)
},
},
{
Name: "docker_networks",
Description: "Every network the runtime has: driver, scope, subnets and gateway, and the running containers on it with their addresses and whether the mesh holds them.",
Run: func(map[string]any) (any, error) { return c.Networks(ctx) },
},
{
Name: "docker_volumes",
Description: "Every volume with the containers mounting it, whether the mesh holds any of them, whether it is anonymous, its compose project, and — when sizes is true (slower) — its size.",
Input: map[string]any{
"unmounted": map[string]any{"type": "boolean", "description": "only volumes no container mounts (default false)"},
"sizes": map[string]any{"type": "boolean", "description": "measure each volume (default false: it walks every volume)"},
},
Run: func(args map[string]any) (any, error) {
return c.Volumes(ctx, flag(args, "unmounted", false), flag(args, "sizes", false))
},
},
{
Name: "docker_events",
Description: "What the runtime did in a window ending now (default the last 60 minutes, at most 24 hours): containers created, started, died, health changes, images pulled — with mesh_held. Exec events are left out unless asked.",
Input: map[string]any{
"minutes": map[string]any{"type": "integer", "description": "how far back (default 60, at most 1440)"},
"type": map[string]any{"type": "string", "description": "only one kind: container, image, network, volume, daemon, plugin or builder"},
"limit": map[string]any{"type": "integer", "description": "the latest this many (default 200, at most 2000)"},
"execs": map[string]any{"type": "boolean", "description": "include exec_* events (default false: health checks make many)"},
},
Run: func(args map[string]any) (any, error) {
minutes, err := bounded(args, "minutes", 60, 1440)
if err != nil {
return nil, err
}
limit, err := bounded(args, "limit", 200, 2000)
if err != nil {
return nil, err
}
return c.Events(ctx, minutes, optional(args, "type"), limit, flag(args, "execs", false))
},
},
{
Name: "docker_daemon_config",
Description: "The runtime's configuration: /etc/docker/daemon.json as it is on disk, the daemon's essentials as it runs now (docker info: version, storage and logging drivers, " +
"live restore, root directory, insecure registries, warnings), and where the two differ — keys a reload or only a restart would take.",
Run: func(map[string]any) (any, error) { return c.DaemonConfig(ctx) },
},
{
Name: "docker_unlabelled",
Description: "The containers the mesh does not hold — the cleanup list — each with its image, state, compose project and directory, ports and mounts.",
Run: func(map[string]any) (any, error) { return c.Unlabelled(ctx) },
},
{
Name: "docker_problems",
Description: "Every container that is not well: unhealthy, restarting, dead, killed for memory, exited with a failure, or restarted five times or more — with whether the mesh holds it.",
Run: func(map[string]any) (any, error) {
p, err := c.Problems(ctx)
if err != nil {
return nil, err
}
return map[string]any{"count": len(p), "containers": p}, nil
},
},
{
Name: "docker_ports",
Description: "Every port the containers publish on this machine (address:port -> container port), and the containers on the host's network, which publish whatever they listen on.",
Run: func(map[string]any) (any, error) {
p, err := c.Ports(ctx)
if err != nil {
return nil, err
}
return map[string]any{"count": len(p), "ports": p}, nil
},
},
}
}
func text(args map[string]any, key string) (string, error) {
s, _ := args[key].(string)
if s = strings.TrimSpace(s); s == "" {
return "", fmt.Errorf("%s is required", key)
}
return s, nil
}
func optional(args map[string]any, key string) string {
s, _ := args[key].(string)
return strings.TrimSpace(s)
}
func flag(args map[string]any, key string, def bool) bool {
if b, ok := args[key].(bool); ok {
return b
}
return def
}
// bounded is a whole number argument, defaulted when absent and held to a ceiling.
func bounded(args map[string]any, key string, def, most int) (int, error) {
v, ok := args[key]
if !ok || v == nil {
return def, nil
}
f, ok := v.(float64)
if !ok || f != math.Trunc(f) || f < 1 {
return 0, fmt.Errorf("%s must be a whole number of at least 1", key)
}
return int(math.Min(f, float64(most))), nil
}
+59
View File
@@ -0,0 +1,59 @@
package main
import (
"bytes"
"context"
"errors"
"fmt"
"os/exec"
"strings"
"time"
)
// Ran is what a command did: its output, its exit status, and why it never ran to an answer.
type Ran struct {
Stdout string
Stderr string
Status int
// Err is "ENOENT" when the program is not installed, or says it was ended for taking too long.
Err string
}
// Runner runs one command, so every tool can be tested without a daemon.
type Runner func(ctx context.Context, name string, args ...string) Ran
// CallTimeout is how long one docker command may take: below the runtime's thirty-second call
// limit, so a daemon that hangs is answered as such rather than as a call the runtime gave up on.
const CallTimeout = 20 * time.Second
// ExecRunner runs a command on this machine, bounded by CallTimeout.
func ExecRunner(ctx context.Context, name string, args ...string) Ran {
ctx, cancel := context.WithTimeout(ctx, CallTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, name, args...)
var out, errb bytes.Buffer
cmd.Stdout, cmd.Stderr = &out, &errb
err := cmd.Run()
r := Ran{Stdout: out.String(), Stderr: errb.String()}
var exitErr *exec.ExitError
switch {
case errors.Is(ctx.Err(), context.DeadlineExceeded):
r.Status, r.Err = 124, fmt.Sprintf("no answer within %d s", int(CallTimeout/time.Second))
case errors.Is(err, exec.ErrNotFound):
r.Status, r.Err = 127, "ENOENT"
case errors.As(err, &exitErr):
r.Status = exitErr.ExitCode()
case err != nil:
r.Status, r.Err = 1, err.Error()
}
return r
}
func firstLine(s string) string {
for _, l := range strings.Split(s, "\n") {
if l = strings.TrimSpace(l); l != "" {
return l
}
}
return ""
}
@@ -0,0 +1,60 @@
package main
import (
"encoding/json"
"os"
"reflect"
"sort"
"strings"
"testing"
)
func TestTheToolsServedAreTheToolsTheManifestNames(t *testing.T) {
raw, err := os.ReadFile("../../module.json")
if err != nil {
t.Fatal(err)
}
var m struct {
Tools []string `json:"tools"`
}
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
served := []string{}
for _, tool := range tools(client(&fake{}, 1000)) {
if !strings.HasPrefix(tool.Name, "docker_") || tool.Description == "" || tool.Run == nil {
t.Errorf("tool %q", tool.Name)
}
served = append(served, tool.Name)
}
sort.Strings(served)
listed := append([]string{}, m.Tools...)
sort.Strings(listed)
if !reflect.DeepEqual(served, listed) {
t.Fatalf("served %v, manifest %v", served, listed)
}
}
func TestNumbersAreDefaultedAndBounded(t *testing.T) {
if n, _ := bounded(map[string]any{}, "lines", 200, 2000); n != 200 {
t.Error(n)
}
if n, _ := bounded(map[string]any{"lines": float64(99999)}, "lines", 200, 2000); n != 2000 {
t.Error(n)
}
for _, bad := range []any{float64(0), float64(-1), float64(1.5), "10"} {
if _, err := bounded(map[string]any{"lines": bad}, "lines", 200, 2000); err == nil {
t.Errorf("%v accepted", bad)
}
}
}
func TestAStopFromTheToolNeedsAContainer(t *testing.T) {
for _, tool := range tools(client(&fake{}, 1000)) {
if tool.Name == "docker_stop" {
if _, err := tool.Run(map[string]any{}); err == nil {
t.Fatal("a stop without a container was accepted")
}
}
}
}
+5
View File
@@ -0,0 +1,5 @@
module docker
go 1.22
require git.novox.be/novox/mesh-sdk/go v0.1.6
+2
View File
@@ -0,0 +1,2 @@
git.novox.be/novox/mesh-sdk/go v0.1.6 h1:9qzdYONYbJdWcu6sxQcq9v1LI0JxcfkiKYkMUzJSkVQ=
git.novox.be/novox/mesh-sdk/go v0.1.6/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
+94
View File
@@ -0,0 +1,94 @@
{
"module": "docker",
"version": "1",
"capabilities": [
"package-manager",
"service-manager",
"privileged"
],
"claims": [
{
"name": "node-container-runtime",
"scope": "node"
}
],
"tools": [
"docker_list",
"docker_inspect",
"docker_logs",
"docker_stats",
"docker_start",
"docker_stop",
"docker_restart",
"docker_top",
"docker_images",
"docker_prune",
"docker_disk_usage",
"docker_networks",
"docker_volumes",
"docker_events",
"docker_daemon_config",
"docker_unlabelled",
"docker_problems",
"docker_ports"
],
"resources": [
{
"id": "package",
"type": "package",
"package": "docker"
},
{
"id": "buildx",
"type": "package",
"package": "docker-buildx"
},
{
"id": "socket",
"type": "service",
"unit": "docker.socket",
"state": "running",
"boot": "enabled"
},
{
"id": "prune-service",
"type": "file",
"path": "/etc/systemd/system/docker-prune.service",
"mode": "0644",
"content": "# Generated by the mesh. Do not edit — module docker writes this file and replaces it at every push.\n[Unit]\nDescription=Prune dangling images and unused build cache (the mesh's docker module)\n# Never volumes, never a container, never an image a container uses: dangling\n# images and build cache nothing refers to, unused for a week. What a person\n# prunes beyond that is docker_prune's, by hand.\nAfter=docker.service\nConditionPathExists=/run/docker.sock\n\n[Service]\nType=oneshot\nNice=19\nIOSchedulingClass=idle\nExecStart=/usr/bin/docker image prune --force --filter until=168h\nExecStart=/usr/bin/docker builder prune --force --filter until=168h\n"
},
{
"id": "prune-timer",
"type": "file",
"path": "/etc/systemd/system/docker-prune.timer",
"mode": "0644",
"content": "# Generated by the mesh. Do not edit — module docker writes this file and replaces it at every push.\n[Unit]\nDescription=Weekly prune of dangling images and unused build cache (the mesh's docker module)\n\n[Timer]\nOnCalendar=weekly\nRandomizedDelaySec=1h\nPersistent=true\n\n[Install]\nWantedBy=timers.target\n"
},
{
"id": "prune",
"type": "service",
"unit": "docker-prune.timer",
"state": "running",
"boot": "enabled",
"restart-on": [
"prune-service",
"prune-timer"
]
}
],
"build": {
"artifacts": [
{
"name": "tools",
"kind": "bundle",
"language": "go",
"system": "arch",
"from": "cmd/docker-tools",
"binary": "docker-tools",
"loads": [
"docker-tools"
]
}
]
}
}
+221 -36
View File
@@ -1,51 +1,236 @@
// fail2ban's own code, in the module (novox/hq ADR 0039). The jails and the daemon are declared // fail2ban's own code, in the module (novox/hq ADR 0039). The jails are composed by the mesh from
// resources — the mesh writes /etc/fail2ban/jail.d/* and keeps fail2ban.service running (see // the modules a machine runs (to-be 31) and written as declared resources; the daemon is kept
// module.json). This code exists only to read and steer the *live* state the daemon owns at // running by one. This code exists only to read and steer the *live* state the daemon owns: who is
// runtime: which IPs are banned right now, and the manual ban/unban an operator reaches for. That // banned now and until when, and the ban or release an operator asks for — the node-intrusion-
// state (the running bans, /var/lib/fail2ban's sqlite) is fail2ban's, not the mesh's — the mesh // prevention seat's four verbs (ADR 0179). The daemon's state is fail2ban's, not the mesh's: the
// reconciles the config, never the ban list. // mesh composes the jails and never writes the ban list.
//
// Spoken through fail2ban-client over the daemon's socket. Client and daemon come from the one
// package this module declares on the machine, and the socket is root's: root is the module's
// concern (ADR 0175 §4), and the runtime loading this bundle runs as the operator's account (to-be
// 38 WP4), so the client is run through sudo without a prompt where the account is not root.
import { execFile } from "node:child_process"; import { execFile } from "node:child_process";
import { accessSync, constants } from "node:fs";
import { isIP } from "node:net";
import { delimiter, join } from "node:path";
import { promisify } from "node:util"; import { promisify } from "node:util";
const run = promisify(execFile); const execFileP = promisify(execFile);
/** A command runner, so the verbs can be tested without a daemon. */
export type Runner = (cmd: string, args: string[]) => Promise<string>;
/** The command as it is run: as given when this process is root, else through sudo without a
* prompt. The daemon's socket answers only to root. */
export function escalated(cmd: string, args: string[], uid: number | undefined = process.getuid?.()): [string, string[]] {
if (uid === 0) return [cmd, args];
return ["sudo", ["-n", cmd, ...args]];
}
/** Whether a tool is on this machine: an executable of that name on the path, or where the
* system keeps its administration. */
export function installed(tool: string, path: string = process.env.PATH ?? ""): boolean {
const dirs = [...path.split(delimiter), "/usr/sbin", "/sbin", "/usr/bin"].filter((d) => d !== "");
return dirs.some((dir) => {
try {
accessSync(join(dir, tool), constants.X_OK);
return true;
} catch {
return false;
}
});
}
export const execRunner: Runner = async (cmd, args) => {
if (!installed(cmd)) throw new Error(`${cmd} is not installed on this machine`);
const [program, argv] = escalated(cmd, args);
try {
const { stdout } = await execFileP(program, argv, { maxBuffer: 16 * 1024 * 1024 });
return stdout;
} catch (err) {
const e = err as { code?: string | number; stderr?: string; stdout?: string; message?: string };
const said = `${e.stdout ?? ""}${e.stderr ?? ""}`.trim();
// What failed is named by how it failed: sudo missing is a spawn error, sudo refusing speaks
// on its own stderr line, and the rest is the client's own answer.
if (program === "sudo") {
if (e.code === "ENOENT") throw new Error(`${cmd} needs root, and sudo is not installed here for the runtime's account to escalate with`);
if (/^sudo:/m.test(said)) throw new Error(`${cmd} needs root and the runtime's account may not run it without a prompt: ${said}`);
}
if (/Failed to access socket path|Is fail2ban running|Permission denied to socket/i.test(said)) {
throw new Error("fail2ban is not running on this machine, or its socket does not answer the runtime's account");
}
// fail2ban-client's own last line is the one a person reads ("Sorry but the jail 'x' does not exist").
const lines = said.split("\n").map((l) => l.trim()).filter(Boolean);
throw new Error(lines.length ? lines[lines.length - 1] : (e.message ?? `${cmd} failed`));
}
};
/** One jail as the daemon reports it. */
export interface JailStatus {
jail: string;
/** What the jail is reading: files or journal matches, as fail2ban names them. */
watching: string[];
/** Addresses with failures counted against them right now, and all failures since the jail started. */
failing: { now: number; total: number };
/** Addresses held right now, and all bans since the jail started. */
banned: { now: number; total: number; addresses: string[] };
}
/** One ban as the daemon holds it. */
export interface Ban {
ip: string;
jail: string;
/** When the ban was placed, in the machine's local time as fail2ban prints it. */
since: string;
/** When the ban ends; "never" for a permanent ban. */
until: string;
}
export interface JailSettings {
jail: string;
bantime: string;
findtime: string;
maxretry: number;
ignoreip: string[];
actions: string[];
/** The log files the jail reads, when it reads files. */
logpath: string[];
/** The journal match the jail reads, when it reads the journal. */
journalmatch: string;
}
export class Fail2banClient { export class Fail2banClient {
static fromEnv(_env: NodeJS.ProcessEnv = process.env): Fail2banClient { private readonly run: Runner;
constructor(run: Runner = execRunner) {
this.run = run;
}
/** The daemon as this machine has it, through its own client. */
static onThisMachine(): Fail2banClient {
return new Fail2banClient(); return new Fail2banClient();
} }
/** Overview of every jail, or the detailed status of one — currently-banned IPs and totals. */ private client(...args: string[]): Promise<string> {
async status(jail?: string): Promise<string> { return this.run("fail2ban-client", args);
}
/** The jails the daemon runs, by name. */
async jails(): Promise<string[]> {
const out = await this.client("status");
const m = out.match(/Jail list:\s*(.*)/);
if (!m) return [];
return m[1].split(",").map((j) => j.trim()).filter(Boolean);
}
/** Every jail with what it watches and holds, or one jail's detail. */
async status(jail?: string): Promise<{ jails: JailStatus[] }> {
const names = jail ? [jail] : await this.jails();
const jails: JailStatus[] = [];
for (const name of names) {
jails.push(parseJailStatus(name, await this.client("status", name)));
}
return { jails };
}
/** Every address banned now, with the jail holding it and when the ban ends. */
async banned(jail?: string): Promise<{ banned: Ban[] }> {
const names = jail ? [jail] : await this.jails();
const banned: Ban[] = [];
for (const name of names) {
banned.push(...parseBans(name, await this.client("get", name, "banip", "--with-time")));
}
banned.sort((a, b) => a.until.localeCompare(b.until) || a.ip.localeCompare(b.ip));
return { banned };
}
/** Ban one address in one jail now. The daemon's own answer is how many addresses it added. */
async ban(ip: string, jail: string): Promise<{ banned: Ban | null; added: number }> {
address(ip);
name(jail);
const out = await this.client("set", jail, "banip", ip);
const added = Number.parseInt(out.trim(), 10) || 0;
const held = (await this.banned(jail)).banned.find((b) => b.ip === ip) ?? null;
return { banned: held, added };
}
/** Let one address go, from one jail or from every jail. The daemon's answer is how many it released. */
async unban(ip: string, jail?: string): Promise<{ released: number; ip: string; jail: string | "every jail" }> {
address(ip);
let out: string;
if (jail) { if (jail) {
const { stdout } = await run("sudo", ["fail2ban-client", "status", jail]); name(jail);
return stdout; out = await this.client("set", jail, "unbanip", ip);
} else {
out = await this.client("unban", ip);
} }
const { stdout: overview } = await run("sudo", ["fail2ban-client", "status"]); return { released: Number.parseInt(out.trim(), 10) || 0, ip, jail: jail ?? "every jail" };
const match = overview.match(/Jail list:\s*(.+)/);
if (!match) return overview;
const jails = match[1].split(",").map((j) => j.trim()).filter(Boolean);
const parts: string[] = [overview.trimEnd(), ""];
for (const j of jails) {
const { stdout } = await run("sudo", ["fail2ban-client", "status", j]);
parts.push(`=== ${j} ===`, stdout.trimEnd(), "");
}
return parts.join("\n");
} }
/** Manually ban an IP in a jail. Mutates live state, not a mesh-managed file. */ /** One jail's effective settings — the module's own tool, beside the seat's verbs. */
async ban(jail: string, ip: string): Promise<string> { async settings(jail: string): Promise<JailSettings> {
const { stdout } = await run("sudo", ["fail2ban-client", "set", jail, "banip", ip]); name(jail);
return stdout; const get = (key: string) => this.client("get", jail, key);
} const [bantime, findtime, maxretry, ignoreip, actions, logpath, journalmatch] = await Promise.all([
get("bantime"), get("findtime"), get("maxretry"), get("ignoreip"), get("actions"), get("logpath"),
/** Unban an IP from one jail, or from every jail when no jail is given. */ get("journalmatch"),
async unban(ip: string, jail?: string): Promise<string> { ]);
const args = jail return {
? ["fail2ban-client", "set", jail, "unbanip", ip] jail,
: ["fail2ban-client", "unban", ip]; bantime: bantime.trim(),
const { stdout } = await run("sudo", args); findtime: findtime.trim(),
return stdout; maxretry: Number.parseInt(maxretry.trim(), 10),
ignoreip: listed(ignoreip),
actions: actions.split("\n").slice(1).map((l) => l.trim()).filter(Boolean),
logpath: /No file is currently monitored/.test(logpath) ? [] : listed(logpath),
journalmatch: journalmatch.split("\n").slice(1).map((l) => l.trim()).filter(Boolean).join(" "),
};
} }
} }
/** fail2ban's tree listings: lines like "|- 127.0.0.0/8" and "`- ::1", after a heading. */
function listed(out: string): string[] {
return out
.split("\n")
.map((l) => l.replace(/^[\s|`-]+/, "").trim())
.filter((l, i) => i > 0 && l.length > 0);
}
export function parseJailStatus(jail: string, out: string): JailStatus {
const field = (label: string) => {
const m = out.match(new RegExp(label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + ":\\t?\\s*(.*)"));
return m ? m[1].trim() : "";
};
const num = (label: string) => Number.parseInt(field(label), 10) || 0;
const watching = [field("File list"), field("Journal matches")].filter(Boolean);
return {
jail,
watching,
failing: { now: num("Currently failed"), total: num("Total failed") },
banned: {
now: num("Currently banned"),
total: num("Total banned"),
addresses: field("Banned IP list").split(/\s+/).filter(Boolean),
},
};
}
/** `get <jail> banip --with-time` prints one ban per line: "IP \tsince + seconds = until". */
export function parseBans(jail: string, out: string): Ban[] {
const bans: Ban[] = [];
for (const line of out.split("\n")) {
const m = line.match(/^(\S+)\s+(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) \+ (-?\d+) = (\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}|\S+)/);
if (!m) continue;
bans.push({ ip: m[1], jail, since: m[2], until: Number(m[3]) < 0 ? "never" : m[4] });
}
return bans;
}
function address(ip: string): void {
if (!isIP(ip)) throw new Error(`${JSON.stringify(ip)} is not an address`);
}
function name(jail: string): void {
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(jail)) throw new Error(`${JSON.stringify(jail)} is not a jail's name`);
}
+44 -6
View File
@@ -7,9 +7,22 @@
"claims": [ "claims": [
{ {
"name": "node-intrusion-prevention", "name": "node-intrusion-prevention",
"scope": "node" "scope": "node",
"serves": [
"status",
"banned",
"ban",
"unban"
]
} }
], ],
"tools": [
"fail2ban_settings"
],
"jailing": {
"into": "/etc/fail2ban/jail.d/mesh.conf",
"filter-into": "/etc/fail2ban/filter.d"
},
"resources": [ "resources": [
{ {
"id": "package", "id": "package",
@@ -28,19 +41,31 @@
"path": "/etc/fail2ban/action.d", "path": "/etc/fail2ban/action.d",
"mode": "0755" "mode": "0755"
}, },
{
"id": "filter-d",
"type": "directory",
"path": "/etc/fail2ban/filter.d",
"mode": "0755"
},
{
"id": "run-dir",
"type": "directory",
"path": "/var/run/fail2ban",
"mode": "0755"
},
{ {
"id": "jail-local", "id": "jail-local",
"type": "file", "type": "file",
"path": "/etc/fail2ban/jail.local", "path": "/etc/fail2ban/jail.local",
"mode": "0644", "mode": "0644",
"content": "[INCLUDES]\n\nbefore = paths-arch.conf\n\n[DEFAULT]\n\n# Never act on the machine itself or on a tunnel peer: the mesh's private range is\n# ${machine:mesh-range}, named here rather than written as a value the module cannot\n# know (novox/hq ADR 0112). Without this, fail2ban could ban the mesh's own nodes.\nignoreip = 127.0.0.1/8 ::1 ${machine:mesh-range}\n\nbantime = 10m\nfindtime = 10m\nmaxretry = 5\n\n# Ban through iptables, not through a firewall front-end the machine may not have. ufw is\n# installed on two of this mesh's machines and absent on the other two, and fail2ban finds out\n# only at ban time: the service reports healthy, the jail counts the attempt, the ban command\n# exits 127, and nothing is blocked. Proven on 2026-09-28 -- 'ufw: command not found' on a\n# machine the mesh reported as protected.\n#\n# The action below is this module's own, already used by the recidive jail on every machine\n# here, and it bans in DOCKER-USER as well as INPUT, so a container's published port is\n# covered too.\nbanaction = iptables-allports-dualchain\nbanaction_allports = iptables-allports-dualchain\n\n[sshd]\nenabled = true\nport = ssh\nlogpath = %(sshd_log)s\nbackend = %(sshd_backend)s\n" "content": "[INCLUDES]\n\nbefore = paths-arch.conf\n\n[DEFAULT]\n\n# Never act on the machine itself or on a tunnel peer: the mesh's private range is\n# ${machine:mesh-range}, named here rather than written as a value the module cannot\n# know (novox/hq ADR 0112). Without this, fail2ban could ban the mesh's own nodes.\n# **A ban list never holds a neighbour.** The mesh's own range is named rather than written\n# (novox/hq ADR 0112), and every private range beside it: a source on one is somebody's own\n# network, not the internet. On a machine behind a router that reflects local traffic, every\n# client in the house arrives as the gateway's address — so one mistyped local request banned\n# 192.168.1.1 on the home server and would have cut the whole house off from it (ADR 0186).\nignoreip = 127.0.0.1/8 ::1 ${machine:mesh-range} 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 169.254.0.0/16 fc00::/7 fe80::/10\n\n# Three failures in a day ban for a day (novox/hq ADR 0179). The attackers this mesh sees pace\n# themselves at one try every ten minutes, under any ten-minute window; a day's window counts\n# them, and a day's ban costs a person who mistyped three times once, from one address, while\n# the mesh's own range is never banned at all.\nbantime = 1d\nfindtime = 1d\nmaxretry = 3\n\n# Ban through iptables, not through a firewall front-end the machine may not have. ufw is\n# installed on two of this mesh's machines and absent on the other two, and fail2ban finds out\n# only at ban time: the service reports healthy, the jail counts the attempt, the ban command\n# exits 127, and nothing is blocked. Proven on 2026-09-28 -- 'ufw: command not found' on a\n# machine the mesh reported as protected.\n#\n# The action below is this module's own, already used by the recidive jail on every machine\n# here, and it bans in DOCKER-USER as well as INPUT, so a container's published port is\n# covered too.\nbanaction = iptables-allports-dualchain\nbanaction_allports = iptables-allports-dualchain\n\n[sshd]\nenabled = true\nport = ssh\nlogpath = %(sshd_log)s\nbackend = %(sshd_backend)s\n"
}, },
{ {
"id": "jail-sshd", "id": "jail-sshd",
"type": "file", "type": "file",
"path": "/etc/fail2ban/jail.d/sshd.conf", "path": "/etc/fail2ban/jail.d/sshd.conf",
"mode": "0644", "mode": "0644",
"content": "[sshd]\nenabled = true\nport = ssh\nlogpath = %(sshd_log)s\nbackend = %(sshd_backend)s\nmaxretry = 5\n" "content": "[sshd]\nenabled = true\nport = ssh\nlogpath = %(sshd_log)s\nbackend = %(sshd_backend)s\nmaxretry = 3\nfindtime = 1d\nbantime = 1d\n"
}, },
{ {
"id": "log", "id": "log",
@@ -55,7 +80,7 @@
"type": "file", "type": "file",
"path": "/etc/fail2ban/jail.d/recidive.conf", "path": "/etc/fail2ban/jail.d/recidive.conf",
"mode": "0644", "mode": "0644",
"content": "[recidive]\nenabled = true\nlogpath = /var/log/fail2ban.log\n# Ban in both INPUT (host services like SSH) and DOCKER-USER (container services)\nbanaction = iptables-allports-dualchain\nbantime = 1w\nfindtime = 1d\n" "content": "[recidive]\nenabled = true\nlogpath = /var/log/fail2ban.log\n# Ban in both INPUT (host services like SSH) and DOCKER-USER (container services)\nbanaction = iptables-allports-dualchain\n# Banned twice in two weeks, by any jail, is banned for four (novox/hq ADR 0179).\nbantime = 4w\nfindtime = 2w\nmaxretry = 2\n"
}, },
{ {
"id": "action-dualchain", "id": "action-dualchain",
@@ -81,8 +106,21 @@
"jail-local", "jail-local",
"jail-sshd", "jail-sshd",
"jail-recidive", "jail-recidive",
"action-dualchain" "action-dualchain",
"composed-jails"
] ]
} }
] ],
"build": {
"artifacts": [
{
"name": "tools",
"kind": "bundle",
"language": "typescript",
"entrypoints": [
"tools/index.js"
]
}
]
}
} }
+6 -2
View File
@@ -1,14 +1,18 @@
{ {
"name": "@novox/module-fail2ban", "name": "@novox/module-fail2ban",
"version": "0.1.0", "version": "0.1.0",
"description": "fail2ban — intrusion prevention: the mesh declares the jails and keeps the daemon running; its ban/unban/status tools live here.", "description": "fail2ban \u2014 intrusion prevention: the mesh composes the jails and keeps the daemon running; this module holds the node-intrusion-prevention seat and serves its verbs status, banned, ban and unban (novox/hq to-be 31, ADR 0179).",
"type": "module", "type": "module",
"private": true, "private": true,
"dependencies": { "dependencies": {
"@novox/mesh-sdk": "^0.1.0" "@novox/mesh-sdk": "^0.1.1"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
"typescript": "^5.6.0" "typescript": "^5.6.0"
},
"scripts": {
"build": "tsc client.ts tools/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --rootDir . --outDir dist",
"test": "node --test --experimental-strip-types 'test/*.test.ts'"
} }
} }
+114
View File
@@ -0,0 +1,114 @@
// The intrusion prevention's verbs over a fake daemon, with the shapes fail2ban-client 1.1.0 printed
// on the control node on 2026-10-02 (novox/hq ADR 0179).
import { test } from "node:test";
import assert from "node:assert/strict";
import { Fail2banClient, escalated, installed, parseBans, parseJailStatus, type Runner } from "../client.ts";
const STATUS = "Status\n|- Number of jail:\t2\n`- Jail list:\trecidive, sshd\n";
const RECIDIVE =
"Status for the jail: recidive\n|- Filter\n| |- Currently failed:\t36\n| |- Total failed:\t149\n" +
"| `- File list:\t/var/log/fail2ban.log\n`- Actions\n |- Currently banned:\t9\n |- Total banned:\t13\n" +
" `- Banned IP list:\t195.178.110.30 45.148.10.240 92.118.39.71\n";
const SSHD =
"Status for the jail: sshd\n|- Filter\n| |- Currently failed:\t5\n| |- Total failed:\t11776\n" +
"| `- Journal matches:\t_SYSTEMD_UNIT=sshd.service + _COMM=sshd\n`- Actions\n |- Currently banned:\t0\n" +
" |- Total banned:\t150\n `- Banned IP list:\t\n";
const WITH_TIME =
"195.178.110.30 \t2026-09-26 23:18:47 + 604800 = 2026-10-03 23:18:47\n" +
"92.118.39.71 \t2026-09-28 10:33:49 + 604800 = 2026-10-05 10:33:49\n";
function fake(answers: Record<string, string>, calls: string[][] = []): Runner {
return async (cmd, args) => {
calls.push([cmd, ...args]);
const key = args.join(" ");
if (key in answers) return answers[key];
throw new Error(`unexpected ${cmd} ${key}`);
};
}
test("a jail's status is read into numbers, what it watches and who it holds", () => {
const s = parseJailStatus("recidive", RECIDIVE);
assert.deepEqual(s, {
jail: "recidive",
watching: ["/var/log/fail2ban.log"],
failing: { now: 36, total: 149 },
banned: { now: 9, total: 13, addresses: ["195.178.110.30", "45.148.10.240", "92.118.39.71"] },
});
const j = parseJailStatus("sshd", SSHD);
assert.deepEqual(j.watching, ["_SYSTEMD_UNIT=sshd.service + _COMM=sshd"]);
assert.deepEqual(j.banned, { now: 0, total: 150, addresses: [] });
});
test("status covers every jail the daemon lists, or the one named", async () => {
const calls: string[][] = [];
const f = new Fail2banClient(fake({ status: STATUS, "status recidive": RECIDIVE, "status sshd": SSHD }, calls));
const all = await f.status();
assert.deepEqual(all.jails.map((j) => j.jail), ["recidive", "sshd"]);
const one = await f.status("sshd");
assert.equal(one.jails.length, 1);
assert.deepEqual(calls[calls.length - 1], ["fail2ban-client", "status", "sshd"]);
});
test("bans are read with when they were placed and when they end, a permanent one as never", () => {
const bans = parseBans("recidive", WITH_TIME + "203.0.113.9 \t2026-10-01 00:00:00 + -1 = never\n");
assert.equal(bans.length, 3);
assert.deepEqual(bans[0], { ip: "195.178.110.30", jail: "recidive", since: "2026-09-26 23:18:47", until: "2026-10-03 23:18:47" });
assert.equal(bans[2].until, "never");
assert.deepEqual(parseBans("sshd", "\n"), []);
});
test("banned gathers every jail's bans, soonest to end first", async () => {
const f = new Fail2banClient(fake({
status: STATUS,
"get recidive banip --with-time": WITH_TIME,
"get sshd banip --with-time": "198.51.100.7 \t2026-10-02 15:06:58 + 600 = 2026-10-02 15:16:58\n",
}));
const { banned } = await f.banned();
assert.deepEqual(banned.map((b) => `${b.ip}@${b.jail}`), ["198.51.100.7@sshd", "195.178.110.30@recidive", "92.118.39.71@recidive"]);
});
test("ban asks the daemon by jail and answers with the ban as held; a non-address is refused before anything runs", async () => {
const calls: string[][] = [];
const f = new Fail2banClient(fake({
"set recidive banip 198.51.100.7": "1\n",
"get recidive banip --with-time": WITH_TIME + "198.51.100.7 \t2026-10-02 17:00:00 + 604800 = 2026-10-09 17:00:00\n",
}, calls));
const r = await f.ban("198.51.100.7", "recidive");
assert.equal(r.added, 1);
assert.equal(r.banned?.until, "2026-10-09 17:00:00");
assert.deepEqual(calls[0], ["fail2ban-client", "set", "recidive", "banip", "198.51.100.7"]);
await assert.rejects(() => f.ban("not-an-ip", "recidive"), /is not an address/);
await assert.rejects(() => f.ban("198.51.100.7", "a jail; rm"), /is not a jail's name/);
assert.equal(calls.length, 2);
});
test("unban releases from one jail or from every jail", async () => {
const calls: string[][] = [];
const f = new Fail2banClient(fake({ "set sshd unbanip 198.51.100.7": "1\n", "unban 198.51.100.7": "2\n" }, calls));
assert.deepEqual(await f.unban("198.51.100.7", "sshd"), { released: 1, ip: "198.51.100.7", jail: "sshd" });
assert.deepEqual(await f.unban("198.51.100.7"), { released: 2, ip: "198.51.100.7", jail: "every jail" });
assert.deepEqual(calls[1], ["fail2ban-client", "unban", "198.51.100.7"]);
});
test("a jail's settings are read from the daemon's listings", async () => {
const f = new Fail2banClient(fake({
"get sshd bantime": "86400\n", "get sshd findtime": "86400\n", "get sshd maxretry": "3\n",
"get sshd ignoreip": "These IP addresses/networks are ignored:\n|- 127.0.0.0/8\n|- 10.10.0.0/24\n`- ::1\n",
"get sshd actions": "The jail sshd has the following actions:\niptables-allports-dualchain\n",
"get sshd logpath": "No file is currently monitored\n",
"get sshd journalmatch": "Current match filter:\n_SYSTEMD_UNIT=sshd.service + _COMM=sshd\n",
}));
assert.deepEqual(await f.settings("sshd"), {
jail: "sshd", bantime: "86400", findtime: "86400", maxretry: 3,
ignoreip: ["127.0.0.0/8", "10.10.0.0/24", "::1"], actions: ["iptables-allports-dualchain"],
logpath: [], journalmatch: "_SYSTEMD_UNIT=sshd.service + _COMM=sshd",
});
});
test("the client runs as given by root and through sudo without a prompt by anyone else", () => {
assert.deepEqual(escalated("fail2ban-client", ["status"], 0), ["fail2ban-client", ["status"]]);
assert.deepEqual(escalated("fail2ban-client", ["set", "sshd", "banip", "198.51.100.7"], 1000),
["sudo", ["-n", "fail2ban-client", "set", "sshd", "banip", "198.51.100.7"]]);
assert.equal(installed("sh"), true);
assert.equal(installed("no-such-client-of-the-mesh"), false);
});
+45 -38
View File
@@ -1,55 +1,62 @@
// fail2ban's tools — reading and steering the live ban state. The jails themselves are declared // The intrusion prevention's tools: the node-intrusion-prevention seat's four verbs — who is banned,
// resources (module.json); these three touch what the running daemon holds: what is banned now, // the jails' state, ban one, let one go — and the module's own reading of a jail's settings
// and the manual ban/unban an operator reaches for. The daemon's state is fail2ban's own, so this // (novox/hq to-be 31, ADR 0179). The jails themselves are composed by the mesh from the modules a
// is the only way to see or change it — the mesh reconciles the config, not the bans. // machine runs and written as declared resources; these touch only what the running daemon holds.
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
import { Fail2banClient } from "../client.js"; import { Fail2banClient } from "../client.js";
export function getFail2banTools(fail2ban: Fail2banClient): ToolDefinition[] { export function getSeatVerbs(fail2ban: Fail2banClient): ToolDefinition[] {
return [ return [
{ {
name: "fail2ban_status", name: "status",
description: description:
"fail2ban status on this node — the jails and their live bans. Omit `jail` for every jail, or name one for its detail.", "Every jail on this machine with what it watches, how many addresses it is counting failures against and holding now, and the totals since it started; one jail's detail when named.",
input: { input: { jail: { type: "string", description: "one jail (optional)" } },
type: "object", run: async (args) => fail2ban.status(args.jail ? String(args.jail) : undefined),
properties: {
jail: {
type: "string",
description: "A specific jail (e.g. sshd, recidive); omit for the overview of all jails.",
},
},
},
run: async (args) => ({ status: await fail2ban.status(args.jail as string | undefined) }),
}, },
{ {
name: "fail2ban_ban", name: "banned",
description: "Manually ban an IP address in a jail — a live change to the running daemon, not a mesh-managed file.", description: "Every address banned on this machine right now, with the jail that holds it, when it was banned and when the ban ends.",
input: { input: { jail: { type: "string", description: "one jail (optional)" } },
type: "object", run: async (args) => fail2ban.banned(args.jail ? String(args.jail) : undefined),
properties: {
jail: { type: "string", description: "Jail name (e.g. sshd, recidive)." },
ip: { type: "string", description: "IP address to ban." },
},
required: ["jail", "ip"],
},
run: async (args) => ({ result: await fail2ban.ban(args.jail as string, args.ip as string) }),
}, },
{ {
name: "fail2ban_unban", name: "ban",
description: "Unban an IP address from one jail, or from every jail when `jail` is omitted.", description:
"Ban one address in one jail now, for the jail's ban time — an operator's act on the live ban list, which the mesh never writes itself.",
input: { input: {
type: "object", ip: { type: "string", description: "the address" },
properties: { jail: { type: "string", description: "the jail to hold it (recidive for the long ban)" },
ip: { type: "string", description: "IP address to unban." },
jail: { type: "string", description: "A specific jail; omit to unban from all jails." },
},
required: ["ip"],
}, },
run: async (args) => ({ result: await fail2ban.unban(args.ip as string, args.jail as string | undefined) }), run: async (args) => fail2ban.ban(String(args.ip ?? ""), String(args.jail ?? "")),
},
{
name: "unban",
description: "Let one address go, from one jail or from every jail when none is named.",
input: {
ip: { type: "string", description: "the address" },
jail: { type: "string", description: "one jail (optional)" },
},
run: async (args) => fail2ban.unban(String(args.ip ?? ""), args.jail ? String(args.jail) : undefined),
}, },
]; ];
} }
registerModuleTools("fail2ban", () => getFail2banTools(Fail2banClient.fromEnv())); export function getFail2banTools(fail2ban: Fail2banClient): ToolDefinition[] {
return [
{
name: "fail2ban_settings",
description:
"One jail's effective settings on this machine: ban time, window, tries, the addresses it never bans, its actions and what it reads.",
input: { jail: { type: "string", description: "the jail" } },
run: async (args) => fail2ban.settings(String(args.jail ?? "")),
},
];
}
const fail2ban = Fail2banClient.onThisMachine();
// The seat's verbs under the seat's name: the runtime serves them on the seat's subjects where this
// module holds it (ADR 0159, 0160). The module's own under its own.
registerModuleTools("node-intrusion-prevention", () => getSeatVerbs(fail2ban));
registerModuleTools("fail2ban", () => getFail2banTools(fail2ban));
-37
View File
@@ -1,37 +0,0 @@
# gitea's runtime: the tool runtime, carrying this module's compiled provisioner, tools and event
# consumer.
#
# **Built from this module's own directory and nothing else.** The sdk is in the base image, so
# nothing is copied out of a neighbouring checkout — which is what lets the mesh build this from a
# repository and a path (novox/hq ADR 0069) rather than only on a workstation that happens to have
# the siblings.
#
# Two bases, named rather than pinned: the image this is COMPILED in, and the image it RUNS in.
# They are different images on purpose — the first carries a compiler and the second must not, or
# every running container would carry one it never invokes. The mesh answers both with the copies it
# holds, because a fingerprint written here would name one particular copy and no other mesh has it
# (novox/hq issue 044). Declared in module.json's `build.on`; deliberately no defaults, so a build
# nobody told stops here and says which module to build first.
ARG BUILD_BASE
ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build
# Compiled under /app/modules so `@novox/mesh-sdk` resolves upward into the base's own
# node_modules — the module is compiled against exactly the sdk it will run against.
WORKDIR /app/modules/gitea
COPY . .
# The compiler is invoked by its real path rather than through node_modules/.bin, whose entries are
# symlinks to a launcher that requires its library relatively — resolved away when the base image
# was assembled.
RUN node /app/node_modules/typescript/bin/tsc client.ts token.ts index.ts provisioner/index.ts tools/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE}
# **No apt packages.** gitea's provisioner talks to the forge over HTTP (the gitea REST API), not
# through a CLI the way postgres drives psql — so the runtime base holds everything this needs.
COPY --from=build /app/modules/gitea/dist /app/modules/gitea/dist
# What a tool host should load from this module: its event consumer and its tools, which are
# separate entrypoints because they are loaded by different things. The provisioner is the third,
# and is not listed here — the declaration names it in the container's `args`, because it is what
# this module's own container runs. One image, because they are one module and share a client.
ENV MESH_TOOL_MODULES=/app/modules/gitea/dist/index.js,/app/modules/gitea/dist/tools/index.js,/app/modules/gitea/dist/provisioner/index.js
+73
View File
@@ -45,6 +45,14 @@ export interface GiteaPull {
html_url: string; html_url: string;
} }
export interface GiteaComment {
id: number;
user?: string;
body: string;
created_at?: string;
html_url: string;
}
export interface GiteaLabel { export interface GiteaLabel {
id: number; id: number;
name: string; name: string;
@@ -95,6 +103,13 @@ export class GiteaClient {
if (res.status === 401) { if (res.status === 401) {
token = await this.tokens.renew(token); token = await this.tokens.renew(token);
res = await this.send(path, options, token); res = await this.send(path, options, token);
} else if (res.status === 403) {
// A kept token minted before a scope was added lacks it. The forge says so; the source
// re-mints with the whole list and the call is retried once. Any other 403 stays a 403.
const text = await res.text();
if (!MintedToken.lacksScope(res.status, text)) throw new Error(`Gitea API ${path}: 403 ${text}`);
token = await this.tokens.renew(token);
res = await this.send(path, options, token);
} }
if (!res.ok) throw new Error(`Gitea API ${path}: ${res.status} ${await res.text()}`); if (!res.ok) throw new Error(`Gitea API ${path}: ${res.status} ${await res.text()}`);
if (res.status === 204) return null as T; if (res.status === 204) return null as T;
@@ -250,6 +265,64 @@ export class GiteaClient {
); );
} }
/** Close or reopen a pull request without merging it. A pull request is an issue to the forge's
* state machine, and the pulls endpoint takes the same `state`. */
async setPullState(owner: string, repo: string, index: number, state: "open" | "closed"): Promise<GiteaPull> {
return GiteaClient.mapPull(
await this.request<any>(`/repos/${owner}/${repo}/pulls/${index}`, { method: "PATCH", body: JSON.stringify({ state }) }),
);
}
/** Change a pull request's title or body; a field left undefined is left alone. */
async updatePullRequest(owner: string, repo: string, index: number, data: { title?: string; body?: string }): Promise<GiteaPull> {
return GiteaClient.mapPull(
await this.request<any>(`/repos/${owner}/${repo}/pulls/${index}`, { method: "PATCH", body: JSON.stringify(data) }),
);
}
/** The unified diff of a pull request, as text. */
async pullDiff(owner: string, repo: string, index: number): Promise<string> {
return this.requestText(`/repos/${owner}/${repo}/pulls/${index}.diff`);
}
/** Every comment on an issue or pull request, oldest first. */
async listComments(owner: string, repo: string, index: number): Promise<GiteaComment[]> {
const raw = await this.request<any[]>(`/repos/${owner}/${repo}/issues/${index}/comments`);
return (raw ?? []).map((c) => ({
id: Number(c?.id ?? 0),
user: c?.user?.login,
body: String(c?.body ?? ""),
created_at: c?.created_at,
html_url: String(c?.html_url ?? ""),
}));
}
/** One file's contents at a ref (default the repository's default branch), decoded. */
async getFile(owner: string, repo: string, path: string, ref?: string): Promise<{ path: string; ref?: string; sha: string; size: number; content: string }> {
const qs = ref ? `?ref=${encodeURIComponent(ref)}` : "";
const f = await this.request<any>(`/repos/${owner}/${repo}/contents/${path.split("/").map(encodeURIComponent).join("/")}${qs}`);
if (!f || f.type !== "file") throw new Error(`Gitea API: ${path} is not a file`);
const content = f.encoding === "base64" ? Buffer.from(String(f.content ?? ""), "base64").toString("utf8") : String(f.content ?? "");
return { path, ref, sha: String(f.sha ?? ""), size: Number(f.size ?? content.length), content };
}
async listBranches(owner: string, repo: string): Promise<{ name: string; commit: string; protected: boolean }[]> {
const raw = await this.request<any[]>(`/repos/${owner}/${repo}/branches?limit=100`);
return (raw ?? []).map((b) => ({ name: String(b?.name ?? ""), commit: String(b?.commit?.id ?? ""), protected: Boolean(b?.protected) }));
}
async deleteBranch(owner: string, repo: string, branch: string): Promise<void> {
await this.request(`/repos/${owner}/${repo}/branches/${encodeURIComponent(branch)}`, { method: "DELETE" });
}
/** A request whose answer is text, not JSON — a diff. Same token handling as request(). */
private async requestText(path: string): Promise<string> {
const token = await this.tokens.current();
const res = await this.send(path, { headers: { Accept: "text/plain" } }, token);
if (!res.ok) throw new Error(`Gitea API ${path}: ${res.status} ${await res.text()}`);
return res.text();
}
async mergePullRequest(owner: string, repo: string, index: number, method = "merge", deleteBranch = false): Promise<void> { async mergePullRequest(owner: string, repo: string, index: number, method = "merge", deleteBranch = false): Promise<void> {
await this.request(`/repos/${owner}/${repo}/pulls/${index}/merge`, { await this.request(`/repos/${owner}/${repo}/pulls/${index}/merge`, {
method: "POST", method: "POST",
+35 -50
View File
@@ -85,20 +85,17 @@
"scope": "mesh" "scope": "mesh"
} }
], ],
"own-secrets": {
"broker": "/var/lib/mesh/gitea/broker"
},
"resources": [ "resources": [
{ {
"id": "mesh-state", "id": "mesh-state",
"type": "directory", "type": "directory",
"path": "/var/lib/mesh/gitea", "mode": "0700",
"mode": "0700" "place": "mesh"
}, },
{ {
"id": "runtime-state", "id": "runtime-state",
"type": "directory", "type": "directory",
"path": "/var/lib/mesh/gitea/state", "path": "${dir:mesh-state}/state",
"mode": "0700" "mode": "0700"
}, },
{ {
@@ -145,7 +142,8 @@
"volumes": [ "volumes": [
"${dir:data}:/data" "${dir:data}:/data"
], ],
"secrets-in-environment": "gitea honours GITEA__database__PASSWD__FILE and GITEA__security__INTERNAL_TOKEN__FILE; convertible, awaiting a bed that proves it" "secrets-in-environment": "gitea honours GITEA__database__PASSWD__FILE and GITEA__security__INTERNAL_TOKEN__FILE; convertible, awaiting a bed that proves it",
"logging": "journald"
}, },
{ {
"id": "admin-bootstrap", "id": "admin-bootstrap",
@@ -175,36 +173,10 @@
{ {
"id": "runtime-config", "id": "runtime-config",
"type": "file", "type": "file",
"path": "/var/lib/mesh/gitea/config.json", "path": "${dir:mesh-state}/config.json",
"mode": "0600", "mode": "0600",
"content": "{}\n", "content": "{}\n",
"merge": "json" "merge": "json"
},
{
"id": "runtime",
"type": "container",
"name": "mesh-gitea",
"network": "host",
"volumes": [
"/var/lib/mesh/gitea/broker:/run/secrets/broker:ro",
"/var/lib/mesh/gitea/config.json:/run/config/config.json:ro",
"${dir:grants}:${dir:grants}:ro",
"${dir:state}/admin.secret:/run/secrets/admin:ro",
"/var/lib/mesh/gitea/state:/run/state"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_GITEA_URL": "http://127.0.0.1:${port:3000}",
"MESH_GITEA_CONFIG_FILE": "/run/config/config.json",
"MESH_GITEA_ADMIN_USER": "mesh-admin",
"MESH_GITEA_ADMIN_PASSWORD_FILE": "/run/secrets/admin",
"MESH_GITEA_STATE_DIR": "/run/state",
"MESH_RECEIVES": "${dir:grants}/npm.json"
},
"artifact": "runtime",
"restart-on": [
"runtime-config"
]
} }
], ],
"provides": [ "provides": [
@@ -218,24 +190,37 @@
} }
], ],
"build": { "build": {
"on": [
{
"arg": "BUILD_BASE",
"module": "mesh-tools",
"artifact": "build"
},
{
"arg": "RUNTIME_BASE",
"module": "mesh-tools",
"artifact": "runtime"
}
],
"artifacts": [ "artifacts": [
{ {
"name": "runtime", "name": "code",
"kind": "image", "kind": "bundle",
"from": "Dockerfile" "language": "typescript",
"entrypoints": [
"index.js",
"tools/index.js",
"provisioner/index.js"
],
"loads": [
"index.js",
"tools/index.js",
"provisioner/index.js"
],
"env": {
"MESH_GITEA_URL": "http://127.0.0.1:${port:3000}",
"MESH_GITEA_CONFIG_FILE": "${dir:mesh-state}/config.json",
"MESH_GITEA_ADMIN_USER": "mesh-admin",
"MESH_GITEA_ADMIN_PASSWORD_FILE": "${dir:state}/admin.secret",
"MESH_GITEA_STATE_DIR": "${dir:runtime-state}",
"MESH_RECEIVES": "${dir:grants}/npm.json"
}
} }
] ]
} },
"jails": [
{
"name": "gitea",
"failregex": "^.*Failed authentication attempt for .* from <HOST>(?::\\d+)?\\s*$\n ^.*Invalid user .* from <HOST> port \\d+\\s*$\n ^.*User \\S+ from <HOST> not allowed because .*$",
"jail": "backend = systemd\njournalmatch = CONTAINER_NAME=gitea\nport = http,https,222\nmaxretry = 3\nfindtime = 1d\nbantime = 1d"
}
]
} }
+140 -2
View File
@@ -29,7 +29,11 @@ interface Forge {
mints: number; mints: number;
lastScopes: string[] | null; lastScopes: string[] | null;
tokens: Map<string, string>; tokens: Map<string, string>;
scopesOf: Map<string, string[]>;
admins: Map<string, string>; admins: Map<string, string>;
pullState: string;
pullTitle: string;
branchDeleted: boolean;
close(): Promise<void>; close(): Promise<void>;
} }
@@ -40,6 +44,9 @@ function fakeForge(): Promise<Forge> {
tokens: new Map<string, string>(), // name -> value tokens: new Map<string, string>(), // name -> value
scopesOf: new Map<string, string[]>(), // value -> scopes, so a route can enforce them like gitea does scopesOf: new Map<string, string[]>(), // value -> scopes, so a route can enforce them like gitea does
admins: new Map([[ADMIN, PASSWORD]]), admins: new Map([[ADMIN, PASSWORD]]),
pullState: "open",
pullTitle: "The console shipped",
branchDeleted: false,
}; };
// write:X implies read:X — gitea's own rule (models/auth/access_token_scope.go). // write:X implies read:X — gitea's own rule (models/auth/access_token_scope.go).
const covers = (scopes: string[], required: string): boolean => const covers = (scopes: string[], required: string): boolean =>
@@ -85,6 +92,51 @@ function fakeForge(): Promise<Forge> {
} }
return json(res, 405, { message: "method not allowed" }); return json(res, 405, { message: "method not allowed" });
} }
const tokenOf = (): string => {
const h = req.headers.authorization ?? "";
return h.startsWith("token ") ? h.slice(6) : "";
};
const pull = url.pathname.match(/^\/api\/v1\/repos\/novox\/hq\/pulls\/(\d+)(\.diff)?$/);
if (pull) {
if (![...forge.tokens.values()].includes(tokenOf())) return json(res, 401, { message: "token is required" });
if (pull[2]) {
res.writeHead(200, { "Content-Type": "text/plain" });
return res.end("diff --git a/x b/x\n--- a/x\n+++ b/x\n@@ -1 +1 @@\n-old\n+new\n");
}
if (req.method === "PATCH") {
const patch = await body(req);
forge.pullState = patch?.state ?? forge.pullState;
forge.pullTitle = patch?.title ?? forge.pullTitle;
}
return json(res, 200, { number: Number(pull[1]), title: forge.pullTitle, state: forge.pullState, merged: false,
user: { login: "mesh-admin" }, head: { ref: "feat/x" }, base: { ref: "main" }, html_url: "http://fake/novox/hq/pulls/" + pull[1] });
}
if (url.pathname === "/api/v1/repos/novox/hq/issues/223/comments") {
return json(res, 200, [{ id: 1, user: { login: "jochen" }, body: "landed elsewhere", created_at: "2026-10-01T00:00:00Z", html_url: "http://fake/c/1" }]);
}
if (url.pathname === "/api/v1/repos/novox/hq/contents/README.md") {
return json(res, 200, { type: "file", encoding: "base64", sha: "abc", size: 5, content: Buffer.from("hello").toString("base64") });
}
if (url.pathname === "/api/v1/repos/novox/hq/branches") {
return json(res, 200, [{ name: "main", protected: true, commit: { id: "aaaa" } }, { name: "feat/x", protected: false, commit: { id: "bbbb" } }]);
}
if (url.pathname === "/api/v1/repos/novox/hq/branches/feat%2Fx" || url.pathname === "/api/v1/repos/novox/hq/branches/feat/x") {
if (req.method === "DELETE") { forge.branchDeleted = true; return json(res, 204, null); }
}
if (url.pathname === "/api/v1/repos/search") {
// The client lists through the search endpoint since 2026-09-28 (the forge's whole view);
// it sits under `repository`, which write:repository covers.
const h = req.headers.authorization ?? "";
const value = h.startsWith("token ") ? h.slice(6) : "";
if (![...forge.tokens.values()].includes(value)) return json(res, 401, { message: "token is required" });
if (!covers(forge.scopesOf.get(value) ?? [], "read:repository")) {
return json(res, 403, { message: `token does not have at least one of required scope(s), required=[read:repository]` });
}
return json(res, 200, {
ok: true,
data: [{ full_name: "novox/hq", name: "hq", owner: { login: "novox" }, private: true, html_url: "http://fake/novox/hq" }],
});
}
if (url.pathname === "/api/v1/user/repos") { if (url.pathname === "/api/v1/user/repos") {
const h = req.headers.authorization ?? ""; const h = req.headers.authorization ?? "";
const value = h.startsWith("token ") ? h.slice(6) : ""; const value = h.startsWith("token ") ? h.slice(6) : "";
@@ -101,6 +153,21 @@ function fakeForge(): Promise<Forge> {
{ full_name: "novox/hq", name: "hq", owner: { login: "novox" }, private: true, html_url: "http://fake/novox/hq" }, { full_name: "novox/hq", name: "hq", owner: { login: "novox" }, private: true, html_url: "http://fake/novox/hq" },
]); ]);
} }
const adminUser = url.pathname.match(/^\/api\/v1\/admin\/users\/([^/]+)$/);
if (adminUser && req.method === "PATCH") {
const h = req.headers.authorization ?? "";
const value = h.startsWith("token ") ? h.slice(6) : "";
if (![...forge.tokens.values()].includes(value)) return json(res, 401, { message: "token is required" });
if (!covers(forge.scopesOf.get(value) ?? [], "write:admin")) {
return json(res, 403, {
message: `token does not have at least one of required scope(s), required=[write:admin]`,
});
}
const login = decodeURIComponent(adminUser[1]);
if (login === "untouchable") return json(res, 403, { message: "user untouchable may not be edited" });
const patch = await body(req);
return json(res, 200, { login, is_admin: patch?.admin === true });
}
return json(res, 404, { message: "no such route in the fake" }); return json(res, 404, { message: "no such route in the fake" });
}); });
return new Promise((resolve) => { return new Promise((resolve) => {
@@ -110,7 +177,11 @@ function fakeForge(): Promise<Forge> {
url: `http://127.0.0.1:${port}`, url: `http://127.0.0.1:${port}`,
get mints() { return forge.mints; }, get mints() { return forge.mints; },
get lastScopes() { return forge.lastScopes; }, get lastScopes() { return forge.lastScopes; },
get pullState() { return forge.pullState; },
get pullTitle() { return forge.pullTitle; },
get branchDeleted() { return forge.branchDeleted; },
tokens: forge.tokens, tokens: forge.tokens,
scopesOf: forge.scopesOf,
admins: forge.admins, admins: forge.admins,
close: () => new Promise((r) => server.close(() => r())), close: () => new Promise((r) => server.close(() => r())),
}); });
@@ -152,14 +223,14 @@ function minted(env: NodeJS.ProcessEnv, logs: string[]): GiteaClient {
const forge = await fakeForge(); const forge = await fakeForge();
after(() => forge.close()); after(() => forge.close());
test("first start: mints with the admin account, keeps the token at 0600, asks for two scopes only", async () => { test("first start: mints with the admin account, keeps the token at 0600, asks for the tools' scopes only", async () => {
const { env, file, logs } = await delivered(forge); const { env, file, logs } = await delivered(forge);
const repos = await minted(env, logs).listRepos(); const repos = await minted(env, logs).listRepos();
assert.equal(repos[0]?.full_name, "novox/hq"); assert.equal(repos[0]?.full_name, "novox/hq");
assert.equal(forge.mints, 1); assert.equal(forge.mints, 1);
assert.deepEqual(forge.lastScopes, ["write:repository", "write:issue", "read:user"]); assert.deepEqual(forge.lastScopes, ["write:repository", "write:issue", "read:user", "write:admin"]);
assert.deepEqual(forge.lastScopes, [...TOKEN_SCOPES]); assert.deepEqual(forge.lastScopes, [...TOKEN_SCOPES]);
const token = forge.tokens.get("mesh-tools")!; const token = forge.tokens.get("mesh-tools")!;
assert.equal(await readFile(file, "utf8"), token + "\n"); assert.equal(await readFile(file, "utf8"), token + "\n");
@@ -197,6 +268,34 @@ test("the forge rejects the kept token (its data was restored): minted afresh, o
assert.ok(logs.some((l) => l.startsWith("the forge rejected the kept token")), logs.join("\n")); assert.ok(logs.some((l) => l.startsWith("the forge rejected the kept token")), logs.join("\n"));
}); });
test("a kept token from before write:admin: the forge refuses the admin route for the scope, the token is re-minted with the whole list, and the call goes through", async () => {
const { env, file, logs } = await delivered(forge);
const client = minted(env, logs);
await client.listRepos();
const before = forge.mints;
const old = forge.tokens.get("mesh-tools")!;
forge.scopesOf.set(old, ["write:repository", "write:issue", "read:user"]); // minted by the previous build
const user = await client.api<{ login: string; is_admin: boolean }>("/admin/users/mesh_novox_builder", {
method: "PATCH",
body: JSON.stringify({ admin: true }),
});
assert.equal(user.is_admin, true);
assert.equal(forge.mints, before + 1);
assert.deepEqual(forge.lastScopes, [...TOKEN_SCOPES]);
assert.notEqual(forge.tokens.get("mesh-tools"), old);
assert.equal(await readFile(file, "utf8"), forge.tokens.get("mesh-tools") + "\n");
assert.ok(logs.some((l) => l.startsWith("the forge rejected the kept token")), logs.join("\n"));
// A 403 that is not about scopes is the forge's answer, not a reason to mint.
const again = forge.mints;
await assert.rejects(
client.api("/admin/users/untouchable", { method: "PATCH", body: JSON.stringify({ admin: true }) }),
/403 .*untouchable/,
);
assert.equal(forge.mints, again);
});
test("the kept file is gone but the forge still holds a token by that name: replaced, not refused", async () => { test("the kept file is gone but the forge still holds a token by that name: replaced, not refused", async () => {
const { env, file, logs } = await delivered(forge); const { env, file, logs } = await delivered(forge);
await minted(env, logs).listRepos(); await minted(env, logs).listRepos();
@@ -301,6 +400,16 @@ test("the tools register once there is a way to a token, and the first call mint
"gitea_list_repos", "gitea_create_repo", "gitea_delete_repo", "gitea_list_repos", "gitea_create_repo", "gitea_delete_repo",
"gitea_list_issues", "gitea_get_issue", "gitea_create_issue", "gitea_close_issue", "gitea_add_comment", "gitea_list_issues", "gitea_get_issue", "gitea_create_issue", "gitea_close_issue", "gitea_add_comment",
"gitea_list_pull_requests", "gitea_get_pull_request", "gitea_create_pull_request", "gitea_merge_pull_request", "gitea_list_pull_requests", "gitea_get_pull_request", "gitea_create_pull_request", "gitea_merge_pull_request",
"gitea_close_pull_request",
"gitea_reopen_pull_request",
"gitea_update_pull_request",
"gitea_pull_request_files",
"gitea_pull_request_diff",
"gitea_list_comments",
"gitea_reopen_issue",
"gitea_get_file",
"gitea_list_branches",
"gitea_delete_branch",
"gitea_list_labels", "gitea_create_label", "gitea_list_labels", "gitea_create_label",
"gitea_api", "gitea_api",
], ],
@@ -311,3 +420,32 @@ test("the tools register once there is a way to a token, and the first call mint
assert.equal(result.repos.length, 1); assert.equal(result.repos.length, 1);
assert.equal(forge.mints, before + 1); assert.equal(forge.mints, before + 1);
}); });
// The forge's tools reach every action a review needs without a checkout and without the API
// escape hatch: close a pull request whose work landed elsewhere, read its diff, its comments, a
// file, the branches, and delete the branch left behind. Against the fake forge, through the
// compiled tools, the way the console calls them.
test("a pull request can be closed, read and cleaned up through the tools", async () => {
const { env } = await delivered(forge);
const tools = collectTools(env).find((c) => c.module === "gitea")!.tools;
const tool = (name: string) => tools.find((t) => t.name === name)!;
for (const name of ["gitea_close_pull_request", "gitea_reopen_pull_request", "gitea_update_pull_request", "gitea_pull_request_files",
"gitea_pull_request_diff", "gitea_list_comments", "gitea_reopen_issue", "gitea_get_file", "gitea_list_branches", "gitea_delete_branch"]) {
assert.ok(tool(name), `${name} is not a tool`);
}
const closed = (await tool("gitea_close_pull_request").run({ owner: "novox", repo: "hq", number: 223 })) as { pull: { state: string } };
assert.equal(closed.pull.state, "closed");
assert.equal(forge.pullState, "closed");
const renamed = (await tool("gitea_update_pull_request").run({ owner: "novox", repo: "hq", number: 223, title: "Superseded" })) as { pull: { title: string } };
assert.equal(renamed.pull.title, "Superseded");
const diff = (await tool("gitea_pull_request_diff").run({ owner: "novox", repo: "hq", number: 223 })) as { diff: string };
assert.match(diff.diff, /^diff --git/);
const comments = (await tool("gitea_list_comments").run({ owner: "novox", repo: "hq", number: 223 })) as { comments: { body: string }[] };
assert.equal(comments.comments[0].body, "landed elsewhere");
const file = (await tool("gitea_get_file").run({ owner: "novox", repo: "hq", path: "README.md" })) as { file: { content: string } };
assert.equal(file.file.content, "hello");
const branches = (await tool("gitea_list_branches").run({ owner: "novox", repo: "hq" })) as { branches: { name: string }[] };
assert.deepEqual(branches.branches.map((b) => b.name), ["main", "feat/x"]);
await tool("gitea_delete_branch").run({ owner: "novox", repo: "hq", branch: "feat/x" });
assert.equal(forge.branchDeleted, true);
});
+14 -3
View File
@@ -34,15 +34,21 @@ export const TOKEN_NAME = "mesh-tools";
* It sits under the `user` category despite listing repositories, not `repository` * It sits under the `user` category despite listing repositories, not `repository`
* — confirmed against the running forge (1.27.3), which answered * — confirmed against the running forge (1.27.3), which answered
* `required=[read:user]` to a token carrying only the other two. * `required=[read:user]` to a token carrying only the other two.
* Nothing under /admin, /orgs or write:user — the escape-hatch tool reaches only what these three cover. * write:admin — /admin/users: the forge's own users are the mesh's to settle, such as making
* the builder's login a site admin so every repository the mesh may build is
* clonable (novox/hq 229). Nothing under /orgs or write:user.
*
* A token kept from before a scope was added lacks it: the forge answers such a call with
* `403 token does not have at least one of required scope(s)`, and the client treats that like a
* 401 — the source re-mints by name, with the whole list, and the call is retried once.
*/ */
export const TOKEN_SCOPES: readonly string[] = ["write:repository", "write:issue", "read:user"]; export const TOKEN_SCOPES: readonly string[] = ["write:repository", "write:issue", "read:user", "write:admin"];
/** Where a client's token comes from, and what to do when the forge says it is wrong. */ /** Where a client's token comes from, and what to do when the forge says it is wrong. */
export interface TokenSource { export interface TokenSource {
/** The token to authenticate with now; minted, read or configured. */ /** The token to authenticate with now; minted, read or configured. */
current(): Promise<string>; current(): Promise<string>;
/** The forge answered 401 to `rejected`. A fresh token, or a plain error when there is nothing to renew with. */ /** The forge answered 401 to `rejected`, or 403 for a scope it lacks. A fresh token, or a plain error when there is nothing to renew with. */
renew(rejected: string): Promise<string>; renew(rejected: string): Promise<string>;
} }
@@ -170,6 +176,11 @@ export class MintedToken implements TokenSource {
return this.mint("the forge rejected the kept token — minting a fresh one"); return this.mint("the forge rejected the kept token — minting a fresh one");
} }
/** What the forge's scoped tokens say when a kept token predates a scope the tools now need. */
static lacksScope(status: number, body: string): boolean {
return status === 403 && /required scope/i.test(body);
}
/** One mint at a time: concurrent first calls share it, rather than each minting its own. */ /** One mint at a time: concurrent first calls share it, rather than each minting its own. */
private mint(why: string): Promise<string> { private mint(why: string): Promise<string> {
if (this.inflight === null) { if (this.inflight === null) {
+125
View File
@@ -254,6 +254,131 @@ export function getGiteaTools(gitea: GiteaClient): ToolDefinition[] {
}, },
}, },
{
name: "gitea_close_pull_request",
description: "Close a pull request without merging it — one whose work landed elsewhere, or was abandoned.",
input: {
owner: { type: "string", description: "the repository owner" },
repo: { type: "string", description: "the repository name" },
number: { type: "number", description: "the PR number" },
},
run: async (args) => ({
pull: await gitea.setPullState(String(args.owner), String(args.repo), Number(args.number), "closed"),
}),
},
{
name: "gitea_reopen_pull_request",
description: "Reopen a closed, unmerged pull request.",
input: {
owner: { type: "string", description: "the repository owner" },
repo: { type: "string", description: "the repository name" },
number: { type: "number", description: "the PR number" },
},
run: async (args) => ({
pull: await gitea.setPullState(String(args.owner), String(args.repo), Number(args.number), "open"),
}),
},
{
name: "gitea_update_pull_request",
description: "Change a pull request's title or body; a field not given is left as it is.",
input: {
owner: { type: "string", description: "the repository owner" },
repo: { type: "string", description: "the repository name" },
number: { type: "number", description: "the PR number" },
title: { type: "string", description: "the new title (optional)" },
body: { type: "string", description: "the new body, markdown (optional)" },
},
run: async (args) => ({
pull: await gitea.updatePullRequest(String(args.owner), String(args.repo), Number(args.number), {
title: args.title === undefined ? undefined : String(args.title),
body: args.body === undefined ? undefined : String(args.body),
}),
}),
},
{
name: "gitea_pull_request_files",
description: "The files a pull request changes, as paths from the repository's root (up to 100; says when there are more).",
input: {
owner: { type: "string", description: "the repository owner" },
repo: { type: "string", description: "the repository name" },
number: { type: "number", description: "the PR number" },
},
run: async (args) => gitea.listPullFiles(String(args.owner), String(args.repo), Number(args.number)),
},
{
name: "gitea_pull_request_diff",
description: "A pull request's unified diff, as text — for reviewing it without a checkout.",
input: {
owner: { type: "string", description: "the repository owner" },
repo: { type: "string", description: "the repository name" },
number: { type: "number", description: "the PR number" },
},
run: async (args) => ({
diff: await gitea.pullDiff(String(args.owner), String(args.repo), Number(args.number)),
}),
},
{
name: "gitea_list_comments",
description: "Every comment on an issue or pull request, oldest first.",
input: {
owner: { type: "string", description: "the repository owner" },
repo: { type: "string", description: "the repository name" },
number: { type: "number", description: "the issue or PR number" },
},
run: async (args) => ({
comments: await gitea.listComments(String(args.owner), String(args.repo), Number(args.number)),
}),
},
{
name: "gitea_reopen_issue",
description: "Reopen a closed issue.",
input: {
owner: { type: "string", description: "the repository owner" },
repo: { type: "string", description: "the repository name" },
number: { type: "number", description: "the issue number" },
},
run: async (args) => ({
issue: await gitea.setIssueState(String(args.owner), String(args.repo), Number(args.number), "open"),
}),
},
// ---- Contents and branches ----
{
name: "gitea_get_file",
description: "One file's contents from a repository, decoded, at a branch, tag or commit (default the repository's default branch).",
input: {
owner: { type: "string", description: "the repository owner" },
repo: { type: "string", description: "the repository name" },
path: { type: "string", description: "the file's path from the repository's root" },
ref: { type: "string", description: "branch, tag or commit (optional)" },
},
run: async (args) => ({
file: await gitea.getFile(String(args.owner), String(args.repo), String(args.path), args.ref ? String(args.ref) : undefined),
}),
},
{
name: "gitea_list_branches",
description: "Every branch of a repository with the commit it points at.",
input: {
owner: { type: "string", description: "the repository owner" },
repo: { type: "string", description: "the repository name" },
},
run: async (args) => ({ branches: await gitea.listBranches(String(args.owner), String(args.repo)) }),
},
{
name: "gitea_delete_branch",
description: "Delete a branch — a feature branch whose pull request was closed rather than merged. Refused by the forge for a protected branch.",
input: {
owner: { type: "string", description: "the repository owner" },
repo: { type: "string", description: "the repository name" },
branch: { type: "string", description: "the branch name" },
},
run: async (args) => {
await gitea.deleteBranch(String(args.owner), String(args.repo), String(args.branch));
return { deleted: true, branch: String(args.branch) };
},
},
// ---- Labels ---- // ---- Labels ----
{ {
name: "gitea_list_labels", name: "gitea_list_labels",
-24
View File
@@ -1,24 +0,0 @@
# gitlab's runtime: the tool runtime, carrying this module's compiled code.
#
# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in
# the base images, published like any other artifact — which is what makes this buildable by the
# mesh from a repository and a path (novox/hq ADR 0069) rather than only on a workstation that
# happens to have the siblings.
#
# Two bases, named rather than pinned (novox/hq issue 044): the image this is COMPILED in and the
# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`.
ARG BUILD_BASE
ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build
WORKDIR /app/modules/gitlab
COPY . .
RUN node /app/node_modules/typescript/bin/tsc client.ts tools/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE}
COPY --from=build /app/modules/gitlab/dist /app/modules/gitlab/dist
# Every serve-time entrypoint, loaded by the runtime in serve mode: tools and events serve, and a
# provider's provisioner runs its reconcile loop in the same process, with the broker connected —
# the convention novox/hq issues 060/061 settled.
ENV MESH_TOOL_MODULES=/app/modules/gitlab/dist/tools/index.js
+17 -43
View File
@@ -2,69 +2,43 @@
"module": "gitlab", "module": "gitlab",
"version": "1", "version": "1",
"own-secrets": { "own-secrets": {
"token": "/var/lib/gitlab/token", "token": "${dir:state}/token"
"broker": "/var/lib/mesh/gitlab/broker"
}, },
"resources": [ "resources": [
{
"id": "mesh-state",
"type": "directory",
"path": "/var/lib/mesh/gitlab",
"mode": "0700"
},
{ {
"id": "state", "id": "state",
"type": "directory", "type": "directory",
"path": "/var/lib/gitlab", "mode": "0700",
"mode": "0700" "place": "."
}, },
{ {
"id": "config", "id": "config",
"type": "file", "type": "file",
"path": "/var/lib/gitlab/config.json", "path": "${dir:state}/config.json",
"merge": "json", "merge": "json",
"content": "{}", "content": "{}",
"mode": "0600" "mode": "0600"
},
{
"id": "runtime",
"type": "container",
"name": "mesh-runtime-gitlab",
"network": "host",
"volumes": [
"/var/lib/gitlab/config.json:/run/config/config.json:ro",
"/var/lib/gitlab/token:/run/secrets/token:ro",
"/var/lib/mesh/gitlab/broker:/run/secrets/broker:ro"
],
"env": {
"MESH_GITLAB_TOKEN_FILE": "/run/secrets/token",
"MESH_GITLAB_CONFIG_FILE": "/run/config/config.json",
"MESH_BROKER_FILE": "/run/secrets/broker"
},
"artifact": "runtime"
} }
], ],
"capabilities": [ "capabilities": [
"container-runtime" "container-runtime"
], ],
"build": { "build": {
"on": [
{
"arg": "BUILD_BASE",
"module": "mesh-tools",
"artifact": "build"
},
{
"arg": "RUNTIME_BASE",
"module": "mesh-tools",
"artifact": "runtime"
}
],
"artifacts": [ "artifacts": [
{ {
"name": "runtime", "name": "tools",
"kind": "image", "kind": "bundle",
"from": "Dockerfile" "language": "typescript",
"entrypoints": [
"tools/index.js"
],
"loads": [
"tools/index.js"
],
"env": {
"MESH_GITLAB_TOKEN_FILE": "${dir:state}/token",
"MESH_GITLAB_CONFIG_FILE": "${dir:state}/config.json"
}
} }
] ]
} }
-24
View File
@@ -1,24 +0,0 @@
# grafana's runtime: the tool runtime, carrying this module's compiled code.
#
# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in
# the base images, published like any other artifact — which is what makes this buildable by the
# mesh from a repository and a path (novox/hq ADR 0069) rather than only on a workstation that
# happens to have the siblings.
#
# Two bases, named rather than pinned (novox/hq issue 044): the image this is COMPILED in and the
# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`.
ARG BUILD_BASE
ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build
WORKDIR /app/modules/grafana
COPY . .
RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts tools/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE}
COPY --from=build /app/modules/grafana/dist /app/modules/grafana/dist
# Every serve-time entrypoint, loaded by the runtime in serve mode: tools and events serve, and a
# provider's provisioner runs its reconcile loop in the same process, with the broker connected —
# the convention novox/hq issues 060/061 settled.
ENV MESH_TOOL_MODULES=/app/modules/grafana/dist/index.js,/app/modules/grafana/dist/tools/index.js
+21 -41
View File
@@ -5,8 +5,7 @@
"alert.firing" "alert.firing"
], ],
"own-secrets": { "own-secrets": {
"admin": "/var/lib/mesh/grafana/admin", "admin": "${dir:mesh-state}/admin"
"broker": "/var/lib/mesh/grafana/broker"
}, },
"capabilities": [ "capabilities": [
"container-runtime" "container-runtime"
@@ -24,8 +23,8 @@
{ {
"id": "mesh-state", "id": "mesh-state",
"type": "directory", "type": "directory",
"path": "/var/lib/mesh/grafana", "mode": "0700",
"mode": "0700" "place": "mesh"
}, },
{ {
"id": "state", "id": "state",
@@ -108,29 +107,10 @@
{ {
"id": "runtime-config", "id": "runtime-config",
"type": "file", "type": "file",
"path": "/var/lib/mesh/grafana/config.json", "path": "${dir:mesh-state}/config.json",
"mode": "0600", "mode": "0600",
"content": "{\n \"user\": \"admin\",\n \"password\": \"${secret:admin}\"\n}\n", "content": "{\n \"user\": \"admin\",\n \"password\": \"${secret:admin}\"\n}\n",
"merge": "json" "merge": "json"
},
{
"id": "runtime",
"type": "container",
"name": "mesh-grafana",
"network": "host",
"volumes": [
"/var/lib/mesh/grafana/broker:/run/secrets/broker:ro",
"/var/lib/mesh/grafana/config.json:/run/config/config.json:ro"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_GRAFANA_URL": "http://127.0.0.1:${port:3000}",
"MESH_GRAFANA_CONFIG_FILE": "/run/config/config.json"
},
"restart-on": [
"runtime-config"
],
"artifact": "runtime"
} }
], ],
"requires": [ "requires": [
@@ -158,27 +138,27 @@
"influxdb-api": "${dir:state}/influxdb.json" "influxdb-api": "${dir:state}/influxdb.json"
}, },
"secrets": { "secrets": {
"oidc-client": "/var/lib/mesh/grafana/oidc-client", "oidc-client": "${dir:mesh-state}/oidc-client",
"influxdb-api": "/var/lib/mesh/grafana/influxdb-api" "influxdb-api": "${dir:mesh-state}/influxdb-api"
}, },
"build": { "build": {
"on": [
{
"arg": "BUILD_BASE",
"module": "mesh-tools",
"artifact": "build"
},
{
"arg": "RUNTIME_BASE",
"module": "mesh-tools",
"artifact": "runtime"
}
],
"artifacts": [ "artifacts": [
{ {
"name": "runtime", "name": "code",
"kind": "image", "kind": "bundle",
"from": "Dockerfile" "language": "typescript",
"entrypoints": [
"index.js",
"tools/index.js"
],
"loads": [
"index.js",
"tools/index.js"
],
"env": {
"MESH_GRAFANA_URL": "http://127.0.0.1:${port:3000}",
"MESH_GRAFANA_CONFIG_FILE": "${dir:mesh-state}/config.json"
}
} }
] ]
} }
+5 -5
View File
@@ -15,7 +15,7 @@
} }
}, },
"binds": { "binds": {
"route": "/var/lib/hello-web/route.json" "route": "${dir:state}/route.json"
}, },
"listens": [ "listens": [
{ {
@@ -30,13 +30,13 @@
{ {
"id": "state", "id": "state",
"type": "directory", "type": "directory",
"path": "/var/lib/hello-web", "mode": "0700",
"mode": "0700" "place": "."
}, },
{ {
"id": "page", "id": "page",
"type": "file", "type": "file",
"path": "/var/lib/hello-web/index.html", "path": "${dir:state}/index.html",
"mode": "0644", "mode": "0644",
"content": "hello from hello-web, routed by the mesh\n" "content": "hello from hello-web, routed by the mesh\n"
}, },
@@ -54,7 +54,7 @@
"8080" "8080"
], ],
"volumes": [ "volumes": [
"/var/lib/hello-web/index.html:/www/index.html:ro" "${dir:state}/index.html:/www/index.html:ro"
], ],
"args": [ "args": [
"sh", "sh",
-24
View File
@@ -1,24 +0,0 @@
# home-assistant's runtime: the tool runtime, carrying this module's compiled code.
#
# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in
# the base images, published like any other artifact — which is what makes this buildable by the
# mesh from a repository and a path (novox/hq ADR 0069) rather than only on a workstation that
# happens to have the siblings.
#
# Two bases, named rather than pinned (novox/hq issue 044): the image this is COMPILED in and the
# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`.
ARG BUILD_BASE
ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build
WORKDIR /app/modules/home-assistant
COPY . .
RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts tools/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE}
COPY --from=build /app/modules/home-assistant/dist /app/modules/home-assistant/dist
# Every serve-time entrypoint, loaded by the runtime in serve mode: tools and events serve, and a
# provider's provisioner runs its reconcile loop in the same process, with the broker connected —
# the convention novox/hq issues 060/061 settled.
ENV MESH_TOOL_MODULES=/app/modules/home-assistant/dist/index.js,/app/modules/home-assistant/dist/tools/index.js
+99 -47
View File
@@ -9,8 +9,7 @@
"state.changed" "state.changed"
], ],
"own-secrets": { "own-secrets": {
"broker": "/var/lib/mesh/home-assistant/broker", "token": "${dir:mesh-state}/token"
"token": "/var/lib/mesh/home-assistant/token"
}, },
"listens": [ "listens": [
{ {
@@ -18,98 +17,151 @@
"port": 8123, "port": 8123,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
"why": "the dashboard and the API" "why": "the dashboard, the API and the companion apps"
},
{
"name": "sonos-events",
"port": 1400,
"protocol": "tcp",
"from": "mesh",
"why": "the Sonos integration's event callback: speakers push their state changes here"
},
{
"name": "webrtc",
"port": 18555,
"protocol": "tcp",
"from": "mesh",
"why": "the bundled go2rtc's WebRTC port, which camera streams to a browser use"
} }
], ],
"resources": [ "resources": [
{ {
"id": "mesh-state", "id": "mesh-state",
"type": "directory", "type": "directory",
"path": "/var/lib/mesh/home-assistant", "mode": "0700",
"mode": "0700" "place": "mesh"
},
{
"id": "state",
"type": "directory",
"mode": "0700",
"place": "."
}, },
{ {
"id": "config", "id": "config",
"type": "directory", "type": "directory",
"path": "/services/home-assistant/config", "mode": "0700"
"mode": "0700", },
"owner": "1000:1000" {
"id": "written",
"type": "directory",
"mode": "0700"
}, },
{ {
"id": "server", "id": "server",
"type": "container", "type": "container",
"name": "home-assistant", "name": "home-assistant",
"image": "ghcr.io/home-assistant/home-assistant@sha256:14931c6b13756317849f46da1d01b45937a1150db66c081cfe529d48215943fe", "image": "ghcr.io/home-assistant/home-assistant@sha256:d8922685169707fd91e8b9729902d975f06157d005e422874d201e0261dda196",
"network": "host", "network": "host",
"env": { "env": {
"TZ": "Etc/UTC" "TZ": "Etc/UTC"
}, },
"volumes": [ "volumes": [
"/services/home-assistant/config:/config" "${dir:config}:/config"
] ]
}, },
{ {
"id": "runtime-config", "id": "runtime-config",
"type": "file", "type": "file",
"path": "/var/lib/mesh/home-assistant/config.json", "path": "${dir:mesh-state}/config.json",
"mode": "0600", "mode": "0600",
"content": "{}\n", "content": "{}\n",
"merge": "json" "merge": "json"
}, },
{ {
"id": "runtime", "id": "provisions-env",
"type": "container", "type": "file",
"name": "mesh-home-assistant", "path": "${dir:state}/provisions.env",
"network": "host", "mode": "0600",
"volumes": [ "content": "MESH_HOMEASSISTANT_URL=http://127.0.0.1:${port:8123}\nMESH_HOMEASSISTANT_TOKEN_FILE=${dir:mesh-state}/token\nMESH_PROVISIONS_DIR=${dir:state}\nMESH_WRITTEN_DIR=${dir:written}\n"
"/var/lib/mesh/home-assistant/broker:/run/secrets/broker:ro", },
"/var/lib/mesh/home-assistant/token:/run/secrets/token:ro", {
"/var/lib/mesh/home-assistant/config.json:/run/config/config.json:ro", "id": "provisions",
"/services/home-assistant/config:/var/lib/home-assistant/config:ro" "type": "process",
"name": "home-assistant-provisions",
"artifact": "code",
"run": [
"node",
"provisions/index.js"
],
"run-once": true,
"env-file": [
"${dir:state}/provisions.env"
], ],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_HOMEASSISTANT_URL": "http://127.0.0.1:8123",
"MESH_HOMEASSISTANT_TOKEN_FILE": "/run/secrets/token",
"MESH_HOMEASSISTANT_CONFIG_FILE": "/run/config/config.json",
"MESH_HOMEASSISTANT_CONFIG_DIR": "/var/lib/home-assistant/config"
},
"restart-on": [ "restart-on": [
"runtime-config" "provisions-env",
], "bound-mqtt-topic",
"artifact": "runtime" "secret-mqtt-topic",
"bound-sonarr-api",
"secret-sonarr-api",
"bound-radarr-api",
"secret-radarr-api",
"bound-lidarr-api",
"secret-lidarr-api"
]
} }
], ],
"requires": [ "requires": [
"route" "lidarr-api",
"mqtt-topic",
"radarr-api",
"route",
"sonarr-api"
], ],
"contributes": { "contributes": {
"mqtt-topic": {
"topics": [
"#"
]
},
"route": { "route": {
"label": "home-assistant", "label": "home-assistant",
"endpoint": "web" "endpoint": "web"
} }
}, },
"binds": { "binds": {
"route": "/var/lib/mesh/home-assistant/route.json" "route": "${dir:state}/route.json",
"mqtt-topic": "${dir:state}/mqtt-topic.json",
"sonarr-api": "${dir:state}/sonarr-api.json",
"radarr-api": "${dir:state}/radarr-api.json",
"lidarr-api": "${dir:state}/lidarr-api.json"
},
"secrets": {
"mqtt-topic": "${dir:state}/mqtt-topic.secret",
"sonarr-api": "${dir:state}/sonarr-api.secret",
"radarr-api": "${dir:state}/radarr-api.secret",
"lidarr-api": "${dir:state}/lidarr-api.secret"
}, },
"build": { "build": {
"on": [
{
"arg": "BUILD_BASE",
"module": "mesh-tools",
"artifact": "build"
},
{
"arg": "RUNTIME_BASE",
"module": "mesh-tools",
"artifact": "runtime"
}
],
"artifacts": [ "artifacts": [
{ {
"name": "runtime", "name": "code",
"kind": "image", "kind": "bundle",
"from": "Dockerfile" "language": "typescript",
"entrypoints": [
"index.js",
"tools/index.js",
"provisions/index.js"
],
"loads": [
"index.js",
"tools/index.js"
],
"env": {
"MESH_HOMEASSISTANT_URL": "http://127.0.0.1:${port:8123}",
"MESH_HOMEASSISTANT_TOKEN_FILE": "${dir:mesh-state}/token",
"MESH_HOMEASSISTANT_CONFIG_FILE": "${dir:mesh-state}/config.json"
}
} }
] ]
} }
+6 -1
View File
@@ -1,9 +1,14 @@
{ {
"name": "@novox/module-home-assistant", "name": "@novox/module-home-assistant",
"version": "0.1.0", "version": "0.1.0",
"description": "home-assistant — home automation platform. Its API client, tools and events live here (novox/hq ADR 0039).", "description": "home-assistant \u2014 home automation platform. Its API client, tools and events live here (novox/hq ADR 0039).",
"type": "module", "type": "module",
"private": true, "private": true,
"scripts": {
"build": "tsc client.ts index.ts tools/index.ts provisions/hass.ts provisions/probe.ts provisions/connections.ts provisions/mesh.ts provisions/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist",
"typecheck": "tsc -p tsconfig.json",
"test": "node --test --experimental-strip-types 'test/*.test.ts'"
},
"dependencies": { "dependencies": {
"@novox/mesh-sdk": "^0.1.0" "@novox/mesh-sdk": "^0.1.0"
}, },
@@ -0,0 +1,486 @@
// How home-assistant's provisions step brings Home Assistant's integrations in line with what the
// mesh bound: the MQTT integration to `mqtt-topic`, the Sonarr, Radarr and Lidarr integrations to
// `sonarr-api`, `radarr-api` and `lidarr-api`. Pure logic over two seams — Home Assistant's config
// flows (hass.ts) and the broker/apps — so it is tested against fakes (test/provisions.test.ts).
//
// The half that reads files and talks HTTP lives beside it (mesh.ts, hass.ts, probe.ts, index.ts).
import { createHash } from "node:crypto";
import type { Hass, SchemaField } from "./hass.js";
import type { Probe } from "./probe.js";
/** What the mesh wrote at `binds.<provision>` (the controller's binding document). */
export interface Binding {
provision?: string;
from?: string;
at?: string;
as?: string;
serves?: Record<string, unknown>;
}
/** How one provision came out. Never carries a credential. */
export type Outcome =
| { what: string; result: "unchanged"; note?: string }
| { what: string; result: "written"; fields: string[]; note?: string }
| { what: string; result: "equivalent"; note: string }
| { what: string; result: "refused"; problem: string };
/** A port the binding serves, or undefined when it names none usable. */
export function portOf(serves: Record<string, unknown> | undefined): number | undefined {
const port = Number(serves?.port);
return Number.isInteger(port) && port > 0 && port <= 65535 ? port : undefined;
}
/** A host as it goes into a URL: an IPv6 literal bracketed. */
export function urlHost(host: string): string {
return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
}
/**
* What this step last wrote, per target, as a digest: the only way to know "already as the mesh
* says" for a credential Home Assistant will not show back. A sha256 over the target and the values,
* never the values; kept in the module's own placed directory.
*/
export interface Marks {
get(name: string): Promise<string | undefined>;
set(name: string, digest: string): Promise<void>;
}
export function digest(...parts: (string | number)[]): string {
return createHash("sha256").update(parts.map(String).join("\u0000")).digest("hex");
}
/** An error as text with the credential taken out, raw and URL-encoded. */
export function scrub(err: unknown, ...secrets: (string | undefined)[]): string {
let text = err instanceof Error ? err.message : String(err);
for (const s of secrets) {
if (!s) continue;
for (const form of new Set([s, encodeURIComponent(s)])) text = text.split(form).join("***");
}
return text;
}
/**
* What a form would submit if a person pressed "submit" without touching it: each field's
* suggested value (what Home Assistant pre-fills from the entry), else its default; a section's
* fields nested under its name. The step lays only the connection fields over this, so every other
* choice the entry carries is sent back exactly as Home Assistant showed it.
*/
export function formValues(schema: readonly SchemaField[] | null | undefined): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const field of schema ?? []) {
if (Array.isArray(field.schema)) {
out[field.name] = formValues(field.schema);
continue;
}
const suggested = field.description?.suggested_value;
if (suggested !== undefined && suggested !== null) out[field.name] = suggested;
else if (field.default !== undefined) out[field.name] = field.default;
}
return out;
}
/** Whether a form has a field of this name at its top level. */
export function hasField(schema: readonly SchemaField[] | null | undefined, name: string): boolean {
return (schema ?? []).some((f) => f.name === name);
}
// ---- MQTT ----
// Home Assistant's MQTT integration, pointed at the broker the mesh bound — `mqtt-topic`.
//
// **Why a step.** Home Assistant keeps its broker, login and password in its MQTT config entry
// (`.storage/core.config_entries`), not in a file the mesh could fill with `${bound:mqtt-topic:at}`.
// So this reads the binding and the pair credential and makes the entry say the same thing, through
// the MQTT integration's own reconfigure flow — the flow its "Reconfigure" button runs, which tests
// the connection itself and saves nothing it could not connect with.
//
// **Only the connection, and only when it differs.** Broker, port, username, password. The protocol
// version, client id, keepalive, TLS choices and discovery options the entry holds are sent back
// exactly as Home Assistant pre-filled them. Whether the password already matches cannot be read
// back (Home Assistant never shows a stored password), so the step keeps a digest of what it last
// wrote: equal broker/port/username and an equal digest is "already as the mesh says".
//
// **Nothing loses its connection without someone seeing it.** Before Home Assistant is touched the
// broker itself is asked whether it takes the delivered login (the provisioner creates it within
// seconds of the grant): if not, nothing is written and the step fails saying why, and Home
// Assistant keeps the login it has — the carried `luffy` on ace, which mosquitto keeps. If Home
// Assistant's own connection test refuses the new settings, the flow saves nothing, and the step
// fails with Home Assistant's reason. A login that may not subscribe to the discovery topics is said
// as a warning: discovery would find nothing.
export const MQTT_PROVISION = "mqtt-topic";
/** Home Assistant's discovery prefix, subscribed to whenever discovery is on (the default). */
export const DISCOVERY_FILTER = "homeassistant/#";
export interface MqttWanted {
host: string;
port: number;
username: string;
password: string;
}
export type Wanted = { ok: true; want: MqttWanted } | { ok: false; problem: string };
/** The broker, port and login the mesh says Home Assistant uses. */
export function wantedMqtt(binding: Binding | undefined, credential: string | undefined): Wanted {
if (!binding) return { ok: false, problem: `no binding for ${MQTT_PROVISION} was delivered — the mesh writes it before this step runs` };
const host = typeof binding.at === "string" ? binding.at.trim() : "";
if (!host) return { ok: false, problem: `the ${MQTT_PROVISION} binding names no host (at)` };
const port = portOf(binding.serves);
if (port === undefined) return { ok: false, problem: `the ${MQTT_PROVISION} binding serves no usable port (${String(binding.serves?.port)})` };
const scheme = binding.serves?.scheme;
if (scheme !== undefined && scheme !== "mqtt") {
return { ok: false, problem: `the ${MQTT_PROVISION} binding serves scheme ${String(scheme)}; this step writes plain MQTT` };
}
const username = typeof binding.as === "string" ? binding.as.trim() : "";
if (!username) return { ok: false, problem: `the ${MQTT_PROVISION} binding names no login (as)` };
const password = (credential ?? "").replace(/\n$/, "");
if (!password) return { ok: false, problem: `the ${MQTT_PROVISION} credential is empty or was not delivered` };
return { ok: true, want: { host, port, username, password } };
}
export interface MqttDeps {
hass: Hass;
probe: Probe;
marks: Marks;
}
const markFor = (entryId: string, w: MqttWanted): string => digest("mqtt", entryId, w.host, w.port, w.username, w.password);
/** Bring Home Assistant's MQTT entry in line with the mesh. Never throws: every failure is an outcome. */
export async function reconcileMqtt(deps: MqttDeps, binding: Binding | undefined, credential: string | undefined): Promise<Outcome> {
const what = "mqtt";
const w = wantedMqtt(binding, credential);
if ("problem" in w) return { what, result: "refused", problem: w.problem };
const want = w.want;
// The broker first: a login it does not take is never written into Home Assistant.
let note: string | undefined;
try {
const probe = await deps.probe(want.host, want.port, want.username, want.password, DISCOVERY_FILTER);
if (probe.connack === 4 || probe.connack === 5) {
return {
what,
result: "refused",
problem:
`the broker at ${want.host}:${want.port} does not (yet) take the login ${want.username} with the delivered ` +
`password (CONNACK ${probe.connack}); mosquitto's provisioner creates it from the grant — nothing was ` +
`written, and Home Assistant keeps the broker login it has`,
};
}
if (probe.connack !== 0) {
return { what, result: "refused", problem: `the broker at ${want.host}:${want.port} answered CONNACK ${probe.connack}; nothing was written` };
}
if (probe.suback === 0x80) {
note =
`warning: ${want.username} may not subscribe to ${DISCOVERY_FILTER} — MQTT discovery will find nothing; ` +
`grant it with the mqtt-topic contribution's \`topics\``;
}
} catch (err) {
return {
what,
result: "refused",
problem: `the broker at ${want.host}:${want.port} could not be asked: ${scrub(err, want.password)}; nothing was written`,
};
}
try {
const entries = (await deps.hass.entries("mqtt")).filter((e) => e.domain === "mqtt");
if (entries.length > 1) {
return { what, result: "refused", problem: `Home Assistant has ${entries.length} MQTT entries; which one the mesh owns is not guessed` };
}
if (entries.length === 0) return await createEntry(deps, want, note);
const entry = entries[0];
const flow = await deps.hass.startFlow("mqtt", entry.entry_id);
if (flow.type !== "form" || !flow.flow_id || !flow.data_schema) {
if (flow.flow_id) await deps.hass.abortFlow(flow.flow_id);
return { what, result: "refused", problem: `Home Assistant's MQTT reconfigure flow answered ${flow.type}${flow.reason ? ` (${flow.reason})` : ""}` };
}
const current = formValues(flow.data_schema);
const fields: string[] = [];
if (String(current.broker ?? "") !== want.host) fields.push("broker");
if (Number(current.port ?? 0) !== want.port) fields.push("port");
if (String(current.username ?? "") !== want.username) fields.push("username");
if ((await deps.marks.get("mqtt")) !== markFor(entry.entry_id, want)) fields.push("password");
if (fields.length === 0) {
await deps.hass.abortFlow(flow.flow_id);
return note ? { what, result: "unchanged", note } : { what, result: "unchanged" };
}
const saved = await deps.hass.stepFlow(flow.flow_id, {
...current,
broker: want.host,
port: want.port,
username: want.username,
password: want.password,
});
if (saved.type === "abort" && saved.reason === "reconfigure_successful") {
await deps.marks.set("mqtt", markFor(entry.entry_id, want));
return { what, result: "written", fields, ...(note ? { note } : {}) };
}
if (saved.flow_id) await deps.hass.abortFlow(saved.flow_id);
return {
what,
result: "refused",
problem:
`Home Assistant's own connection test refused ${want.username}@${want.host}:${want.port} ` +
`(${describe(saved)}); its MQTT entry is unchanged`,
};
} catch (err) {
return { what, result: "refused", problem: scrub(err, want.password) };
}
}
/** A fresh Home Assistant has no MQTT entry: made through the integration's user flow. */
async function createEntry(deps: MqttDeps, want: MqttWanted, note?: string): Promise<Outcome> {
const what = "mqtt";
let flow = await deps.hass.startFlow("mqtt");
if (flow.type === "form" && flow.step_id !== "broker" && flow.flow_id) {
// Anything before the broker form (none outside the Supervisor) is not this step's to answer.
await deps.hass.abortFlow(flow.flow_id);
return { what, result: "refused", problem: `Home Assistant's MQTT user flow asked ${flow.step_id} before the broker` };
}
if (flow.type !== "form" || !flow.flow_id) {
return { what, result: "refused", problem: `Home Assistant's MQTT user flow answered ${describe(flow)}` };
}
const shown = formValues(flow.data_schema);
// A new entry's form has no value for its two certificate choices (a reconfigure pre-fills them
// from the entry): plain MQTT, so neither a CA nor a client certificate.
const other = (shown.other_settings ?? {}) as Record<string, unknown>;
if (flow.data_schema?.some((f) => f.name === "other_settings")) {
shown.other_settings = { set_ca_cert: "off", set_client_cert: false, ...other };
}
flow = await deps.hass.stepFlow(flow.flow_id, {
...shown,
broker: want.host,
port: want.port,
username: want.username,
password: want.password,
});
if (flow.type === "create_entry") {
const id = (flow.result as { entry_id?: string } | undefined)?.entry_id;
if (id) await deps.marks.set("mqtt", markFor(id, want));
return { what, result: "written", fields: ["entry"], ...(note ? { note } : {}) };
}
if (flow.flow_id) await deps.hass.abortFlow(flow.flow_id);
return { what, result: "refused", problem: `Home Assistant refused a new MQTT entry for ${want.host}:${want.port} (${describe(flow)})` };
}
export function describe(r: { type: string; reason?: string; errors?: Record<string, string> | null }): string {
const errors = r.errors ? Object.entries(r.errors).map(([k, v]) => `${k}: ${v}`).join(", ") : "";
return [r.type, r.reason, errors].filter(Boolean).join(" — ");
}
// ---- Sonarr, Radarr, Lidarr ----
// Home Assistant's Sonarr, Radarr and Lidarr integrations, pointed at the apps the mesh bound —
// `sonarr-api`, `radarr-api`, `lidarr-api` (their providers: mesh-catalog #156).
//
// **What Home Assistant lets anyone change, and what it does not.** Each integration keeps a URL and
// an API key in its config entry. None of the three has a reconfigure flow: Home Assistant changes
// them only through the flow its UI runs —
// - a **user flow** makes a new entry (validated against the app);
// - a **reauth flow**, which Home Assistant starts by itself when the app refuses the key it holds,
// takes a new key (Sonarr) or a new URL and key (Radarr, Lidarr);
// - anything else — the URL of a working entry — only by removing the integration and adding it
// again, which throws away its entities' names, areas and history links. **This step never
// removes an entry.**
// So, per app:
// 1. The bound key is tried against the bound app first. Refused, nothing is written: until the
// operator accepts the app's own key for this pair, the mesh delivers a value it minted, which
// no Servarr app takes (novox/hq ADR 0092) — the failure names the `secret accept` that fixes it.
// 2. No entry: one is made through the user flow.
// 3. A reauth flow Home Assistant started for the entry: finished with the bound key (and URL,
// where the integration's reauth asks for one).
// 4. An entry whose URL (read from the device the integration registered, `configuration_url`)
// is the bound one and which is loaded: already as the mesh says. The key needs no digest here:
// a Servarr app has one key, so an entry loaded against the app holds the key the app took.
// 5. A working entry at a different URL that reaches **the same app** — the same process, by the
// app's own status (start time, data folder, version) — is left as it is and said: ace's entries
// say `127.0.0.1:<port>` and the binding says `ace.internal:<port>`, one Sonarr either way.
// 6. Anything else is refused, loudly, with what the operator can do; nothing is removed.
export interface ServarrApp {
/** The integration's domain, also the app. */
domain: "sonarr" | "radarr" | "lidarr";
/** The provision it is required as: the `requires`, `binds` and `secrets` key. */
provision: string;
/** The app's status endpoint: answers 401 to a wrong key, and says which process answered. */
statusPath: string;
}
export const APPS: readonly ServarrApp[] = [
{ domain: "sonarr", provision: "sonarr-api", statusPath: "/api/v3/system/status" },
{ domain: "radarr", provision: "radarr-api", statusPath: "/api/v3/system/status" },
{ domain: "lidarr", provision: "lidarr-api", statusPath: "/api/v1/system/status" },
];
/** The HTTP the step needs toward the apps, so a test can stand fakes in. */
export interface Http {
fetch(url: string, init?: { method?: string; headers?: Record<string, string> }): Promise<{ status: number; text(): Promise<string> }>;
}
export type AppWanted = { ok: true; url: string; key: string; from: string } | { ok: false; problem: string };
/** The URL and key the mesh says Home Assistant uses for this app. */
export function wantedApp(spec: ServarrApp, binding: Binding | undefined, credential: string | undefined): AppWanted {
if (!binding) return { ok: false, problem: `no binding for ${spec.provision} was delivered — the mesh writes it before this step runs` };
const at = typeof binding.at === "string" ? binding.at.trim() : "";
if (!at) return { ok: false, problem: `the ${spec.provision} binding names no host (at)` };
const port = portOf(binding.serves);
if (port === undefined) return { ok: false, problem: `the ${spec.provision} binding serves no usable port (${String(binding.serves?.port)})` };
const scheme = typeof binding.serves?.scheme === "string" && binding.serves.scheme ? binding.serves.scheme : "http";
if (scheme !== "http" && scheme !== "https") return { ok: false, problem: `the ${spec.provision} binding serves scheme ${scheme}` };
const base = typeof binding.serves?.["url-base"] === "string" ? String(binding.serves["url-base"]).trim().replace(/^\/+|\/+$/g, "") : "";
const key = (credential ?? "").trim();
if (!key) return { ok: false, problem: `the ${spec.provision} credential is empty or was not delivered` };
return {
ok: true,
url: `${scheme}://${urlHost(at)}:${port}${base ? `/${base}` : ""}`,
key,
from: typeof binding.from === "string" ? binding.from : "",
};
}
/** Two URLs naming the same place: scheme, host, port (explicit or default) and base path. */
export function sameUrl(a: string | null | undefined, b: string): boolean {
if (!a) return false;
try {
const x = new URL(a);
const y = new URL(b);
const port = (u: URL) => u.port || (u.protocol === "https:" ? "443" : "80");
const path = (u: URL) => u.pathname.replace(/\/+$/, "");
return x.protocol === y.protocol && x.hostname.toLowerCase() === y.hostname.toLowerCase() && port(x) === port(y) && path(x) === path(y);
} catch {
return false;
}
}
type Status = { taken: true; status: Record<string, unknown> } | { taken: false };
/** The app's status with this key: `taken: false` when it refuses the key; throws when it cannot be asked. */
export async function appStatus(http: Http, spec: ServarrApp, url: string, key: string): Promise<Status> {
const res = await http.fetch(`${url.replace(/\/+$/, "")}${spec.statusPath}`, {
method: "GET",
headers: { "X-Api-Key": key, Accept: "application/json" },
});
if (res.status === 401 || res.status === 403) return { taken: false };
if (res.status < 200 || res.status >= 300) throw new Error(`${spec.domain} answered ${res.status} at ${spec.statusPath}`);
return { taken: true, status: JSON.parse(await res.text()) as Record<string, unknown> };
}
/** Whether two status answers came from one running app. */
export function sameInstance(a: Record<string, unknown>, b: Record<string, unknown>): boolean {
const facts = ["startTime", "appData", "version"];
return facts.every((k) => a[k] !== undefined && a[k] !== null && a[k] === b[k]);
}
/** The remedy for a refused key, in the controller's words (ADR 0092). */
export function acceptRemedy(spec: ServarrApp, from: string): string {
return (
`${spec.domain} refuses the ${spec.provision} credential the mesh delivered, so nothing was written into ` +
`Home Assistant. A Servarr app has one API key and the mesh cannot make it: accept ${spec.domain}'s own key ` +
`for this pair — \`secret accept <this node> home-assistant ${spec.provision} --provider ${from || "<its node>"} ` +
`--from <file holding ${spec.domain}'s ApiKey>\``
);
}
export interface ServarrDeps {
hass: Hass;
http: Http;
}
/** The input a Servarr form takes: what it shows, with the URL (where asked) and the key laid over. */
function servarrInput(schema: readonly SchemaField[] | null | undefined, url: string, key: string): Record<string, unknown> {
const input = formValues(schema);
if (hasField(schema, "url")) input.url = url;
if (hasField(schema, "api_key")) input.api_key = key;
return input;
}
/** Bring Home Assistant's entry for one app in line with the mesh. Never throws. */
export async function reconcileApp(deps: ServarrDeps, spec: ServarrApp, binding: Binding | undefined, credential: string | undefined): Promise<Outcome> {
const what = spec.domain;
const w = wantedApp(spec, binding, credential);
if ("problem" in w) return { what, result: "refused", problem: w.problem };
let bound: Status;
try {
bound = await appStatus(deps.http, spec, w.url, w.key);
} catch (err) {
return { what, result: "refused", problem: `${spec.domain} could not be asked at ${w.url}: ${scrub(err, w.key)}` };
}
if (!bound.taken) return { what, result: "refused", problem: acceptRemedy(spec, w.from) };
try {
const entries = (await deps.hass.entries(spec.domain)).filter((e) => e.domain === spec.domain);
if (entries.length > 1) {
return { what, result: "refused", problem: `Home Assistant has ${entries.length} ${spec.domain} entries; which one the mesh owns is not guessed` };
}
// No entry: made, through the integration's own user flow, which validates the key itself.
if (entries.length === 0) {
const flow = await deps.hass.startFlow(spec.domain);
if (flow.type !== "form" || !flow.flow_id) return { what, result: "refused", problem: `Home Assistant's ${spec.domain} user flow answered ${describe(flow)}` };
const made = await deps.hass.stepFlow(flow.flow_id, servarrInput(flow.data_schema, w.url, w.key));
if (made.type === "create_entry") return { what, result: "written", fields: ["entry"] };
if (made.flow_id) await deps.hass.abortFlow(made.flow_id);
return { what, result: "refused", problem: `Home Assistant refused a new ${spec.domain} entry at ${w.url} (${describe(made)})` };
}
const entry = entries[0];
if (entry.disabled_by) return { what, result: "unchanged", note: `the ${spec.domain} entry is disabled (by ${entry.disabled_by}); left alone` };
// A reauth Home Assistant started because the app refused its key: finished with the bound one.
const reauth = (await deps.hass.flowsInProgress()).find(
(f) => f.handler === spec.domain && f.context?.source === "reauth" && f.context?.entry_id === entry.entry_id,
);
if (reauth) {
let step = await deps.hass.stepFlow(reauth.flow_id, {}); // reauth_confirm: a confirmation, no fields
if (step.type === "form" && step.flow_id && step.step_id !== "reauth_confirm") {
const input = servarrInput(step.data_schema, w.url, w.key);
const fields = ["api_key", ...(hasField(step.data_schema, "url") ? ["url"] : [])];
step = await deps.hass.stepFlow(step.flow_id, input);
if (step.type === "abort" && step.reason === "reauth_successful") return { what, result: "written", fields };
}
return { what, result: "refused", problem: `Home Assistant's ${spec.domain} reauth did not take the bound key and URL (${describe(step)})` };
}
const device = (await deps.hass.devices()).find((d) => d.config_entries?.includes(entry.entry_id) && d.configuration_url);
const current = device?.configuration_url ?? undefined;
if (entry.state === "loaded" && sameUrl(current, w.url)) return { what, result: "unchanged" };
if (entry.state === "loaded" && current) {
let there: Status | undefined;
try {
there = await appStatus(deps.http, spec, current, w.key);
} catch {
there = undefined;
}
if (there?.taken && sameInstance(there.status, bound.status)) {
return {
what,
result: "equivalent",
note:
`Home Assistant reaches ${spec.domain} at ${current}, the same running app the mesh bound at ${w.url}; ` +
`Home Assistant has no way to change a working ${spec.domain} entry's URL short of removing it, so it is left as it is`,
};
}
}
return {
what,
result: "refused",
problem:
`Home Assistant's ${spec.domain} entry (${entry.state ?? "unknown state"}) points at ${current ?? "an unknown URL"}, ` +
`not the ${spec.domain} the mesh bound at ${w.url}. Home Assistant only lets a working entry's URL change by ` +
`removing and re-adding the integration, which this step never does: remove it in Home Assistant ` +
`(Settings → Devices & services → ${spec.domain}) and the next run adds it at the bound URL`,
};
} catch (err) {
return { what, result: "refused", problem: scrub(err, w.key) };
}
}
+160
View File
@@ -0,0 +1,160 @@
// Home Assistant's own configuration API, as the provisions step uses it — the supported way to
// change an integration's connection. Home Assistant keeps every integration in
// `.storage/core.config_entries`, a file it owns and rewrites; the mesh may not write it, and it is
// not a file the mesh could merge into. What Home Assistant offers instead is the same thing its UI
// uses: **config flows** over REST (`/api/config/config_entries/flow`) — a user flow creates an
// entry, a reconfigure flow changes one, a reauth flow (which Home Assistant starts itself when a
// credential stops working) replaces its credential — each validated by the integration's own
// connection test before anything is saved. The two things REST does not answer (which flows Home
// Assistant has started, which device an entry made) come over its WebSocket API.
//
// Nothing here reads `.storage`. Authenticated with the module's accepted long-lived access token.
/** A config entry as `GET /api/config/config_entries/entry` lists it — no data, no credentials. */
export interface ConfigEntry {
entry_id: string;
domain: string;
title?: string;
source?: string;
state?: string;
disabled_by?: string | null;
}
/** One field of a flow's form, as Home Assistant serializes a voluptuous schema. */
export interface SchemaField {
name: string;
type?: string;
required?: boolean;
optional?: boolean;
default?: unknown;
description?: { suggested_value?: unknown } | null;
/** A section (`type: "expandable"`) carries its own fields. */
schema?: SchemaField[];
}
/** What a flow answered: another form, an entry made, or the flow ended (abort). */
export interface FlowResult {
type: string;
flow_id?: string;
handler?: string;
step_id?: string;
data_schema?: SchemaField[] | null;
errors?: Record<string, string> | null;
reason?: string;
result?: { entry_id?: string } | unknown;
}
/** A flow in progress that Home Assistant started itself (a reauth, a discovery). */
export interface FlowProgress {
flow_id: string;
handler: string;
step_id?: string;
context?: { source?: string; entry_id?: string };
}
/** A device from the device registry; an integration names where its app is as configuration_url. */
export interface DeviceEntry {
id: string;
config_entries?: string[];
configuration_url?: string | null;
}
export interface Hass {
entries(domain: string): Promise<ConfigEntry[]>;
/** A user flow for `handler`, or — given an entry — a reconfigure flow for it. */
startFlow(handler: string, entryId?: string): Promise<FlowResult>;
stepFlow(flowId: string, input: Record<string, unknown>): Promise<FlowResult>;
abortFlow(flowId: string): Promise<void>;
flowsInProgress(): Promise<FlowProgress[]>;
devices(): Promise<DeviceEntry[]>;
}
/** Home Assistant over HTTP: REST for entries and flows, one short WebSocket session per question. */
export class HassApi implements Hass {
private readonly base: string;
constructor(url: string, private readonly token: string) {
this.base = url.replace(/\/$/, "");
}
private async rest(method: string, path: string, body?: unknown): Promise<unknown> {
const res = await fetch(`${this.base}${path}`, {
method,
headers: {
Authorization: `Bearer ${this.token}`,
Accept: "application/json",
...(body !== undefined ? { "Content-Type": "application/json" } : {}),
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const text = await res.text();
if (!res.ok) {
// Home Assistant's error text names fields, never echoes their values.
throw new Error(`Home Assistant ${method} ${path} answered ${res.status}${text ? `: ${text.slice(0, 200)}` : ""}`);
}
return text ? (JSON.parse(text) as unknown) : undefined;
}
async entries(domain: string): Promise<ConfigEntry[]> {
return ((await this.rest("GET", `/api/config/config_entries/entry?domain=${encodeURIComponent(domain)}`)) ??
[]) as ConfigEntry[];
}
async startFlow(handler: string, entryId?: string): Promise<FlowResult> {
return (await this.rest("POST", "/api/config/config_entries/flow", {
handler,
show_advanced_options: true,
...(entryId ? { entry_id: entryId } : {}),
})) as FlowResult;
}
async stepFlow(flowId: string, input: Record<string, unknown>): Promise<FlowResult> {
return (await this.rest("POST", `/api/config/config_entries/flow/${encodeURIComponent(flowId)}`, input)) as FlowResult;
}
async abortFlow(flowId: string): Promise<void> {
await this.rest("DELETE", `/api/config/config_entries/flow/${encodeURIComponent(flowId)}`).catch(() => undefined);
}
async flowsInProgress(): Promise<FlowProgress[]> {
return (await this.ws("config_entries/flow/progress")) as FlowProgress[];
}
async devices(): Promise<DeviceEntry[]> {
return (await this.ws("config/device_registry/list")) as DeviceEntry[];
}
/** One WebSocket command: connect, authenticate, ask, close. */
private ws(type: string): Promise<unknown> {
const url = `${this.base.replace(/^http/, "ws")}/api/websocket`;
return new Promise((resolve, reject) => {
const socket = new WebSocket(url);
const timer = setTimeout(() => {
socket.close();
reject(new Error(`Home Assistant's WebSocket did not answer ${type} within 30s`));
}, 30_000);
const done = (fn: () => void): void => {
clearTimeout(timer);
socket.close();
fn();
};
socket.onerror = () => done(() => reject(new Error(`Home Assistant's WebSocket at ${url} failed`)));
socket.onmessage = (event: { data: unknown }) => {
const msg = JSON.parse(String(event.data)) as {
type: string;
id?: number;
success?: boolean;
result?: unknown;
error?: { message?: string };
};
if (msg.type === "auth_required") socket.send(JSON.stringify({ type: "auth", access_token: this.token }));
else if (msg.type === "auth_invalid") done(() => reject(new Error("Home Assistant refused the token")));
else if (msg.type === "auth_ok") socket.send(JSON.stringify({ id: 1, type }));
else if (msg.type === "result" && msg.id === 1) {
if (msg.success) done(() => resolve(msg.result));
else done(() => reject(new Error(`Home Assistant ${type}: ${msg.error?.message ?? "failed"}`)));
}
};
});
}
}
@@ -0,0 +1,84 @@
// home-assistant's provisions step — run once by the host after Home Assistant starts, and again
// whenever a binding or pair credential it reads changes (the container's `restart-on`, novox/hq
// ADR 0099). It points Home Assistant's MQTT integration at the `mqtt-topic` broker and its Sonarr,
// Radarr and Lidarr integrations at the `sonarr-api`, `radarr-api` and `lidarr-api` apps, through
// Home Assistant's own config flows (connections.ts). It connects to no mesh broker.
//
// Exits non-zero when anything could not be put right, so the node reports the step failed and the
// host runs it again on the next apply. Declared last in the manifest, so its failing gates nothing
// else of home-assistant's (novox/hq ADR 0136). Never prints a key or password.
import { join } from "node:path";
import { APPS, MQTT_PROVISION, reconcileApp, reconcileMqtt, type Outcome } from "./connections.js";
import { HassApi } from "./hass.js";
import { marksIn, readBinding, readIfThere } from "./mesh.js";
import { probeBroker } from "./probe.js";
const dir = process.env.MESH_PROVISIONS_DIR ?? "/run/provisions";
const url = process.env.MESH_HOMEASSISTANT_URL ?? "http://127.0.0.1:8123";
const token = (await readIfThere(process.env.MESH_HOMEASSISTANT_TOKEN_FILE))?.trim() ?? "";
const marks = marksIn(process.env.MESH_WRITTEN_DIR ?? "/var/lib/home-assistant-provisions");
const waitSeconds = Number(process.env.MESH_HOMEASSISTANT_WAIT_SECONDS ?? "300");
if (!token) {
console.error("[hass-provisions] no Home Assistant token — home-assistant's own `token` secret has not been accepted");
process.exit(1);
}
/** Home Assistant answers /api/ with 200 once it is up and the token is good. */
async function ready(): Promise<boolean> {
const until = Date.now() + waitSeconds * 1000;
for (;;) {
try {
const res = await fetch(`${url.replace(/\/$/, "")}/api/`, { headers: { Authorization: `Bearer ${token}` } });
if (res.status === 200) return true;
if (res.status === 401 || res.status === 403) {
console.error("[hass-provisions] Home Assistant refuses the token — accept a long-lived access token it issued");
return false;
}
} catch {
// not listening yet
}
if (Date.now() >= until) return false;
await new Promise((r) => setTimeout(r, 3000));
}
}
if (!(await ready())) {
console.error(`[hass-provisions] Home Assistant did not answer at ${url} within ${waitSeconds}s`);
process.exit(1);
}
const hass = new HassApi(url, token);
const read = async (p: string) => [await readBinding(join(dir, `${p}.json`)), await readIfThere(join(dir, `${p}.secret`))] as const;
const outcomes: Outcome[] = [];
{
const [binding, secret] = await read(MQTT_PROVISION);
outcomes.push(await reconcileMqtt({ hass, probe: probeBroker, marks }, binding, secret));
}
for (const spec of APPS) {
const [binding, secret] = await read(spec.provision);
outcomes.push(await reconcileApp({ hass, http: { fetch: (u, init) => fetch(u, init) } }, spec, binding, secret));
}
let failed = 0;
for (const o of outcomes) {
switch (o.result) {
case "unchanged":
console.log(`[hass-provisions] ${o.what}: already as the mesh says${o.note ? ` — ${o.note}` : ""}`);
break;
case "written":
console.log(`[hass-provisions] ${o.what}: wrote ${o.fields.join(", ")}; Home Assistant's own test passed${o.note ? ` — ${o.note}` : ""}`);
break;
case "equivalent":
console.log(`[hass-provisions] ${o.what}: ${o.note}`);
break;
case "refused":
failed++;
console.error(`[hass-provisions] ${o.what}: ${o.problem}`);
break;
}
}
process.exitCode = failed > 0 ? 1 : 0;
+42
View File
@@ -0,0 +1,42 @@
// What the mesh delivered to home-assistant's provisions step, and the step's own small memory.
//
// Per provision it requires, the mesh writes two files beside each other (the manifest's `binds` and
// `secrets`): `<provision>.json`, the binding — where the provider is (`at`), what it serves (`port`,
// `scheme`, …) and the login this module presents (`as`) — and `<provision>.secret`, the pair
// credential. Nothing here guesses a host, a port or a key.
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
import { join } from "node:path";
import type { Binding, Marks } from "./connections.js";
/** A file the mesh wrote, or undefined when it is not there. */
export async function readIfThere(path: string | undefined): Promise<string | undefined> {
if (!path) return undefined;
return readFile(path, "utf8").catch(() => undefined);
}
/** A binding file parsed, or undefined when absent or not JSON. */
export async function readBinding(path: string): Promise<Binding | undefined> {
const raw = await readIfThere(path);
if (raw === undefined) return undefined;
try {
return JSON.parse(raw) as Binding;
} catch {
return undefined;
}
}
export function marksIn(dir: string): Marks {
return {
async get(name) {
return (await readIfThere(join(dir, `${name}.digest`)))?.trim() || undefined;
},
async set(name, value) {
await mkdir(dir, { recursive: true, mode: 0o700 });
const path = join(dir, `${name}.digest`);
await writeFile(`${path}.tmp`, `${value}\n`, { mode: 0o600 });
await rename(`${path}.tmp`, path);
},
};
}
+117
View File
@@ -0,0 +1,117 @@
// Ask the broker, before Home Assistant is told anything, whether it takes the login and password
// the mesh delivered — and whether that login may subscribe to Home Assistant's discovery topics.
//
// One MQTT 3.1.1 session: CONNECT (clean, a throwaway client id, so Home Assistant's own session is
// never taken over), read the CONNACK, optionally SUBSCRIBE once and read the SUBACK, DISCONNECT.
// No dependency: the handful of bytes MQTT needs for this are written here.
import { randomBytes } from "node:crypto";
import { connect } from "node:net";
export interface ProbeResult {
/** 0 accepted; 4 bad username or password; 5 not authorised. */
connack: number;
/** The SUBACK return code for the filter asked about: 0–2 granted, 0x80 refused. */
suback?: number;
}
export type Probe = (host: string, port: number, username: string, password: string, subscribe?: string) => Promise<ProbeResult>;
function str(v: string): Buffer {
const b = Buffer.from(v, "utf8");
const len = Buffer.alloc(2);
len.writeUInt16BE(b.length);
return Buffer.concat([len, b]);
}
function packet(type: number, body: Buffer): Buffer {
let remaining = body.length;
const lenBytes: number[] = [];
do {
let byte = remaining % 128;
remaining = Math.floor(remaining / 128);
if (remaining > 0) byte |= 0x80;
lenBytes.push(byte);
} while (remaining > 0);
return Buffer.concat([Buffer.from([type, ...lenBytes]), body]);
}
/** The first complete packet in `buf`: its type byte, its body, and how many bytes it took. */
export function firstPacket(buf: Buffer): { type: number; body: Buffer; used: number } | undefined {
if (buf.length < 2) return undefined;
let length = 0;
let multiplier = 1;
let i = 1;
for (;;) {
if (i >= buf.length) return undefined;
const byte = buf[i++];
length += (byte & 0x7f) * multiplier;
if ((byte & 0x80) === 0) break;
multiplier *= 128;
if (i > 4) throw new Error("malformed MQTT remaining length");
}
if (buf.length < i + length) return undefined;
return { type: buf[0], body: buf.subarray(i, i + length), used: i + length };
}
export const probeBroker: Probe = (host, port, username, password, subscribe) => {
const connectBody = Buffer.concat([
str("MQTT"),
Buffer.from([4, 0xc2, 0, 10]), // level 4 (3.1.1); username + password + clean session; keepalive 10s
str(`mesh-probe-${randomBytes(6).toString("hex")}`),
str(username),
str(password),
]);
return new Promise((resolve, reject) => {
const socket = connect({ host, port });
let buf = Buffer.alloc(0);
const result: ProbeResult = { connack: -1 };
const timer = setTimeout(() => {
socket.destroy();
reject(new Error(`no answer from the broker at ${host}:${port} within 10s`));
}, 10_000);
const finish = (): void => {
clearTimeout(timer);
if (result.connack === 0) socket.end(Buffer.from([0xe0, 0]));
else socket.destroy();
resolve(result);
};
socket.on("connect", () => socket.write(packet(0x10, connectBody)));
socket.on("data", (chunk) => {
buf = Buffer.concat([buf, chunk]);
for (;;) {
let p;
try {
p = firstPacket(buf);
} catch (err) {
clearTimeout(timer);
socket.destroy();
reject(err);
return;
}
if (!p) return;
buf = buf.subarray(p.used);
const kind = p.type >> 4;
if (kind === 2) {
result.connack = p.body[1] ?? -1;
if (result.connack !== 0 || !subscribe) return finish();
// SUBSCRIBE, packet id 1, one filter at QoS 0.
socket.write(packet(0x82, Buffer.concat([Buffer.from([0, 1]), str(subscribe), Buffer.from([0])])));
} else if (kind === 9) {
result.suback = p.body[2];
return finish();
}
}
});
socket.on("error", (err) => {
clearTimeout(timer);
reject(err);
});
socket.on("close", () => {
if (result.connack === -1) {
clearTimeout(timer);
reject(new Error(`the broker at ${host}:${port} closed the connection without answering`));
}
});
});
};
@@ -0,0 +1,293 @@
// What holds home-assistant's provisions step (provisions/*.ts): Home Assistant's MQTT entry is
// made to use the broker, port and login the mesh bound — only after the broker takes that login,
// through the reconfigure flow, keeping every other setting as Home Assistant pre-filled it, and not
// again once it already says so; its Sonarr/Radarr/Lidarr entries are made, finished (reauth), left
// alone when they already reach the bound app, and never removed; a key the app refuses (the mesh's
// minted value before the operator accepts the app's) is never written.
//
// Home Assistant and the apps are fakes answering as the real ones do (flow shapes checked against
// ghcr.io/home-assistant/home-assistant 2026.9.3, the build ace runs).
import { test } from "node:test";
import assert from "node:assert/strict";
import type { ConfigEntry, DeviceEntry, FlowProgress, FlowResult, Hass, SchemaField } from "../provisions/hass.ts";
import type { Binding, Marks } from "../provisions/connections.ts";
import { APPS, formValues, reconcileApp, reconcileMqtt, sameUrl, type Http, type ServarrApp } from "../provisions/connections.ts";
import type { Probe } from "../provisions/probe.ts";
const PWD_NOT_CHANGED = "__**password_not_changed**__";
const MINTED = "mesh-minted-password";
function mqttBinding(): Binding {
return { provision: "mqtt-topic", from: "ace", at: "ace.internal", as: "mesh_ace_hass", serves: { scheme: "mqtt", port: 1883 } };
}
/** The MQTT reconfigure form as Home Assistant serializes it, pre-filled from an entry. */
function brokerForm(data: Record<string, unknown>): SchemaField[] {
return [
{ name: "broker", type: "string", required: true, description: { suggested_value: data.broker } },
{ name: "port", type: "integer", required: true, default: 1883, description: { suggested_value: data.port } },
{ name: "protocol", type: "select", required: true, default: "3.1.1", description: { suggested_value: data.protocol } },
{ name: "username", type: "string", optional: true, description: { suggested_value: data.username } },
{ name: "password", type: "string", optional: true, description: { suggested_value: data.password ? PWD_NOT_CHANGED : undefined } },
{
name: "other_settings",
type: "expandable",
required: true,
schema: [
{ name: "keepalive", type: "integer", optional: true, description: { suggested_value: 60 } },
{ name: "transport", type: "select", required: true, default: "tcp", description: { suggested_value: "tcp" } },
{ name: "set_ca_cert", type: "select", required: true, description: { suggested_value: "off" } },
{ name: "set_client_cert", type: "boolean", required: true, description: { suggested_value: false } },
],
},
];
}
interface FakeOpts {
entries?: Record<string, (ConfigEntry & { data: Record<string, unknown> })[]>;
/** What Home Assistant's own connection test accepts. */
accepts?: (data: Record<string, unknown>) => boolean;
reauth?: FlowProgress[];
devices?: DeviceEntry[];
}
function fakeHass(opts: FakeOpts = {}) {
const entries = opts.entries ?? {};
const calls: string[] = [];
const submitted: Record<string, unknown>[] = [];
const flows = new Map<string, { handler: string; entryId?: string; step: string; reauth?: boolean }>();
let n = 0;
const accepts = opts.accepts ?? (() => true);
const form = (id: string, step: string, schema: SchemaField[], errors?: Record<string, string>): FlowResult => ({
type: "form", flow_id: id, step_id: step, data_schema: schema, errors: errors ?? null,
});
const servarrUser: SchemaField[] = [
{ name: "url", type: "string", required: true },
{ name: "api_key", type: "string", required: true },
{ name: "more_options", type: "expandable", required: true, schema: [{ name: "verify_ssl", type: "boolean", optional: true, default: false }] },
];
const hass: Hass = {
async entries(domain) {
calls.push(`entries ${domain}`);
return (entries[domain] ?? []).map(({ data: _d, ...e }) => e);
},
async startFlow(handler, entryId) {
calls.push(`start ${handler}${entryId ? ` ${entryId}` : ""}`);
const id = `f${++n}`;
if (handler === "mqtt") {
const entry = entryId ? entries.mqtt.find((e) => e.entry_id === entryId) : undefined;
if (entryId && !entry) return { type: "abort", reason: "not_found" };
flows.set(id, { handler, entryId, step: "broker" });
return form(id, "broker", brokerForm(entry?.data ?? {}));
}
if (entryId) return { type: "abort", reason: "not_implemented" }; // no reconfigure for Servarr
flows.set(id, { handler, step: "user" });
return form(id, "user", servarrUser);
},
async stepFlow(flowId, input) {
calls.push(`step ${flowId}`);
const flow = flows.get(flowId);
if (!flow) throw new Error(`Home Assistant POST flow/${flowId} answered 404`);
if (flow.step === "reauth_confirm") {
flow.step = "user";
return form(flowId, "user", [
{ name: "url", type: "string", required: true, default: "http://old:1" },
{ name: "api_key", type: "string", optional: true },
{ name: "verify_ssl", type: "boolean", optional: true, default: false },
]);
}
submitted.push(input);
if (flow.handler === "mqtt") {
const entry = entries.mqtt?.find((e) => e.entry_id === flow.entryId);
const data = { ...input, ...(input.password === PWD_NOT_CHANGED ? { password: entry?.data.password } : {}) };
if (!accepts(data)) return form(flowId, "broker", brokerForm(data), { base: "cannot_connect" });
flows.delete(flowId);
if (entry) {
entry.data = data;
return { type: "abort", reason: "reconfigure_successful" };
}
(entries.mqtt ??= []).push({ entry_id: "new-mqtt", domain: "mqtt", state: "loaded", data });
return { type: "create_entry", result: { entry_id: "new-mqtt" } };
}
if (!accepts(input)) return form(flowId, "user", servarrUser, { base: "invalid_auth" });
flows.delete(flowId);
if (flow.reauth) return { type: "abort", reason: "reauth_successful" };
(entries[flow.handler] ??= []).push({ entry_id: `new-${flow.handler}`, domain: flow.handler, state: "loaded", data: input });
return { type: "create_entry", result: { entry_id: `new-${flow.handler}` } };
},
async abortFlow(flowId) {
calls.push(`abort ${flowId}`);
flows.delete(flowId);
},
async flowsInProgress() {
for (const f of opts.reauth ?? []) flows.set(f.flow_id, { handler: f.handler, entryId: f.context?.entry_id, step: "reauth_confirm", reauth: true });
return opts.reauth ?? [];
},
async devices() {
return opts.devices ?? [];
},
};
return { hass, calls, submitted, entries };
}
function memoryMarks(): Marks & { store: Map<string, string> } {
const store = new Map<string, string>();
return { store, get: async (k) => store.get(k), set: async (k, v) => void store.set(k, v) };
}
const takes = (suback = 0): Probe => async (_h, _p, user, pass) => ({ connack: user === "mesh_ace_hass" && pass === MINTED ? 0 : 5, suback });
const aceMqttEntry = () => ({
entry_id: "7d1e", domain: "mqtt", state: "loaded",
data: { broker: "127.0.0.1", port: 1883, protocol: "5", username: "luffy", password: "luffys-password" },
});
test("mqtt: the broker is asked first; a login it does not take is never written", async () => {
const f = fakeHass({ entries: { mqtt: [aceMqttEntry()] } });
const out = await reconcileMqtt({ hass: f.hass, probe: async () => ({ connack: 5 }), marks: memoryMarks() }, mqttBinding(), MINTED);
assert.equal(out.result, "refused");
assert.match((out as { problem: string }).problem, /does not \(yet\) take the login mesh_ace_hass/);
assert.deepEqual(f.calls, []); // Home Assistant not even asked
assert.equal(f.entries.mqtt[0].data.username, "luffy");
});
test("mqtt: ace's entry (127.0.0.1, luffy) is moved to the bound broker and login, every other setting kept", async () => {
const f = fakeHass({ entries: { mqtt: [aceMqttEntry()] } });
const marks = memoryMarks();
const out = await reconcileMqtt({ hass: f.hass, probe: takes(), marks }, mqttBinding(), `${MINTED}\n`);
assert.deepEqual(out, { what: "mqtt", result: "written", fields: ["broker", "username", "password"] });
assert.deepEqual(f.entries.mqtt[0].data, {
broker: "ace.internal", port: 1883, protocol: "5", username: "mesh_ace_hass", password: MINTED,
other_settings: { keepalive: 60, transport: "tcp", set_ca_cert: "off", set_client_cert: false },
});
assert.ok(marks.store.get("mqtt"));
assert.ok(![...marks.store.values()].some((v) => v.includes(MINTED)));
// Run again: nothing differs, the flow is opened to read and closed without submitting.
const before = f.submitted.length;
const again = await reconcileMqtt({ hass: f.hass, probe: takes(), marks }, mqttBinding(), MINTED);
assert.deepEqual(again, { what: "mqtt", result: "unchanged" });
assert.equal(f.submitted.length, before);
assert.match(f.calls.at(-1) ?? "", /^abort /);
});
test("mqtt: a new password alone is written (the digest tells)", async () => {
const f = fakeHass({ entries: { mqtt: [aceMqttEntry()] } });
const marks = memoryMarks();
await reconcileMqtt({ hass: f.hass, probe: takes(), marks }, mqttBinding(), MINTED);
const rotated: Probe = async () => ({ connack: 0, suback: 0 });
const out = await reconcileMqtt({ hass: f.hass, probe: rotated, marks }, mqttBinding(), "rotated");
assert.deepEqual(out, { what: "mqtt", result: "written", fields: ["password"] });
assert.equal(f.entries.mqtt[0].data.password, "rotated");
});
test("mqtt: Home Assistant's own connection test refusing saves nothing and fails loudly", async () => {
const f = fakeHass({ entries: { mqtt: [aceMqttEntry()] }, accepts: () => false });
const marks = memoryMarks();
const out = await reconcileMqtt({ hass: f.hass, probe: takes(), marks }, mqttBinding(), MINTED);
assert.equal(out.result, "refused");
assert.match((out as { problem: string }).problem, /cannot_connect.*unchanged/);
assert.equal(f.entries.mqtt[0].data.username, "luffy");
assert.equal(marks.store.size, 0);
});
test("mqtt: a fresh Home Assistant gets an entry; a grant without the discovery topics is warned about", async () => {
const f = fakeHass();
const out = await reconcileMqtt({ hass: f.hass, probe: takes(0x80), marks: memoryMarks() }, mqttBinding(), MINTED);
assert.equal(out.result, "written");
assert.match((out as { note?: string }).note ?? "", /may not subscribe to homeassistant\/#/);
assert.equal(f.entries.mqtt[0].data.broker, "ace.internal");
});
test("mqtt: two entries, or a binding without a port, are refused rather than guessed", async () => {
const f = fakeHass({ entries: { mqtt: [aceMqttEntry(), { ...aceMqttEntry(), entry_id: "other" }] } });
assert.equal((await reconcileMqtt({ hass: f.hass, probe: takes(), marks: memoryMarks() }, mqttBinding(), MINTED)).result, "refused");
const noPort = { ...mqttBinding(), serves: {} };
assert.match(((await reconcileMqtt({ hass: f.hass, probe: takes(), marks: memoryMarks() }, noPort, MINTED)) as { problem: string }).problem, /no usable port/);
});
// ---- Servarr ----
const SONARR = APPS.find((a) => a.domain === "sonarr") as ServarrApp;
const RADARR = APPS.find((a) => a.domain === "radarr") as ServarrApp;
const KEY = "the-apps-own-key";
const servarrBinding = (port: number, at = "ace.internal"): Binding => ({ provision: "sonarr-api", from: "ace", at, as: "mesh_ace_hass", serves: { scheme: "http", port, "url-base": "" } });
/** One running Sonarr, answering on several addresses (127.0.0.1 and ace.internal are one host). */
function apps(instances: Record<string, { startTime: string }>): Http & { asked: string[] } {
const asked: string[] = [];
return {
asked,
async fetch(url, init) {
asked.push(url);
const u = new URL(url);
const inst = instances[`${u.hostname}:${u.port}`];
if (!inst) throw new Error("connect ECONNREFUSED");
if (init?.headers?.["X-Api-Key"] !== KEY) return { status: 401, text: async () => "" };
return { status: 200, text: async () => JSON.stringify({ version: "4.0.15", appData: "/config", startTime: inst.startTime }) };
},
};
}
const oneSonarr = () => apps({ "ace.internal:8989": { startTime: "t1" }, "127.0.0.1:8989": { startTime: "t1" } });
const sonarrEntry = (state = "loaded") => ({ entry_id: "5a1d", domain: "sonarr", state, data: { url: "http://127.0.0.1:8989", api_key: KEY } });
test("servarr: the mesh's minted key is never written; the remedy names the accept", async () => {
const f = fakeHass({ entries: { sonarr: [sonarrEntry()] } });
const out = await reconcileApp({ hass: f.hass, http: oneSonarr() }, SONARR, servarrBinding(8989), "minted-by-the-mesh");
assert.equal(out.result, "refused");
assert.match((out as { problem: string }).problem, /secret accept <this node> home-assistant sonarr-api --provider ace/);
assert.deepEqual(f.calls, []);
});
test("servarr: ace's entry at 127.0.0.1 reaches the same Sonarr the mesh bound at ace.internal — left, and said", async () => {
const f = fakeHass({ entries: { sonarr: [sonarrEntry()] }, devices: [{ id: "d", config_entries: ["5a1d"], configuration_url: "http://127.0.0.1:8989" }] });
const out = await reconcileApp({ hass: f.hass, http: oneSonarr() }, SONARR, servarrBinding(8989), KEY);
assert.equal(out.result, "equivalent");
assert.equal(f.submitted.length, 0);
});
test("servarr: an entry already at the bound URL is unchanged", async () => {
const f = fakeHass({ entries: { sonarr: [sonarrEntry()] }, devices: [{ id: "d", config_entries: ["5a1d"], configuration_url: "http://ace.internal:8989" }] });
assert.deepEqual(await reconcileApp({ hass: f.hass, http: oneSonarr() }, SONARR, servarrBinding(8989), KEY), { what: "sonarr", result: "unchanged" });
});
test("servarr: a working entry that reaches a different app is refused, and nothing is removed", async () => {
const f = fakeHass({ entries: { sonarr: [sonarrEntry()] }, devices: [{ id: "d", config_entries: ["5a1d"], configuration_url: "http://127.0.0.1:8989" }] });
const two = apps({ "ace.internal:8989": { startTime: "t1" }, "127.0.0.1:8989": { startTime: "another" } });
const out = await reconcileApp({ hass: f.hass, http: two }, SONARR, servarrBinding(8989), KEY);
assert.equal(out.result, "refused");
assert.match((out as { problem: string }).problem, /never does/);
assert.equal(f.entries.sonarr.length, 1);
});
test("servarr: no entry — one is made at the bound URL through the user flow", async () => {
const f = fakeHass({ entries: {} });
const out = await reconcileApp({ hass: f.hass, http: oneSonarr() }, SONARR, servarrBinding(8989), KEY);
assert.deepEqual(out, { what: "sonarr", result: "written", fields: ["entry"] });
assert.deepEqual(f.submitted[0], { url: "http://ace.internal:8989", api_key: KEY, more_options: { verify_ssl: false } });
});
test("servarr: a reauth Home Assistant started is finished with the bound URL and key", async () => {
const entry = { ...sonarrEntry("setup_error"), domain: "radarr", entry_id: "1955" };
const f = fakeHass({
entries: { radarr: [entry] },
reauth: [{ flow_id: "r1", handler: "radarr", step_id: "reauth_confirm", context: { source: "reauth", entry_id: "1955" } }],
});
const radarr = apps({ "ace.internal:7878": { startTime: "t" } });
const out = await reconcileApp({ hass: f.hass, http: radarr }, RADARR, { ...servarrBinding(7878), provision: "radarr-api" }, KEY);
assert.deepEqual(out, { what: "radarr", result: "written", fields: ["api_key", "url"] });
assert.deepEqual(f.submitted[0], { url: "http://ace.internal:7878", api_key: KEY, verify_ssl: false });
});
test("form values: suggested first, then default, sections nested", () => {
assert.deepEqual(formValues(brokerForm({ broker: "b", port: 1, protocol: "5", username: "u", password: "p" })), {
broker: "b", port: 1, protocol: "5", username: "u", password: PWD_NOT_CHANGED,
other_settings: { keepalive: 60, transport: "tcp", set_ca_cert: "off", set_client_cert: false },
});
assert.ok(sameUrl("http://ace.internal:8989/", "http://ace.internal:8989"));
assert.ok(sameUrl("http://ACE.internal", "http://ace.internal:80"));
assert.ok(!sameUrl("http://127.0.0.1:8989", "http://ace.internal:8989"));
});
+10 -1
View File
@@ -8,5 +8,14 @@
"skipLibCheck": true, "skipLibCheck": true,
"noEmit": true "noEmit": true
}, },
"include": ["client.ts", "index.ts", "tools/index.ts"] "include": [
"client.ts",
"index.ts",
"tools/index.ts",
"provisions/hass.ts",
"provisions/probe.ts",
"provisions/connections.ts",
"provisions/mesh.ts",
"provisions/index.ts"
]
} }
-24
View File
@@ -1,24 +0,0 @@
# icecast's runtime: the tool runtime, carrying this module's compiled code.
#
# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in
# the base images, published like any other artifact — which is what makes this buildable by the
# mesh from a repository and a path (novox/hq ADR 0069) rather than only on a workstation that
# happens to have the siblings.
#
# Two bases, named rather than pinned (novox/hq issue 044): the image this is COMPILED in and the
# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`.
ARG BUILD_BASE
ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build
WORKDIR /app/modules/icecast
COPY . .
RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts tools/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE}
COPY --from=build /app/modules/icecast/dist /app/modules/icecast/dist
# Every serve-time entrypoint, loaded by the runtime in serve mode: tools and events serve, and a
# provider's provisioner runs its reconcile loop in the same process, with the broker connected —
# the convention novox/hq issues 060/061 settled.
ENV MESH_TOOL_MODULES=/app/modules/icecast/dist/index.js,/app/modules/icecast/dist/tools/index.js
+18 -40
View File
@@ -28,9 +28,6 @@
"stream.started", "stream.started",
"stream.stopped" "stream.stopped"
], ],
"own-secrets": {
"broker": "/var/lib/mesh/icecast/broker"
},
"listens": [ "listens": [
{ {
"name": "stream", "name": "stream",
@@ -44,8 +41,8 @@
{ {
"id": "mesh-state", "id": "mesh-state",
"type": "directory", "type": "directory",
"path": "/var/lib/mesh/icecast", "mode": "0700",
"mode": "0700" "place": "mesh"
}, },
{ {
"id": "state", "id": "state",
@@ -91,49 +88,30 @@
{ {
"id": "runtime-config", "id": "runtime-config",
"type": "file", "type": "file",
"path": "/var/lib/mesh/icecast/config.json", "path": "${dir:mesh-state}/config.json",
"mode": "0600", "mode": "0600",
"content": "{}\n", "content": "{}\n",
"merge": "json" "merge": "json"
},
{
"id": "runtime",
"type": "container",
"name": "mesh-icecast",
"network": "icecast",
"volumes": [
"/var/lib/mesh/icecast/broker:/run/secrets/broker:ro",
"/var/lib/mesh/icecast/config.json:/run/config/config.json:ro"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_ICECAST_URL": "http://icecast:8000",
"MESH_ICECAST_CONFIG_FILE": "/run/config/config.json"
},
"restart-on": [
"runtime-config"
],
"artifact": "runtime"
} }
], ],
"build": { "build": {
"on": [
{
"arg": "BUILD_BASE",
"module": "mesh-tools",
"artifact": "build"
},
{
"arg": "RUNTIME_BASE",
"module": "mesh-tools",
"artifact": "runtime"
}
],
"artifacts": [ "artifacts": [
{ {
"name": "runtime", "name": "code",
"kind": "image", "kind": "bundle",
"from": "Dockerfile" "language": "typescript",
"entrypoints": [
"index.js",
"tools/index.js"
],
"loads": [
"index.js",
"tools/index.js"
],
"env": {
"MESH_ICECAST_URL": "http://127.0.0.1:${port:8000}",
"MESH_ICECAST_CONFIG_FILE": "${dir:mesh-state}/config.json"
}
} }
] ]
} }
-24
View File
@@ -1,24 +0,0 @@
# influxdb's runtime: the tool runtime, carrying this module's compiled code.
#
# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in
# the base images, published like any other artifact — which is what makes this buildable by the
# mesh from a repository and a path (novox/hq ADR 0069) rather than only on a workstation that
# happens to have the siblings.
#
# Two bases, named rather than pinned (novox/hq issue 044): the image this is COMPILED in and the
# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`.
ARG BUILD_BASE
ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build
WORKDIR /app/modules/influxdb
COPY . .
RUN node /app/node_modules/typescript/bin/tsc client.ts grants.ts provisioner/index.ts tools/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE}
COPY --from=build /app/modules/influxdb/dist /app/modules/influxdb/dist
# Every serve-time entrypoint, loaded by the runtime in serve mode: tools and events serve, and a
# provider's provisioner runs its reconcile loop in the same process, with the broker connected —
# the convention novox/hq issues 060/061 settled.
ENV MESH_TOOL_MODULES=/app/modules/influxdb/dist/tools/index.js,/app/modules/influxdb/dist/provisioner/index.js
+20 -42
View File
@@ -11,7 +11,6 @@
"container-runtime" "container-runtime"
], ],
"own-secrets": { "own-secrets": {
"broker": "/var/lib/mesh/influxdb/broker",
"admin": "${dir:state}/admin.secret", "admin": "${dir:state}/admin.secret",
"admin-token": "${dir:state}/admin-token.secret" "admin-token": "${dir:state}/admin-token.secret"
}, },
@@ -42,8 +41,8 @@
{ {
"id": "mesh-state", "id": "mesh-state",
"type": "directory", "type": "directory",
"path": "/var/lib/mesh/influxdb", "mode": "0700",
"mode": "0700" "place": "mesh"
}, },
{ {
"id": "state", "id": "state",
@@ -96,33 +95,10 @@
{ {
"id": "runtime-config", "id": "runtime-config",
"type": "file", "type": "file",
"path": "/var/lib/mesh/influxdb/config.json", "path": "${dir:mesh-state}/config.json",
"mode": "0600", "mode": "0600",
"content": "{}\n", "content": "{}\n",
"merge": "json" "merge": "json"
},
{
"id": "runtime",
"type": "container",
"name": "mesh-influxdb",
"network": "host",
"volumes": [
"/var/lib/mesh/influxdb/broker:/run/secrets/broker:ro",
"/var/lib/mesh/influxdb/config.json:/run/config/config.json:ro",
"${dir:state}/admin-token.secret:/run/secrets/admin-token:ro",
"${dir:grants}:${dir:grants}:ro"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_INFLUXDB_URL": "http://127.0.0.1:${port:8086}",
"MESH_INFLUXDB_CONFIG_FILE": "/run/config/config.json",
"MESH_INFLUXDB_TOKEN_FILE": "/run/secrets/admin-token",
"MESH_RECEIVES": "${dir:grants}/mesh.json"
},
"restart-on": [
"runtime-config"
],
"artifact": "runtime"
} }
], ],
"requires": [ "requires": [
@@ -135,23 +111,25 @@
} }
}, },
"build": { "build": {
"on": [
{
"arg": "BUILD_BASE",
"module": "mesh-tools",
"artifact": "build"
},
{
"arg": "RUNTIME_BASE",
"module": "mesh-tools",
"artifact": "runtime"
}
],
"artifacts": [ "artifacts": [
{ {
"name": "runtime", "name": "code",
"kind": "image", "kind": "bundle",
"from": "Dockerfile" "language": "typescript",
"entrypoints": [
"tools/index.js",
"provisioner/index.js"
],
"loads": [
"tools/index.js",
"provisioner/index.js"
],
"env": {
"MESH_INFLUXDB_URL": "http://127.0.0.1:${port:8086}",
"MESH_INFLUXDB_CONFIG_FILE": "${dir:mesh-state}/config.json",
"MESH_INFLUXDB_TOKEN_FILE": "${dir:state}/admin-token.secret",
"MESH_RECEIVES": "${dir:grants}/mesh.json"
}
} }
] ]
} }
+20 -14
View File
@@ -26,13 +26,13 @@
} }
}, },
"binds": { "binds": {
"mongodb-database": "/var/lib/invoicing/database.json", "mongodb-database": "${dir:state}/database.json",
"s3-bucket": "/var/lib/invoicing/store.json", "s3-bucket": "${dir:state}/store.json",
"route": "/var/lib/invoicing/route.json" "route": "${dir:state}/route.json"
}, },
"secrets": { "secrets": {
"mongodb-database": "/var/lib/invoicing/database.secret", "mongodb-database": "${dir:state}/database.secret",
"s3-bucket": "/var/lib/invoicing/store.secret" "s3-bucket": "${dir:state}/store.secret"
}, },
"listens": [ "listens": [
{ {
@@ -54,21 +54,21 @@
{ {
"id": "mesh-state", "id": "mesh-state",
"type": "directory", "type": "directory",
"path": "/var/lib/mesh/invoicing", "mode": "0700",
"mode": "0700" "place": "mesh"
}, },
{ {
"id": "state", "id": "state",
"type": "directory", "type": "directory",
"path": "/var/lib/invoicing", "mode": "0700",
"mode": "0700" "place": "."
}, },
{ {
"id": "api-env", "id": "api-env",
"type": "file", "type": "file",
"path": "/var/lib/invoicing/api.env", "path": "${dir:state}/api.env",
"mode": "0600", "mode": "0600",
"content": "NODE_ENV=production\nPORT=9000\nMONGO_URL=mongodb://${bound:mongodb-database:as}:${secret:mongodb-database}@${bound:mongodb-database:at}:${bound:mongodb-database:port}/${bound:mongodb-database:as}?authSource=${bound:mongodb-database:as}\nMONGO_DB=${bound:mongodb-database:as}\nMINIO_BUCKET=mesh-novox-invoice\nMINIO_ENDPOINT=${bound:s3-bucket:at}\nMINIO_PORT=${bound:s3-bucket:port}\nMINIO_ACCESSKEY=${bound:s3-bucket:as}\nMINIO_SECRET=${secret:s3-bucket}\n" "content": "NODE_ENV=production\nPORT=9000\nMONGO_URL=mongodb://${bound:mongodb-database:as}:${secret:mongodb-database}@${bound:mongodb-database:at}:${bound:mongodb-database:port}/${bound:mongodb-database:as}?authSource=${bound:mongodb-database:as}\nMONGO_DB=${bound:mongodb-database:as}\nMINIO_BUCKET=${bound:s3-bucket:bucket}\nMINIO_ENDPOINT=${bound:s3-bucket:at}\nMINIO_PORT=${bound:s3-bucket:port}\nMINIO_ACCESSKEY=${bound:s3-bucket:as}\nMINIO_SECRET=${secret:s3-bucket}\n"
}, },
{ {
"id": "net", "id": "net",
@@ -87,7 +87,10 @@
}, },
"ports": [ "ports": [
"80" "80"
] ],
"names-on-purpose": {
"registry-api.novox.be": "built outside the mesh, from the application's own repository, and pulled from the registry that built it; moves when that repository is a build source on the git seat (novox/hq ADR 0155, issue 122)"
}
}, },
{ {
"id": "api", "id": "api",
@@ -100,12 +103,15 @@
"GID": "2201" "GID": "2201"
}, },
"env-file": [ "env-file": [
"/var/lib/invoicing/api.env" "${dir:state}/api.env"
], ],
"ports": [ "ports": [
"9000" "9000"
], ],
"secrets-in-environment": "the application's own code reads MONGO_URL and MINIO_SECRET from the environment (invoicing-app server/src/config.js); converting is that repository's change" "secrets-in-environment": "the application's own code reads MONGO_URL and MINIO_SECRET from the environment (invoicing-app server/src/config.js); converting is that repository's change",
"names-on-purpose": {
"registry-api.novox.be": "built outside the mesh, from the application's own repository, and pulled from the registry that built it; moves when that repository is a build source on the git seat (novox/hq ADR 0155, issue 122)"
}
} }
] ]
} }
-24
View File
@@ -1,24 +0,0 @@
# jackett's runtime: the tool runtime, carrying this module's compiled code.
#
# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in
# the base images, published like any other artifact — which is what makes this buildable by the
# mesh from a repository and a path (novox/hq ADR 0069) rather than only on a workstation that
# happens to have the siblings.
#
# Two bases, named rather than pinned (novox/hq issue 044): the image this is COMPILED in and the
# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`.
ARG BUILD_BASE
ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build
WORKDIR /app/modules/jackett
COPY . .
RUN node /app/node_modules/typescript/bin/tsc client.ts tools/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE}
COPY --from=build /app/modules/jackett/dist /app/modules/jackett/dist
# Every serve-time entrypoint, loaded by the runtime in serve mode: tools and events serve, and a
# provider's provisioner runs its reconcile loop in the same process, with the broker connected —
# the convention novox/hq issues 060/061 settled.
ENV MESH_TOOL_MODULES=/app/modules/jackett/dist/tools/index.js
-136
View File
@@ -1,136 +0,0 @@
// The Jackett API client — jackett's own code, living in the module (novox/hq ADR 0039). Jackett is
// an indexer proxy: it normalises many torrent trackers behind one Torznab surface. This client
// talks its /api/v2.0 REST API, and only jackett's tools import it.
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
export interface JackettIndexer {
id: string;
name: string;
type: string; // "public" | "private" | "semi-public"
configured: boolean;
siteLink?: string;
lastError?: string;
}
export interface JackettResult {
title: string;
tracker: string;
category?: string;
size: number;
seeders?: number;
peers?: number;
publishDate?: string;
link?: string;
}
/** The settings-merged config the mesh delivers (novox/hq ADR 0046): { url, apiKey, token, password, user, ... }. */
function meshConfig(file?: string): Record<string, string> {
if (!file) return {};
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
catch { return {}; }
}
export class JackettClient {
readonly baseUrl: string;
constructor(
url: string,
private readonly apiKey: string,
) {
this.baseUrl = url.replace(/\/$/, "");
}
/**
* Build from the module's resolved environment. Jackett's REST API is keyed, so both the URL and
* the key must be present. The key is read from the settings-merged config or MESH_JACKETT_API_KEY,
* or, failing those, discovered from Jackett's own ServerConfig.json under MESH_JACKETT_CONFIG_DIR
* — the file Jackett writes it to, as sonarr/radarr read theirs from config.xml — so a running
* server needs no key configured by hand and no secret has to be put in an assignment. Without a
* URL or key there is nothing to talk to, so this throws and the module contributes no tools
* rather than failing half-configured.
*/
static fromEnv(env: NodeJS.ProcessEnv = process.env): JackettClient {
const cfg = meshConfig(env.MESH_JACKETT_CONFIG_FILE);
const url = cfg.url ?? env.MESH_JACKETT_URL;
const apiKey = cfg.apiKey ?? env.MESH_JACKETT_API_KEY
?? JackettClient.detectApiKey(env.MESH_JACKETT_CONFIG_DIR ?? "/config");
if (!url) throw new Error("no Jackett URL — set MESH_JACKETT_URL");
if (!apiKey) throw new Error("no Jackett API key — set MESH_JACKETT_API_KEY or make the config dir readable");
return new JackettClient(url, apiKey);
}
/** Discover the API key from Jackett's ServerConfig.json (the linuxserver image keeps it at
* <config>/Jackett/ServerConfig.json), falling back to null. */
static detectApiKey(configDir: string): string | null {
for (const file of [join(configDir, "Jackett", "ServerConfig.json"), join(configDir, "ServerConfig.json")]) {
if (!existsSync(file)) continue;
try {
const key = (JSON.parse(readFileSync(file, "utf8")) as { APIKey?: unknown }).APIKey;
if (typeof key === "string" && key) return key;
} catch { /* unreadable or mid-write: try the next, then give up */ }
}
return null;
}
private async get(path: string, params: Record<string, string> = {}): Promise<any> {
const url = new URL(`${this.baseUrl}${path}`);
url.searchParams.set("apikey", this.apiKey);
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
const res = await fetch(url.toString(), { headers: { Accept: "application/json" } });
if (!res.ok) throw new Error(`Jackett API ${path}: ${res.status} ${await res.text()}`);
return res.json();
}
/**
* The configured indexers Jackett proxies. `configured=false` also lists the ones not set up.
* Read from the Torznab `t=indexers` feed, not /api/v2.0/indexers: that one is the web UI's and
* wants a login cookie (it answers an API-key request with a redirect), while the Torznab feed is
* what the key is for. The feed carries no last error, so `lastError` stays unset.
*/
async getIndexers(configuredOnly = true): Promise<JackettIndexer[]> {
const url = new URL(`${this.baseUrl}/api/v2.0/indexers/all/results/torznab/api`);
url.searchParams.set("apikey", this.apiKey);
url.searchParams.set("t", "indexers");
url.searchParams.set("configured", configuredOnly ? "true" : "false");
const res = await fetch(url.toString(), { headers: { Accept: "application/xml" } });
if (!res.ok) throw new Error(`Jackett API torznab t=indexers: ${res.status} ${await res.text()}`);
const xml = await res.text();
// Torznab reports failures (a wrong key among them) as 200 with an <error> body.
const err = xml.match(/<error code="(\d+)" description="([^"]*)"/);
if (err) throw new Error(`Jackett API torznab t=indexers: error ${err[1]} ${err[2]}`);
const text = (block: string, tag: string) =>
block.match(new RegExp(`<${tag}>([^<]*)</${tag}>`))?.[1];
const out: JackettIndexer[] = [];
for (const m of xml.matchAll(/<indexer id="([^"]+)" configured="([^"]+)">([\s\S]*?)<\/indexer>/g)) {
out.push({
id: m[1],
name: text(m[3], "title") ?? m[1],
type: text(m[3], "type") ?? "unknown",
configured: m[2] === "true",
siteLink: text(m[3], "link"),
});
}
return out;
}
/**
* A Torznab search across one indexer, or the "all" aggregate. Jackett returns a normalised JSON
* result set regardless of the underlying tracker, which is the whole point of the proxy.
*/
async search(query: string, indexer = "all", limit = 25): Promise<JackettResult[]> {
const raw = await this.get(`/api/v2.0/indexers/${encodeURIComponent(indexer)}/results`, { Query: query });
const results = Array.isArray(raw?.Results) ? raw.Results : [];
return results.slice(0, limit).map((r: any) => ({
title: r.Title,
tracker: r.Tracker ?? r.TrackerId ?? "unknown",
category: Array.isArray(r.CategoryDesc) ? r.CategoryDesc.join(", ") : r.CategoryDesc,
size: r.Size ?? 0,
seeders: r.Seeders,
peers: r.Peers,
publishDate: r.PublishDate,
link: r.Link ?? r.Details,
}));
}
}
-131
View File
@@ -1,131 +0,0 @@
{
"module": "jackett",
"version": "1",
"provides": [
{
"name": "jackett-api",
"scope": "mesh"
}
],
"serves": {
"jackett-api": {
"scheme": "http",
"port": 9117,
"url-base": ""
}
},
"capabilities": [
"container-runtime"
],
"listens": [
{
"name": "web",
"port": 9117,
"protocol": "tcp",
"from": "mesh",
"why": "the indexer proxy: its web UI, and the Torznab feeds the *arr apps search through, which other modules reach as jackett-api"
}
],
"resources": [
{
"id": "mesh-state",
"type": "directory",
"path": "/var/lib/mesh/jackett",
"mode": "0700"
},
{
"id": "state",
"type": "directory",
"mode": "0700",
"place": "."
},
{
"id": "config",
"type": "directory",
"mode": "0700",
"owner": "1000:1000"
},
{
"id": "server",
"type": "container",
"name": "jackett",
"image": "lscr.io/linuxserver/jackett@sha256:7b19f4f6ac33d855ca9226600ecbd096ee678f66da28b13a7c09980b035ff583",
"env": {
"PUID": "1000",
"PGID": "1000",
"TZ": "Etc/UTC"
},
"ports": [
"9117"
],
"volumes": [
"${dir:config}:/config"
]
},
{
"id": "runtime-config",
"type": "file",
"path": "${dir:state}/config.json",
"mode": "0600",
"content": "{}\n",
"merge": "json"
},
{
"id": "runtime",
"type": "container",
"name": "mesh-jackett",
"network": "host",
"volumes": [
"/var/lib/mesh/jackett/broker:/run/secrets/broker:ro",
"${dir:state}/config.json:/run/config/config.json:ro",
"${dir:config}:/var/lib/jackett/config:ro"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_JACKETT_URL": "http://127.0.0.1:${port:9117}",
"MESH_JACKETT_CONFIG_FILE": "/run/config/config.json",
"MESH_JACKETT_CONFIG_DIR": "/var/lib/jackett/config"
},
"restart-on": [
"runtime-config"
],
"artifact": "runtime"
}
],
"own-secrets": {
"broker": "/var/lib/mesh/jackett/broker"
},
"requires": [
"route"
],
"contributes": {
"route": {
"label": "indexers",
"endpoint": "web"
}
},
"binds": {
"route": "${dir:state}/route.json"
},
"build": {
"on": [
{
"arg": "BUILD_BASE",
"module": "mesh-tools",
"artifact": "build"
},
{
"arg": "RUNTIME_BASE",
"module": "mesh-tools",
"artifact": "runtime"
}
],
"artifacts": [
{
"name": "runtime",
"kind": "image",
"from": "Dockerfile"
}
]
}
}
-14
View File
@@ -1,14 +0,0 @@
{
"name": "@novox/module-jackett",
"version": "0.1.0",
"description": "jackett — indexer proxy. Its API client and tools live here (novox/hq ADR 0039).",
"type": "module",
"private": true,
"dependencies": {
"@novox/mesh-sdk": "^0.1.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.6.0"
}
}
-48
View File
@@ -1,48 +0,0 @@
// jackett's tools — its own code (novox/hq ADR 0039), importing jackett's client. Jackett has
// nothing worth watching (an indexer proxy answers queries; it has no timeline of its own), so it
// is a tools-only module: no events entrypoint, no broker. What is useful is asking it things.
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
import { JackettClient } from "../client.js";
export function getJackettTools(jackett: JackettClient): ToolDefinition[] {
return [
{
name: "jackett_indexers",
description: "List the indexers Jackett proxies, with their type and site.",
input: { all: { type: "boolean", description: "include indexers not yet configured (default false)" } },
run: async (args) => {
const indexers = await jackett.getIndexers(!args.all);
return { count: indexers.length, indexers };
},
},
{
name: "jackett_search",
description: "Torznab search across Jackett's indexers, returning normalised torrent results.",
input: {
query: { type: "string", description: "the search query" },
indexer: { type: "string", description: 'an indexer id, or "all" to aggregate (default "all")' },
limit: { type: "number", description: "max results (default 25)" },
},
run: async (args) => {
const query = String(args.query);
const results = await jackett.search(
query,
args.indexer ? String(args.indexer) : "all",
args.limit ? Number(args.limit) : 25,
);
return { query, count: results.length, results };
},
},
];
}
// Only exposed when Jackett is configured; otherwise jackett contributes no tools rather than
// failing the whole runtime.
registerModuleTools("jackett", (env) => {
try {
return getJackettTools(JackettClient.fromEnv(env));
} catch {
return [];
}
});
-24
View File
@@ -1,24 +0,0 @@
# jira's runtime: the tool runtime, carrying this module's compiled code.
#
# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in
# the base images, published like any other artifact — which is what makes this buildable by the
# mesh from a repository and a path (novox/hq ADR 0069) rather than only on a workstation that
# happens to have the siblings.
#
# Two bases, named rather than pinned (novox/hq issue 044): the image this is COMPILED in and the
# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`.
ARG BUILD_BASE
ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build
WORKDIR /app/modules/jira
COPY . .
RUN node /app/node_modules/typescript/bin/tsc client.ts tools/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE}
COPY --from=build /app/modules/jira/dist /app/modules/jira/dist
# Every serve-time entrypoint, loaded by the runtime in serve mode: tools and events serve, and a
# provider's provisioner runs its reconcile loop in the same process, with the broker connected —
# the convention novox/hq issues 060/061 settled.
ENV MESH_TOOL_MODULES=/app/modules/jira/dist/tools/index.js
+17 -43
View File
@@ -2,69 +2,43 @@
"module": "jira", "module": "jira",
"version": "1", "version": "1",
"own-secrets": { "own-secrets": {
"token": "/var/lib/jira/token", "token": "${dir:state}/token"
"broker": "/var/lib/mesh/jira/broker"
}, },
"resources": [ "resources": [
{
"id": "mesh-state",
"type": "directory",
"path": "/var/lib/mesh/jira",
"mode": "0700"
},
{ {
"id": "state", "id": "state",
"type": "directory", "type": "directory",
"path": "/var/lib/jira", "mode": "0700",
"mode": "0700" "place": "."
}, },
{ {
"id": "config", "id": "config",
"type": "file", "type": "file",
"path": "/var/lib/jira/config.json", "path": "${dir:state}/config.json",
"merge": "json", "merge": "json",
"content": "{}", "content": "{}",
"mode": "0600" "mode": "0600"
},
{
"id": "runtime",
"type": "container",
"name": "mesh-runtime-jira",
"network": "host",
"volumes": [
"/var/lib/jira/config.json:/run/config/config.json:ro",
"/var/lib/jira/token:/run/secrets/token:ro",
"/var/lib/mesh/jira/broker:/run/secrets/broker:ro"
],
"env": {
"MESH_JIRA_TOKEN_FILE": "/run/secrets/token",
"MESH_JIRA_CONFIG_FILE": "/run/config/config.json",
"MESH_BROKER_FILE": "/run/secrets/broker"
},
"artifact": "runtime"
} }
], ],
"capabilities": [ "capabilities": [
"container-runtime" "container-runtime"
], ],
"build": { "build": {
"on": [
{
"arg": "BUILD_BASE",
"module": "mesh-tools",
"artifact": "build"
},
{
"arg": "RUNTIME_BASE",
"module": "mesh-tools",
"artifact": "runtime"
}
],
"artifacts": [ "artifacts": [
{ {
"name": "runtime", "name": "tools",
"kind": "image", "kind": "bundle",
"from": "Dockerfile" "language": "typescript",
"entrypoints": [
"tools/index.js"
],
"loads": [
"tools/index.js"
],
"env": {
"MESH_JIRA_TOKEN_FILE": "${dir:state}/token",
"MESH_JIRA_CONFIG_FILE": "${dir:state}/config.json"
}
} }
] ]
} }
-30
View File
@@ -1,30 +0,0 @@
# keycloak's runtime: the tool runtime, carrying this module's compiled code.
#
# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in
# the base images, published like any other artifact — which is what makes this buildable by the
# mesh from a repository and a path (novox/hq ADR 0069) rather than only on a workstation that
# happens to have the siblings.
#
# Two bases, named rather than pinned (novox/hq issue 044): the image this is COMPILED in and the
# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`.
ARG BUILD_BASE
ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build
# Compiled under /app/modules so `@novox/mesh-sdk` resolves upward into the base's own
# node_modules — the module is compiled against exactly the sdk it will run against. The compiler
# is invoked by its real path: node_modules/.bin entries are launcher symlinks the base image
# resolved away.
WORKDIR /app/modules/keycloak
COPY . .
RUN node /app/node_modules/typescript/bin/tsc client.ts oidc.ts index.ts provisioner/index.ts tools/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE}
COPY --from=build /app/modules/keycloak/dist /app/modules/keycloak/dist
# Every serve-time entrypoint, loaded by the runtime in serve mode: tools and events serve, and a
# provider's provisioner runs its reconcile loop in the same process, with the broker connected —
# the convention novox/hq issues 060/061 settled. A container that instead ran only its
# provisioner (`run`) served no tools and emitted no events; a container that named no command
# ran no provisioner at all.
ENV MESH_TOOL_MODULES=/app/modules/keycloak/dist/index.js,/app/modules/keycloak/dist/tools/index.js,/app/modules/keycloak/dist/provisioner/index.js
+38 -58
View File
@@ -21,11 +21,11 @@
} }
}, },
"binds": { "binds": {
"postgres-database": "/var/lib/keycloak/database.json", "postgres-database": "${dir:state}/database.json",
"route": "/var/lib/keycloak/route.json" "route": "${dir:state}/route.json"
}, },
"secrets": { "secrets": {
"postgres-database": "/var/lib/keycloak/database.secret" "postgres-database": "${dir:state}/database.secret"
}, },
"capabilities": [ "capabilities": [
"container-runtime" "container-runtime"
@@ -51,49 +51,48 @@
"oidc-client": { "oidc-client": {
"authorization-path": "/protocol/openid-connect/auth", "authorization-path": "/protocol/openid-connect/auth",
"token-path": "/protocol/openid-connect/token", "token-path": "/protocol/openid-connect/token",
"userinfo-path": "/protocol/openid-connect/userinfo" "userinfo-path": "/protocol/openid-connect/userinfo",
"issuer": "${setting:issuer}"
} }
}, },
"receives": { "receives": {
"oidc-client": "/var/lib/keycloak/grants/mesh.json" "oidc-client": "${dir:grants}/mesh.json"
}, },
"grants": { "grants": {
"oidc-client": "/var/lib/keycloak/grants" "oidc-client": "${dir:grants}"
}, },
"own-secrets": { "own-secrets": {
"admin": "/var/lib/keycloak/admin.secret", "admin": "${dir:state}/admin.secret"
"broker": "/var/lib/mesh/keycloak/broker"
}, },
"resources": [ "resources": [
{ {
"id": "mesh-state", "id": "mesh-state",
"type": "directory", "type": "directory",
"path": "/var/lib/mesh/keycloak", "mode": "0700",
"mode": "0700" "place": "mesh"
}, },
{ {
"id": "state", "id": "state",
"type": "directory", "type": "directory",
"path": "/var/lib/keycloak", "mode": "0700",
"mode": "0700" "place": "."
}, },
{ {
"id": "grants", "id": "grants",
"type": "directory", "type": "directory",
"path": "/var/lib/keycloak/grants",
"mode": "0700" "mode": "0700"
}, },
{ {
"id": "admin-env", "id": "admin-env",
"type": "file", "type": "file",
"path": "/var/lib/keycloak/admin.env", "path": "${dir:state}/admin.env",
"mode": "0600", "mode": "0600",
"content": "KEYCLOAK_ADMIN=admin\nKEYCLOAK_ADMIN_PASSWORD=${secret:admin}\n" "content": "KEYCLOAK_ADMIN=admin\nKEYCLOAK_ADMIN_PASSWORD=${secret:admin}\n"
}, },
{ {
"id": "database-env", "id": "database-env",
"type": "file", "type": "file",
"path": "/var/lib/keycloak/database.env", "path": "${dir:state}/database.env",
"mode": "0600", "mode": "0600",
"content": "KC_DB_URL=jdbc:postgresql://${bound:postgres-database:at}:${bound:postgres-database:port}/${bound:postgres-database:as}\nKC_DB_USERNAME=${bound:postgres-database:as}\nKC_DB_PASSWORD=${secret:postgres-database}\n" "content": "KC_DB_URL=jdbc:postgresql://${bound:postgres-database:at}:${bound:postgres-database:port}/${bound:postgres-database:as}\nKC_DB_USERNAME=${bound:postgres-database:as}\nKC_DB_PASSWORD=${secret:postgres-database}\n"
}, },
@@ -105,7 +104,7 @@
{ {
"id": "hostname", "id": "hostname",
"type": "file", "type": "file",
"path": "/var/lib/keycloak/hostname.env", "path": "${dir:state}/hostname.env",
"mode": "0644", "mode": "0644",
"content": "KC_HOSTNAME=https://${bound:route:name}\n" "content": "KC_HOSTNAME=https://${bound:route:name}\n"
}, },
@@ -125,9 +124,9 @@
"KC_PROXY_HEADERS": "xforwarded" "KC_PROXY_HEADERS": "xforwarded"
}, },
"env-file": [ "env-file": [
"/var/lib/keycloak/admin.env", "${dir:state}/admin.env",
"/var/lib/keycloak/database.env", "${dir:state}/database.env",
"/var/lib/keycloak/hostname.env" "${dir:state}/hostname.env"
], ],
"ports": [ "ports": [
"8080" "8080"
@@ -140,53 +139,34 @@
{ {
"id": "runtime-config", "id": "runtime-config",
"type": "file", "type": "file",
"path": "/var/lib/mesh/keycloak/config.json", "path": "${dir:mesh-state}/config.json",
"mode": "0600", "mode": "0600",
"content": "{}\n", "content": "{}\n",
"merge": "json" "merge": "json"
},
{
"id": "runtime",
"type": "container",
"name": "mesh-keycloak",
"network": "host",
"volumes": [
"/var/lib/mesh/keycloak/broker:/run/secrets/broker:ro",
"/var/lib/mesh/keycloak/config.json:/run/config/config.json:ro",
"/var/lib/keycloak/admin.secret:/run/secrets/admin:ro",
"/var/lib/keycloak/grants:/var/lib/keycloak/grants:ro"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_KEYCLOAK_URL": "http://127.0.0.1:${port:8080}",
"MESH_KEYCLOAK_CONFIG_FILE": "/run/config/config.json",
"MESH_KEYCLOAK_PASSWORD_FILE": "/run/secrets/admin",
"MESH_RECEIVES": "/var/lib/keycloak/grants/mesh.json"
},
"restart-on": [
"runtime-config"
],
"artifact": "runtime"
} }
], ],
"build": { "build": {
"on": [
{
"arg": "BUILD_BASE",
"module": "mesh-tools",
"artifact": "build"
},
{
"arg": "RUNTIME_BASE",
"module": "mesh-tools",
"artifact": "runtime"
}
],
"artifacts": [ "artifacts": [
{ {
"name": "runtime", "name": "code",
"kind": "image", "kind": "bundle",
"from": "Dockerfile" "language": "typescript",
"entrypoints": [
"index.js",
"tools/index.js",
"provisioner/index.js"
],
"loads": [
"index.js",
"tools/index.js",
"provisioner/index.js"
],
"env": {
"MESH_KEYCLOAK_URL": "http://127.0.0.1:${port:8080}",
"MESH_KEYCLOAK_CONFIG_FILE": "${dir:mesh-state}/config.json",
"MESH_KEYCLOAK_PASSWORD_FILE": "${dir:state}/admin.secret",
"MESH_RECEIVES": "${dir:grants}/mesh.json"
}
} }
] ]
} }
+93
View File
@@ -0,0 +1,93 @@
{
"module": "lab",
"version": "1",
"capabilities": [
"container-runtime",
"virtualisation"
],
"resources": [
{
"id": "state",
"type": "directory",
"mode": "0700",
"place": "."
},
{
"id": "work",
"type": "directory",
"path": "/var/lib/mesh-lab-runs",
"mode": "0700"
},
{
"id": "runtime-env",
"type": "file",
"path": "${dir:state}/lab.env",
"mode": "0600",
"content": "MESH_LAB_FORGE=${setting:forge}\n"
},
{
"id": "git",
"type": "package",
"package": "git"
},
{
"id": "make",
"type": "package",
"package": "make"
},
{
"id": "python",
"type": "package",
"package": "python"
},
{
"id": "file",
"type": "package",
"package": "file"
},
{
"id": "iproute2",
"type": "package",
"package": "iproute2"
},
{
"id": "sudo",
"type": "package",
"package": "sudo"
},
{
"id": "npm",
"type": "package",
"package": "npm"
},
{
"id": "go",
"type": "package",
"package": "go"
},
{
"id": "incus",
"type": "package",
"package": "incus"
}
],
"build": {
"artifacts": [
{
"name": "code",
"kind": "bundle",
"language": "typescript",
"entrypoints": [
"tools/index.js"
],
"loads": [
"tools/index.js"
],
"env": {
"MESH_LAB_WORK": "${dir:work}",
"MESH_LAB_ENV_FILE": "${dir:state}/lab.env"
}
}
]
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"name": "@novox/module-lab",
"version": "0.1.0",
"description": "lab — the lab, as a module: runs beds against the forge's branches when the mesh asks (novox/hq ADR 0172).",
"type": "module",
"private": true,
"dependencies": { "@novox/mesh-sdk": "^0.1.0" },
"devDependencies": { "@types/node": "^22.0.0", "typescript": "^5.6.0" }
}
+108
View File
@@ -0,0 +1,108 @@
// lab's tools — the lab, as the mesh asks for it (novox/hq ADR 0172). They run on the machine the
// lab is assigned to, and only there: a bed raises virtual machines on that machine's virtualisation.
import { spawnSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
import { listRuns, readStatus, REPOSITORIES, running, start, stop, tail } from "./runs.js";
export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] {
const work = env.MESH_LAB_WORK ?? "/var/lib/mesh-lab-runs";
// The forge is an operator's setting, which reaches a file and never a bundle's words (novox/hq
// ADR 0192): read from the env-file the mesh fills, at each call, so a changed setting is used
// without restarting the runtime. MESH_LAB_FORGE itself still wins, for a hand-run instance.
const forgeOf = (): string => (env.MESH_LAB_FORGE ?? wordIn(env.MESH_LAB_ENV_FILE, "MESH_LAB_FORGE")).replace(/\/+$/, "");
return [
{
name: "lab_check",
description: "Whether this machine can run the lab's beds: the lab's own check, against the forge's main branch.",
input: {},
run: async () => {
const forge = forgeOf();
if (!forge) return { ok: false, output: "the lab's forge is not set: settings for lab, {\"forge\": \"<url>\"}" };
const dir = `${work}/check`;
spawnSync("rm", ["-rf", dir]);
const clone = spawnSync("git", ["clone", "--quiet", "--depth", "1", `${forge}/novox/mesh-lab.git`, dir], { encoding: "utf8" });
if (clone.status !== 0) return { ok: false, output: clone.stderr };
spawnSync("npm", ["ci", "--no-audit", "--no-fund", "--loglevel=error"], { cwd: dir, encoding: "utf8" });
const check = spawnSync("node", ["--experimental-strip-types", "src/cli.ts", "check"], { cwd: dir, encoding: "utf8" });
return { ok: check.status === 0, output: `${check.stdout}${check.stderr}`.trim() };
},
},
{
name: "lab_run",
description:
"Run the lab's beds against branches on the forge: fresh checkouts of every repository the lab builds, " +
"side by side, then the suite on the named test files. Answers at once with the run's id; lab_status " +
"and lab_log follow it. One run at a time.",
input: {
tests: { type: "string", description: "the bed test files, comma-separated, relative to mesh-lab (e.g. test/integration/mesh.test.ts)" },
refs: {
type: "string",
description: `a JSON object of repository to branch, for any of ${REPOSITORIES.join(", ")}; the rest run main`,
},
},
run: async (args) => {
const forge = forgeOf();
if (!forge) return { started: false, reason: "the lab's forge is not set: settings for lab, {\"forge\": \"<url>\"}" };
const tests = String(args.tests ?? "").split(",").map((s) => s.trim()).filter(Boolean);
if (tests.length === 0) return { started: false, reason: "name at least one bed test file" };
let refs: Record<string, string> = {};
if (args.refs) {
try {
refs = JSON.parse(String(args.refs)) as Record<string, string>;
} catch {
return { started: false, reason: "refs is not a JSON object of repository to branch" };
}
}
const stranger = Object.keys(refs).filter((r) => !REPOSITORIES.includes(r));
if (stranger.length > 0) return { started: false, reason: `the lab does not build ${stranger.join(", ")}` };
const busy = running(work);
if (busy) return { started: false, reason: `${busy.id} is still ${busy.state}; one run at a time`, running: busy };
return { started: true, run: start(work, forge, tests, refs) };
},
},
{
name: "lab_status",
description: "A run's state, the commits it tested and how it ended — or every run, newest first, when no id is given.",
input: { id: { type: "string", description: "the run's id (optional)" } },
run: async (args) => {
if (args.id) return readStatus(work, String(args.id)) ?? { found: false, id: String(args.id) };
return { runs: listRuns(work).slice(0, 10) };
},
},
{
name: "lab_log",
description: "The last lines of a run's log.",
input: {
id: { type: "string", description: "the run's id" },
lines: { type: "number", description: "how many lines from the end (default 200)" },
},
run: async (args) => ({ id: String(args.id), log: tail(work, String(args.id), Number(args.lines ?? 200)) }),
},
{
name: "lab_stop",
description: "Stop a run and everything it started.",
input: { id: { type: "string", description: "the run's id" } },
run: async (args) => stop(work, String(args.id)) ?? { found: false, id: String(args.id) },
},
];
}
/** One word from an env-file (`KEY=value` lines), or "" when the file or the word is absent. */
export function wordIn(file: string | undefined, word: string): string {
if (!file) return "";
let text: string;
try {
text = readFileSync(file, "utf8");
} catch {
return "";
}
for (const line of text.split("\n")) {
const at = line.indexOf("=");
if (at > 0 && line.slice(0, at).trim() === word) return line.slice(at + 1).trim();
}
return "";
}
registerModuleTools("lab", (env) => getLabTools(env));
+175
View File
@@ -0,0 +1,175 @@
// A lab run: fresh checkouts of the named branches, side by side, then the lab's suite on the named
// beds (novox/hq ADR 0172).
//
// **A run is a detached script with its own process group**, so it outlives the tool call that started
// it and `stop` ends everything it started. It writes what it is doing to a status file beside its log,
// and that file is the whole of what the tools read back: a runtime that restarts mid-run still answers
// for it, and says it was lost rather than pretending it is still going.
import { spawn } from "node:child_process";
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
/** The repositories a lab run checks out, side by side, as the lab expects its siblings. */
export const REPOSITORIES = ["mesh-lab", "mesh-controller", "mesh-host", "mesh-catalog", "mesh-tools", "mesh-sdk"];
export interface RunStatus {
id: string;
state: "checking-out" | "building" | "running" | "passed" | "failed" | "stopped" | "lost";
started: string;
ended?: string;
tests: string[];
refs: Record<string, string>;
commits?: Record<string, string>;
exit?: number;
pid?: number;
}
export function runDir(work: string, id: string): string {
return join(work, id);
}
function statusPath(work: string, id: string): string {
return join(runDir(work, id), "status.json");
}
export function readStatus(work: string, id: string): RunStatus | undefined {
try {
const s = JSON.parse(readFileSync(statusPath(work, id), "utf8")) as RunStatus;
// A run whose process is gone while its status still says it is going was lost — the runtime or
// the machine restarted under it. Said, rather than left reading as running for ever.
if (!["passed", "failed", "stopped", "lost"].includes(s.state) && s.pid && !alive(s.pid)) {
s.state = "lost";
}
return s;
} catch {
return undefined;
}
}
function alive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
export function listRuns(work: string): RunStatus[] {
if (!existsSync(work)) return [];
return readdirSync(work)
.filter((d) => d.startsWith("run-"))
.map((id) => readStatus(work, id))
.filter((s): s is RunStatus => !!s)
.sort((a, b) => b.started.localeCompare(a.started));
}
/** The run still going, if any: one at a time, because two would contend for the same machine. */
export function running(work: string): RunStatus | undefined {
return listRuns(work).find((s) => !["passed", "failed", "stopped", "lost"].includes(s.state));
}
const shellQuote = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`;
/**
* The script one run executes. Every step writes its state first, so a run that dies says where.
*
* The environment is the one the lab's README describes for a run against sibling checkouts, pointed
* at this run's own tree, so what is built and claimed is exactly what was checked out.
*/
export function script(work: string, id: string, forge: string, tests: string[], refs: Record<string, string>): string {
const dir = runDir(work, id);
const setState = (state: string) =>
`node -e ${shellQuote(
`const f=${JSON.stringify(join(dir, "status.json"))};const s=JSON.parse(require("fs").readFileSync(f,"utf8"));s.state=${JSON.stringify(state)};require("fs").writeFileSync(f,JSON.stringify(s,null,2))`,
)}`;
const clones = REPOSITORIES.map((repo) => {
const ref = refs[repo] ?? "main";
return [
`git clone --quiet --depth 50 --branch ${shellQuote(ref)} ${shellQuote(`${forge}/novox/${repo}.git`)} ${shellQuote(join(dir, repo))}`,
`echo "${repo} $(git -C ${shellQuote(join(dir, repo))} rev-parse HEAD)" >> ${shellQuote(join(dir, "commits.txt"))}`,
].join("\n");
}).join("\n");
const bin = join(dir, "bin");
return `set -euo pipefail
cd ${shellQuote(dir)}
${setState("checking-out")}
${clones}
node -e ${shellQuote(
`const fs=require("fs");const f=${JSON.stringify(join(dir, "status.json"))};const s=JSON.parse(fs.readFileSync(f,"utf8"));s.commits=Object.fromEntries(fs.readFileSync(${JSON.stringify(join(dir, "commits.txt"))},"utf8").trim().split("\\n").map(l=>l.split(" ")));fs.writeFileSync(f,JSON.stringify(s,null,2))`,
)}
${setState("building")}
# The @novox scope resolves from the mesh's own package registry on the forge, as the build machine
# resolves it; nothing else is asked of it.
printf '%s\n' ${shellQuote(`@novox:registry=${forge}/api/packages/novox/npm/`)} > ${shellQuote(join(dir, ".npmrc"))}
export NPM_CONFIG_USERCONFIG=${shellQuote(join(dir, ".npmrc"))}
for repo in mesh-sdk mesh-tools mesh-lab; do (cd ${shellQuote(dir)}/$repo && npm ci --no-audit --no-fund --loglevel=error); done
(cd ${shellQuote(dir)}/mesh-sdk && npm run build --if-present)
(cd ${shellQuote(dir)}/mesh-tools && npm run build --if-present)
mkdir -p ${shellQuote(bin)}
for p in postgres-provisioner objectstore-provisioner route-proxy; do
(cd ${shellQuote(dir)}/mesh-controller && CGO_ENABLED=0 go build -o ${shellQuote(bin)}/$p ./examples/$p)
done
export MESH_LAB_HOST_BINARY=${shellQuote(join(dir, "mesh-host", "mesh-host"))}
export MESH_LAB_BUNDLE=${shellQuote(join(dir, "mesh-host", "examples", "foundation-first-node-nats.lock"))}
export MESH_LAB_MODULES=${shellQuote(join(dir, "mesh-controller", "examples", "modules"))}
export MESH_LAB_BUILDER=${shellQuote(join(dir, "mesh-controller", "build", "mesh-builder"))}
export MESH_LAB_BOOTSTRAP_BINARY=${shellQuote(join(dir, "mesh-host", "mesh-bootstrap"))}
export MESH_LAB_CATALOG=${shellQuote(join(dir, "mesh-catalog", "modules"))}
export MESH_LAB_PROVISIONER=${shellQuote(join(bin, "postgres-provisioner"))}
export MESH_LAB_OBJECTSTORE_PROVISIONER=${shellQuote(join(bin, "objectstore-provisioner"))}
export MESH_LAB_ROUTE_PROXY=${shellQuote(join(bin, "route-proxy"))}
${setState("running")}
cd ${shellQuote(join(dir, "mesh-lab"))}
node --experimental-strip-types src/cli.ts suite ${tests.map(shellQuote).join(" ")}
`;
}
/** start begins a run and returns at once with its status. */
export function start(work: string, forge: string, tests: string[], refs: Record<string, string>): RunStatus {
const id = `run-${new Date().toISOString().replace(/[:.]/g, "-")}`;
const dir = runDir(work, id);
mkdirSync(dir, { recursive: true });
const status: RunStatus = { id, state: "checking-out", started: new Date().toISOString(), tests, refs };
writeFileSync(statusPath(work, id), JSON.stringify(status, null, 2));
writeFileSync(join(dir, "run.sh"), script(work, id, forge, tests, refs), { mode: 0o700 });
// The wrapper records how the run ended, then removes the checkouts and keeps the log and status: a
// run's tree is its own, and the next run starts from fresh ones (novox/hq ADR 0172).
const wrapper = `bash ${shellQuote(join(dir, "run.sh"))} > ${shellQuote(join(dir, "run.log"))} 2>&1; code=$?
node -e ${shellQuote(
`const f=${JSON.stringify(statusPath(work, id))};const s=JSON.parse(require("fs").readFileSync(f,"utf8"));if(s.state!=="stopped"){s.state=process.argv[1]==="0"?"passed":"failed"};s.exit=Number(process.argv[1]);s.ended=new Date().toISOString();require("fs").writeFileSync(f,JSON.stringify(s,null,2))`,
)} "$code"
cd ${shellQuote(dir)} && rm -rf ${REPOSITORIES.map(shellQuote).join(" ")} bin`;
const child = spawn("bash", ["-c", wrapper], { detached: true, stdio: "ignore" });
child.unref();
status.pid = child.pid;
writeFileSync(statusPath(work, id), JSON.stringify(status, null, 2));
return status;
}
/** stop ends a run and everything it started, by its process group. */
export function stop(work: string, id: string): RunStatus | undefined {
const s = readStatus(work, id);
if (!s || !s.pid) return s;
if (["passed", "failed", "stopped", "lost"].includes(s.state)) return s;
s.state = "stopped";
writeFileSync(statusPath(work, id), JSON.stringify(s, null, 2));
try {
process.kill(-s.pid, "SIGTERM");
} catch {
// Already gone between the read and the kill.
}
return s;
}
/** tail is the last lines of a run's log. */
export function tail(work: string, id: string, lines: number): string {
try {
const all = readFileSync(join(runDir(work, id), "run.log"), "utf8").split("\n");
return all.slice(-Math.max(1, lines)).join("\n");
} catch {
return "";
}
}

Some files were not shown because too many files have changed in this diff Show More