Compare commits

...
Author SHA1 Message Date
jochen a069d61fbd Module state is hq ADR 0201 after all: the derived-value record moved to 0202 on hq main 2026-10-04 11:02:46 +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
248 changed files with 39843 additions and 3199 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.
+26 -62
View File
@@ -14,19 +14,10 @@
"secrets": { "secrets": {
"model-access": "${dir:state}/access-token" "model-access": "${dir:state}/access-token"
}, },
"own-secrets": {
"broker": "${dir:mesh-state}/broker"
},
"emits": [ "emits": [
"usage.session" "usage.session"
], ],
"resources": [ "resources": [
{
"id": "mesh-state",
"type": "directory",
"mode": "0700",
"place": "mesh"
},
{ {
"id": "state", "id": "state",
"type": "directory", "type": "directory",
@@ -46,66 +37,39 @@
}, },
{ {
"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": [
"${dir:state}:/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": [
"${dir:mesh-state}/broker:/run/secrets/broker:ro",
"${dir:state}:/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"
}
} }
] ]
} }
+18 -17
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(
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}`); 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);
-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
+12 -33
View File
@@ -5,27 +5,21 @@
"consumes": [ "consumes": [
"**" "**"
], ],
"own-secrets": {
"broker": "${dir:state}/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"
}
} }
] ]
}, },
@@ -40,21 +34,6 @@
"id": "trail", "id": "trail",
"type": "directory", "type": "directory",
"mode": "0700" "mode": "0700"
},
{
"id": "run",
"type": "container",
"name": "mesh-audit-logger",
"network": "host",
"volumes": [
"${dir:state}/broker:/run/secrets/broker:ro",
"${dir: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
+14 -36
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": "${dir:mesh-state}/broker"
}, },
"listens": [ "listens": [
{ {
@@ -92,45 +91,24 @@
"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": [
"${dir:mesh-state}/broker:/run/secrets/broker:ro",
"${dir:mesh-state}/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"
}
} }
] ]
} }
@@ -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": [
@@ -36,19 +37,19 @@
"mode": "0700" "mode": "0700"
}, },
{ {
"id": "builder-env", "id": "agent-env",
"type": "file", "type": "file",
"path": "${dir:mesh-state}/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=${dir: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": [
"${dir:mesh-state}/builder.env" "${dir:mesh-state}/build-agent.env"
], ],
"volumes": [ "volumes": [
"${dir:mesh-state}:/run/mesh:ro", "${dir:mesh-state}:/run/mesh:ro",
@@ -56,7 +57,7 @@
"/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"
} }
+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"));
});
+208
View File
@@ -0,0 +1,208 @@
// 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 { readFileSync, watchFile } from "node:fs";
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 */
}
const asRoot = process.getuid?.() === 0;
const cmd = asRoot ? ["install", "-D", "-m", "0644", "/dev/stdin", path] : ["sudo", "-n", "install", "-D", "-m", "0644", "/dev/stdin", path];
const r = spawnSync(cmd[0], cmd.slice(1), { input: content, encoding: "utf8" });
if (r.status !== 0) {
throw new Error(`${name}: could not be written to ${MANAGED_DIR} (${(r.stderr || r.error?.message || "").trim()}); ` +
`the module writes there through the operator account's passwordless sudo`);
}
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). Awaited, so the
// managed directory holds every server that applies here before the bundle says what it serves.
try {
await 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
}
});
} catch (err) {
loud("watching the MCP servers")(err);
}
// 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"));
});
}
+12
View File
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true
},
"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
+17 -42
View File
@@ -18,20 +18,13 @@
"public-dns": "${dir:grants}/mesh.json" "public-dns": "${dir:grants}/mesh.json"
}, },
"own-secrets": { "own-secrets": {
"token": "${dir:state}/token", "token": "${dir:state}/token"
"broker": "${dir:mesh-state}/broker"
}, },
"emits": [ "emits": [
"record.created", "record.created",
"record.removed" "record.removed"
], ],
"resources": [ "resources": [
{
"id": "mesh-state",
"type": "directory",
"mode": "0700",
"place": "mesh"
},
{ {
"id": "state", "id": "state",
"type": "directory", "type": "directory",
@@ -50,48 +43,30 @@
"merge": "json", "merge": "json",
"content": "{}", "content": "{}",
"mode": "0600" "mode": "0600"
},
{
"id": "runtime",
"type": "container",
"name": "mesh-cloudflare-dns",
"network": "host",
"volumes": [
"${dir:state}/config.json:/run/config/config.json:ro",
"${dir:grants}:/grants",
"${dir:state}/token:/run/secrets/token:ro",
"${dir:mesh-state}/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
+14 -40
View File
@@ -3,16 +3,9 @@
"version": "1", "version": "1",
"slug": "confl", "slug": "confl",
"own-secrets": { "own-secrets": {
"token": "${dir:state}/token", "token": "${dir:state}/token"
"broker": "${dir:mesh-state}/broker"
}, },
"resources": [ "resources": [
{
"id": "mesh-state",
"type": "directory",
"mode": "0700",
"place": "mesh"
},
{ {
"id": "state", "id": "state",
"type": "directory", "type": "directory",
@@ -26,46 +19,27 @@
"merge": "json", "merge": "json",
"content": "{}", "content": "{}",
"mode": "0600" "mode": "0600"
},
{
"id": "runtime",
"type": "container",
"name": "mesh-runtime-confluence",
"network": "host",
"volumes": [
"${dir:state}/config.json:/run/config/config.json:ro",
"${dir:state}/token:/run/secrets/token:ro",
"${dir:mesh-state}/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"
}
} }
] ]
} }
+4 -1
View File
@@ -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
-23
View File
@@ -1,23 +0,0 @@
# fail2ban's runtime: the tool runtime, carrying the intrusion prevention's verbs and the client they
# speak through.
#
# Built from this module's own directory and nothing else (novox/hq ADR 0069). Two bases, named in
# module.json's `build.on`: the image this is compiled in and the image it runs in.
ARG BUILD_BASE
ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build
WORKDIR /app/modules/fail2ban
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}
# The daemon runs on the machine, declared by this module; what runs here is only its client, which
# speaks to the daemon over the socket the machine shares into this container (novox/hq ADR 0179).
# The package brings the client and the daemon together; the daemon is never started here.
RUN apt-get update \
&& apt-get install -y --no-install-recommends fail2ban \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /app/modules/fail2ban/dist /app/modules/fail2ban/dist
ENV MESH_TOOL_MODULES=/app/modules/fail2ban/dist/tools/index.js
+41 -9
View File
@@ -5,12 +5,15 @@
// prevention seat's four verbs (ADR 0179). The daemon's state is fail2ban's, not the mesh's: the // prevention seat's four verbs (ADR 0179). The daemon's state is fail2ban's, not the mesh's: the
// mesh composes the jails and never writes the ban list. // mesh composes the jails and never writes the ban list.
// //
// Spoken through fail2ban-client over the daemon's socket, which the machine shares into this // Spoken through fail2ban-client over the daemon's socket. Client and daemon come from the one
// runtime; so the client here is the one from the runtime's own package and the daemon is the // package this module declares on the machine, and the socket is root's: root is the module's
// machine's, and the two meet at /var/run/fail2ban/fail2ban.sock. // 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 { isIP } from "node:net";
import { delimiter, join } from "node:path";
import { promisify } from "node:util"; import { promisify } from "node:util";
const execFileP = promisify(execFile); const execFileP = promisify(execFile);
@@ -18,16 +21,44 @@ const execFileP = promisify(execFile);
/** A command runner, so the verbs can be tested without a daemon. */ /** A command runner, so the verbs can be tested without a daemon. */
export type Runner = (cmd: string, args: string[]) => Promise<string>; export type Runner = (cmd: string, args: string[]) => Promise<string>;
export const execRunner: Runner = async (cmd, args) => { /** 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 { try {
const { stdout } = await execFileP(cmd, args, { maxBuffer: 16 * 1024 * 1024 }); 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; return stdout;
} catch (err) { } catch (err) {
const e = err as { code?: string | number; stderr?: string; stdout?: string; message?: string }; const e = err as { code?: string | number; stderr?: string; stdout?: string; message?: string };
const said = `${e.stdout ?? ""}${e.stderr ?? ""}`.trim(); const said = `${e.stdout ?? ""}${e.stderr ?? ""}`.trim();
if (e.code === "ENOENT") throw new Error(`${cmd} is not in this runtime`); // What failed is named by how it failed: sudo missing is a spawn error, sudo refusing speaks
if (/Failed to access socket path|Is fail2ban running/i.test(said)) { // on its own stderr line, and the rest is the client's own answer.
throw new Error("fail2ban is not running on this machine, or its socket is not shared with this runtime"); 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"). // 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); const lines = said.split("\n").map((l) => l.trim()).filter(Boolean);
@@ -76,7 +107,8 @@ export class Fail2banClient {
this.run = run; this.run = run;
} }
static fromEnv(_env: NodeJS.ProcessEnv = process.env): Fail2banClient { /** The daemon as this machine has it, through its own client. */
static onThisMachine(): Fail2banClient {
return new Fail2banClient(); return new Fail2banClient();
} }
+6 -38
View File
@@ -19,9 +19,6 @@
"tools": [ "tools": [
"fail2ban_settings" "fail2ban_settings"
], ],
"own-secrets": {
"broker": "${dir:mesh-state}/broker"
},
"jailing": { "jailing": {
"into": "/etc/fail2ban/jail.d/mesh.conf", "into": "/etc/fail2ban/jail.d/mesh.conf",
"filter-into": "/etc/fail2ban/filter.d" "filter-into": "/etc/fail2ban/filter.d"
@@ -56,12 +53,6 @@
"path": "/var/run/fail2ban", "path": "/var/run/fail2ban",
"mode": "0755" "mode": "0755"
}, },
{
"id": "mesh-state",
"type": "directory",
"mode": "0700",
"place": "mesh"
},
{ {
"id": "jail-local", "id": "jail-local",
"type": "file", "type": "file",
@@ -118,40 +109,17 @@
"action-dualchain", "action-dualchain",
"composed-jails" "composed-jails"
] ]
},
{
"id": "runtime",
"type": "container",
"name": "mesh-fail2ban",
"artifact": "runtime",
"network": "host",
"volumes": [
"${dir:mesh-state}/broker:/run/secrets/broker:ro",
"/var/run/fail2ban:/var/run/fail2ban"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/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": "tools",
"kind": "image", "kind": "bundle",
"from": "Dockerfile" "language": "typescript",
"entrypoints": [
"tools/index.js"
]
} }
] ]
} }
+1 -1
View File
@@ -12,7 +12,7 @@
"typescript": "^5.6.0" "typescript": "^5.6.0"
}, },
"scripts": { "scripts": {
"build": "tsc client.ts tools/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist", "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'" "test": "node --test --experimental-strip-types 'test/*.test.ts'"
} }
} }
+9 -1
View File
@@ -2,7 +2,7 @@
// on the control node on 2026-10-02 (novox/hq ADR 0179). // on the control node on 2026-10-02 (novox/hq ADR 0179).
import { test } from "node:test"; import { test } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { Fail2banClient, parseBans, parseJailStatus, type Runner } from "../client.ts"; 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 STATUS = "Status\n|- Number of jail:\t2\n`- Jail list:\trecidive, sshd\n";
const RECIDIVE = const RECIDIVE =
@@ -104,3 +104,11 @@ test("a jail's settings are read from the daemon's listings", async () => {
logpath: [], journalmatch: "_SYSTEMD_UNIT=sshd.service + _COMM=sshd", 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);
});
+1 -1
View File
@@ -55,7 +55,7 @@ export function getFail2banTools(fail2ban: Fail2banClient): ToolDefinition[] {
]; ];
} }
const fail2ban = Fail2banClient.fromEnv(); const fail2ban = Fail2banClient.onThisMachine();
// The seat's verbs under the seat's name: the runtime serves them on the seat's subjects where this // 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. // module holds it (ADR 0159, 0160). The module's own under its own.
registerModuleTools("node-intrusion-prevention", () => getSeatVerbs(fail2ban)); registerModuleTools("node-intrusion-prevention", () => getSeatVerbs(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
+22 -45
View File
@@ -85,9 +85,6 @@
"scope": "mesh" "scope": "mesh"
} }
], ],
"own-secrets": {
"broker": "${dir:mesh-state}/broker"
},
"resources": [ "resources": [
{ {
"id": "mesh-state", "id": "mesh-state",
@@ -180,32 +177,6 @@
"mode": "0600", "mode": "0600",
"content": "{}\n", "content": "{}\n",
"merge": "json" "merge": "json"
},
{
"id": "runtime",
"type": "container",
"name": "mesh-gitea",
"network": "host",
"volumes": [
"${dir:mesh-state}/broker:/run/secrets/broker:ro",
"${dir:mesh-state}/config.json:/run/config/config.json:ro",
"${dir:grants}:${dir:grants}:ro",
"${dir:state}/admin.secret:/run/secrets/admin:ro",
"${dir:runtime-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": [
@@ -219,30 +190,36 @@
} }
], ],
"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": [ "jails": [
{ {
"name": "gitea", "name": "gitea",
"failregex": "^.*Failed authentication attempt for .* from <HOST>(?::\\d+)?\\s*$", "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" "jail": "backend = systemd\njournalmatch = CONTAINER_NAME=gitea\nport = http,https,222\nmaxretry = 3\nfindtime = 1d\nbantime = 1d"
} }
] ]
-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
+14 -40
View File
@@ -2,16 +2,9 @@
"module": "gitlab", "module": "gitlab",
"version": "1", "version": "1",
"own-secrets": { "own-secrets": {
"token": "${dir:state}/token", "token": "${dir:state}/token"
"broker": "${dir:mesh-state}/broker"
}, },
"resources": [ "resources": [
{
"id": "mesh-state",
"type": "directory",
"mode": "0700",
"place": "mesh"
},
{ {
"id": "state", "id": "state",
"type": "directory", "type": "directory",
@@ -25,46 +18,27 @@
"merge": "json", "merge": "json",
"content": "{}", "content": "{}",
"mode": "0600" "mode": "0600"
},
{
"id": "runtime",
"type": "container",
"name": "mesh-runtime-gitlab",
"network": "host",
"volumes": [
"${dir:state}/config.json:/run/config/config.json:ro",
"${dir:state}/token:/run/secrets/token:ro",
"${dir:mesh-state}/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
+16 -36
View File
@@ -5,8 +5,7 @@
"alert.firing" "alert.firing"
], ],
"own-secrets": { "own-secrets": {
"admin": "${dir:mesh-state}/admin", "admin": "${dir:mesh-state}/admin"
"broker": "${dir:mesh-state}/broker"
}, },
"capabilities": [ "capabilities": [
"container-runtime" "container-runtime"
@@ -112,25 +111,6 @@
"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": [
"${dir:mesh-state}/broker:/run/secrets/broker:ro",
"${dir:mesh-state}/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": [
@@ -162,23 +142,23 @@
"influxdb-api": "${dir:mesh-state}/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"
}
} }
] ]
} }
-27
View File
@@ -1,27 +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 provisions/hass.ts provisions/probe.ts provisions/connections.ts provisions/mesh.ts provisions/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
# NOT dist/provisions/index.js: that is a step the host runs to completion, named by the
# `provisions` container's args as `mesh-tools run …` (novox/hq ADR 0052). Listed here it would run
# inside the serving sidecar too, and exit it.
+33 -61
View File
@@ -9,7 +9,6 @@
"state.changed" "state.changed"
], ],
"own-secrets": { "own-secrets": {
"broker": "${dir:mesh-state}/broker",
"token": "${dir:mesh-state}/token" "token": "${dir:mesh-state}/token"
}, },
"listens": [ "listens": [
@@ -80,55 +79,27 @@
"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"
"${dir:mesh-state}/broker:/run/secrets/broker:ro",
"${dir:mesh-state}/token:/run/secrets/token:ro",
"${dir:mesh-state}/config.json:/run/config/config.json:ro"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_HOMEASSISTANT_URL": "http://127.0.0.1:${port:8123}",
"MESH_HOMEASSISTANT_TOKEN_FILE": "/run/secrets/token",
"MESH_HOMEASSISTANT_CONFIG_FILE": "/run/config/config.json"
},
"restart-on": [
"runtime-config"
],
"artifact": "runtime"
}, },
{ {
"id": "provisions", "id": "provisions",
"type": "container", "type": "process",
"name": "mesh-home-assistant-provisions", "name": "home-assistant-provisions",
"network": "host", "artifact": "code",
"run-once": true, "run": [
"volumes": [ "node",
"${dir:mesh-state}/token:/run/secrets/token:ro", "provisions/index.js"
"${dir:written}:/var/lib/home-assistant-provisions",
"${dir:state}/mqtt-topic.json:/run/provisions/mqtt-topic.json:ro",
"${dir:state}/mqtt-topic.secret:/run/provisions/mqtt-topic.secret:ro",
"${dir:state}/sonarr-api.json:/run/provisions/sonarr-api.json:ro",
"${dir:state}/sonarr-api.secret:/run/provisions/sonarr-api.secret:ro",
"${dir:state}/radarr-api.json:/run/provisions/radarr-api.json:ro",
"${dir:state}/radarr-api.secret:/run/provisions/radarr-api.secret:ro",
"${dir:state}/lidarr-api.json:/run/provisions/lidarr-api.json:ro",
"${dir:state}/lidarr-api.secret:/run/provisions/lidarr-api.secret:ro"
], ],
"env": { "run-once": true,
"MESH_HOMEASSISTANT_URL": "http://127.0.0.1:${port:8123}", "env-file": [
"MESH_HOMEASSISTANT_TOKEN_FILE": "/run/secrets/token", "${dir:state}/provisions.env"
"MESH_PROVISIONS_DIR": "/run/provisions",
"MESH_WRITTEN_DIR": "/var/lib/home-assistant-provisions"
},
"args": [
"run",
"/app/modules/home-assistant/dist/provisions/index.js"
], ],
"restart-on": [ "restart-on": [
"provisions-env",
"bound-mqtt-topic", "bound-mqtt-topic",
"secret-mqtt-topic", "secret-mqtt-topic",
"bound-sonarr-api", "bound-sonarr-api",
@@ -137,8 +108,7 @@
"secret-radarr-api", "secret-radarr-api",
"bound-lidarr-api", "bound-lidarr-api",
"secret-lidarr-api" "secret-lidarr-api"
], ]
"artifact": "runtime"
} }
], ],
"requires": [ "requires": [
@@ -173,23 +143,25 @@
"lidarr-api": "${dir:state}/lidarr-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"
}
} }
] ]
} }
-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
+15 -37
View File
@@ -28,9 +28,6 @@
"stream.started", "stream.started",
"stream.stopped" "stream.stopped"
], ],
"own-secrets": {
"broker": "${dir:mesh-state}/broker"
},
"listens": [ "listens": [
{ {
"name": "stream", "name": "stream",
@@ -95,45 +92,26 @@
"mode": "0600", "mode": "0600",
"content": "{}\n", "content": "{}\n",
"merge": "json" "merge": "json"
},
{
"id": "runtime",
"type": "container",
"name": "mesh-icecast",
"network": "icecast",
"volumes": [
"${dir:mesh-state}/broker:/run/secrets/broker:ro",
"${dir:mesh-state}/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
+17 -39
View File
@@ -11,7 +11,6 @@
"container-runtime" "container-runtime"
], ],
"own-secrets": { "own-secrets": {
"broker": "${dir:mesh-state}/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"
}, },
@@ -100,29 +99,6 @@
"mode": "0600", "mode": "0600",
"content": "{}\n", "content": "{}\n",
"merge": "json" "merge": "json"
},
{
"id": "runtime",
"type": "container",
"name": "mesh-influxdb",
"network": "host",
"volumes": [
"${dir:mesh-state}/broker:/run/secrets/broker:ro",
"${dir:mesh-state}/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"
}
} }
] ]
} }
+1 -1
View File
@@ -68,7 +68,7 @@
"type": "file", "type": "file",
"path": "${dir:state}/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",
-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
+14 -40
View File
@@ -2,16 +2,9 @@
"module": "jira", "module": "jira",
"version": "1", "version": "1",
"own-secrets": { "own-secrets": {
"token": "${dir:state}/token", "token": "${dir:state}/token"
"broker": "${dir:mesh-state}/broker"
}, },
"resources": [ "resources": [
{
"id": "mesh-state",
"type": "directory",
"mode": "0700",
"place": "mesh"
},
{ {
"id": "state", "id": "state",
"type": "directory", "type": "directory",
@@ -25,46 +18,27 @@
"merge": "json", "merge": "json",
"content": "{}", "content": "{}",
"mode": "0600" "mode": "0600"
},
{
"id": "runtime",
"type": "container",
"name": "mesh-runtime-jira",
"network": "host",
"volumes": [
"${dir:state}/config.json:/run/config/config.json:ro",
"${dir:state}/token:/run/secrets/token:ro",
"${dir:mesh-state}/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
+20 -40
View File
@@ -62,8 +62,7 @@
"oidc-client": "${dir:grants}" "oidc-client": "${dir:grants}"
}, },
"own-secrets": { "own-secrets": {
"admin": "${dir:state}/admin.secret", "admin": "${dir:state}/admin.secret"
"broker": "${dir:mesh-state}/broker"
}, },
"resources": [ "resources": [
{ {
@@ -144,49 +143,30 @@
"mode": "0600", "mode": "0600",
"content": "{}\n", "content": "{}\n",
"merge": "json" "merge": "json"
},
{
"id": "runtime",
"type": "container",
"name": "mesh-keycloak",
"network": "host",
"volumes": [
"${dir:mesh-state}/broker:/run/secrets/broker:ro",
"${dir:mesh-state}/config.json:/run/config/config.json:ro",
"${dir:state}/admin.secret:/run/secrets/admin:ro",
"${dir:grants}:${dir: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": "${dir: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"
}
} }
] ]
} }
-31
View File
@@ -1,31 +0,0 @@
# lab's runtime: the tool runtime, carrying this module's code, and the toolchain the lab's suite
# builds the mesh with (novox/hq ADR 0172). It reaches the machine's virtualisation and container
# runtime through their sockets, so what it raises is what a hand run on this machine raises.
#
# Every download is pinned by its checksum: an image that builds the mesh is the last place to take
# whatever an upstream serves today.
ARG BUILD_BASE
ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build
WORKDIR /app/modules/lab
COPY . .
RUN node /app/node_modules/typescript/bin/tsc tools/index.ts tools/runs.ts --rootDir . \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE}
RUN apt-get update \
&& apt-get install -y --no-install-recommends git make ca-certificates curl python3 file iproute2 sudo \
&& rm -rf /var/lib/apt/lists/*
RUN curl -fsSL -o /tmp/go.tgz https://go.dev/dl/go1.26.8.linux-amd64.tar.gz \
&& echo "d0f743b33e8d8945e6b1f432edd15785c70507121d6e2a723b21285eddf8b57b /tmp/go.tgz" | sha256sum -c - \
&& tar -C /usr/local -xzf /tmp/go.tgz && rm /tmp/go.tgz
RUN curl -fsSL -o /usr/local/bin/incus https://github.com/lxc/incus/releases/download/v7.5.1/bin.linux.incus.x86_64 \
&& echo "7bd6223b369f4d693fcde695bd8549a73b5b3d403735329212483702aa22c179 /usr/local/bin/incus" | sha256sum -c - \
&& chmod 0755 /usr/local/bin/incus
RUN curl -fsSL -o /tmp/docker.tgz https://download.docker.com/linux/static/stable/x86_64/docker-28.5.2.tgz \
&& echo "ea90cfd12e1eeb12aa1c971741adb8bd4ed88e2a574eaac13f5029a1dbc6300d /tmp/docker.tgz" | sha256sum -c - \
&& tar -C /tmp -xzf /tmp/docker.tgz docker/docker && mv /tmp/docker/docker /usr/local/bin/docker && rm -rf /tmp/docker /tmp/docker.tgz
ENV PATH=/usr/local/go/bin:$PATH
COPY --from=build /app/modules/lab/dist /app/modules/lab/dist
ENV MESH_TOOL_MODULES=/app/modules/lab/dist/tools/index.js
+55 -44
View File
@@ -5,16 +5,7 @@
"container-runtime", "container-runtime",
"virtualisation" "virtualisation"
], ],
"own-secrets": {
"broker": "${dir:mesh-state}/broker"
},
"resources": [ "resources": [
{
"id": "mesh-state",
"type": "directory",
"mode": "0700",
"place": "mesh"
},
{ {
"id": "state", "id": "state",
"type": "directory", "type": "directory",
@@ -35,47 +26,67 @@
"content": "MESH_LAB_FORGE=${setting:forge}\n" "content": "MESH_LAB_FORGE=${setting:forge}\n"
}, },
{ {
"id": "runtime", "id": "git",
"type": "container", "type": "package",
"name": "mesh-lab", "package": "git"
"network": "host",
"env-file": [
"${dir:state}/lab.env"
],
"volumes": [
"${dir:mesh-state}/broker:/run/secrets/broker:ro",
"${dir:work}:${dir:work}",
"/var/run/docker.sock:/var/run/docker.sock",
"/var/lib/incus/unix.socket:/var/lib/incus/unix.socket"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_LAB_WORK": "${dir:work}"
}, },
"restart-on": [ {
"runtime-env" "id": "make",
], "type": "package",
"artifact": "runtime" "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": { "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"
],
"loads": [
"tools/index.js"
],
"env": {
"MESH_LAB_WORK": "${dir:work}",
"MESH_LAB_ENV_FILE": "${dir:state}/lab.env"
}
} }
] ]
} }
+23 -1
View File
@@ -2,18 +2,23 @@
// lab is assigned to, and only there: a bed raises virtual machines on that machine's virtualisation. // lab is assigned to, and only there: a bed raises virtual machines on that machine's virtualisation.
import { spawnSync } from "node:child_process"; import { spawnSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
import { listRuns, readStatus, REPOSITORIES, running, start, stop, tail } from "./runs.js"; import { listRuns, readStatus, REPOSITORIES, running, start, stop, tail } from "./runs.js";
export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] { export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] {
const work = env.MESH_LAB_WORK ?? "/var/lib/mesh-lab-runs"; const work = env.MESH_LAB_WORK ?? "/var/lib/mesh-lab-runs";
const forge = (env.MESH_LAB_FORGE ?? "").replace(/\/+$/, ""); // 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 [ return [
{ {
name: "lab_check", name: "lab_check",
description: "Whether this machine can run the lab's beds: the lab's own check, against the forge's main branch.", description: "Whether this machine can run the lab's beds: the lab's own check, against the forge's main branch.",
input: {}, input: {},
run: async () => { run: async () => {
const forge = forgeOf();
if (!forge) return { ok: false, output: "the lab's forge is not set: settings for lab, {\"forge\": \"<url>\"}" }; if (!forge) return { ok: false, output: "the lab's forge is not set: settings for lab, {\"forge\": \"<url>\"}" };
const dir = `${work}/check`; const dir = `${work}/check`;
spawnSync("rm", ["-rf", dir]); spawnSync("rm", ["-rf", dir]);
@@ -38,6 +43,7 @@ export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] {
}, },
}, },
run: async (args) => { run: async (args) => {
const forge = forgeOf();
if (!forge) return { started: false, reason: "the lab's forge is not set: settings for lab, {\"forge\": \"<url>\"}" }; 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); 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" }; if (tests.length === 0) return { started: false, reason: "name at least one bed test file" };
@@ -83,4 +89,20 @@ export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] {
]; ];
} }
/** 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)); registerModuleTools("lab", (env) => getLabTools(env));
-24
View File
@@ -1,24 +0,0 @@
# letta'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/letta
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/letta/dist /app/modules/letta/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/letta/dist/tools/index.js
+14 -36
View File
@@ -26,8 +26,7 @@
}, },
"own-secrets": { "own-secrets": {
"server-password": "${dir:state}/server-password.secret", "server-password": "${dir:state}/server-password.secret",
"openai-api-key": "${dir:state}/openai-api-key.secret", "openai-api-key": "${dir:state}/openai-api-key.secret"
"broker": "${dir:mesh-state}/broker"
}, },
"listens": [ "listens": [
{ {
@@ -84,45 +83,24 @@
"mode": "0600", "mode": "0600",
"content": "{\n \"password\": \"${secret:server-password}\"\n}\n", "content": "{\n \"password\": \"${secret:server-password}\"\n}\n",
"merge": "json" "merge": "json"
},
{
"id": "runtime",
"type": "container",
"name": "mesh-letta",
"network": "letta",
"volumes": [
"${dir:mesh-state}/broker:/run/secrets/broker:ro",
"${dir:mesh-state}/config.json:/run/config/config.json:ro"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_LETTA_URL": "http://letta:8283",
"MESH_LETTA_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_LETTA_URL": "http://127.0.0.1:${port:8283}",
"MESH_LETTA_CONFIG_FILE": "${dir:mesh-state}/config.json"
}
} }
] ]
} }
-30
View File
@@ -1,30 +0,0 @@
# mailu'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/mailu
COPY . .
RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts tools/index.ts provisioner/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE}
COPY --from=build /app/modules/mailu/dist /app/modules/mailu/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/mailu/dist/index.js,/app/modules/mailu/dist/tools/index.js,/app/modules/mailu/dist/provisioner/index.js
+30 -41
View File
@@ -135,11 +135,15 @@
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
"why": "automx: mail client autoconfiguration; the autoconfig, autodiscover and automx names are route grants reaching it here" "why": "automx: mail client autoconfiguration; the autoconfig, autodiscover and automx names are route grants reaching it here"
},
{
"name": "admin-api",
"port": 8080,
"protocol": "tcp",
"from": "machine",
"why": "the admin API, which this module's own code reaches on loopback from the node's runtime now that it runs outside the mailu network"
} }
], ],
"own-secrets": {
"broker": "${dir:mesh-state}/broker"
},
"resources": [ "resources": [
{ {
"id": "mesh-state", "id": "mesh-state",
@@ -305,6 +309,9 @@
"name": "mailu-admin", "name": "mailu-admin",
"image": "ghcr.io/mailu/admin@sha256:6dbfdadc4a9590dcb7652357b505200115b689b74008653bbf369e4599a3be5a", "image": "ghcr.io/mailu/admin@sha256:6dbfdadc4a9590dcb7652357b505200115b689b74008653bbf369e4599a3be5a",
"network": "mailu", "network": "mailu",
"ports": [
"8080"
],
"env-file": [ "env-file": [
"${dir:state}/mailu.env", "${dir:state}/mailu.env",
"${dir:state}/secret.env", "${dir:state}/secret.env",
@@ -473,31 +480,6 @@
"content": "{}\n", "content": "{}\n",
"merge": "json" "merge": "json"
}, },
{
"id": "runtime",
"type": "container",
"name": "mesh-mailu",
"network": "mailu",
"volumes": [
"${dir:mesh-state}/broker:/run/secrets/broker:ro",
"${dir:state}/api-token.secret:/run/secrets/api-token:ro",
"${dir:grants}:${dir:grants}:ro",
"${dir:mesh-state}/config.json:/run/config/config.json:ro",
"/var/run/docker.sock:/var/run/docker.sock"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_MAILU_URL": "http://mailu-admin:8080/api/v1",
"MESH_MAILU_API_KEY_FILE": "/run/secrets/api-token",
"MESH_MAILU_IMAP_CONTAINER": "mailu-imap",
"MESH_MAILU_CONFIG_FILE": "/run/config/config.json",
"MESH_RECEIVES": "${dir:grants}/mesh.json"
},
"restart-on": [
"runtime-config"
],
"artifact": "runtime"
},
{ {
"id": "automx", "id": "automx",
"type": "container", "type": "container",
@@ -517,16 +499,6 @@
], ],
"build": { "build": {
"on": [ "on": [
{
"arg": "BUILD_BASE",
"module": "mesh-tools",
"artifact": "build"
},
{
"arg": "RUNTIME_BASE",
"module": "mesh-tools",
"artifact": "runtime"
},
{ {
"arg": "PYTHON_BASE", "arg": "PYTHON_BASE",
"image": "python@sha256:25f3cfeaceca14921366af4d1240b56457ef46273bdb508c7b0e8f469f6fd228" "image": "python@sha256:25f3cfeaceca14921366af4d1240b56457ef46273bdb508c7b0e8f469f6fd228"
@@ -534,9 +506,26 @@
], ],
"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_MAILU_URL": "http://127.0.0.1:${port:8080}/api/v1",
"MESH_MAILU_API_KEY_FILE": "${dir:state}/api-token.secret",
"MESH_MAILU_IMAP_CONTAINER": "mailu-imap",
"MESH_MAILU_CONFIG_FILE": "${dir:mesh-state}/config.json",
"MESH_RECEIVES": "${dir:grants}/mesh.json"
}
}, },
{ {
"name": "automx", "name": "automx",
-55
View File
@@ -1,55 +0,0 @@
# mesh-catalog's runtime: the tool runtime, carrying the catalogue's compiled graph, its consumer
# of what the builder announces, and its tools.
#
# **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 with 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/mesh-catalog
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 pg.d.ts store.ts index.ts tools/index.ts prepare/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
# **A module may need something the base image does not carry.** The base holds what every module
# needs — the sdk, the broker client — and a postgres driver is not that: the one other module that
# reaches a database shells out to psql instead. So the catalogue brings its own.
#
# Installed into an empty directory rather than into the module's, because the module's package.json
# also names `@novox/mesh-sdk`, which is not on any registry — it is in the base image. Asking npm to
# resolve this module's dependencies would therefore fail on the one it already has.
RUN mkdir -p /deps && cd /deps && \
npm install --omit=dev --no-audit --no-fund --no-package-lock pg@8
FROM ${RUNTIME_BASE}
COPY --from=build /app/modules/mesh-catalog/dist /app/modules/mesh-catalog/dist
# Beside the compiled code, so `pg` resolves from it while `@novox/mesh-sdk` keeps walking up to the
# base image's own node_modules — the module gets its extra dependency without shadowing the sdk it
# was compiled against.
COPY --from=build /deps/node_modules /app/modules/mesh-catalog/node_modules
# Both entrypoints, loaded in serve mode.
#
# **A consumer cannot be started with `run`.** That mode imports an entrypoint without binding a
# broker — it is for a step that does its work offline and exits — and the catalogue's whole job is
# to listen for what the builder announces. Serve binds the broker first, then imports these, so
# `on()` has something to subscribe to.
ENV MESH_TOOL_MODULES=/app/modules/mesh-catalog/dist/index.js,/app/modules/mesh-catalog/dist/tools/index.js
# And what prepares this module's state, for the runtime's `prepare` mode (novox/hq ADR 0135). Named
# here, beside the entrypoints above, because the module knows which of its files prepares its state
# and nothing else could: the mesh asks one word and this says what answers it.
ENV MESH_PREPARE=/app/modules/mesh-catalog/dist/prepare/index.js
+24 -36
View File
@@ -25,9 +25,6 @@
"secrets": { "secrets": {
"postgres-database": "${dir:state}/database.secret" "postgres-database": "${dir:state}/database.secret"
}, },
"own-secrets": {
"broker": "${dir:mesh-state}/broker"
},
"consumes": [ "consumes": [
"mesh-build-machine.built", "mesh-build-machine.built",
"mesh-controller.built-before" "mesh-controller.built-before"
@@ -38,14 +35,7 @@
"rebuild-needed", "rebuild-needed",
"catching-up" "catching-up"
], ],
"prepares": true,
"resources": [ "resources": [
{
"id": "mesh-state",
"type": "directory",
"mode": "0700",
"place": "mesh"
},
{ {
"id": "state", "id": "state",
"type": "directory", "type": "directory",
@@ -60,43 +50,41 @@
"content": "postgresql://${bound:postgres-database:as}:${secret:postgres-database}@${bound:postgres-database:at}:${bound:postgres-database:port}/${bound:postgres-database:as}\n" "content": "postgresql://${bound:postgres-database:as}:${secret:postgres-database}@${bound:postgres-database:at}:${bound:postgres-database:port}/${bound:postgres-database:as}\n"
}, },
{ {
"id": "runtime", "id": "prepare",
"type": "container", "type": "process",
"name": "mesh-catalog", "name": "mesh-catalog-prepare",
"network": "host", "artifact": "code",
"volumes": [ "run": [
"${dir:mesh-state}/broker:/run/secrets/broker:ro", "node",
"${dir:state}:/run/state", "prepare/index.js"
"${dir:state}/database.url:/run/secrets/database-url:ro"
], ],
"run-once": true,
"env": { "env": {
"MESH_BROKER_FILE": "/run/secrets/broker", "DATABASE_URL_FILE": "${dir:state}/database.url"
"DATABASE_URL_FILE": "/run/secrets/database-url"
}, },
"artifact": "runtime",
"restart-on": [ "restart-on": [
"database-url" "database-url"
] ]
} }
], ],
"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",
"prepare/index.js"
],
"loads": [
"index.js",
"tools/index.js"
],
"env": {
"DATABASE_URL_FILE": "${dir:state}/database.url"
}
} }
] ]
} }
+3 -3
View File
@@ -1,9 +1,9 @@
// Ambient types for `pg` (node-postgres), which ships its types only via the separate `@types/pg` // Ambient types for `pg` (node-postgres), which ships its types only via the separate `@types/pg`
// package. Rather than pull that in at tsc time, this declares the exact slice model-usage uses — // package. Rather than pull that in at tsc time, this declares the exact slice model-usage uses —
// the same precedent anthropic-manager sets for `tweetnacl-sealedbox-js` (a local ambient .d.ts, // the same precedent anthropic-manager sets for `tweetnacl-sealedbox-js` (a local ambient .d.ts,
// listed in tsconfig `include`, default-imported). The real `pg` is installed into the module's // listed in tsconfig `include`, default-imported). The real `pg` is the package.json dependency the
// runtime image (package.json `dependencies`; novox/hq ADR 0052), so this types the code without // builder installs and inlines into the module's bundle (novox/hq ADR 0198 §4), so this types the
// deciding what runs. // code without deciding what runs.
declare module "pg" { declare module "pg" {
/** One checked-out connection. Needed because registering a module-version and its edges is one /** One checked-out connection. Needed because registering a module-version and its edges is one
* act: a half-written registration is a graph that lies about what something was built against. */ * act: a half-written registration is a graph that lies about what something was built against. */
+3 -2
View File
@@ -7,8 +7,9 @@
// nothing anywhere said so. // nothing anywhere said so.
// //
// Nothing here connects to the broker. Preparation runs before the version that would use it, so // Nothing here connects to the broker. Preparation runs before the version that would use it, so
// there is nothing yet to talk to; the runtime's `prepare` mode imports this and awaits it, and this // there is nothing yet to talk to: the host runs this file as a run-once process, with the module's
// process exiting non-zero is how the host knows not to start the runtime. // words and no bus (novox/hq ADR 0198 §3), before the node's runtime is started with the version
// that needs it, and this process exiting non-zero is how the host knows the step did not complete.
import { Graph } from "../store.js"; import { Graph } from "../store.js";
const graph = Graph.fromEnv(); const graph = Graph.fromEnv();
-13
View File
@@ -1,13 +0,0 @@
# The console (novox/hq ADR 0152, design 34): the mesh's tools for whoever is on a machine, served
# over MCP on that machine's loopback.
#
# **Nothing is compiled here.** The console is the tool runtime's own client — `mesh serve` — which
# the runtime image already carries beside the runtime it runs modules with. This recipe changes the
# program the image starts and nothing else, so the console is exactly the client a person can run by
# hand, started by the mesh instead, on the credential the mesh sealed to the machine.
#
# One base, named rather than pinned: the mesh answers with the copy it holds (novox/hq issue 044).
ARG RUNTIME_BASE
FROM ${RUNTIME_BASE}
ENTRYPOINT ["node", "dist/mesh.js"]
-38
View File
@@ -1,38 +0,0 @@
# mesh-console
The mesh's tools, on the machine a person sits at, served by a module the mesh assigned there
(novox/hq [ADR 0152](https://git.novox.be/novox/hq), design 34).
Assign it to a machine and an agent on that machine has the mesh's tools at
`http://127.0.0.1:<port>/mcp` — MCP over HTTP, `initialize`, `tools/list`, `tools/call`. A person at
a terminal reaches the same endpoint with `mesh tools --console http://127.0.0.1:<port>` and
`mesh call <module>.<tool> --console …`, with no credential of their own: the console holds it.
## What it is
The tool runtime's own client, `mesh serve`, started by the mesh on the credential it sealed to the
machine for `<node>.mesh-console`. The manifest says three things nothing else in the catalogue says
together:
- `invokes: ["*"]` — it calls every tool on the mesh, and the bus grants exactly that publish side;
- a listener `from: machine` — loopback only, and the filter opens nothing for it;
- no `emits`, no `consumes`, no `tools` — nothing on the bus can address it.
**Loopback is the authority boundary.** Whoever can connect is on the machine, and whoever is on the
machine is the account that owns the mesh there (ADR 0034, ADR 0144). There is no token and no login,
and `mesh serve` refuses to bind anything but a loopback address.
## What it lists
What the running modules answer: every tool runtime serves a `tools` verb for its module, and the
console asks the catalogue which modules the mesh holds and each module what it serves. A module that
did not answer — not assigned, not up, or built before the runtime answered `tools` — is named in the
list's `_meta.notAnswering` and can still be called by `<module>.<tool>`.
The mesh's own verbs (`status`, `push`, `assign`) are the `mesh-controller` seat's tools under
ADR 0132 and are not served on the bus yet; they appear here when they are.
## Port
The manifest declares port 4270 and the mesh assigns the machine port as it does for any listener;
the console binds `127.0.0.1:${port:4270}`. `node show <machine>` says which port a machine was given.
-64
View File
@@ -1,64 +0,0 @@
{
"module": "mesh-console",
"version": "1",
"slug": "console",
"capabilities": [
"container-runtime"
],
"invokes": [
"*"
],
"own-secrets": {
"broker": "${dir:mesh-state}/broker"
},
"listens": [
{
"name": "mcp",
"port": 4270,
"protocol": "tcp",
"from": "machine",
"why": "the mesh's tools for whoever is on this machine, over MCP on loopback; the machine's login is the authority (novox/hq ADR 0152)"
}
],
"resources": [
{
"id": "mesh-state",
"type": "directory",
"mode": "0700",
"place": "mesh"
},
{
"id": "server",
"type": "container",
"name": "mesh-console",
"network": "host",
"args": [
"serve"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_CONSOLE_LISTEN": "127.0.0.1:${port:4270}"
},
"volumes": [
"${dir:mesh-state}/broker:/run/secrets/broker:ro"
],
"artifact": "runtime"
}
],
"build": {
"on": [
{
"arg": "RUNTIME_BASE",
"module": "mesh-tools",
"artifact": "runtime"
}
],
"artifacts": [
{
"name": "runtime",
"kind": "image",
"from": "Dockerfile"
}
]
}
}
-22
View File
@@ -1,22 +0,0 @@
# mesh-vault's runtime: the tool runtime, carrying this module's compiled provisioner, tools and event
# consumer. The same shape as postgres's, minus the client the database needs: mesh-vault reaches no
# server, because what it provides is a value the mesh already delivered to its node.
#
# **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 (novox/hq ADR 0069). Two bases, named rather than
# pinned — the image this is COMPILED in and the image it RUNS in — answered by the mesh from
# `build.on` in module.json (novox/hq issue 044).
ARG BUILD_BASE
ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build
WORKDIR /app/modules/vault
COPY . .
RUN node /app/node_modules/typescript/bin/tsc client.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/vault/dist /app/modules/vault/dist
# The entrypoints a tool host loads from this module: its event consumer, its tools and its
# provisioner — one image, one process, one broker account (novox/hq ADR 0052).
ENV MESH_TOOL_MODULES=/app/modules/vault/dist/index.js,/app/modules/vault/dist/tools/index.js,/app/modules/vault/dist/provisioner/index.js
+18 -43
View File
@@ -27,16 +27,7 @@
"secret": "${dir:grants}" "secret": "${dir:grants}"
}, },
"keeps": "/var/lib/mesh-vault/root", "keeps": "/var/lib/mesh-vault/root",
"own-secrets": {
"broker": "${dir:mesh-state}/broker"
},
"resources": [ "resources": [
{
"id": "mesh-state",
"type": "directory",
"mode": "0700",
"place": "mesh"
},
{ {
"id": "state", "id": "state",
"type": "directory", "type": "directory",
@@ -57,45 +48,29 @@
"id": "root", "id": "root",
"type": "directory", "type": "directory",
"mode": "0700" "mode": "0700"
},
{
"id": "runtime",
"type": "container",
"name": "mesh-vault",
"network": "host",
"volumes": [
"${dir:mesh-state}/broker:/run/secrets/broker:ro",
"${dir:grants}:${dir:grants}:ro",
"${dir:ledger}:${dir:ledger}",
"${dir:root}:${dir:root}:ro"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_RECEIVES": "${dir:grants}/mesh.json",
"MESH_VAULT_LEDGER": "${dir:ledger}",
"MESH_VAULT_ROOT": "${dir:root}"
},
"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_RECEIVES": "${dir:grants}/mesh.json",
"MESH_VAULT_LEDGER": "${dir:ledger}",
"MESH_VAULT_ROOT": "${dir:root}"
}
} }
] ]
}, },
-40
View File
@@ -1,40 +0,0 @@
# minio'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
ARG MC_CLI
# Named so the final stage's COPY --from can reference a stage, not an ARG — the legacy builder
# this host still runs doesn't expand ARGs inside COPY --from, only inside FROM.
FROM ${MC_CLI} AS mccli
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/minio
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/minio/dist /app/modules/minio/dist
# The provisioner shells out to mc to actually create buckets and service accounts on the running
# minio server — mc itself was never in this runtime image, only in minio's own. Silently retried
# "spawn mc ENOENT" forever: a requirement was granted at the control-plane level without ever
# materializing the credential on minio. /usr/bin/mc there is a symlink to the real binary, mcli —
# both copied so the symlink resolves.
COPY --from=mccli /usr/bin/mcli /usr/bin/mcli
COPY --from=mccli /usr/bin/mc /usr/bin/mc
# 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/minio/dist/tools/index.js,/app/modules/minio/dist/provisioner/index.js
+5 -15
View File
@@ -303,21 +303,11 @@ export class MinioClient {
// --- module-scoped helpers ------------------------------------------------- // --- module-scoped helpers -------------------------------------------------
/** A deterministic 20-char access key id from a consumer name, so removal needs no stored state: // **Neither the access key nor the bucket is derived here any more.** `accessKeyFor` minted an id
* the provisioner recomputes the same id at teardown that it minted at creation. */ // of its own until the mesh took that over (ADR 0048: the login is the mesh's, handed to both
export function accessKeyFor(consumer: string): string { // ends), and `bucketFor` derived the bucket until the mesh took that over too (ADR 0201: the rule
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; // is a line of this module's manifest, filled per consumer and delivered to both ends). Both
const digest = createHash("sha256").update(consumer).digest(); // survived with no callers, which is the state a rule comes back from; they are gone.
let out = "";
for (let i = 0; i < 20; i++) out += chars[digest[i] % chars.length];
return out;
}
/** A DNS-safe bucket name derived from a consumer — the removable identity of its storage. */
export function bucketFor(consumer: string): string {
const name = consumer.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 63);
return name.length >= 3 ? name : `mesh-${name}`;
}
function bucketPolicy(bucket: string): string { function bucketPolicy(bucket: string): string {
return JSON.stringify({ return JSON.stringify({
+26 -46
View File
@@ -49,7 +49,8 @@
"s3-bucket": { "s3-bucket": {
"scheme": "http", "scheme": "http",
"region": "eu-west", "region": "eu-west",
"port": 9000 "port": 9000,
"bucket": "${consumer:as:dns}"
} }
}, },
"receives": { "receives": {
@@ -59,16 +60,9 @@
"s3-bucket": "${dir:grants}" "s3-bucket": "${dir:grants}"
}, },
"own-secrets": { "own-secrets": {
"root": "${dir:state}/root.secret", "root": "${dir:state}/root.secret"
"broker": "${dir:mesh-state}/broker"
}, },
"resources": [ "resources": [
{
"id": "mesh-state",
"type": "directory",
"mode": "0700",
"place": "mesh"
},
{ {
"id": "state", "id": "state",
"type": "directory", "type": "directory",
@@ -127,48 +121,34 @@
} }
}, },
{ {
"id": "runtime", "id": "client",
"type": "container", "type": "package",
"name": "mesh-minio", "package": "minio-client"
"network": "minio-net",
"volumes": [
"${dir:mesh-state}/broker:/run/secrets/broker:ro",
"${dir:grants}:${dir:grants}:ro",
"${dir:state}/root.secret:/run/secrets/root:ro"
],
"env": {
"MESH_MINIO_ENDPOINT": "http://minio:9000",
"MESH_MINIO_ROOT_USER": "meshroot",
"MESH_MINIO_ROOT_PASSWORD_FILE": "/run/secrets/root",
"MESH_MINIO_REGION": "eu-west",
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_RECEIVES": "${dir:grants}/mesh.json"
},
"artifact": "runtime"
} }
], ],
"build": { "build": {
"on": [
{
"arg": "BUILD_BASE",
"module": "mesh-tools",
"artifact": "build"
},
{
"arg": "RUNTIME_BASE",
"module": "mesh-tools",
"artifact": "runtime"
},
{
"arg": "MC_CLI",
"image": "docker.io/pgsty/minio@sha256:b6bfe7239bfc83fb90d31612d9704d86039dd714f7904b3f1ad68f211e602372"
}
],
"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_MINIO_ENDPOINT": "http://127.0.0.1:${port:9000}",
"MESH_MINIO_ROOT_USER": "meshroot",
"MESH_MINIO_ROOT_PASSWORD_FILE": "${dir:state}/root.secret",
"MESH_MINIO_REGION": "eu-west",
"MESH_MINIO_MC_BIN": "mcli",
"MESH_MINIO_MC_CONFIG": "${dir:state}/mc",
"MESH_RECEIVES": "${dir:grants}/mesh.json"
}
} }
] ]
} }
+1 -1
View File
@@ -5,7 +5,7 @@
"type": "module", "type": "module",
"private": true, "private": true,
"dependencies": { "dependencies": {
"@novox/mesh-sdk": "^0.1.1" "@novox/mesh-sdk": "^0.1.7"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
+31 -7
View File
@@ -10,18 +10,24 @@
// **The access key and its secret are the mesh's, not the provisioner's (ADR 0048).** The mesh // **The access key and its secret are the mesh's, not the provisioner's (ADR 0048).** The mesh
// derives the login (the access-key id) and hands it to both ends, and mints the secret key. minio // derives the login (the access-key id) and hands it to both ends, and mints the secret key. minio
// creates the service account under exactly that access key with exactly that secret — a credential // creates the service account under exactly that access key with exactly that secret — a credential
// the provisioner invented is one the consumer could never present. The bucket is derived from the // the provisioner invented is one the consumer could never present.
// login, so teardown recomputes it with nothing to persist. //
// **The bucket name is the mesh's too (ADR 0201).** It used to be computed here, from the login,
// and every consumer transcribed the same rule into its own definition by hand — two copies of
// one rule with nothing comparing them, and one of three was wrong for months. Now the rule is a
// line of this module's manifest (`serves.s3-bucket.bucket: ${consumer:as:dns}`), the mesh fills
// it per consumer, and the same filled value reaches this provisioner and the consumer's own
// configuration. There is no second computation to disagree with.
import { runProvisioner, type Provision } from "@novox/mesh-sdk/provisioner"; import { runProvisioner, type Provision } from "@novox/mesh-sdk/provisioner";
import { emit } from "@novox/mesh-sdk/events"; import { emit } from "@novox/mesh-sdk/events";
import { MinioClient, bucketFor } from "../client.js"; import { MinioClient } from "../client.js";
const minio = MinioClient.fromEnv(); const minio = MinioClient.fromEnv();
runProvisioner("s3-bucket", { runProvisioner("s3-bucket", {
async create(p: Provision): Promise<void> { async create(p: Provision): Promise<void> {
const bucket = bucketFor(p.as); const bucket = bucketNamed(p.derived);
const accessKeyId = p.as; const accessKeyId = p.as;
if (!(await minio.bucketExists(bucket))) await minio.createBucket(bucket); if (!(await minio.bucketExists(bucket))) await minio.createBucket(bucket);
@@ -38,8 +44,8 @@ runProvisioner("s3-bucket", {
}); });
}, },
async remove(p: { as: string }): Promise<void> { async remove(p: { as: string; derived: Readonly<Record<string, unknown>> }): Promise<void> {
const bucket = bucketFor(p.as); const bucket = bucketNamed(p.derived);
// Revoking the key is what cuts the consumer's access. The bucket is emptied-then-dropped only if // Revoking the key is what cuts the consumer's access. The bucket is emptied-then-dropped only if
// empty; a bucket that still holds objects is left for an operator rather than erroring on every // empty; a bucket that still holds objects is left for an operator rather than erroring on every
@@ -57,10 +63,28 @@ runProvisioner("s3-bucket", {
// Asked every minute by the harness: whether the backend still holds this consumer exactly as // Asked every minute by the harness: whether the backend still holds this consumer exactly as
// the mesh gave it, so a login lost behind the provisioner's back is made again (novox/hq issue 120). // the mesh gave it, so a login lost behind the provisioner's back is made again (novox/hq issue 120).
async holds(p: Provision): Promise<boolean> { async holds(p: Provision): Promise<boolean> {
return minio.canReachAs(bucketFor(p.as), p.as, p.password); return minio.canReachAs(bucketNamed(p.derived), p.as, p.password);
}, },
}); });
/** The bucket the mesh derived for this consumer.
*
* Absent means this module is running against a control plane that does not fill `${consumer:…}`
* yet, or a manifest whose `serves` block lost the line. Both are the same mistake from here —
* nobody said which bucket — and both are said rather than guessed: a provisioner that fell back
* to deriving one would restore the second rule and hide the fault behind a bucket that happens
* to be right. */
function bucketNamed(derived: Readonly<Record<string, unknown>>): string {
const bucket = derived.bucket;
if (typeof bucket !== "string" || bucket === "") {
throw new Error(
"the mesh did not say which bucket this consumer gets: minio's manifest must serve " +
"`bucket` under s3-bucket (novox/hq ADR 0201)",
);
}
return bucket;
}
/** Emit best-effort: a broker hiccup is logged and dropped, never allowed to throw back and fail a /** Emit best-effort: a broker hiccup is logged and dropped, never allowed to throw back and fail a
* bucket that was made. */ * bucket that was made. */
async function announce(type: string, body: unknown): Promise<void> { async function announce(type: string, body: unknown): Promise<void> {
-37
View File
@@ -1,37 +0,0 @@
# mongodb'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/mongodb
COPY . .
RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts tools/index.ts provisioner/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE}
# mongodb's client shells out to `mongosh`, installed from MongoDB's own apt repo so its shared
# libraries come with it — copying the bare binary out of the mongo image leaves it unable to load.
RUN apt-get update && apt-get install -y --no-install-recommends gnupg curl ca-certificates \
&& curl -fsSL https://pgp.mongodb.com/server-7.0.asc | gpg --dearmor -o /usr/share/keyrings/mongodb.gpg \
&& echo "deb [signed-by=/usr/share/keyrings/mongodb.gpg] https://repo.mongodb.org/apt/debian bookworm/mongodb-org/7.0 main" > /etc/apt/sources.list.d/mongodb.list \
&& apt-get update && apt-get install -y --no-install-recommends mongodb-mongosh \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /app/modules/mongodb/dist /app/modules/mongodb/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/mongodb/dist/index.js,/app/modules/mongodb/dist/tools/index.js,/app/modules/mongodb/dist/provisioner/index.js
+69 -78
View File
@@ -1,19 +1,16 @@
// mongodb's admin client — mongodb's own code, living in the module (novox/hq ADR 0039). Both this // mongodb's admin client — mongodb's own code, living in the module (novox/hq ADR 0039). Both this
// module's tools and its provisioner import it, and nothing outside mongodb does. // module's tools and its provisioner import it, and nothing outside mongodb does.
// //
// Commands run through `mongosh`, not a wire-protocol driver: the module may take NO npm dependency // **The backend's own driver, inside the bundle** (novox/hq ADR 0198 §4). This used to shell out to
// beyond @novox/mesh-sdk, and hand-rolling the MongoDB wire protocol + SCRAM auth is more surface // `mongosh`, which the module's container installed from MongoDB's apt repository; the module's code
// than this should carry — so it shells out to the shell the mongodb image ships, the same way // now runs in the node's runtime, on machines whose system carries no mongosh, so it speaks to the
// postgres drives itself through `psql`, minio through `mc` and mailu through doveadm. One boundary, // server through the official `mongodb` driver its package.json names — installed and inlined into
// `evalJs()`, and every method is built on it: a snippet of JavaScript is evaluated server-side and // the bundle by the builder. One connection per call, as one mongosh invocation was: the module is
// its result comes back as EJSON on stdout. // called rarely, and a pool held open across calls would hold a credential the mesh may rotate.
import { randomBytes } from "node:crypto"; import { randomBytes } from "node:crypto";
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
import { execFile } from "node:child_process"; import { MongoClient as Driver, MongoServerError, BSON, type Document } from "mongodb";
import { promisify } from "node:util";
const run = promisify(execFile);
export interface DatabaseInfo { export interface DatabaseInfo {
readonly name: string; readonly name: string;
@@ -59,32 +56,26 @@ export class MongoClient {
return this.conn.port; return this.conn.port;
} }
/** The admin connection URI mongosh authenticates with, credentials percent-encoded. */ /** The admin connection URI, credentials percent-encoded. */
private uri(): string { private uri(): string {
const u = encodeURIComponent(this.conn.user); const u = encodeURIComponent(this.conn.user);
const p = encodeURIComponent(this.conn.password); const p = encodeURIComponent(this.conn.password);
const a = encodeURIComponent(this.conn.authSource); const a = encodeURIComponent(this.conn.authSource);
return `mongodb://${u}:${p}@${this.conn.host}:${this.conn.port}/?authSource=${a}`; return `mongodb://${u}:${p}@${this.conn.host}:${this.conn.port}/?authSource=${a}&directConnection=true`;
} }
/** /**
* Evaluate a JavaScript snippet server-side through `mongosh` and parse the JSON it prints (see * The one execution boundary: connect as the administrator, do `work`, and close — a failure to
* header). The snippet MUST `print()` exactly one JSON document as its only stdout — every method * connect or to authenticate rejects here rather than returning a partial success.
* below ends in `print(EJSON.stringify(...))`. `--quiet` suppresses the shell banner so stdout is
* the JSON alone; a non-zero exit (auth failure, bad command) rejects here rather than returning
* a partial success.
*/ */
async evalJs<T>(js: string): Promise<T> { private async admin<T>(work: (client: Driver) => Promise<T>): Promise<T> {
const { stdout } = await run( const client = new Driver(this.uri(), { serverSelectionTimeoutMS: 10_000 });
"mongosh", try {
[this.uri(), "--quiet", "--eval", js], await client.connect();
{ maxBuffer: 16 << 20 }, return await work(client);
); } finally {
const text = stdout.trim(); await client.close();
if (text.length === 0) {
throw new Error("mongosh returned no output — the eval printed nothing");
} }
return JSON.parse(text) as T;
} }
/** /**
@@ -94,19 +85,16 @@ export class MongoClient {
* password and roles, so a rotated credential converges. * password and roles, so a rotated credential converges.
*/ */
async createDatabaseAndUser(database: string, user: string, password: string): Promise<void> { async createDatabaseAndUser(database: string, user: string, password: string): Promise<void> {
const js = ` await this.admin(async (client) => {
const target = db.getSiblingDB(${lit(database)}); const target = client.db(database);
let existing = null; const roles = [{ role: "dbOwner", db: database }];
try { existing = target.getUser(${lit(user)}); } catch (e) { existing = null; } const found = await target.command({ usersInfo: user });
const roles = [{ role: "dbOwner", db: ${lit(database)} }]; if (Array.isArray(found.users) && found.users.length > 0) {
if (existing) { await target.command({ updateUser: user, pwd: password, roles });
target.updateUser(${lit(user)}, { pwd: ${lit(password)}, roles: roles });
} else { } else {
target.createUser({ user: ${lit(user)}, pwd: ${lit(password)}, roles: roles }); await target.command({ createUser: user, pwd: password, roles });
} }
print(EJSON.stringify({ ok: 1 })); });
`;
await this.evalJs<{ ok: number }>(js);
} }
/** /**
@@ -115,45 +103,43 @@ print(EJSON.stringify({ ok: 1 }));
* authentication failure or a missing role; an unreachable server rejects (novox/hq issue 120). * authentication failure or a missing role; an unreachable server rejects (novox/hq issue 120).
*/ */
async canAuthenticateAs(database: string, user: string, password: string): Promise<boolean> { async canAuthenticateAs(database: string, user: string, password: string): Promise<boolean> {
// Connected without credentials, then authenticated inside the eval from the environment, so // Credentials as options, never in a URI, so the consumer's password is in no message a failed
// the consumer's password is neither on argv nor in the message of a failed command. // connection prints.
const uri = `mongodb://${this.conn.host}:${this.conn.port}/?serverSelectionTimeoutMS=10000`; const client = new Driver(`mongodb://${this.conn.host}:${this.conn.port}/?directConnection=true`, {
const js = auth: { username: user, password },
"const t = db.getSiblingDB(process.env.MESH_HOLDS_DB);" + authSource: database,
"t.auth(process.env.MESH_HOLDS_USER, process.env.MESH_HOLDS_PW);" + serverSelectionTimeoutMS: 10_000,
"print(EJSON.stringify(t.runCommand({ connectionStatus: 1 }).authInfo.authenticatedUserRoles))"; });
let stdout: string;
try { try {
({ stdout } = await run("mongosh", [uri, "--quiet", "--eval", js], { await client.connect();
env: { ...process.env, MESH_HOLDS_DB: database, MESH_HOLDS_USER: user, MESH_HOLDS_PW: password }, const status = await client.db(database).command({ connectionStatus: 1 });
timeout: 30_000, const roles = (status.authInfo?.authenticatedUserRoles ?? []) as { role: string; db: string }[];
}));
} catch (err) {
const text = `${(err as { stderr?: string }).stderr ?? ""}${(err as { stdout?: string }).stdout ?? ""}`;
if (/Authentication failed|AuthenticationFailed/i.test(text)) return false;
throw new Error(`mongosh could not check ${user}: ${text.trim().slice(0, 500) || String((err as Error).message).split("\n")[0]}`);
}
const roles = JSON.parse(stdout.trim()) as { role: string; db: string }[];
return roles.some((r) => r.role === "dbOwner" && r.db === database); return roles.some((r) => r.role === "dbOwner" && r.db === database);
} catch (err) {
if (isAuthFailure(err)) return false;
throw new Error(`mongodb could not check ${user}: ${String((err as Error).message).split("\n")[0]}`);
} finally {
await client.close();
}
} }
/** Drop a database and its owning user, idempotently. Dropping the database evicts its data; the /** Drop a database and its owning user, idempotently. Dropping the database evicts its data; the
* user is removed first so a re-grant of the same login starts clean. */ * user is removed first so a re-grant of the same login starts clean. */
async dropDatabaseAndUser(database: string, user: string): Promise<void> { async dropDatabaseAndUser(database: string, user: string): Promise<void> {
const js = ` await this.admin(async (client) => {
const target = db.getSiblingDB(${lit(database)}); const target = client.db(database);
try { target.dropUser(${lit(user)}); } catch (e) {} try {
target.dropDatabase(); await target.command({ dropUser: user });
print(EJSON.stringify({ ok: 1 })); } catch (err) {
`; if (!(err instanceof MongoServerError && err.code === 11)) throw err; // 11: UserNotFound
await this.evalJs<{ ok: number }>(js); }
await target.dropDatabase();
});
} }
/** List the databases on the server, with on-disk size, for the mongodb_list_databases tool. */ /** List the databases on the server, with on-disk size, for the mongodb_list_databases tool. */
async listDatabases(): Promise<DatabaseInfo[]> { async listDatabases(): Promise<DatabaseInfo[]> {
const res = await this.evalJs<{ databases: { name: string; sizeOnDisk?: number }[] }>( const res = await this.admin((client) => client.db("admin").admin().listDatabases());
`print(EJSON.stringify(db.adminCommand({ listDatabases: 1 })));`,
);
return (res.databases ?? []) return (res.databases ?? [])
.map((d) => ({ name: String(d.name), sizeBytes: Number(d.sizeOnDisk ?? 0) })) .map((d) => ({ name: String(d.name), sizeBytes: Number(d.sizeOnDisk ?? 0) }))
.sort((a, b) => a.name.localeCompare(b.name)); .sort((a, b) => a.name.localeCompare(b.name));
@@ -162,6 +148,8 @@ print(EJSON.stringify({ ok: 1 }));
/** /**
* Run a read-only `find` against a collection in a named database, for the mongodb_query tool. * Run a read-only `find` against a collection in a named database, for the mongodb_query tool.
* `find` mutates nothing; the limit is capped so a tool call cannot stream an unbounded result. * `find` mutates nothing; the limit is capped so a tool call cannot stream an unbounded result.
* Documents come back as relaxed Extended JSON — an ObjectId as `{"$oid": …}` — exactly as the
* shell's `EJSON.stringify` rendered them before.
*/ */
async find( async find(
database: string, database: string,
@@ -170,26 +158,29 @@ print(EJSON.stringify({ ok: 1 }));
limit: number, limit: number,
): Promise<Record<string, unknown>[]> { ): Promise<Record<string, unknown>[]> {
const capped = Math.max(1, Math.min(limit, 1000)); const capped = Math.max(1, Math.min(limit, 1000));
const js = const docs = await this.admin((client) =>
`print(EJSON.stringify(` + client
`db.getSiblingDB(${lit(database)}).getCollection(${lit(collection)})` + .db(database)
`.find(${JSON.stringify(filter)}).limit(${capped}).toArray()` + .collection(collection)
`));`; .find(BSON.EJSON.deserialize(filter as Document, { relaxed: true }) as Document)
return this.evalJs<Record<string, unknown>[]>(js); .limit(capped)
.toArray(),
);
return BSON.EJSON.serialize(docs, { relaxed: true }) as Record<string, unknown>[];
} }
} }
/** An authentication failure, as the server or the driver reports it. */
function isAuthFailure(err: unknown): boolean {
if (err instanceof MongoServerError && err.code === 18) return true; // 18: AuthenticationFailed
return /Authentication failed|AuthenticationFailed/i.test(String((err as Error)?.message ?? ""));
}
/** Generate a URL-safe password. */ /** Generate a URL-safe password. */
export function generatePassword(): string { export function generatePassword(): string {
return randomBytes(24).toString("base64url"); return randomBytes(24).toString("base64url");
} }
/** Embed a value as a JavaScript literal inside a mongosh snippet — JSON.stringify escapes quotes,
* backslashes and control characters, so a string cannot break out of the snippet. */
function lit(val: unknown): string {
return JSON.stringify(val);
}
function readSecretFile(path: string | undefined): string | undefined { function readSecretFile(path: string | undefined): string | undefined {
if (!path) return undefined; if (!path) return undefined;
try { try {
+3 -3
View File
@@ -1,9 +1,9 @@
// mongodb's events entrypoint, loaded by the per-node tool host (the provisioner container runs // mongodb's events entrypoint, launched by the node's runtime beside its tools and provisioner
// ./provisioner separately). The database lifecycle events are EMITTED from the provisioner, where // (novox/hq ADR 0198). The database lifecycle events are EMITTED from the provisioner, where
// the lifecycle actually happens (novox/hq ADR 0041/0042): // the lifecycle actually happens (novox/hq ADR 0041/0042):
// module.mongodb.database.provisioned — a consumer's database + owning user was created // module.mongodb.database.provisioned — a consumer's database + owning user was created
// module.mongodb.database.deprovisioned — that database was removed // module.mongodb.database.deprovisioned — that database was removed
// Here in the tool host we react to them, keeping a lightweight audit trail of who was granted a // Here in the runtime we react to them, keeping a lightweight audit trail of who was granted a
// database and who lost one — observability the provider itself is best placed to log. // database and who lost one — observability the provider itself is best placed to log.
import { on } from "@novox/mesh-sdk/events"; import { on } from "@novox/mesh-sdk/events";
+28 -43
View File
@@ -39,17 +39,9 @@
"mongodb-database": "${dir:grants}" "mongodb-database": "${dir:grants}"
}, },
"own-secrets": { "own-secrets": {
"root": "${dir:state}/root.secret", "root": "${dir:state}/root.secret"
"broker": "${dir:mesh-state}/broker"
}, },
"secrets-owner": "999:999",
"resources": [ "resources": [
{
"id": "mesh-state",
"type": "directory",
"mode": "0700",
"place": "mesh"
},
{ {
"id": "state", "id": "state",
"type": "directory", "type": "directory",
@@ -71,6 +63,14 @@
"type": "network", "type": "network",
"name": "mongodb" "name": "mongodb"
}, },
{
"id": "server-root",
"type": "file",
"path": "${dir:state}/server-root.secret",
"mode": "0400",
"owner": "999:999",
"content": "${secret:root}"
},
{ {
"id": "server", "id": "server",
"type": "container", "type": "container",
@@ -86,46 +86,31 @@
], ],
"volumes": [ "volumes": [
"${dir:data}:/data/db", "${dir:data}:/data/db",
"${dir:state}/root.secret:/run/secrets/root:ro" "${dir:state}/server-root.secret:/run/secrets/root:ro"
] ]
},
{
"id": "runtime",
"type": "container",
"name": "mesh-mongodb",
"network": "mongodb",
"volumes": [
"${dir:mesh-state}/broker:/run/secrets/broker:ro",
"${dir:grants}:${dir:grants}:ro",
"${dir:state}/root.secret:/run/secrets/root:ro"
],
"env": {
"MESH_PROVISION_MONGODB": "mongodb://root@mongodb-server:27017/admin?authSource=admin",
"MESH_PROVISION_PASSWORD_FILE": "/run/secrets/root",
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_RECEIVES": "${dir:grants}/mesh.json"
},
"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_PROVISION_MONGODB": "mongodb://root@127.0.0.1:${port:27017}/admin?authSource=admin",
"MESH_PROVISION_PASSWORD_FILE": "${dir:state}/root.secret",
"MESH_RECEIVES": "${dir:grants}/mesh.json"
}
} }
] ]
} }
+2 -1
View File
@@ -5,7 +5,8 @@
"type": "module", "type": "module",
"private": true, "private": true,
"dependencies": { "dependencies": {
"@novox/mesh-sdk": "^0.1.1" "@novox/mesh-sdk": "^0.1.1",
"mongodb": "^6.21.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
+1 -2
View File
@@ -11,8 +11,7 @@
// same-named database under exactly that login — a name the consumer cannot learn is a database it // same-named database under exactly that login — a name the consumer cannot learn is a database it
// cannot reach. // cannot reach.
// //
// The commands run through MongoClient.evalJs(), which is the module's one execution boundary (see // The commands run through MongoClient, the official driver inside this bundle (see client.ts).
// client.ts).
import { runProvisioner, type Provision } from "@novox/mesh-sdk/provisioner"; import { runProvisioner, type Provision } from "@novox/mesh-sdk/provisioner";
import { emit } from "@novox/mesh-sdk/events"; import { emit } from "@novox/mesh-sdk/events";
+2 -2
View File
@@ -1,6 +1,6 @@
// mongodb's tools — mongodb's own code (novox/hq ADR 0039), importing mongodb's own client. They // mongodb's tools — mongodb's own code (novox/hq ADR 0039), importing mongodb's own client. They
// return structured data; the mesh serves them through the sdk's tool harness. Both call through // return structured data; the mesh serves them through the sdk's tool harness. Both call the server
// MongoClient.evalJs(), the module's one execution boundary (see client.ts). // through MongoClient, the driver inside this bundle (see client.ts).
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
import { MongoClient } from "../client.js"; import { MongoClient } from "../client.js";
-28
View File
@@ -1,28 +0,0 @@
# mosquitto'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/mosquitto
COPY . .
RUN node /app/node_modules/typescript/bin/tsc topics.ts client.ts index.ts tools/index.ts provisioner/index.ts bootstrap/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE}
# mosquitto's client and bootstrap drive `mosquitto_ctrl`; the apt package carries it with its
# shared libraries — the musl binary from the eclipse image would not load on this glibc base.
RUN apt-get update && apt-get install -y --no-install-recommends mosquitto \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /app/modules/mosquitto/dist /app/modules/mosquitto/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/mosquitto/dist/index.js,/app/modules/mosquitto/dist/tools/index.js,/app/modules/mosquitto/dist/provisioner/index.js
+36 -4
View File
@@ -18,6 +18,7 @@ import { randomBytes } from "node:crypto";
import { connect as tcpConnect } from "node:net"; import { connect as tcpConnect } from "node:net";
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
import { execFile } from "node:child_process"; import { execFile } from "node:child_process";
import { basename, dirname } from "node:path";
import { promisify } from "node:util"; import { promisify } from "node:util";
import { missingAcls, parseRoleAcls, staleAcls, wantedAcls } from "./topics.js"; import { missingAcls, parseRoleAcls, staleAcls, wantedAcls } from "./topics.js";
@@ -30,6 +31,14 @@ export interface MqttConn {
/** The Dynamic Security admin client the runtime authenticates as. */ /** The Dynamic Security admin client the runtime authenticates as. */
readonly adminUser: string; readonly adminUser: string;
readonly adminPassword: string; readonly adminPassword: string;
/**
* The broker's own container, when `mosquitto_ctrl` is run inside it rather than from this
* machine's packages. The broker's image carries the tool at the broker's version, and inside it
* the broker listens on 127.0.0.1:1883 whatever port the machine publishes.
*/
readonly container?: string;
/** The broker's image, to seed the security file before the broker has ever started. */
readonly image?: string;
} }
export class MosquittoClient { export class MosquittoClient {
@@ -53,7 +62,11 @@ export class MosquittoClient {
"mosquitto host or admin password is not set — mosquitto's own code cannot reach the broker", "mosquitto host or admin password is not set — mosquitto's own code cannot reach the broker",
); );
} }
return new MosquittoClient({ host, port, adminUser, adminPassword: adminPassword ?? "" }); return new MosquittoClient({
host, port, adminUser, adminPassword: adminPassword ?? "",
container: env.MESH_MQTT_CTRL_CONTAINER || undefined,
image: env.MESH_MQTT_CTRL_IMAGE || undefined,
});
} }
get host(): string { get host(): string {
@@ -84,16 +97,18 @@ export class MosquittoClient {
* to this single-purpose runtime container; see the module README. * to this single-purpose runtime container; see the module README.
*/ */
async ctl(...args: string[]): Promise<string> { async ctl(...args: string[]): Promise<string> {
const inside = this.conn.container !== undefined;
const base = [ const base = [
"-h", this.conn.host, "-h", inside ? "127.0.0.1" : this.conn.host,
"-p", String(this.conn.port), "-p", inside ? "1883" : String(this.conn.port),
"-u", this.conn.adminUser, "-u", this.conn.adminUser,
"-P", this.conn.adminPassword, "-P", this.conn.adminPassword,
]; ];
let stdout: string; let stdout: string;
let stderr: string; let stderr: string;
try { try {
({ stdout, stderr } = await run("mosquitto_ctrl", [...base, "dynsec", ...args], { const [command, argv] = this.ctrl([...base, "dynsec", ...args]);
({ stdout, stderr } = await run(command, argv, {
maxBuffer: 16 << 20, maxBuffer: 16 << 20,
timeout: 30_000, timeout: 30_000,
})); }));
@@ -240,10 +255,27 @@ export class MosquittoClient {
async initBootstrapFile(configFile: string): Promise<void> { async initBootstrapFile(configFile: string): Promise<void> {
// `dynsec init <file> <admin-username> [admin-password]` is an offline file operation — it does // `dynsec init <file> <admin-username> [admin-password]` is an offline file operation — it does
// not connect to the broker. The password is a positional argument (omitting it prompts). // not connect to the broker. The password is a positional argument (omitting it prompts).
if (this.conn.image) {
// Before the broker has ever started there is no container to enter: a throwaway one from the
// broker's own image writes the file into the directory the broker will mount.
await run("docker", [
"run", "--rm", "--entrypoint", "mosquitto_ctrl",
"-v", `${dirname(configFile)}:/mosquitto/data`,
this.conn.image,
"dynsec", "init", `/mosquitto/data/${basename(configFile)}`, this.conn.adminUser, this.conn.adminPassword,
], { maxBuffer: 16 << 20 });
return;
}
await run("mosquitto_ctrl", ["dynsec", "init", configFile, this.conn.adminUser, this.conn.adminPassword], { await run("mosquitto_ctrl", ["dynsec", "init", configFile, this.conn.adminUser, this.conn.adminPassword], {
maxBuffer: 16 << 20, maxBuffer: 16 << 20,
}); });
} }
/** How `mosquitto_ctrl` is run here: inside the broker's container when one is named. */
ctrl(argv: string[]): [string, string[]] {
if (this.conn.container) return ["docker", ["exec", this.conn.container, "mosquitto_ctrl", ...argv]];
return ["mosquitto_ctrl", argv];
}
} }
/** Generate a URL-safe password with no argv- or MQTT-hostile characters. */ /** Generate a URL-safe password with no argv- or MQTT-hostile characters. */
+42 -53
View File
@@ -32,8 +32,7 @@
"mqtt-topic": "${dir:grants}" "mqtt-topic": "${dir:grants}"
}, },
"own-secrets": { "own-secrets": {
"admin": "${dir:mesh-state}/admin", "admin": "${dir:mesh-state}/admin"
"broker": "${dir:mesh-state}/broker"
}, },
"listens": [ "listens": [
{ {
@@ -89,25 +88,28 @@
"name": "mosquitto" "name": "mosquitto"
}, },
{ {
"id": "bootstrap", "id": "bootstrap-env",
"type": "container", "type": "file",
"name": "mosquitto-bootstrap", "path": "${dir:state}/bootstrap.env",
"run-once": true, "mode": "0600",
"volumes": [ "content": "MESH_PROVISION_MQTT=127.0.0.1:${port:1883}\nMESH_PROVISION_ADMIN_USER=mesh-admin\nMESH_PROVISION_PASSWORD_FILE=${dir:mesh-state}/admin\nMESH_DYNSEC_FILE=${dir:data}/dynamic-security.json\nMESH_MQTT_CTRL_IMAGE=eclipse-mosquitto@sha256:38c0da4f2ef84284d47b3b3eeea1cb3bdeabe81ee10caf0cd5c5ff61ee3ea408\n"
"${dir:data}:/mosquitto/data",
"${dir:mesh-state}/admin:/run/secrets/admin:ro"
],
"env": {
"MESH_PROVISION_MQTT": "mosquitto:1883",
"MESH_PROVISION_ADMIN_USER": "mesh-admin",
"MESH_PROVISION_PASSWORD_FILE": "/run/secrets/admin",
"MESH_DYNSEC_FILE": "/mosquitto/data/dynamic-security.json"
}, },
"args": [ {
"run", "id": "bootstrap",
"/app/modules/mosquitto/dist/bootstrap/index.js" "type": "process",
"name": "mosquitto-bootstrap",
"artifact": "code",
"run": [
"node",
"bootstrap/index.js"
], ],
"artifact": "runtime" "run-once": true,
"env-file": [
"${dir:state}/bootstrap.env"
],
"restart-on": [
"bootstrap-env"
]
}, },
{ {
"id": "server", "id": "server",
@@ -123,45 +125,32 @@
"${dir:data}:/mosquitto/data", "${dir:data}:/mosquitto/data",
"${dir:state}/mosquitto.conf:/mosquitto/config/mosquitto.conf:ro" "${dir:state}/mosquitto.conf:/mosquitto/config/mosquitto.conf:ro"
] ]
},
{
"id": "runtime",
"type": "container",
"name": "mesh-mosquitto",
"network": "mosquitto",
"volumes": [
"${dir:mesh-state}/broker:/run/secrets/broker:ro",
"${dir:grants}:${dir:grants}:ro",
"${dir:mesh-state}/admin:/run/secrets/admin:ro"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_RECEIVES": "${dir:grants}/mesh.json",
"MESH_PROVISION_MQTT": "mosquitto:1883",
"MESH_PROVISION_ADMIN_USER": "mesh-admin",
"MESH_PROVISION_PASSWORD_FILE": "/run/secrets/admin"
},
"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",
"bootstrap/index.js"
],
"loads": [
"index.js",
"tools/index.js",
"provisioner/index.js"
],
"env": {
"MESH_RECEIVES": "${dir:grants}/mesh.json",
"MESH_PROVISION_MQTT": "127.0.0.1:${port:1883}",
"MESH_PROVISION_ADMIN_USER": "mesh-admin",
"MESH_PROVISION_PASSWORD_FILE": "${dir:mesh-state}/admin",
"MESH_MQTT_CTRL_CONTAINER": "mosquitto"
}
} }
] ]
} }
+19
View File
@@ -0,0 +1,19 @@
// Run after `npm run build`.
// mosquitto_ctrl runs inside the broker's own container when the manifest names it, so a machine
// needs no mosquitto package (whose index may be too stale to install from) and the tool always
// matches the broker's version.
import assert from "node:assert/strict";
import { test } from "node:test";
import { MosquittoClient } from "../dist/client.js"; // compiled: client.ts uses parameter properties, which type stripping cannot run
const env = { MESH_PROVISION_MQTT: "127.0.0.1:21883", MESH_MQTT_PASSWORD: "pw" };
test("named, the broker's container runs mosquitto_ctrl", () => {
const c = MosquittoClient.fromEnv({ ...env, MESH_MQTT_CTRL_CONTAINER: "mosquitto" });
assert.deepEqual(c.ctrl(["dynsec", "listClients"]), ["docker", ["exec", "mosquitto", "mosquitto_ctrl", "dynsec", "listClients"]]);
});
test("unnamed, this machine's mosquitto_ctrl runs", () => {
const c = MosquittoClient.fromEnv(env);
assert.deepEqual(c.ctrl(["dynsec", "listClients"]), ["mosquitto_ctrl", ["dynsec", "listClients"]]);
});
-43
View File
@@ -1,43 +0,0 @@
# mssql'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/mssql
COPY . .
RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts tools/index.ts provisioner/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
# **sqlcmd, which this module's client drives, has to be here** — it never was, so every tool failed
# with `spawn sqlcmd ENOENT`. go-sqlcmd is one static binary; fetched at a pinned release and checked
# against its digest, so a build that receives anything else stops here.
FROM ${BUILD_BASE} AS sqlcmd
ARG SQLCMD_VERSION=v1.10.0
ARG SQLCMD_SHA256=92516d98c63d99b0994de5b61350c91f6915f9b76f139a59039fbcb225c2e987
RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates bzip2 \
&& curl -fsSL -o /tmp/sqlcmd.tar.bz2 \
"https://github.com/microsoft/go-sqlcmd/releases/download/${SQLCMD_VERSION}/sqlcmd-linux-amd64.tar.bz2" \
&& echo "${SQLCMD_SHA256} /tmp/sqlcmd.tar.bz2" | sha256sum -c - \
&& tar -xjf /tmp/sqlcmd.tar.bz2 -C /usr/local/bin sqlcmd
FROM ${RUNTIME_BASE}
COPY --from=sqlcmd /usr/local/bin/sqlcmd /usr/local/bin/sqlcmd
COPY --from=build /app/modules/mssql/dist /app/modules/mssql/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/mssql/dist/index.js,/app/modules/mssql/dist/tools/index.js,/app/modules/mssql/dist/provisioner/index.js
+111 -98
View File
@@ -1,22 +1,74 @@
// mssql's admin client — mssql's own code, living in the module (novox/hq ADR 0039). Both this // mssql's admin client — mssql's own code, living in the module (novox/hq ADR 0039). Both this
// module's tools and its provisioner import it, and nothing outside mssql does. // module's tools and its provisioner import it, and nothing outside mssql does.
// //
// SQL is executed through `sqlcmd`, not a wire-protocol driver: the module may take NO npm // **The backend's own driver, inside the bundle** (novox/hq ADR 0198 §4). This used to shell out to
// dependency beyond @novox/mesh-sdk, and hand-rolling the TDS handshake, pre-login and query // `sqlcmd`, a binary the module's container fetched; the module's code now runs in the node's
// protocol is more surface than this should carry — so it shells out to the client the mssql // runtime, on machines whose system carries no SQL Server client, so it speaks TDS through the
// tools ship, the same way postgres drives itself through `psql`, minio through `mc`, and mailu // `mssql` driver its package.json names — installed and inlined into the bundle by the builder. One
// through doveadm. One boundary, `run()`, and every method is built on it. // boundary, `session()`, and every method is built on it: a connection as one login to one database,
// opened for one call and closed after, as one sqlcmd invocation was.
// //
// Structured rows come back as JSON: SQL Server itself renders the result with `FOR JSON`, and // Structured rows still come back as JSON rendered by SQL Server itself (`FOR JSON`), so a tool's
// this parses the single JSON document sqlcmd prints — far more robust than parsing sqlcmd's // answer is shaped exactly as it was: SQL Server owns the quoting and typing.
// column-aligned text, since SQL Server owns the quoting and typing.
import { isIP } from "node:net";
import { randomBytes } from "node:crypto"; import { randomBytes } from "node:crypto";
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
import { execFile } from "node:child_process"; import sql from "mssql";
import { promisify } from "node:util";
const run = promisify(execFile); /** Where a session connects, and as whom. */
export interface Target {
readonly host: string;
readonly port: number;
readonly user: string;
readonly password: string;
readonly database: string;
}
/** One login's connection to one database: run a batch, answer the rows of its last result set. */
export interface Session {
/** `params` are bound as NVARCHAR parameters (`@name`), never written into the text. */
run(text: string, params?: Record<string, string>): Promise<Record<string, unknown>[]>;
close(): Promise<void>;
}
/** How a session is opened — the driver, or a test's fake. */
export type Connect = (to: Target) => Promise<Session>;
/**
* The driver's session: TLS, trusting the self-signed certificate the mssql image ships with (what
* sqlcmd's `-C` did), one connection, closed with the session.
*/
export const connectWithDriver: Connect = async (to) => {
const pool = new sql.ConnectionPool({
server: to.host,
port: to.port,
user: to.user,
password: to.password,
database: to.database,
// TLS names a host, never an address: Node refuses an IP as the server name (DEP0123, an error
// since Node 25), and the module reaches its server on loopback. The certificate is trusted
// either way, so the name only has to be one TLS accepts.
options: { encrypt: true, trustServerCertificate: true, ...(isIP(to.host) ? { serverName: "localhost" } : {}) },
pool: { min: 0, max: 1 },
connectionTimeout: 15_000,
requestTimeout: 60_000,
});
await pool.connect();
return {
async run(text, params = {}) {
const request = pool.request();
const names = Object.keys(params);
for (const name of names) request.input(name, sql.NVarChar, params[name]);
// A batch when nothing is bound — CREATE DATABASE must stand alone in its batch, which a
// parameterised query (sp_executesql) is not.
const result = names.length > 0 ? await request.query(text) : await request.batch(text);
const sets = (result.recordsets ?? []) as Record<string, unknown>[][];
return sets.length > 0 ? sets[sets.length - 1] : [];
},
close: () => pool.close(),
};
};
export interface QueryResult { export interface QueryResult {
/** The leading keyword of the statement, e.g. "SELECT", "CREATE". */ /** The leading keyword of the statement, e.g. "SELECT", "CREATE". */
@@ -45,20 +97,17 @@ export interface MssqlConn {
*/ */
export const READER = "mesh_mssql_reader"; export const READER = "mesh_mssql_reader";
/** Who a sqlcmd invocation logs in as, and whether the text is a caller's rather than the module's. */ /** Who a session logs in as. */
interface Invocation { interface Invocation {
readonly user: string; readonly user: string;
readonly password: string; readonly password: string;
/**
* A caller's text: sqlcmd substitutes no `$(NAME)` in it, which would read this process's
* environment — the administrator's password among it. (Its own commands are kept out by the
* caller's text never beginning a line; see readOnlyQuery.)
*/
readonly caller: boolean;
} }
export class MssqlClient { export class MssqlClient {
constructor(private readonly conn: MssqlConn) {} constructor(
private readonly conn: MssqlConn,
private readonly connect: Connect = connectWithDriver,
) {}
/** The reader is made once per process: idempotent, and repeating it re-sets a rotated password. */ /** The reader is made once per process: idempotent, and repeating it re-sets a rotated password. */
private readerReady?: Promise<void>; private readerReady?: Promise<void>;
@@ -90,63 +139,41 @@ export class MssqlClient {
return this.conn.port; return this.conn.port;
} }
/** /** Execute a batch that returns no rows (DDL and the like). A failed statement rejects. */
* Execute a batch that returns no rows (DDL and the like), through `sqlcmd`. The password is async exec(text: string, database = "master"): Promise<void> {
* passed by SQLCMDPASSWORD, never on argv, the way postgres passes PGPASSWORD; `-b` makes a await this.session(text, database);
* failed statement an error here rather than a success with a warning, and `-C` trusts the
* server's self-signed certificate the mssql image ships with.
*/
async exec(sql: string, database = "master"): Promise<void> {
await this.sqlcmd(sql, database);
} }
/** /**
* Run a SELECT and return its rows as objects. The caller's SQL must be a single SELECT; it is * Run a SELECT and return its rows as objects. The caller's SQL must be a single SELECT; it is
* wrapped so SQL Server renders the result with `FOR JSON PATH`, and the JSON document sqlcmd * wrapped so SQL Server renders the result with `FOR JSON PATH`, and the JSON document it answers
* prints (split across output lines for a large result, and reassembled here) is parsed. An * (split across rows for a large result, and reassembled here) is parsed. An empty result yields
* empty result yields no output at all — an empty array. * no rows — an empty array. `params` are bound as `@name`, never written into the text.
*/ */
async query( async query(
select: string, select: string,
database = "master", database = "master",
variables: Record<string, string> = {}, params: Record<string, string> = {},
): Promise<Record<string, unknown>[]> { ): Promise<Record<string, unknown>[]> {
const wrapped = `SET NOCOUNT ON;\n${stripTrailingSemis(select)}\nFOR JSON PATH, INCLUDE_NULL_VALUES;`; const wrapped = `SET NOCOUNT ON;\n${stripTrailingSemis(select)}\nFOR JSON PATH, INCLUDE_NULL_VALUES;`;
const stdout = await this.sqlcmd(wrapped, database, variables); return parseJsonRows(await this.session(wrapped, database, params));
return parseJsonRows(stdout);
} }
/** The one execution boundary: invoke `sqlcmd` and return its concatenated stdout. */ /** The one execution boundary: open a session as `as`, run `text`, close it. */
private async sqlcmd( private async session(
sql: string, text: string,
database: string, database: string,
variables: Record<string, string> = {}, params: Record<string, string> = {},
as: Invocation = { user: this.conn.user, password: this.conn.password, caller: false }, as: Invocation = { user: this.conn.user, password: this.conn.password },
): Promise<string> { ): Promise<Record<string, unknown>[]> {
// `-h -1` drops the column-header rule; `-y 0`/`-Y 0` lift the display-width cap so a long const session = await this.connect({
// JSON document is not truncated; `-W` trims trailing whitespace so the JSON chunks rejoin host: this.conn.host, port: this.conn.port, user: as.user, password: as.password, database,
// cleanly. sqlcmd from the mssql-tools ships in the runtime container, the way `psql` ships });
// with postgres's — the module owns its own code (ADR 0039) and shells out to it. try {
const { stdout } = await run( return await session.run(text, params);
"sqlcmd", } finally {
[ await session.close();
"-S", `${this.conn.host},${this.conn.port}`, }
"-U", as.user,
"-d", database,
...(as.caller ? ["-x"] : []),
"-C",
"-b",
"-h", "-1",
"-y", "0",
"-Y", "0",
"-W",
"-Q", sql,
],
// `variables` reach sqlcmd as environment variables, which it substitutes as `$(NAME)` scripting
// variables: a value that must not appear on argv, or in the message of a failed command.
{ env: { ...process.env, ...variables, SQLCMDPASSWORD: as.password }, maxBuffer: 16 << 20 },
);
return stdout;
} }
/** /**
@@ -173,7 +200,7 @@ export class MssqlClient {
`SELECT 1 AS ok FROM sys.databases WHERE name = ${literal(database)}`, `SELECT 1 AS ok FROM sys.databases WHERE name = ${literal(database)}`,
); );
if (dbs.length === 0) { if (dbs.length === 0) {
// CREATE DATABASE must stand alone in its batch; it runs as its own sqlcmd invocation. // CREATE DATABASE must stand alone in its batch; it runs as its own session.
await this.exec(`CREATE DATABASE ${ident(database)}`); await this.exec(`CREATE DATABASE ${ident(database)}`);
} }
@@ -206,15 +233,14 @@ export class MssqlClient {
* nothing logs in and no failed-login is recorded (novox/hq issue 120). * nothing logs in and no failed-login is recorded (novox/hq issue 120).
*/ */
async holdsLogin(database: string, login: string, password: string): Promise<boolean> { async holdsLogin(database: string, login: string, password: string): Promise<boolean> {
// The password reaches sqlcmd as a scripting variable from the environment, never inside the // The password is a bound parameter, never inside the query text, so it is in no message of a
// query text, so it is neither on argv nor in the message of a failed command. It is the mesh's // failed statement.
// minted value, which carries no quote.
const server = await this.query( const server = await this.query(
`SELECT CAST(CASE WHEN EXISTS (SELECT 1 FROM sys.sql_logins WHERE name = ${literal(login)} ` + `SELECT CAST(CASE WHEN EXISTS (SELECT 1 FROM sys.sql_logins WHERE name = ${literal(login)} ` +
`AND is_disabled = 0 AND PWDCOMPARE(N'$(MESHHOLDSPW)', password_hash) = 1) ` + `AND is_disabled = 0 AND PWDCOMPARE(@meshholdspw, password_hash) = 1) ` +
`AND DB_ID(${literal(database)}) IS NOT NULL THEN 1 ELSE 0 END AS int) AS ok`, `AND DB_ID(${literal(database)}) IS NOT NULL THEN 1 ELSE 0 END AS int) AS ok`,
"master", "master",
{ MESHHOLDSPW: password }, { meshholdspw: password },
); );
if (Number(server[0]?.ok) !== 1) return false; if (Number(server[0]?.ok) !== 1) return false;
// The user must be this login's, by SID, and a db_owner. A user orphaned by a restore has the // The user must be this login's, by SID, and a db_owner. A user orphaned by a restore has the
@@ -294,35 +320,27 @@ export class MssqlClient {
* (novox/hq issue 193). Read-only by the login, not by a transaction wrapped around the text; the * (novox/hq issue 193). Read-only by the login, not by a transaction wrapped around the text; the
* rows are rendered by FOR JSON. Never as the administrator: without the reader's password the call * rows are rendered by FOR JSON. Never as the administrator: without the reader's password the call
* is refused. * is refused.
*
* The text goes to the server as it is, over the driver: there is no client between that reads a
* line of its own (sqlcmd's `:!!`, which could start a program) or substitutes `$(NAME)` from this
* process's environment, so neither the one-line rule nor `-x` has anything left to guard.
*/ */
async readOnlyQuery(database: string, sql: string): Promise<QueryResult> { async readOnlyQuery(database: string, text: string): Promise<QueryResult> {
const password = this.conn.readerPassword; const password = this.conn.readerPassword;
if (!password) throw readerMissing(); if (!password) throw readerMissing();
// **One line, refused otherwise.** sqlcmd reads a line that BEGINS with `:` or `!!` as its own
// command rather than SQL, and `:!!` starts a program in this container, which holds the
// administrator's password. Its switch for refusing those (-X) makes it ignore -Q in the
// version shipped here, so instead no line of a caller's text can begin one: the text follows
// this module's own on the first line, and a line break in it is refused. Proven on a throwaway
// server: the same text at the start of a line ran a program; mid-line it is a syntax error.
if (/[\r\n]/.test(sql)) {
throw new Error(
"mssql_query: the statement must be one line — sqlcmd takes a line beginning with ':' or " +
"'!!' as a command of its own, which can start a program (novox/hq issue 193)",
);
}
this.readerReady ??= this.ensureReader().catch((err) => { this.readerReady ??= this.ensureReader().catch((err) => {
this.readerReady = undefined; // asked again next call, not failed for the process's life this.readerReady = undefined; // asked again next call, not failed for the process's life
throw err; throw err;
}); });
await this.readerReady; await this.readerReady;
const stdout = await this.sqlcmd( const rows = await this.session(
`SET NOCOUNT ON; ${stripTrailingSemis(sql)}\nFOR JSON PATH, INCLUDE_NULL_VALUES;`, `SET NOCOUNT ON; ${stripTrailingSemis(text)}\nFOR JSON PATH, INCLUDE_NULL_VALUES;`,
database, database,
{}, {},
{ user: READER, password, caller: true }, { user: READER, password },
); );
const command = /^\s*([A-Za-z]+)/.exec(sql)?.[1]?.toUpperCase() ?? ""; const command = /^\s*([A-Za-z]+)/.exec(text)?.[1]?.toUpperCase() ?? "";
return { command, rows: parseJsonRows(stdout) }; return { command, rows: parseJsonRows(rows) };
} }
} }
@@ -372,18 +390,13 @@ function safeUrl(raw: string): URL | undefined {
} }
/** /**
* Parse the JSON a FOR JSON query prints through sqlcmd. SQL Server splits a large FOR JSON result * Parse the JSON a FOR JSON query answers. SQL Server splits a large FOR JSON result into
* into ~2033-character chunks, one per output row; with `-h -1 -W` each lands on its own line, so * ~2033-character chunks, one per row of a single column, so the document is reassembled by
* the document is reassembled by concatenating the non-empty lines. No output (an empty result, or * concatenating that column in order. No rows (an empty result, or a pure DDL batch) means none.
* a pure DDL batch) means no rows.
*/ */
function parseJsonRows(stdout: string): Record<string, unknown>[] { function parseJsonRows(rows: Record<string, unknown>[]): Record<string, unknown>[] {
const joined = stdout const joined = rows.map((row) => String(Object.values(row)[0] ?? "")).join("");
.split(/\r?\n/) if (joined.trim().length === 0) return [];
.map((l) => l.trimEnd())
.filter((l) => l.length > 0)
.join("");
if (joined.length === 0) return [];
const parsed = JSON.parse(joined); const parsed = JSON.parse(joined);
return Array.isArray(parsed) ? (parsed as Record<string, unknown>[]) : [parsed as Record<string, unknown>]; return Array.isArray(parsed) ? (parsed as Record<string, unknown>[]) : [parsed as Record<string, unknown>];
} }
+3 -3
View File
@@ -1,9 +1,9 @@
// mssql's events entrypoint, loaded by the per-node tool host (the provisioner container runs // mssql's events entrypoint, launched by the node's runtime beside its tools and provisioner
// ./provisioner separately). The database lifecycle events are EMITTED from the provisioner, where // (novox/hq ADR 0198). The database lifecycle events are EMITTED from the provisioner, where
// the lifecycle actually happens (novox/hq ADR 0041/0042): // the lifecycle actually happens (novox/hq ADR 0041/0042):
// module.mssql.database.provisioned — a consumer's database + login/user was created // module.mssql.database.provisioned — a consumer's database + login/user was created
// module.mssql.database.deprovisioned — that database was removed // module.mssql.database.deprovisioned — that database was removed
// Here in the tool host we react to them, keeping a lightweight audit trail of who was granted a // Here in the runtime we react to them, keeping a lightweight audit trail of who was granted a
// database and who lost one — observability the provider itself is best placed to log. // database and who lost one — observability the provider itself is best placed to log.
import { on } from "@novox/mesh-sdk/events"; import { on } from "@novox/mesh-sdk/events";
+19 -42
View File
@@ -38,16 +38,9 @@
}, },
"own-secrets": { "own-secrets": {
"sa": "${dir:state}/sa.secret", "sa": "${dir:state}/sa.secret",
"broker": "${dir:mesh-state}/broker",
"reader": "${dir:state}/reader.secret" "reader": "${dir:state}/reader.secret"
}, },
"resources": [ "resources": [
{
"id": "mesh-state",
"type": "directory",
"mode": "0700",
"place": "mesh"
},
{ {
"id": "state", "id": "state",
"type": "directory", "type": "directory",
@@ -93,46 +86,30 @@
"${dir:data}:/var/opt/mssql" "${dir:data}:/var/opt/mssql"
], ],
"secrets-in-environment": "the image documents only MSSQL_SA_PASSWORD, no _FILE and no configuration field; not convertible without a wrapper entrypoint" "secrets-in-environment": "the image documents only MSSQL_SA_PASSWORD, no _FILE and no configuration field; not convertible without a wrapper entrypoint"
},
{
"id": "runtime",
"type": "container",
"name": "mesh-mssql",
"network": "mssql",
"volumes": [
"${dir:mesh-state}/broker:/run/secrets/broker:ro",
"${dir:grants}:/var/lib/mssql/grants:ro",
"${dir:state}/sa.secret:/run/secrets/sa:ro",
"${dir:state}/reader.secret:/run/secrets/reader:ro"
],
"env": {
"MESH_PROVISION_MSSQL": "mssql://sa@mssql:1433/master",
"MESH_PROVISION_PASSWORD_FILE": "/run/secrets/sa",
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_RECEIVES": "/var/lib/mssql/grants/mesh.json",
"MESH_MSSQL_READER_PASSWORD_FILE": "/run/secrets/reader"
},
"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_PROVISION_MSSQL": "mssql://sa@127.0.0.1:${port:1433}/master",
"MESH_PROVISION_PASSWORD_FILE": "${dir:state}/sa.secret",
"MESH_RECEIVES": "${dir:grants}/mesh.json",
"MESH_MSSQL_READER_PASSWORD_FILE": "${dir:state}/reader.secret"
}
} }
] ]
} }
+32
View File
@@ -0,0 +1,32 @@
// Ambient types for `mssql`, which ships its types only in the separate `@types/mssql` package. This
// declares the slice client.ts uses — the precedent mesh-catalog's pg.d.ts sets — so the module
// type-checks without deciding what runs: the real `mssql` is the package.json dependency the
// builder installs and inlines into the bundle (novox/hq ADR 0198 §4).
declare module "mssql" {
interface Result {
recordsets: unknown;
}
interface Request {
input(name: string, type: unknown, value: unknown): Request;
query(text: string): Promise<Result>;
batch(text: string): Promise<Result>;
}
class ConnectionPool {
constructor(config: {
server: string;
port?: number;
user?: string;
password?: string;
database?: string;
options?: { encrypt?: boolean; trustServerCertificate?: boolean; serverName?: string };
pool?: { min?: number; max?: number };
connectionTimeout?: number;
requestTimeout?: number;
});
connect(): Promise<ConnectionPool>;
request(): Request;
close(): Promise<void>;
}
const sql: { ConnectionPool: typeof ConnectionPool; NVarChar: unknown };
export default sql;
}
+3 -2
View File
@@ -5,11 +5,12 @@
"type": "module", "type": "module",
"private": true, "private": true,
"scripts": { "scripts": {
"build": "tsc client.ts index.ts tools/index.ts provisioner/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist", "build": "tsc mssql.d.ts client.ts index.ts tools/index.ts provisioner/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist",
"test": "npm run build && node --test --experimental-strip-types 'test/*.test.ts'" "test": "npm run build && node --test --experimental-strip-types 'test/*.test.ts'"
}, },
"dependencies": { "dependencies": {
"@novox/mesh-sdk": "^0.1.1" "@novox/mesh-sdk": "^0.1.1",
"mssql": "^11.0.2"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
+50 -63
View File
@@ -1,96 +1,83 @@
// What holds mssql_query to being read-only (novox/hq issue 193): a caller's statement runs as the // What holds mssql_query to being read-only (novox/hq issue 193): a caller's statement runs as the
// reader login and never as the administrator, with sqlcmd's variable substitution off, on one line // reader login and never as the administrator, with no transaction wrapped around it as text, and
// that follows the module's own — a line break is refused before sqlcmd starts — and with no // without the reader's password the statement is refused.
// transaction wrapped around it as text. Without the reader's password the statement is refused.
// //
// sqlcmd is a fake on PATH that records each call's login, flags and text. That the reader cannot // The driver is a fake session that records each call's login, database, text and bound
// write is the server's to enforce and was proven against a real server; this holds the module to // parameters. That the reader cannot write is the server's to enforce and was proven against a real
// asking for it. Run against the compiled module (npm test builds first), the way the runtime loads it. // server; this holds the module to asking for it. Run against the compiled module (npm test builds
// first), the way the runtime loads it.
import { test, before, after } from "node:test"; import { test } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { MssqlClient, READER } from "../dist/client.js"; import { MssqlClient, READER, type Connect, type Target } from "../dist/client.js";
let dir: string; interface Call extends Target {
let log: string; text: string;
const originalPath = process.env.PATH; params: Record<string, string>;
}
before(async () => { function recording(): { connect: Connect; calls: Call[] } {
dir = await mkdtemp(join(tmpdir(), "mssql-reader-")); const calls: Call[] = [];
log = join(dir, "calls.jsonl"); const connect: Connect = async (to) => ({
await writeFile(join(dir, "sqlcmd"), `#!/usr/bin/env node async run(text, params = {}) {
const fs = require("node:fs"); calls.push({ ...to, text, params });
const args = process.argv.slice(2); if (/FROM sys.server_principals/.test(text)) return [];
const at = (flag) => args[args.indexOf(flag) + 1]; // FOR JSON answers its document split across rows of one column.
fs.appendFileSync(${JSON.stringify(log)}, JSON.stringify({ if (/FOR JSON/.test(text)) return [{ JSON_F52E: '[{"name":"al' }, { JSON_F52E: 'pha","n":1}]' }];
user: at("-U"), database: at("-d"), sql: at("-Q"), noVariables: args.includes("-x"), return [];
password: process.env.SQLCMDPASSWORD, },
}) + "\\n"); async close() {},
const sql = at("-Q");
if (/FROM sys.server_principals/.test(sql)) process.stdout.write("");
else if (/FOR JSON/.test(sql)) process.stdout.write('[{"name":"alpha","n":1}]\\n');
`);
await chmod(join(dir, "sqlcmd"), 0o755);
process.env.PATH = `${dir}:${originalPath}`;
}); });
return { connect, calls };
after(async () => {
process.env.PATH = originalPath;
await rm(dir, { recursive: true, force: true });
});
async function calls(): Promise<Record<string, unknown>[]> {
const text = await readFile(log, "utf8").catch(() => "");
await writeFile(log, "");
return text.split("\n").filter(Boolean).map((line) => JSON.parse(line));
} }
const conn = { host: "127.0.0.1", port: 1433, user: "sa", password: "admin-secret" }; const conn = { host: "127.0.0.1", port: 1433, user: "sa", password: "admin-secret" };
test("a caller's statement runs as the reader, without variables, on the module's first line", async () => { test("a caller's statement runs as the reader, as it was written, on the database it names", async () => {
const client = new MssqlClient({ ...conn, readerPassword: "reader-secret" }); const { connect, calls } = recording();
const client = new MssqlClient({ ...conn, readerPassword: "reader-secret" }, connect);
const result = await client.readOnlyQuery("inventory", "SELECT '$(SQLCMDPASSWORD)' AS p"); const result = await client.readOnlyQuery("inventory", "SELECT '$(SQLCMDPASSWORD)' AS p");
const asked = (await calls()).at(-1)!; const asked = calls.at(-1)!;
assert.equal(asked.user, READER, "the statement never runs as the administrator"); assert.equal(asked.user, READER, "the statement never runs as the administrator");
assert.equal(asked.password, "reader-secret"); assert.equal(asked.password, "reader-secret");
assert.equal(asked.noVariables, true, "no $(NAME) is substituted in a caller's text"); assert.equal(asked.database, "inventory");
const [first] = String(asked.sql).split("\n"); assert.ok(asked.text.startsWith("SET NOCOUNT ON; SELECT '$(SQLCMDPASSWORD)' AS p\nFOR JSON PATH"),
assert.ok(first.startsWith("SET NOCOUNT ON; SELECT '$(SQLCMDPASSWORD)'"), "the caller's text never begins a line"); "the caller's text reaches the server unaltered");
assert.doesNotMatch(String(asked.sql), /BEGIN TRANSACTION|ROLLBACK/, "no transaction wrapped around it as text"); assert.doesNotMatch(asked.text, /BEGIN TRANSACTION|ROLLBACK/, "no transaction wrapped around it as text");
assert.deepEqual(result.rows, [{ name: "alpha", n: 1 }]); assert.deepEqual(result.rows, [{ name: "alpha", n: 1 }], "a FOR JSON document split across rows is reassembled");
assert.equal(result.command, "SELECT"); assert.equal(result.command, "SELECT");
}); });
test("a line break in a caller's statement is refused before sqlcmd starts", async () => {
const client = new MssqlClient({ ...conn, readerPassword: "reader-secret" });
for (const sql of ["SELECT 1\n:!! id", "SELECT 1\r\n:!! id", "SELECT 1\r:!! id"]) {
await assert.rejects(client.readOnlyQuery("inventory", sql), /must be one line/);
}
assert.deepEqual(await calls(), []);
});
test("the reader is made as the administrator, kept out of sysadmin, and granted only reading", async () => { test("the reader is made as the administrator, kept out of sysadmin, and granted only reading", async () => {
const client = new MssqlClient({ ...conn, readerPassword: "reader-secret" }); const { connect, calls } = recording();
const client = new MssqlClient({ ...conn, readerPassword: "reader-secret" }, connect);
await client.readOnlyQuery("inventory", "SELECT 1 AS x"); await client.readOnlyQuery("inventory", "SELECT 1 AS x");
await client.readOnlyQuery("inventory", "SELECT 2 AS x"); await client.readOnlyQuery("inventory", "SELECT 2 AS x");
const made = await calls(); const asAdmin = calls.filter((c) => c.user === "sa").map((c) => c.text);
const asAdmin = made.filter((c) => c.user === "sa").map((c) => String(c.sql));
assert.ok(asAdmin.some((s) => s.startsWith(`CREATE LOGIN [${READER}]`))); assert.ok(asAdmin.some((s) => s.startsWith(`CREATE LOGIN [${READER}]`)));
assert.ok(asAdmin.some((s) => /ALTER SERVER ROLE sysadmin DROP MEMBER/.test(s))); assert.ok(asAdmin.some((s) => /ALTER SERVER ROLE sysadmin DROP MEMBER/.test(s)));
assert.ok(asAdmin.includes(`GRANT CONNECT ANY DATABASE TO [${READER}]`)); assert.ok(asAdmin.includes(`GRANT CONNECT ANY DATABASE TO [${READER}]`));
assert.ok(asAdmin.includes(`GRANT SELECT ALL USER SECURABLES TO [${READER}]`)); assert.ok(asAdmin.includes(`GRANT SELECT ALL USER SECURABLES TO [${READER}]`));
assert.equal(asAdmin.filter((s) => s.startsWith("CREATE LOGIN")).length, 1, "made once, not per call"); assert.equal(asAdmin.filter((s) => s.startsWith("CREATE LOGIN")).length, 1, "made once, not per call");
assert.equal(made.filter((c) => c.user === READER).length, 2); assert.equal(calls.filter((c) => c.user === READER).length, 2);
}); });
test("without the reader's password the statement is refused, and nothing runs as the administrator", async () => { test("without the reader's password the statement is refused, and nothing runs as the administrator", async () => {
const client = new MssqlClient(conn); const { connect, calls } = recording();
const client = new MssqlClient(conn, connect);
await assert.rejects(client.readOnlyQuery("inventory", "SELECT 1"), /refused rather than run as the administrator/); await assert.rejects(client.readOnlyQuery("inventory", "SELECT 1"), /refused rather than run as the administrator/);
assert.deepEqual(await calls(), []); assert.deepEqual(calls, []);
});
test("a consumer's password is checked as a bound parameter, never in the text", async () => {
const { connect, calls } = recording();
const client = new MssqlClient(conn, connect);
await client.holdsLogin("shop", "shop_login", "minted-secret");
const asked = calls[0];
assert.equal(asked.params.meshholdspw, "minted-secret");
assert.doesNotMatch(asked.text, /minted-secret/);
assert.match(asked.text, /PWDCOMPARE\(@meshholdspw, password_hash\)/);
}); });
+1 -1
View File
@@ -8,5 +8,5 @@
"skipLibCheck": true, "skipLibCheck": true,
"noEmit": true "noEmit": true
}, },
"include": ["client.ts", "index.ts", "provisioner/index.ts", "tools/index.ts"] "include": ["mssql.d.ts", "client.ts", "index.ts", "provisioner/index.ts", "tools/index.ts"]
} }
+173
View File
@@ -0,0 +1,173 @@
package main
import (
"context"
"errors"
"fmt"
"math"
"net"
"sort"
"strconv"
"strings"
"time"
)
// Bounds on what a caller may ask: a check is a probe, never a wait anyone can make long.
const (
DefaultTimeout = 3 * time.Second
MostTimeout = 30 * time.Second
)
// TCPResult is what netcheck_tcp answers.
type TCPResult struct {
Host string `json:"host"`
Port int `json:"port"`
Address string `json:"address,omitempty"`
Reachable bool `json:"reachable"`
ElapsedMS int64 `json:"elapsed_ms"`
Error string `json:"error,omitempty"`
}
// CheckTCP opens one TCP connection and closes it, sending nothing. A port that refuses or a host
// that does not answer is a result, not a failure of the tool; only a malformed question is.
func CheckTCP(host string, port int, timeout time.Duration) (TCPResult, error) {
if port < 1 || port > 65535 {
return TCPResult{}, fmt.Errorf("port %d is not a TCP port (1-65535)", port)
}
out := TCPResult{Host: host, Port: port}
start := time.Now()
conn, err := net.DialTimeout("tcp", net.JoinHostPort(host, strconv.Itoa(port)), timeout)
out.ElapsedMS = time.Since(start).Milliseconds()
if err != nil {
out.Error = err.Error()
return out, nil
}
out.Address = conn.RemoteAddr().String()
out.Reachable = true
_ = conn.Close()
return out, nil
}
// DNSResult is what netcheck_dns answers.
type DNSResult struct {
Name string `json:"name"`
Type string `json:"type"`
Answers []string `json:"answers"`
ElapsedMS int64 `json:"elapsed_ms"`
Error string `json:"error,omitempty"`
}
// DNSTypes are the record types netcheck_dns looks up.
var DNSTypes = []string{"A", "AAAA", "CNAME", "TXT", "MX"}
// CheckDNS looks a name up with the machine's resolver. Built without cgo, Go's own resolver reads
// the machine's /etc/resolv.conf and /etc/hosts, which is the resolver this machine's programs use.
// A name that does not resolve is a result with its error; an unknown type is refused.
func CheckDNS(name, kind string, timeout time.Duration) (DNSResult, error) {
kind = strings.ToUpper(strings.TrimSpace(kind))
if kind == "" {
kind = "A"
}
known := false
for _, t := range DNSTypes {
known = known || t == kind
}
if !known {
return DNSResult{}, fmt.Errorf("type %q is not one netcheck_dns looks up (%s)", kind, strings.Join(DNSTypes, ", "))
}
out := DNSResult{Name: name, Type: kind, Answers: []string{}}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
r := net.DefaultResolver
start := time.Now()
var err error
switch kind {
case "A", "AAAA":
network := "ip4"
if kind == "AAAA" {
network = "ip6"
}
var ips []net.IP
if ips, err = r.LookupIP(ctx, network, name); err == nil {
for _, ip := range ips {
out.Answers = append(out.Answers, ip.String())
}
}
case "CNAME":
var cname string
if cname, err = r.LookupCNAME(ctx, name); err == nil {
out.Answers = append(out.Answers, cname)
}
case "TXT":
var txts []string
if txts, err = r.LookupTXT(ctx, name); err == nil {
out.Answers = append(out.Answers, txts...)
}
case "MX":
var mxs []*net.MX
if mxs, err = r.LookupMX(ctx, name); err == nil {
for _, mx := range mxs {
out.Answers = append(out.Answers, fmt.Sprintf("%d %s", mx.Pref, mx.Host))
}
}
}
out.ElapsedMS = time.Since(start).Milliseconds()
if err != nil {
out.Error = err.Error()
}
if kind != "MX" {
sort.Strings(out.Answers)
}
return out, nil
}
// text is a required string argument.
func text(args map[string]any, key string) (string, error) {
s, _ := args[key].(string)
s = strings.TrimSpace(s)
if s == "" {
return "", fmt.Errorf("%s is required", key)
}
return s, nil
}
// whole is an integer argument, given as a JSON number or a numeric string; fallback when absent.
func whole(args map[string]any, key string, fallback int) (int, error) {
v, given := args[key]
if !given || v == nil {
if fallback == 0 {
return 0, fmt.Errorf("%s is required", key)
}
return fallback, nil
}
switch n := v.(type) {
case float64:
if n != math.Trunc(n) {
return 0, fmt.Errorf("%s must be a whole number, not %v", key, n)
}
return int(n), nil
case string:
i, err := strconv.Atoi(strings.TrimSpace(n))
if err != nil {
return 0, fmt.Errorf("%s must be a whole number, not %q", key, n)
}
return i, nil
}
return 0, errors.New(key + " must be a whole number")
}
// timeoutOf is timeout_ms, defaulted and bounded.
func timeoutOf(args map[string]any) (time.Duration, error) {
ms, err := whole(args, "timeout_ms", int(DefaultTimeout/time.Millisecond))
if err != nil {
return 0, err
}
if ms < 1 {
return 0, fmt.Errorf("timeout_ms must be at least 1, not %d", ms)
}
d := time.Duration(ms) * time.Millisecond
if d > MostTimeout {
d = MostTimeout
}
return d, nil
}
@@ -0,0 +1,68 @@
package main
import (
"net"
"testing"
"time"
)
func TestATCPPortThatListensIsReachableAndOneThatDoesNotIsNot(t *testing.T) {
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
port := l.Addr().(*net.TCPAddr).Port
got, err := CheckTCP("127.0.0.1", port, time.Second)
if err != nil || !got.Reachable || got.Error != "" {
t.Fatalf("a listening port: %+v, %v", got, err)
}
l.Close()
got, err = CheckTCP("127.0.0.1", port, time.Second)
if err != nil || got.Reachable || got.Error == "" {
t.Fatalf("a closed port is reported as a result with its error, not a failure: %+v, %v", got, err)
}
}
func TestAPortOutsideTheRangeIsRefused(t *testing.T) {
for _, p := range []int{0, -1, 65536} {
if _, err := CheckTCP("127.0.0.1", p, time.Second); err == nil {
t.Errorf("port %d was accepted", p)
}
}
}
func TestDNSAnswersFromTheMachinesResolverAndRefusesAnUnknownType(t *testing.T) {
got, err := CheckDNS("localhost", "a", time.Second)
if err != nil || got.Type != "A" || len(got.Answers) == 0 {
t.Fatalf("localhost A: %+v, %v", got, err)
}
if _, err := CheckDNS("localhost", "SRV", time.Second); err == nil {
t.Fatal("an unknown record type was accepted")
}
got, err = CheckDNS("no-such-name.invalid", "A", time.Second)
if err != nil || got.Error == "" || len(got.Answers) != 0 {
t.Fatalf("a name that does not resolve is a result with its error: %+v, %v", got, err)
}
}
func TestTimeoutIsDefaultedAndBounded(t *testing.T) {
if d, _ := timeoutOf(map[string]any{}); d != DefaultTimeout {
t.Errorf("default: %v", d)
}
if d, _ := timeoutOf(map[string]any{"timeout_ms": float64(10 * 60 * 1000)}); d != MostTimeout {
t.Errorf("bounded: %v", d)
}
if _, err := timeoutOf(map[string]any{"timeout_ms": float64(0)}); err == nil {
t.Error("a zero timeout was accepted")
}
}
func TestBothToolsAreListedUnprefixed(t *testing.T) {
names := map[string]bool{}
for _, tool := range tools() {
names[tool.Name] = true
}
if !names["netcheck_tcp"] || !names["netcheck_dns"] || len(names) != 2 {
t.Fatalf("tools: %v", names)
}
}
+72
View File
@@ -0,0 +1,72 @@
// netcheck's Go tools bundle (novox/hq ADR 0188, ADR 0193): a process the node's runtime launches
// and speaks MCP over stdio to, through the Go SDK. It serves the two checks that are the machine's
// own sockets and resolver — a TCP connect and a DNS lookup — and nothing that changes anything.
// The module's HTTP check is its TypeScript bundle; the runtime serves both under one module.
package main
import (
"fmt"
"os"
stdio "git.novox.be/novox/mesh-sdk/go"
)
func main() {
// An empty name serves as the module the runtime names (MESH_SERVED_MODULE): netcheck.
if err := stdio.Serve("", tools()); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func tools() []stdio.Tool {
return []stdio.Tool{
{
Name: "netcheck_tcp",
Description: "Check whether a TCP port is reachable from this machine: opens one connection " +
"and closes it at once, sending nothing. Answers reachable, elapsed_ms and the error when not.",
Input: map[string]any{
"host": map[string]any{"type": "string", "description": "host name or IP address"},
"port": map[string]any{"type": "integer", "description": "TCP port, 1-65535"},
"timeout_ms": map[string]any{"type": "integer", "description": "give up after this long (default 3000, at most 30000)"},
},
Run: func(args map[string]any) (any, error) {
host, err := text(args, "host")
if err != nil {
return nil, err
}
port, err := whole(args, "port", 0)
if err != nil {
return nil, err
}
timeout, err := timeoutOf(args)
if err != nil {
return nil, err
}
return CheckTCP(host, port, timeout)
},
},
{
Name: "netcheck_dns",
Description: "Look a name up with this machine's resolver (its /etc/resolv.conf and /etc/hosts). " +
"type is A, AAAA, CNAME, TXT or MX; answers the records found, or the error.",
Input: map[string]any{
"name": map[string]any{"type": "string", "description": "the name to look up"},
"type": map[string]any{"type": "string", "enum": []string{"A", "AAAA", "CNAME", "TXT", "MX"}, "description": "record type (default A)"},
"timeout_ms": map[string]any{"type": "integer", "description": "give up after this long (default 3000, at most 30000)"},
},
Run: func(args map[string]any) (any, error) {
name, err := text(args, "name")
if err != nil {
return nil, err
}
kind, _ := args["type"].(string)
timeout, err := timeoutOf(args)
if err != nil {
return nil, err
}
return CheckDNS(name, kind, timeout)
},
},
}
}
+5
View File
@@ -0,0 +1,5 @@
module netcheck
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=

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