Compare commits

...
Author SHA1 Message Date
jochen a44a1fc51e adwaita: the theme as a module, dark by default, for GTK, Qt, the portal and the cursor (hq ADR 0208)
GTK 3/4 settings, qt6ct, portals.conf and the default cursor as owned files.
GTK_THEME, GTK2_RC_FILES, the Qt words and XCURSOR_* as environment
contributions. The GSettings keys the portal serves go in the xinitrc slot,
replacing the predecessor's appearance script, and the cursor in the
xresources slot.

Qt is drawn by Fusion with qt6ct's darker palette instead of the
user-repository adwaita-qt, which is no longer developed. qt5ct is dropped. The
fonts are research 026's Inter and JetBrains Mono, and portals.conf routes the
Secret interface to gnome-keyring, which nothing answered.

The tools are appearance (dark or light per audience, switched for the session),
cursor, icons, and portal-check (which backend answers which interface, and why).
2026-10-04 13:21:47 +02:00
jochen e4abc6eb88 xterm: the terminal holds node-terminal-emulator; its resources through xorg's slot (hq ADR 0208)
It requires x11-display, names itself in TERMINAL, and contributes its X resources
to the xresources slot normal: today's palette and clipboard keys, JetBrainsMono
Nerd Font, 10000 lines of scrollback, all scoped to XTerm* instead of every Xt
program. It owns no file.

The tools are the seat's open, which starts a terminal through the account's
service manager so it outlives the runtime's restarts, and font (in force, what
fontconfig resolves it to, set for new terminals) and colours.
2026-10-04 13:21:47 +02:00
jochen 34829e39fe i3: the window manager holds node-display-session; its config improved and a checked reload watcher (hq ADR 0208)
It requires x11-display and contributes exec i3 to xinitrc's last slot, and
XDG_CURRENT_DESKTOP/XDG_SESSION_DESKTOP to the environment. It owns
~/.config/i3/config, ending with the config.d include where other modules drop
their files, and /etc/lemurs/wms/i3, which runs the session's start.

Over today's identical config it drops the dead lxpolkit, the D-Bus-activated
portal, the xrdb merge xorg now does, and the execs that XDG autostart already
started. It sets JetBrainsMono Nerd Font, runs i3-sensible-terminal, and declares
dex, which the desktop lacked.

The tools speak i3's IPC: the seat's reload, workspaces and windows, and focus,
move, layout save/restore, exec, kill, bindings, config check, marks and the
scratchpad. A watcher in the bundle (ADR 0198) replaces the predecessor's inotify
script and user unit. It reloads only a configuration i3 -C accepts, and at its
start whatever i3 has not loaded.
2026-10-04 13:21:47 +02:00
jochen 98eb3fe33c lemurs: the login manager holds node-login-manager, its config in the current format (hq ADR 0208)
The official package in place of lemurs-git, and /etc/lemurs/config.toml in lemurs
0.4's structure. It offers only the session scripts that modules place in
/etc/lemurs/wms and /etc/lemurs/wayland, never a package's bare desktop entry, which
skips the session's start. The service is enabled and never started, stopped or
restarted by a push.

The tools are the seat's sessions, the default session (lemurs's cache, through
sudo -n) and logins from the journal and lemurs's own log. The package swap from
lemurs-git is a one-off step for the operator, listed in the README.
2026-10-04 13:21:47 +02:00
jochen b2c170acda xorg: the display server holds node-display-server and writes the session's start (hq ADR 0208)
The X server, its start and its tools as one module. It provides x11-display with
the machine's reach, gated by the host's seat capability. It writes a block at the
start of ~/.xinitrc: the account's environment, an explicit import into the user
manager, the mesh's X resources merged without cpp, autorandr, the xinitrc slots,
~/.xinitrc.local, and the session's exec from the last slot.

Its Go tools serve the seat's displays and layout (autorandr profiles keyed by
EDID), and set-mode, primary, dpi, input devices and settings, keyboard, a
screenshot (xwd decoded in Go) and the server's log. internal/desktop is how
every desktop tool finds the operator's session from the runtime, which has none:
from the session's own processes, reading only its words, confirmed with logind.
2026-10-04 13:21:47 +02:00
mesh-admin b8982b4a7c Merge pull request 'Workstation basics: fonts, docker-compose, snapd, flatpak, cups, bluetooth, xclip, dmenu (hq to-be 42 phase 2), tools in Go' (#269) from feat/phase-2-workstation-basics-rebased into main 2026-10-04 11:02:42 +00:00
jochen 838a6e510b dmenu: the package, which makes the notifier's menu work, and a menu tool (hq to-be 42 phase 2.7)
Research 026/04 counted two plain dmenu calls failing with dmenu installed
nowhere. Measured, they are one line on each workstation: dunst's
`dmenu = /usr/bin/dmenu -p dunst:`. Installing the package fixes both by
existing; the line stays the dunst module's.

No claim: node-launcher is not in the controller's seat table yet, and the
module says so. Two Go tools: menu, shaped like that seat's verb (chosen
line, index, typed, cancelled, timed out within 25 s), and session.
2026-10-04 13:02:23 +02:00
jochen b1b7e58e4b xclip: the package, and the operator's clipboard from the mesh (hq to-be 42 phase 2.7)
A package and nothing else, the tool the desktop's scripts depend on.
Four Go tools: copy, paste (text, base64 for other types, empty when
nothing), targets and session.

The runtime is given no session words, but runs as the account in the
machine's own namespace, so the session is found rather than configured:
the process's DISPLAY, else the account's processes' DISPLAY and XAUTHORITY
from /proc (the window manager's first), else the only X socket with
~/.Xauthority. Measured with both variables unset: :1 found through i3, the
server answered. With no session every tool says so and runs nothing.
2026-10-04 13:02:23 +02:00
jochen 3b43a1ef4d bluetooth: the stack, its daemon, and the devices as tools (hq to-be 42 phase 2.9)
bluez and bluez-utils, and bluetooth.service running and enabled. On both
workstations bluez is installed only as a dependency; declaring it keeps a
clean-up from taking it.

Nine Go tools over bluetoothctl: controller, power, devices with battery
where reported, a bounded scan, connect, disconnect, trust, pair (an agent
that confirms nothing, for headphones) and remove. An act whose output says
it failed is an error whatever the exit status, and one bluez refuses the
account is repeated through sudo -n.
2026-10-04 13:02:23 +02:00
jochen b352f3920e cups: the scheduler and driverless printing, and the printers as tools (hq to-be 42 phase 2.9)
Research 027 asked for cups with the printer's driver. Measured: both
Brother queues already print through IPP Everywhere, so cups and
cups-filters are the whole driver, and the AUR vendor packages beside them
serve no queue. The desktop's Canon is the exception: its 2012 driver is
AUR-only and waits for the mesh's package repository; it stays as found.

Seven Go tools: printers (state, device, driverless or not, supply levels),
queue, cancel (the account first, sudo -n when CUPS refuses it), print,
default, resume, and drivers (which packages bring drivers, which are
foreign, which no queue uses).
2026-10-04 13:02:23 +02:00
jochen 152ef9621d flatpak: the package, Flathub with it, and the installations as tools (hq to-be 42 phase 2.9)
The package ships Flathub in /usr/share/flatpak/remotes.d, so the module
declares no remote of its own and checks it instead. Eleven Go tools: list,
runtimes, remotes (naming the desktop's duplicate user Flathub), updates,
unused, disk usage, and install, remove, update and remove-unused, the
system installation's acts through sudo -n and the account's without.

uninstall --unused has no dry run, so flatpak_unused works it out from
flatpak's own answers: an application's runtime and SDK, the extension
points of what is used, and pins. Acts run as jobs inside the bundle,
because an install outlasts a call.
2026-10-04 13:02:23 +02:00
jochen db297e8bdd snapd: tools for the snaps, the package blocked on the mesh's AUR repository (hq to-be 42 phase 2.9)
snapd is not in the official repositories, and ADR 0205's archive does not
fit a daemon with setuid helpers, so the module declares nothing until
research 027 question 1 (P2) builds it into the mesh's own repository. Not
even its units: on the laptop they do not exist, and the module would fail
there.

Ten Go tools that work wherever snapd is installed and say so where it is
not: status (with AppArmor's absence from the kernel named), list, info,
updates, disk usage with the disabled revisions' share, services, changes,
and install, remove and refresh through sudo -n with --no-wait, answering
snapd's change id.
2026-10-04 13:02:23 +02:00
jochen 45befbbd02 docker-compose: the package, and its projects as tools (hq to-be 42 phase 2.9)
Compose and nothing else, for the two workstations, where it is already
installed by hand; the runtime, buildx and the group stay the docker
module's. Nine Go tools: projects (with their directories from the
containers' labels), ps, logs, a rendered config with secret-looking values
redacted, and up, down, restart and pull by directory or name. Acts run as
jobs inside the bundle, waited on for 18 s and followed with
docker_compose_job, because an up that pulls outlasts a call. down never
removes volumes.
2026-10-04 13:02:23 +02:00
jochen 86cf6d438d fonts: the five decided faces as packages, and what the generic families mean (hq to-be 42 phase 2.1)
JetBrains Mono Nerd Font for monospace, Inter for the interface, the Nerd
Fonts symbols and Noto Color Emoji as fallbacks, Noto for serif. One owned
fontconfig file in the account's conf.d maps monospace, sans-serif,
system-ui, serif and emoji, bound `same`: measured with fontconfig 2.18, a
weakly bound preference loses to Noto Sans Mono. Families today's configs
name but the laptop lacks (Iosevka, the old JetBrains name) stop falling
back to sans-serif.

Six Go tools: families, match, glyph, sources (which hand-copied files can
go and why), config, cache-rebuild. The README lists the hand-copied files
to remove and the modules that must name the new family.
2026-10-04 13:02:23 +02:00
mesh-admin 4d028d40c5 Merge pull request 'Phase 1 system modules: sudo, localization, time-sync, pacman, logrotate, avahi (hq to-be 42), tools in Go' (#268) from feat/phase-1-system-modules-rebased into main 2026-10-04 10:50:38 +00:00
jochen 5c212531da avahi: the discovery daemon declared, and why it hears nothing reported
On all four machines and owned by none. The module declares the package and
the daemon. It leaves nsswitch.conf and nss-mdns as found — the hosts: line is
one list every name source shares, and the host writes blocks, not line
members — and opens nothing: the mesh's filter drops inbound UDP 5353 on every
machine and `listens` has no local-link scope. avahi_status, _browse, _resolve
and _services report both (to-be 42 Phase 1).
2026-10-04 12:50:20 +02:00
jochen 2e082d1680 logrotate: rotation on every machine, its base configuration owned
Rotation ran on one machine of four; the others carried package and fail2ban
rules nothing read, and one log had reached 4.9 GB. The module installs
logrotate, owns /etc/logrotate.conf whole (the distribution's base plus
compress/delaycompress, dropping a hand-set olddir that collides same-named
logs) and enables logrotate.timer. Seven tools from a Go bundle, the journal's
usage and vacuum among them (to-be 42 Phase 1).
2026-10-04 12:50:20 +02:00
jochen d9336d11d0 pacman: the package manager's configuration, mirrors and cache as a module
Mirrors were generated once and never again and caches never cleaned. The
module holds node-package-manager (hq ADR 0207), declares pacman itself, owns
/etc/pacman.conf whole — [options] cannot take an appended block — with the
union of the enabled repositories and improved options, proven by pacman-conf
in its test, and enables reflector.timer (its config owned) and
paccache.timer. Fifteen tools from a Go bundle; transactions run as transient
units so a call's timeout never kills pacman mid-transaction (to-be 42).
2026-10-04 12:50:20 +02:00
jochen 21d8a7f6b4 time-sync: one time daemon, timesyncd, with its servers declared
Three machines ran timesyncd and one ran ntpd. The module declares
timesyncd running with a 50-mesh.conf drop-in (European pool) and ntp absent
(hq ADR 0180). A run-once step of its Go binary stops and disables ntpd first
and takes out only dangling wants-links, so removing the package leaves no
enabled unit pointing at nothing. A provider's drop-in sorting after the
mesh's still wins and is reported, not removed. Tools: time_sync_status,
_servers, _sync_now (to-be 42 Phase 1).
2026-10-04 12:50:20 +02:00
jochen a8d308d440 localization: locale, time zone and console keymap as one module
One machine ran another time zone and a German console keymap with no record
why. The module writes /etc/locale.conf and /etc/vconsole.conf whole and sets
the zone through a run-once step of its own Go binary (timedatectl, read
back): /etc/localtime is a link the mesh may not write (hq ADR 0012) and a
module may not declare an action (ADR 0005). Tools: localization_get,
_time_zone, _locales, _keymaps (to-be 42 Phase 1).
2026-10-04 12:50:20 +02:00
jochen f015aba34a sudo: declare the operator account's passwordless escalation as a module
Three modules' tools act through `sudo -n` and nothing declared that the
account may; each machine said so in a hand-set line in /etc/sudoers. The
module owns the package and /etc/sudoers.d/10-mesh-operator (0440), checked
by visudo in its manifest test, and serves sudo_rules, sudo_check and
sudo_drop_ins from a Go bundle. lab stops declaring the sudo package, which
would collide with this module on the node that runs both (hq ADR 0207,
to-be 42 Phase 1).
2026-10-04 12:50:20 +02:00
mesh-admin 44aafc9b1c Merge pull request 'docker: the container runtime as a module, holding node-container-runtime, tools in Go (hq ADR 0207, to-be 42)' (#267) from feat/docker-module into main 2026-10-04 10:44:38 +00:00
jochen 0d72c3f29a docker: the container runtime as a module, with its tools in Go
Claims node-container-runtime (ADR 0207). Owns the packages, the socket and a weekly
prune of dangling images and unused build cache. Serves 18 tools over every container,
marking the mesh's. daemon.json, docker.service and the docker group are left to a
proposed change: dnsmasq and zsh declare them today, and the controller refuses a
second declaration (README).
2026-10-04 12:43:51 +02:00
mesh-admin 3ac7c0289e Merge pull request 'ssh-client: the mesh's region first in ~/.ssh/config, its hosts in config.d, tools in Go (hq research 027/03, to-be 42)' (#266) from feat/ssh-client-owns-ssh into main 2026-10-04 10:38:56 +00:00
jochen dde9c264f5 ssh-client: the mesh's region first in ~/.ssh/config, its hosts in config.d, tools in Go
The region at the end let earlier Host lines win over the mesh's (research 027/03). A
roster fact cannot be placed at the start, so the region holds one Include of config.d,
and the hosts are config.d/00-mesh, read first. Eight tools; authorized_keys and
known_hosts stay found until the controller holds those facts.
2026-10-04 12:38:40 +02:00
mesh-admin 24f11f2138 Merge pull request 'The licence manager binds a node reporting an account it already holds' (#265) from fix/a-reporting-node-is-bound-to-its-account into main 2026-10-04 10:34:52 +00:00
jochen af63f12129 The licence manager binds a node reporting an account it already holds
Found going live: the other nodes report the adopted account with older
logins, which are never candidates, and the first binding was only made at
adoption — so a node reporting afterwards was never bound (ADR 0206 §7).
2026-10-04 12:34:39 +02:00
mesh-admin a72df57214 Merge pull request 'photos authenticates against the database its user lives in (hq issue 232)' (#264) from fix/photos-authenticates-against-its-own-database into main 2026-10-04 10:34:16 +00:00
jschoubben 5d59b35cf7 photos authenticates against the database its user lives in (hq issue 232)
The provider creates each consumer's user in that consumer's own database.
photos asked for admin, where no such user exists; invoicing, against the
same provider, already asked for the name the mesh gave it and worked.

Invisible until hq 225 was fixed: while the provisioner could not read its
secrets, no user existed anywhere, so 'UserNotFound for db admin' was a true
and complete account of that fault. A fault that explains the symptom is not
evidence there is only one.
2026-10-04 12:33:56 +02:00
mesh-admin b485379505 Merge pull request 'The licence manager (Go) and claude-code's half of ADR 0206' (#262) from feat/the-licence-manager into main 2026-10-04 10:31:25 +00:00
jochen 306d01d74d claude-code in Go (operator: always Go)
The module is one Go binary the runtime launches: the renderer (its
instruction file held byte for byte to the TypeScript one it replaces),
the credentials and identity files, the licence flow of ADR 0206 and the
MCP servers in state. Keeps the TypeScript module's key files, so a node
moving to it keeps its key. The npm package, its tests and its build go.

Both binaries were run together under the real runtime on a test bus with
postgres and a stub vendor: a login was adopted by one exchange, the node
bound and handed an access token, its file left with no refresh token, and
no token in either state.
2026-10-04 12:27:36 +02:00
mesh-admin 19a4055bb5 Merge pull request 'The photo clients publish the endpoint they declare (hq issue 227)' (#263) from fix/the-photo-admin-client-publishes-the-port-it-declares into main 2026-10-04 10:27:22 +00:00
jschoubben 1bc6daf31b The photo clients publish the endpoint they declare (hq issue 227)
Each declares a web endpoint — 4001, 4012, 4013 — and published a bare 80,
which the mesh has nothing to assign for, so 80 reached the machine and
collided with the reverse proxy. Written the long way, the software's 80 is
published at the port the module declares and the mesh rewrites the outer
half to whatever it assigned.

photos is the one that failed on the control node; the other two are the same
fault waiting for a machine that runs a proxy.
2026-10-04 12:25:28 +02:00
jochen 15b2e6b86e The licence manager, in Go, and claude-code's half of ADR 0206
claude-licence-manager holds the anthropic-licence-manager seat: it reads
every node's holdings state, adopts a login it does not hold by refreshing
it (newest first, once per account), keeps each grant alive under a lease,
publishes what each consumer should hold as its bindings state with a
generation, and answers current sealed to the consumer's key. Postgres
store prepared by a run-once step; grants encrypted with the vault's key.

claude-code reports what its node holds (fingerprints and account, never a
token), hands its grant over only when the manager asks, watches its
binding and fetches the token on a newer generation, and writes
access-token-only. Its ask now reads the runtime's answer as a value and
addresses seats as seats.
2026-10-04 12:18:51 +02:00
mesh-admin 9208f7409a Merge pull request 'systemd owns its package; systemd-networkd configures networkd and claims none' (#261) from fix/systemd-owns-its-package into main 2026-10-04 10:17:23 +00:00
jochen a80af7a97f systemd owns its package; systemd-networkd configures networkd and claims none
The service manager's package was declared by the networking module, so the
module that is systemd could not own it and had to leave it out. networkd is a
component of systemd: its module configures it. Removing the package resource
from systemd-networkd uninstalls nothing — the host never removes a package
that is not declared absent.
2026-10-04 12:17:08 +02:00
mesh-admin 83a51832d7 Merge pull request 'claude-code writes its managed files from a staged file, not /dev/stdin' (#258) from fix/claude-code-writes-managed-from-a-file into main 2026-10-04 10:01:17 +00:00
mesh-admin 9e63a258d0 Merge pull request 'zsh: keep each PATH directory once' (#260) from fix/zsh-unique-path into main 2026-10-04 09:34:43 +00:00
jochen 328d90fb88 zsh: keep each PATH directory once
Every nested shell, and every sourced file that prepends, added the same
directories again; a workstation's PATH carried each of several entries three
times. typeset -U in the .zshenv block applies to every zsh.
2026-10-04 11:34:36 +02:00
mesh-admin ca5ab288f6 Merge pull request 'zsh: save history and initialise completion' (#259) from fix/zsh-completion-and-history into main 2026-10-04 09:33:28 +00:00
jochen 5fd0f72221 zsh: save history and initialise completion
zsh saves no history by default (SAVEHIST=0) and nothing called compinit, so
every machine had 30 lines of unsaved history and only basic completion.
Found reviewing the shell on its first machine (hq to-be 41).
2026-10-04 11:33:17 +02:00
jochen 5003dc0377 claude-code writes its managed files from a staged file, not /dev/stdin
Node hands a child its input over a socket, which /dev/stdin cannot open
(ENXIO): on the first assignment nothing under /etc/claude-code was written.
2026-10-04 11:31:44 +02:00
mesh-admin 0a78d130e5 Merge pull request 'claude-code watches its MCP servers beside the handshake, and retries' (#257) from fix/claude-code-watches-without-blocking into main 2026-10-04 09:21:26 +00:00
jochen 4295aad88e claude-code watches its MCP servers beside the handshake, and asks again until the state answers (novox/hq ADR 0201)
Awaited at import, a bucket not yet on the bus — or a grant the bus had not
reloaded — answered after the runtime's 10s handshake, and the module was left
unserved on its first assignment. Also cites module state as ADR 0201, as hq
main numbers it (folds #256).
2026-10-04 11:13:33 +02:00
mesh-admin 9dfd3b1105 Merge pull request 'claude-code: the operator's agent, its managed configuration and the licence consumer side (hq design 36, to-be 40 WP2)' (#244) from feat/claude-code-agent into main 2026-10-04 09:01:31 +00:00
mesh-admin 22e8714040 Merge pull request 'The shell and the account's environment as modules: node-env, zsh, powerlevel10k, two plugins, and systemd finished (hq to-be 41 WP3, WP4)' (#255) from feat/the-shell-and-its-environment into main 2026-10-04 08:55:11 +00:00
jochen 11e7ede8a4 Merge remote-tracking branch 'origin/main' into feat/the-shell-and-its-environment 2026-10-04 10:31:03 +02:00
mesh-admin 27315d35cf Merge pull request 'The store does not collect until every controller composes the window (hq ADR 0189)' (#254) from fix/the-store-collects-once-the-window-is-understood into main 2026-10-04 02:26:57 +00:00
jschoubben 525c639041 The store does not collect until every controller composes the window (hq ADR 0189)
mesh-controller#259 fixes while-stopped to name the container as the machine
knows it — `distribution.store`, not `store`. Until that controller is the
one composing, novox refuses its whole declaration and takes nothing at all.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Merges first: under the controller that still merges settings over served facts this is the same
value, and the controller that stops merging (mesh-controller, feat/the-mesh-places-its-own-files)
needs these declared before it rolls out.
2026-09-30 22:33:19 +02:00
jschoubben 48850ebf90 The mesh's own files for a module are placed by the mesh, not the definition (issue 174)
48 definitions stop naming /var/lib/mesh/<module>: the directory says `place: "mesh"`, the two
subdirectories beneath it (gitea's runtime state, anthropic-manager's output) state their path
beneath it, and every credential, binding, merged file and mount names it as ${dir:mesh-state}.
Resolved on the default root, every definition names exactly the paths it named before —
TestPlacedDirectoriesKeepTheirPaths in mesh-controller, run over both checkouts. Needs the
controller that knows the word (mesh-controller, same branch) one release ahead.
2026-09-30 22:29:28 +02:00
mesh-admin 8bc4b7c389 Merge pull request 'The media chain moves to novox/mesh-media-catalog; home-assistant and searxng keep their parts' (#195) from chore/media-chain-moves-out into main 2026-09-30 19:42:56 +00:00
jschoubben 7d721051f8 The media chain moves to novox/mesh-media-catalog; home-assistant and searxng keep their parts of that stack
jackett leaves: it is registered from novox/mesh-media-catalog with sonarr,
radarr, lidarr, bazarr, nzbget, qbittorrent, bookshelf, plex, tautulli,
kometa and ombi (PRs 145-168 consolidated there). What those branches
changed outside the chain stays here: home-assistant's provisions
(sonarr-api, radarr-api, mqtt-topic — from #147) and searxng's sidecar
dialling the port it was given (#154).
2026-09-30 21:42:42 +02:00
jschoubben b2df040896 Merge pull request 'distribution claims mesh-artifact-store' (#194) from feat/the-artifact-store-seat-is-named-for-its-scope into main
Reviewed-on: http://git.novox.be/novox/mesh-catalog/pulls/194
2026-09-30 19:17:47 +00:00
jschoubben af069dd667 Merge pull request 'A definition names no host path for its own data' (#193) from feat/definitions-place-their-directories into main
Reviewed-on: http://git.novox.be/novox/mesh-catalog/pulls/193
2026-09-30 19:17:00 +00:00
jschoubben 13e13734c5 distribution claims mesh-artifact-store, the seat's name for its scope (novox/hq ADR 0156) 2026-09-30 21:14:40 +02:00
jschoubben eed5e8958a A definition names no host path for its own data
Twenty-eight modules' data directories are placed: the root as place ".", a sub-directory named by
its id, and every host-side reference — binds, secrets, own secrets, grants, receives, file paths,
mounts, env-files — as ${dir:<id>}. Resolved on the default root every path is the one the manifest
named before, which the controller's TestPlacedDirectoriesKeepTheirPaths proves over both checkouts;
so no data moves and no machine sees a change. Five directories whose id is not their last segment
keep their path as a placement (novox/hq issue 119, ADR 0112, design 27).
2026-09-30 21:10:18 +02:00
mesh-admin 12bbcafacf Merge pull request 'gitea: the tools' token carries write:admin; a kept token is re-minted when it lacks a scope' (#192) from feat/gitea-token-write-admin into main 2026-09-30 19:06:05 +00:00
jschoubben d58ed21367 gitea: the tools' token carries write:admin, and a kept token is re-minted when it lacks a scope
The forge's own users are the mesh's to settle — making the builder's login
a site admin so private repos build (hq 229) — and the tools' token had no
write:admin. A token kept from before a scope was added lacks it, so the
client now treats the forge's 403 "required scope" like a 401: the source
re-mints by name with the whole list and retries once. The fake forge in the
tests learns /repos/search, which the client has used since 2026-09-28 and
which had left 9 of the 11 token tests failing on main.
2026-09-30 21:05:52 +02:00
jschoubben 20d8487515 Merge pull request 'website: it listens on the port its container publishes' (#191) from fix/website-listens-its-own-port into main 2026-09-30 19:01:49 +00:00
jschoubben aab40c6e9d website: it listens on the port its container publishes
listens said 4000 while the container publishes 8080; the old assignment's port setting hid it, and
the rename lost the setting, so the proxy dialled a port nothing answered (2026-09-30).
2026-09-30 21:01:47 +02:00
jschoubben ad2aac7bb9 Merge pull request 'website: the container joins the network the module declares' (#190) from fix/website-network into main 2026-09-30 18:56:26 +00:00
jschoubben 202672ee7d website: the container joins the network the module declares
The rename changed the network resource's name and not the container's network, so the container
looked for a network that no longer exists and the site answered 502 (2026-09-30).
2026-09-30 20:56:23 +02:00
mesh-admin ac7a9f2ca8 Merge pull request 'portainer: publish its software ports; the machine side is the mesh's to assign' (#189) from fix/portainer-software-ports into main 2026-09-30 18:54:41 +00:00
jschoubben 80d9e9a7e7 portainer: publish its software ports; the machine side is the mesh's to assign
9090:9000 and 9443:9443 were the predecessor's machine numbers written into the
definition. The manifest now says 9000 and 9443 and the mesh assigns the
machine ports on each node (the portainer slice of #173, which is stale).
2026-09-30 20:54:29 +02:00
jschoubben e0c5acd547 Merge pull request 'No definition names this installation' (#188) from feat/a-definition-names-no-installation into main 2026-09-30 18:49:38 +00:00
jschoubben 3476f1ebec No definition names this installation
keycloak, minio and n8n are told their names from their route bindings; mailu takes its domain, site
name, website and proxy address as settings and its front's name from its route, and the provisioner
reads the domain from the merged config; builder and route-proxy package the controller from the git
seat; the applications built outside the mesh say so per container; matrix says which of the world's
servers it means; the site module is named website, and the why prose no longer names a name (novox/hq
ADR 0155, issues 122 and 134). module check passes over all 77.
2026-09-30 20:49:21 +02:00
mesh-admin 1eb8fa367b Merge pull request 'oidc-client: keycloak makes each consumer its client; grafana logs in through it' (#155) from feat/oidc-client-provision into main 2026-09-30 17:03:32 +00:00
mesh-admin f0c4db843e Merge pull request 'grafana: its directories are placed, its admin password is a file, and it runs the build in use' (#153) from feat/grafana-for-ace into main 2026-09-30 17:03:30 +00:00
mesh-admin ec909d3542 Merge pull request 'nodered: settings.json sits beside settings.js' (#187) from fix/nodered-settings-json-beside-settings-js into main 2026-09-30 16:32:15 +00:00
jschoubben 409fe7ef06 nodered: settings.json sits beside settings.js, which reads it from its own directory
#186 moved settings.js to /data for the image's health check but left
settings.json at /config; settings.js requires ./settings.json, so node-red
crashed at start. Both now mount under /data.
2026-09-30 18:32:11 +02:00
mesh-admin c91d12a11d Merge pull request 'nodered: mount its settings where the image's health check reads them' (#186) from fix/nodered-healthcheck-settings-path into main 2026-09-30 16:28:09 +00:00
jschoubben 82686e44f3 nodered: mount its settings where the image's health check reads them
The image's /healthcheck.js requires /data/settings.js, so with the mesh's
settings mounted at /config the container ran fine but reported unhealthy
forever. Mount the same file at /data/settings.js and point --settings there.
2026-09-30 18:28:05 +02:00
jschoubben 949f5f02c9 Merge pull request 'records: a phrase that wraps, and one under emphasis, is found' (#185) from fix/a-phrase-that-wraps into main 2026-09-30 16:13:47 +00:00
jschoubben 3b95d00afc records: a phrase that wraps, and one under emphasis, is found
The record is prose wrapped at a hundred columns; matched line by line, the first live search for a
sentence of ADR 0025 found nothing. A line is matched together with the next, emphasis marks are
ignored, and a hit still names the line it starts on.
2026-09-30 18:09:21 +02:00
mesh-admin cbf9e9b7a3 Merge pull request 'supabase: own secrets, and unique ids for its containers' (#184) from fix/supabase-own-secrets into main 2026-09-30 15:58:24 +00:00
jschoubben f476255284 supabase: own secrets, and unique ids for its containers
The manifest declared its secrets as secrets.secret.<name> and required a
"secret" provision, so the controller saw no own secrets and every accept
was refused (the influxdb defect of #180). The directories functions and
storage shared their ids with the containers of the same name, which the
host refuses as two resources with one identity. The containers are now
edge-functions and storage-api; the placed directories keep their ids and
paths.
2026-09-30 17:57:57 +02:00
jschoubben 6ad59540d4 Merge pull request 'records: the record is read where it is written' (#183) from feat/the-record-is-read into main
Reviewed-on: http://git.novox.be/novox/mesh-catalog/pulls/183
2026-09-30 15:55:06 +00:00
jschoubben a4892d3ff6 Merge branch 'main' into feat/the-record-is-read 2026-09-30 15:54:54 +00:00
jschoubben 6512878eef records: the record is read where it is written
A module keeping a checkout of a repository of decisions, designs and issues from the git seat,
current on every announced merge and on a timer, answering records_search / records_read /
records_list / records_status / records_sync at the commit it read (novox/hq ADR 0025, ADR 0153).
The repository is a setting; it names no mesh.
2026-09-30 17:45:39 +02:00
mesh-admin 8a0ebe16a3 Merge pull request 'supabase: the self-hosted stack as one module, its secrets rendered into files' (#169) from feat/supabase-for-ace into main 2026-09-30 15:32:33 +00:00
mesh-admin 96fb441f30 Merge pull request 'baserow: placed directories, the database password from a file, and the build ace runs' (#170) from feat/baserow-for-ace into main 2026-09-30 15:32:13 +00:00
mesh-admin b8c701ec94 Merge pull request 'matrix: Conduit and Element, told their own name by the route' (#166) from feat/matrix-for-ace into main 2026-09-30 15:32:09 +00:00
mesh-admin 65ba4e6170 Merge pull request 'mosquitto, influxdb: the provisioner sees its grants where the mesh writes them' (#182) from fix/a-provisioner-sees-its-grants-where-the-mesh-writes-them into main 2026-09-30 15:19:37 +00:00
jschoubben 5b70ffd78f mosquitto, influxdb: the provisioner sees its grants where the mesh writes them
The received grants file names each pair secret by its HOST path. A sidecar
that mounts the placed grants directory at another path inside its
container sees mesh.json and not the secrets beside it — mosquitto's
provisioner reported ENOENT for a file that was there, and nodered's mqtt
step failed against a login that was never created. Mounted at its own
path now, as postgres does.
2026-09-30 17:19:33 +02:00
mesh-admin d3ebb01180 Merge pull request 'nodered: settings are files the mesh writes; editor locked with adminAuth; pin 5.0.7' (#149) from feat/nodered-for-ace into main 2026-09-30 15:06:39 +00:00
jschoubben 0020fa17cd Merge pull request 'mesh-console: the mesh's tools on the machine a person sits at' (#181) from feat/the-console into main
Reviewed-on: http://git.novox.be/novox/mesh-catalog/pulls/181
2026-09-30 14:47:22 +00:00
jschoubben 22c6032144 Merge branch 'main' into feat/the-console 2026-09-30 14:47:11 +00:00
mesh-admin 36b35900a2 Merge pull request 'jackett: its config dir is placed, and its tools find their own key' (#150) from feat/jackett-for-ace into main 2026-09-30 14:40:49 +00:00
mesh-admin 9c97a8a134 Merge pull request 'icecast: its passwords are a file the mesh writes, not the image's environment' (#146) from feat/icecast-for-ace into main 2026-09-30 14:38:14 +00:00
mesh-admin 1bfedd2a9e Merge pull request 'unifi: placed data, mesh-assigned ports, an https route, its password as a file' (#148) from feat/unifi-for-ace into main 2026-09-30 14:38:12 +00:00
jschoubben 769f0724ca mesh-console: the mesh's tools on the machine a person sits at
The tool runtime's own client, mesh serve, started by the mesh on the credential it sealed to the
machine (novox/hq ADR 0152, design 34): invokes every tool, listens from the machine only, holds no
state. Checked with module check before it was ever registered (hq issue 148).
2026-09-30 16:20:06 +02:00
mesh-admin c227592e7c Merge pull request 'influxdb: its admin password and operator token are its own secrets' (#180) from fix/influxdb-admin-credentials-are-accepted into main 2026-09-30 14:19:02 +00:00
jschoubben 50ae89e718 influxdb: its admin password and operator token are its own secrets, so an existing instance's can be accepted
They came from `requires: secret`, minted by the vault — right for a fresh
setup (the image's INIT_* variables read them once), wrong for an instance
that already exists: setup is skipped, the minted values match nothing,
and the provisioner holds a token the server never issued (issue 100).
InfluxDB will not take a chosen token value, so the operator token must be
accepted from the instance (`secret accept ace influxdb admin-token`); the
password can be either. As own secrets both are minted for a fresh install
exactly as before, and accepted where the data already knows them.
Found migrating ace's influxdb.
2026-09-30 16:18:59 +02:00
mesh-admin 3ae63f10c5 Merge pull request 'mosquitto: its directories are placed, not stated, and it runs the build in use' (#144) from feat/mosquitto-placed into main 2026-09-30 14:14:13 +00:00
mesh-admin e7799529e4 Merge pull request 'influxdb: place its directories, hand secrets over as files, name its UI' (#152) from feat/influxdb-for-ace into main 2026-09-30 14:10:50 +00:00
mesh-admin b9068c67bc Merge pull request 'redis: place its directories and run the build in use' (#161) from feat/redis-for-ace into main 2026-09-30 14:10:48 +00:00
mesh-admin 4bf705fea7 Merge pull request 'mssql: place its directories and name the software's port, not a machine's' (#160) from feat/mssql-for-ace into main 2026-09-30 13:55:25 +00:00
mesh-admin 00893d4944 Merge pull request 'letta: placed state, a route, accepted keys, and a runtime that can log in' (#171) from feat/letta-for-ace into main 2026-09-30 13:55:23 +00:00
jschoubben a2b9a9a411 Merge pull request 'dnsmasq: pass the DNSSEC bit down from the validating upstreams' (#179) from fix/resolver-passes-the-dnssec-bit into main 2026-09-30 13:17:38 +00:00
jschoubben 9523105df4 dnsmasq: pass the DNSSEC bit down from the validating upstreams
A program that checks its resolver validates — Mailu's admin does, at
start — could not use the mesh's resolver, and the one it ships instead
knows no mesh name (novox/hq issue 171). proxy-dnssec copies the AD bit
from 1.1.1.1 and 8.8.8.8, both of which validate.
2026-09-30 15:17:34 +02:00
jschoubben 4449f44cf1 Merge pull request 'mailu: admin asks the machine's resolver, not Mailu's own' (#178) from fix/mailu-admin-asks-the-machines-resolver into main 2026-09-30 13:13:29 +00:00
jschoubben 76443ec06d postgres: its server container is not named after the seat
The module's server container was called mesh-store and its data directory
/var/lib/mesh-store — the seat's name reused for the module's own resources,
a leftover from the first migration. On a machine whose postgres holds no
seat (ace, as a database provider only) that produced a container called
mesh-store holding nothing of the kind. The container is now named
postgres. The data directory keeps its path: a path change recreates a
running container on an empty directory (hq 126), and novox's store lives
there.

Rolling this out recreates novox's store container once (a restart on its
bind mount, no data moves). The two catalogue-test failures on this branch
(resolver_manifests_test) fail identically on main today.
2026-09-30 14:56:31 +02:00
jschoubben 9d716ed875 jackett: provide its Torznab API as jackett-api
sonarr, radarr, lidarr and bookshelf reached jackett as http://jackett:9117 (a HAL
container name) or https://indexers.zurag.be (its public route), typed into each app by
hand. The mesh has neither: an app now requires jackett-api and its downloads step writes
the bound address into the app.

Serves scheme, port and url-base; `at` and the machine port come from the binding. Mesh
scope, like sonarr-api: an indexer proxy shares no files with its consumers.

The pair credential is jackett's one API key. The mesh cannot mint it, so the operator
accepts it per consumer pair (ADR 0092), as #156 does for sonarr-api; a consumer's step
refuses a minted value and names the accept.
2026-09-30 13:05:36 +02:00
jschoubben d2f03736fa grafana: its InfluxDB data source comes from the influxdb-api provision
ace's grafana reads InfluxDB through a data source somebody typed into
its database: a LAN address, a database InfluxDB 2 does not have, and a
password for a v1 user of an earlier instance. Nothing in the mesh knew
it existed, so migrating influxdb could only break it further.

grafana now requires influxdb-api, contributes read access, and the mesh
renders a provisioning file grafana reads at start: the address, port,
org's default bucket (as the InfluxQL database) and its own login from
the binding, the password by $__file from the pair credential the mesh
delivers, 0400 for grafana's uid 472. It is a data source of its own
name and uid, read-only in the UI and not the default, so the data
source a person made is never overwritten; a changed binding or a
rotated password restarts grafana, which re-reads the file.

Includes #152 (merged into this branch): influxdb provides influxdb-api.
2026-09-30 13:02:11 +02:00
jschoubben 3c7aafdc21 nodered: its MQTT broker comes from the mesh
Node-RED's one broker node pointed at zurag.be:1884, where nothing listens. nodered now requires
mqtt-topic (asking for every topic: flows follow the devices' own) and a run-once `mqtt` step —
declared last, restarted when the binding, credential or settings change — points the mesh's broker
nodes at the bound broker through Node-RED's admin API with the module's api-token: the node the
step makes itself when none is named, or the ones an assignment names in `mqtt.brokers`. Only host,
port, TLS and the login change; the broker is asked first whether it takes the login; the deploy is
against the revision read ("nodes", so only that node restarts) and a digest makes a rerun a no-op.
A broker node nobody named is never touched. settings.js keeps `mqtt` and `topics` out of Node-RED.
2026-09-30 13:01:14 +02:00
jschoubben 1080f45012 mosquitto: a consumer's grant is the topics it asks for, and its binding says the port
mqtt-topic served nothing: with two listens the mesh could not say which port a consumer dials, so a
consumer had to type 1883 into its config. It now serves the MQTT listener's port (the machine's,
once assigned) and the scheme, so `${bound:mqtt-topic:port}` fills.

The provisioner confined every consumer to `<as>/#`, which leaves nothing for the consumers the
broker exists for: Home Assistant discovers under homeassistant/# and tasmota/discovery/#, and
Node-RED's flows follow the devices' own topics. A consumer now contributes `topics` (MQTT topic
filters) to its mqtt-topic requirement and is granted exactly those; with none, its own subtree as
before. Settings merge into contributions, so an operator narrows a grant per assignment. The role
is brought to exactly the wanted ACLs (stale ones removed), `holds` checks the ACLs too, and an
invalid list is refused, never quietly narrowed. Only the role named for the consumer is touched:
a client carried from the predecessor's password file keeps its own.
2026-09-30 13:01:14 +02:00
jschoubben c5e273e232 Merge feat/influxdb-for-ace (#152) into feat/oidc-client-provision
grafana's data source requires influxdb-api, which influxdb provides only
on #152's branch; merged so this branch's catalogue has the provider of
everything grafana requires. #152 should merge first.
2026-09-30 12:55:43 +02:00
jschoubben 323ef9ec7e influxdb: provide influxdb-api, one mesh-made v1 credential per consumer
grafana's data source and Node-RED's influxdb nodes reached ace's
InfluxDB by a LAN IP or a public name nobody routes, with a credential
somebody made by hand. Now a consumer requires influxdb-api and is told
where it is, which org and default bucket it serves, and signs in with
the password the mesh minted for the pair.

The credential is a v1-compatibility authorization, made per grant by
the new provisioner: InfluxDB 2.x generates API tokens itself and
ignores one the caller sends, so a v2 token could only be accepted by
hand per pair; a v1 authorization takes a caller-chosen password (8-72
characters, the mesh mints 40) and reads/writes every bucket as a
database of its name over InfluxQL and line protocol. A consumer
contributes `access` (read, write, read-write) and, for writing, the
buckets; a missing bucket is made and never deleted. Only
authorizations named mesh_* and marked [mesh] are ever changed or
removed; anything else of that name is refused and left alone.

The org and default bucket are served facts the assignment's settings
set, reaching both the consumers and the provisioner's config.json.
2026-09-30 12:55:38 +02:00
jschoubben 8f459c7023 letta: placed state, a route, accepted keys, and a runtime that can log in
The module named /var/lib/letta, a layout no definition may carry (ADR
0112); state is now a placed directory holding the bindings and secrets.

letta had no route, while the server it replaces is reached by its public
name (a workflow calls it there). It now contributes one for its `web`
endpoint; reach is the assignment's.

The server needs an OpenAI key for agents on OpenAI models, and nothing
gave it one: `openai-api-key` is an own-secret, accepted from the
operator (a key someone chose, not one the mesh can mint). The
server-password is accepted the same way where a server already has
clients. Both, and the database password inside LETTA_PG_URI, stay in the
server's environment: letta 0.6.x reads settings from the environment
only, and its startup.sh starts an embedded PostgreSQL unless
LETTA_PG_URI is set - the declared reason now says so.

The runtime's tools never authenticated: the client sent only a Bearer
token, and 0.6.x's --secure mode checks X-BARE-PASSWORD ("password <it>")
and answers 401 otherwise. The client now sends both. Its password comes
from the runtime config file (the key client.ts reads first) instead of an
env-file, so the runtime container no longer carries a secret in its
environment.

Image: the same 0.6.8 image, now pinned by the index digest ace runs
rather than its amd64 manifest.

Verified: catalogue tests with MESH_CATALOGUE set; tsc -p tsconfig.json in
the mesh-tools build image. Throwaway containers: a fresh 0.6.8 with its
embedded PG and two blocks made through the API; stopped, copied, dumped
from the copy; restored (schema letta + vector pre-made by the superuser,
--no-owner --role, search_path set on the database as the original had
it) into a grant-shaped database on the postgres module's pgvector image
(PG17); started in this shape: alembic finds nothing to do, both blocks
are there, a wrong password is refused with 401, and the patched client
lists agents. Test containers and data removed.
2026-09-30 12:22:12 +02:00
jschoubben ac8556c590 baserow: placed directories, the database password from a file, and the build ace runs
The module named /var/lib/baserow and /services/baserow/data, a layout no
definition may carry (ADR 0112). State and data are now placed directories;
bindings and the grant's secret live in the placed state.

The all-in-one image's entrypoint honours DATABASE_PASSWORD_FILE (file_env
in /baserow.sh), so the grant's password is mounted rather than put in an
env-file, and "secrets-in-environment" is gone (ADR 0086). SECRET_KEY is no
longer minted: the image keeps it, and its JWT signing key, in the data
directory (.secret, .jwt_signing_key) and imports them on start, so a moved
data directory carries the keys its sessions and tokens were made with.
DISABLE_EMBEDDED_PSQL makes a missing grant fail loudly instead of starting
an empty embedded database.

BASEROW_PUBLIC_URL was http://localhost. Baserow answers only the host of
that URL - any other Host is looked up as a published builder site and gets
404, /api/_health/ included - so it is now https://${bound:route:name}
(depends on mesh-controller #149).

The runtime's tools could never have worked: its config was "{}", and the
client's Host override was silently dropped by Node's fetch, so calls by
container name would 404 even with credentials. The client now uses
node:http (which sends the Host it is given, with a Content-Length -
Baserow reads a chunked body as empty) and re-authenticates once when a
cached JWT is refused (access tokens last minutes, the runtime weeks). The
password is the accepted `admin` secret; the email is an assignment
setting merged into the same file, the host is the route's name.

Image pinned to the develop-latest build ace runs today (Baserow 2.3.4,
built 2026-09-18). The old pin (built 2026-09-04) is older than ace's data.

Verified: catalogue tests with MESH_CATALOGUE set; tsc -p tsconfig.json in
the mesh-tools build image. In throwaway containers of the pinned image: a
fresh embedded-PG instance with a user, workspace and 5-row table; stopped,
copied, dumped from the copy (start-only-db); restored with --no-owner
--role into a grant-shaped database on the pgvector image the postgres
module pins (PG17); started with this shape (root 0600 password file,
embedded PSQL disabled, copied data dir without postgres/): health 200,
the user logs in, the 5 rows are there, SECRET_KEY and the JWT key are
imported from the data dir. The patched client lists applications and rows
through the container name with the public Host, and recovers from a
refused token. Test containers and data removed.
2026-09-30 12:16:04 +02:00
jschoubben 62cecc8a2b supabase: the self-hosted stack as one module, its secrets rendered into files
ace runs Supabase under HAL as upstream's 13-container compose: a 2.2 GB
database (1.8 GB of it the dormant `novox` schema, 5.8 M rows in its largest
table), Kong at supabase.zurag.be, the pooler on 5433/6543. This is that stack
as a catalogue module, same images (the digests ace runs), same container
names so an assignment holds the running ones and a take replaces them.

The database stays inside the module. Supabase is a Postgres distribution: its
own image with pgsodium, pg_graphql, pg_net, vault and timescale preloaded, a
superuser (supabase_admin), a dozen reserved roles and a second database
(_supabase). A postgres-database grant - one database, one unprivileged role -
cannot hold it, so ace's data directory moves as a copy, not a dump/restore.

What HAL did by shell and environment the mesh now renders as files:
- kong.yml carries the anon/service keys and the dashboard login (owned by
  kong's uid 100), instead of an entrypoint that eval'd the environment;
- GoTrue reads a dotenv file (auth -c), PostgREST a config file, Vector its yml
  with the Logflare key in it, the database POSTGRES_PASSWORD_FILE and a
  jwt.sql rendered with the secret (owned by postgres, uid 105). None of these
  five containers has a secret in its environment.
- realtime, storage, meta, functions, analytics, studio and supavisor read
  their credentials from the environment only; each declares
  secrets-in-environment with the reason (ADR 0086).
- Upstreams are container names (supabase-db, supabase-kong, ...) instead of
  compose service names, which the mesh does not have.
- SITE_URL / API_EXTERNAL_URL / SUPABASE_PUBLIC_URL are
  https://${bound:route:name} (mesh-controller #149); on ace HAL rendered them
  as "https://supabase." - broken today.
- Vector reads the docker socket, as upstream does, but now includes only this
  module's containers instead of every container's logs on the machine.

Three things compose did that a declaration cannot, done as steps: a run-once
seed copies the image's /etc/postgresql-custom into the placed config
directory with cp -n (a named volume did that implicitly; never overwrites the
pgsodium root key), and two run-once gates wait for the database and for
Logflare, which compose expressed as depends_on: service_healthy.

The pooler bootstrap (pooler.exs) takes the tenant id and pool sizes from
settings.json, the module's one merge:json file, so ace keeps its tenant
"zurag"; and it repoints an existing tenant whose database host is not
supabase-db - HAL created ace's with host "db", which no longer resolves.

Secrets (vault, requires "secret"): postgres, jwt, anon-key,
service-role-key, dashboard-user, dashboard, logflare, pooler-vault,
key-base-a + key-base-b (concatenated: Phoenix wants 64+ bytes, a minted
secret is 40), openai. Three cannot be minted on any machine: anon-key and
service-role-key are JWTs signed with jwt, and pooler-vault must be exactly
32 bytes (AES-256-GCM, found in the bed). They are accepted. On ace every
secret the data already knows is accepted (all but key-base-a/b).

Not carried: Kong's 8443 and Logflare's 4000 on all interfaces (nothing
outside the module uses them); realtime's DB_ENC_KEY stays upstream's constant
(realtime deletes and re-seeds that tenant from its environment every start,
and the key must be exactly 16 bytes).

Verified: catalogue tests with MESH_CATALOGUE pointed here on mesh-controller
main and #149 (on main the render is refused for "name", never written
empty). The #149 resolution with stub providers, turned into a throwaway
stack of all 13 pinned digests with dummy secrets and the rendered files at
their owners and modes: the database initialised through the rendered scripts
(jwt setting applied, _analytics/_supavisor created, roles' password from
POSTGRES_PASSWORD_FILE); through Kong: REST 200 with the anon key and 401
without, auth health and settings 200, storage buckets 200, GraphQL 200, pg-meta
200, an edge function 200, Studio 401 without and 200 with the dashboard login,
realtime tenant health 200; the pooler in session and transaction mode as
postgres.zurag; a tenant set to host "db" was repointed to supabase-db by the
bootstrap and connections worked.
2026-09-30 12:12:27 +02:00
jschoubben 157fab5dc8 matrix: Conduit and Element, told their own name by the route
ace runs a Conduit homeserver (matrix.zurag.be, 5.4 GB of RocksDB, federating)
and Element Web under HAL, configured by environment with the domain
templated in, and Element's config.json carrying matrix.zurag.be literally.

A homeserver's server_name is its permanent identity - every user id, room id
and signature in the database carries it - and it is the name the module is
served under. So it comes from ${bound:route:name-homeserver} (mesh-controller
#149), rendered into a conduit.toml the container reads through CONDUIT_CONFIG,
and into Element's config.json (base_url, default_server_name, the room
directory). Without #149 the render is refused ("name-homeserver"), never
written empty. Two routes, one per endpoint: homeserver (label matrix, 6167)
and element (label element, 80).

Federation needs no 8448: Conduit answers /.well-known/matrix/server with
<name>:443, so peers federate through the route. HAL published 8448 on all
interfaces, but the router never forwarded it; checked from outside, the
well-known, federation version and client versions all answer on 443.

Registration defaults to off. HAL ran with CONDUIT_ALLOW_REGISTRATION=true,
which on ace means anyone on the internet can create an account with the
dummy flow (seen: /register offers m.login.dummy) - on 0.10.13, whose
successor 0.10.14 fixes an account-takeover by any local user. Existing
accounts are unaffected; the operator decides whether to reopen it.

Element's config.json is the one merge:json file (it tolerates `endpoints`),
so a machine can add keys. HAL's map_style_url is not carried: it embedded
a map-tile API key, which belongs in an assignment if wanted.

Images are the digests ace runs (Conduit 0.10.13, Element 1.12.28).

Verified: catalogue tests with MESH_CATALOGUE pointed here on mesh-controller
main and #149; a resolution on #149 renders both names (matrix.zurag.be,
element.zurag.be) into both files and both contributions; throwaway
containers of both pinned digests with the rendered files (root 0644, :ro):
client versions 200, well-known says matrix.zurag.be:443, register refused
M_FORBIDDEN, Element 200 serving the rendered config.json.
2026-09-30 11:59:56 +02:00
jschoubben 1247b8c27e redis: place its directories and run the build in use
The manifest stated /services/redis/data and /var/lib/redis-module - novox's
old layout, paths no definition may carry (ADR 0112). State is now the
assignment's root, grants and data are placed, and the config file, the
secret file, receives and grants all name them as ${dir:...}. Paths inside
the sidecar are its own view and are unchanged.

The data directory and config are owned 999:1000: the image's redis user is
uid 999 in gid 1000 (checked in both builds), which is who owns ace's data
today; 999:999 named a group the image does not use.

Image pinned to the 7.4.11-alpine build ace runs (2026-09-17); the old pin was
the same version, built in August. Older-than-running is never the pin.

Nothing is assigned it anywhere today, so no machine changes.

Verified: catalogue tests pass with MESH_CATALOGUE on this tree; the
declaration composes for ace with every path under /var/lib/redis. The pinned
image ran as a throwaway with a 0600 999:1000 config and a 0700 data dir:
unauthenticated PING is refused (NOAUTH), authenticated SET/GET works,
appendonly is on, the server runs as redis.
2026-09-30 11:57:46 +02:00
jschoubben 0fce3ebf5d mssql: place its directories and name the software's port, not a machine's
The manifest stated /var/lib/mssql, its grants and its SA file by path, and
declared it listens on 4848 - the port one machine's predecessor published,
which is an assignment's fact (ADR 0112, 0138). ace is moving its own
SQL Server (80 GB of work databases) onto the mesh, so the module has to be
the same on every machine.

- state is the assignment's root (place "."), grants is placed, and every
  reference (sa.env, the env-file, the SA and grants mounts, receives,
  grants, own-secrets) names them as ${dir:...}.
- the database endpoint listens on 1433, the port SQL Server uses; a machine
  that must keep an older number pins it in its assignment.
- the image pin is unchanged: it is the digest ace runs today (CU27,
  16.0.4295), the same as novox.

novox is untouched: rendered with novox's own setting ({"ports":{"1433":4848}})
through the controller's Declaration and Rules, every resource - paths,
container names, volumes, env-file, the 4848:1433 mapping, owners, modes - is
byte-identical to what main renders; the only difference is the firewall
rule's comment text (still port 4848, from the mesh).

Verified: catalogue tests pass with MESH_CATALOGUE on this tree. The pinned
image ran as a throwaway on a 0700 10001:0 data dir with a root-owned 0600
env-file (dummy SA), answered sqlcmd as sa; a scratch database stopped,
copied with cp -a, checksummed and started on the copy kept its rows and
CHECKSUM_AGG.
2026-09-30 11:57:45 +02:00
jschoubben 5a906b757d keycloak, grafana: their public names come from the mesh, not the manifest
GF_SERVER_ROOT_URL=https://grafana.zurag.be, KC_HOSTNAME=https://keycloak.novox.be
and the served issuer's novox default were domains in definitions — wrong on
every other machine (ADR 0112). The names now come from ${bound:route:name}
(mesh-controller #149, hq 122): grafana's in oidc.env, keycloak's in a
hostname.env its server reads. The issuer includes the realm and stays the
assignment's, with no default: unset, a consumer asking for it is refused
and the provisioner says so, rather than both quietly using novox's URL.

Rendered through mesh-controller #149 from these manifests on a zurag.be
node: KC_HOSTNAME=https://keycloak.zurag.be, GF_SERVER_ROOT_URL=
https://grafana.zurag.be, OIDC URLs from the issuer setting. Needs #149
merged and rolled out first.
2026-09-30 00:49:08 +02:00
jschoubben d8ee88e487 grafana: log in through keycloak's oidc-client provision
HAL's grafana logged in through a hand-made Keycloak client whose secret sat
in its .env. Requiring oidc-client gives it a client the mesh makes and keeps:
the id and URLs come from the binding, the secret arrives as a file grafana
reads itself (__FILE), and the callback it contributes is what keycloak
registers as its redirect.

GF_SERVER_ROOT_URL is still a literal: a module cannot yet learn the public
name the mesh composes for its own endpoint (hq issue 122), and without it
grafana sends a redirect Keycloak refuses.
2026-09-30 00:39:16 +02:00
jschoubben 54557b77bf keycloak: provide oidc-client, one mesh-made client per consumer
A module that logs people in through Keycloak had to be given a client by
hand, with its secret copied into the consumer's environment. As a provision
the mesh derives the client id (the consumer's identity, mesh_<node>_<module>)
and mints its secret, and delivers both ends: keycloak creates exactly that
confidential client, the consumer names it through ${bound:oidc-client:as}.

The consumer says where its browser comes back to (`callback`) and which
endpoint it is reached on (`label`/`endpoint`), so the redirect is built from
the same names the mesh composes for its route. keycloak serves the issuer and
the endpoint paths under it; the issuer is the one value an assignment sets,
and the realm is read out of it, so consumer and client cannot disagree.

Only what the mesh made is touched: its clients carry mesh.provisioned=true;
a client of the same id without the mark is refused, never adopted, updated
or deleted. The runtime now gets the admin password as a file, which its
tools also needed and never had.
2026-09-30 00:39:16 +02:00
jschoubben c1a65e2354 nodered: the sidecar dials the port it was given
The sidecar runs on the host network and dialled 127.0.0.1:1880, the
software's port; the mesh publishes nodered on a machine port it assigns,
so the tools reached whatever else holds 1880, or nothing (hq 088).
2026-09-29 23:50:11 +02:00
jschoubben a5e21cb438 grafana: its directories are placed, its admin password is a file, and it runs the build in use
The module stated /var/lib/grafana-module and /services/grafana/data, a
layout no definition may carry (ADR 0112). State and data are now placed
directories; the admin secret lives beside the broker account under the
mesh's own state.

The admin password reached grafana through an env-file. Grafana honours
GF_SECURITY_ADMIN_PASSWORD__FILE, so it is now a 0400 file owned by the
image's user (472) and mounted, and "secrets-in-environment" is gone
(ADR 0086).

The runtime sidecar was given no credential at all - its config file was
"{}", so GrafanaClient.fromEnv threw and the tools and the alert watcher
did nothing. It now carries user/password from the same secret, and it
calls grafana on the machine port the mesh assigned (${port:3000}) rather
than a literal 3000.

Image pinned to the 13.2.2 build ace's predecessor runs; the old pin was
13.2.1, older than the data it would open.

Verified: catalogue tests with MESH_CATALOGUE pointing here; a throwaway
container of the pinned image with the file-mounted secret answers
/api/health and authenticates admin with the file's value (default
admin/admin refused); restarted over the same data with a different file
value, the original password still holds - so a migrated instance's
password must be accepted, not minted; data owned by another uid fails to
start, so a moved data directory must be chowned to 472.
2026-09-29 23:43:00 +02:00
jschoubben fe0ed3b74e influxdb: place its directories, hand secrets over as files, name its UI
The manifest named /services/influxdb and /var/lib/influxdb-module — one
machine's paths — and passed the admin password and token through the
environment. ace is moving its 2022 instance onto the mesh, so the module
has to be what it is on any machine.

- data, config and state are placed directories; the data keeps 1000:1000,
  the image's influxdb user, which is who owns ace's data today.
- the init secrets reach the image through its own
  DOCKER_INFLUXDB_INIT_{PASSWORD,ADMIN_TOKEN}_FILE; the vault's files are
  mounted read-only. secrets-in-environment is gone.
- the sidecar reads its token from the same file (MESH_INFLUXDB_TOKEN_FILE,
  added to client.ts) and reaches the server at its assigned machine port
  (${port:8086}) instead of assuming 8086. The unused config-dir mount,
  which held the CLI's copy of the admin token, is dropped.
- the api endpoint contributes a route: the web UI is how people use it,
  and reach is the assignment's to say.

Verified: catalogue tests pass with MESH_CATALOGUE pointed at this tree.
The pinned 2.9.1 image, run on a scratch copy of ace's 2.4.0 data, opens
it, runs its metadata migrations (backing up the pre-upgrade bolt/sqlite)
and hashes the two stored tokens; /health passes. A fresh setup through
the _FILE variables, with dummy secrets as root-owned 0600 files, accepts
the token (200 on /api/v2/buckets) and the password (204 on /signin).
client.ts typechecks strict and reads the token file, tolerating the
endpoints key in its config.
2026-09-29 23:42:18 +02:00
jschoubben 75eee9d4a0 jackett: its config dir is placed, and its tools find their own key
The manifest named /services/jackett/config and /var/lib/mesh/jackett/config.json — host
paths ADR 0112 takes out of definitions. The config dir is now a pathless directory
(${dir:config}) and the runtime's config and route binding live in a placed state dir, as
searxng does.

The image is pinned to v0.24.2627-ls34, the digest ace runs today; the old pin
(v0.24.2517-ls16) was older than the running version.

The runtime reached jackett at a fixed 127.0.0.1:9117; it now uses ${port:9117}, the
machine port the mesh actually assigned.

The tools never loaded: the client needed an API key nobody set. Like sonarr/radarr read
config.xml, it now reads APIKey from Jackett's own ServerConfig.json (the config dir is
already mounted read-only), so no secret goes into an assignment. jackett_indexers called
/api/v2.0/indexers, which is the web UI's endpoint and answers an API key with a redirect;
it now reads the Torznab t=indexers feed, and treats Torznab's 200-with-<error> as a
failure.

Verified: catalogue key tests (MESH_CATALOGUE set, not skipped); a throwaway container of
the pinned image on a fresh 0700 1000:1000 config dir serves its UI; the client discovers
the key from the generated ServerConfig.json, lists 617 indexers through the Torznab feed,
searches via /results, and a wrong key is refused; client.ts typechecks under --strict.
2026-09-29 23:41:21 +02:00
jschoubben 0c91e08bad nodered: its settings are files the mesh writes, and its editor is locked
The catalogue ran the image's defaults: no adminAuth, so a routed Node-RED
editor (which runs arbitrary code) was open to anyone who reached it, and
the module's own tools had no token to present to an install that was locked.

- settings.js (fixed, 0600, uid 1000) carries adminAuth: user admin checked
  against the admin secret -- a minted password, or the bcrypt hash an
  existing install held (accepted), so current logins keep working -- and a
  static bearer token (api-token) the sidecar presents. It loads settings.json
  beside it, the one mergeable file; endpoints is dropped there, and an
  optional timeZone sets process.env.TZ (assignments cannot set env).
- The sidecar's runtime config is no longer merged; it carries the token.
- Directories are placed (state, data), the route binds into state.
- Image pinned to 5.0.7 (a649dd71), what ace runs; the old pin was 5.0.6.
- deployFlows asks for API v2: v1 answers 204 with no body, which the client
  tried to parse as JSON.

Verified: catalogue tests pass against this tree. A throwaway 5.0.7 container
started with the generated files: anonymous /flows 401, bearer api-token 200,
bad token 401, password grant 200/403 with a minted password and with a
bcrypt-hash-accepted one; endpoints and timeZone do not reach /settings;
timeZone Europe/Brussels overrides TZ=Etc/UTC; v1 deploy 204, v2 deploy
answers {rev}.
2026-09-29 23:41:18 +02:00
jschoubben 37c212d5b4 unifi: placed data, mesh-assigned ports, an https route, and its password as a file
The module stated /services/unifi/data and fixed machine ports (8443:8443 and
eight more) — one installation's layout and numbers, which a definition may not
carry (ADR 0038, 0112). Data is now a placed directory (${dir:data}, 1000:1000,
0700), the container publishes its own ports and the mesh assigns the machine
side; an assignment pins them where devices already know them. The L2 endpoint
names the port the software uses (1900), not the one a machine published it on.

The sidecar dialled https://127.0.0.1:8443, true only while the machine port
equals the container's; it now asks for ${port:8443}. Its controller password
was a setting (plaintext in the mesh DB); it is now an own-secret written into
the one mergeable file (ADR 0086). The username stays a setting.

The web UI is contributed as a route to the "web" endpoint over https with
insecure upstream (the controller's own self-signed tls), as mailu's web-tls —
what HAL's hand-written traefik file for unifi does today.

Image pinned to the manifest list ace runs (8.0.24-ls221); the old pin was its
amd64 child, so the image is unchanged.

Verified: catalogue tests pass with MESH_CATALOGUE set; a throwaway container
of the pinned image on a fresh 1000:1000/0700 data dir answers /status (8.0.24,
up) and /inform; the sidecar client built from a config.json carrying site,
password, username and an endpoints key reaches it and is refused only on the
dummy credentials.
2026-09-29 23:39:50 +02:00
jschoubben 718fb12ef7 icecast: its passwords are a file the mesh writes, not the image's environment
The image seds ICECAST_*_PASSWORD from the environment into /etc/icecast.xml;
ADR 0086 wants secrets as files. icecast starts as root, reads its config, then
drops to uid 100, so a root-owned 0600 icecast.xml rendered with ${secret:...}
and mounted read-only works and the entrypoint's seds never fire (no env set).
The "secrets-in-environment" exemption and server.env are gone.

Also: directories are placed (state, logs owned 100:101 so the image's VOLUME
/var/log/icecast is not an anonymous volume per container, as HAL learned);
the server and sidecar share a module network, so the sidecar reaches
http://icecast:8000 instead of assuming machine port 8000 on the host; the
stream endpoint is routed (label "icecast"), as HAL served it via traefik.
Secrets remain mesh-vault grants (requires secret), now under ${dir:state}.

Verified: catalogue tests with MESH_CATALOGUE pointed at this tree; a
throwaway container of the pinned digest (the one ace runs) with the rendered
file (dummy secrets, root 0600, :ro): runs as icecast, status-json 200,
admin 401 without / 200 with the admin secret, a source PUT with the source
secret mounts, a listener receives it, a wrong source password gets 401, logs
land in the uid-100 directory.
2026-09-29 23:39:36 +02:00
jschoubben a32394ec22 mosquitto: its directories are placed, not stated, and it runs the build in use
The module stated /var/lib/mosquitto-module and /services/mosquitto/data —
novox's layout, a path no definition may carry (ADR 0112). State, grants and
data are now placed directories (${dir:state}, ${dir:grants}, ${dir:data}),
the admin secret lives beside the broker account under the mesh's own state,
and the receives/grants maps follow the grants directory. Paths inside the
sidecar are its own view and are unchanged.

Image pinned to the 2.1.2 build ace's predecessor runs (2026-09-17); the old
pin was the same version, built in June.

Found preparing ace, whose broker carries a password-file user (an IoT switch
and home-assistant). Carrying it is a data step, not a manifest one: the
migration repo has scripts/mosquitto-pwdfile-to-dynsec.py, which moves $7$
PBKDF2 entries into the dynsec store hash-for-hash (tested end to end).
2026-09-29 23:05:41 +02:00
664 changed files with 89684 additions and 8115 deletions
+103
View File
@@ -0,0 +1,103 @@
# adwaita
The desktop's theme as a module (novox/hq ADR 0208, research 026, to-be 42 phase 2, step 6): Adwaita
for GTK, Qt, the portal and the cursor, dark by default. It claims no seat, because themes coexist.
- **Packages:** `gnome-themes-extra` (Adwaita-dark for GTK 2 and 3), `adwaita-icon-theme`,
`adwaita-cursors`, `qt6ct`, `xdg-desktop-portal-gtk`.
- **Environment** (ADR 0203), the five words today's `~/.xinitrc` exported plus the cursor:
- `GTK_THEME=Adwaita:dark`;
- `GTK2_RC_FILES=/usr/share/themes/Adwaita-dark/gtk-2.0/gtkrc`;
- `QT_QPA_PLATFORMTHEME=qt6ct`;
- `QT_STYLE_OVERRIDE=Fusion`;
- `QT_SELECT=6`;
- `XCURSOR_THEME=Adwaita`, `XCURSOR_SIZE=24`.
- **Session code** (ADR 0208 §4):
- in the `xinitrc` slot `normal`, the GSettings keys the portal serves (dark, the GTK and icon theme,
the cursor, the UI and monospace fonts), set at every session start. This replaces the
predecessor's `~/scripts/xdg-appearance`;
- in the `xresources` slot `normal`, `Xcursor.theme` and `Xcursor.size`.
## What it owns
| path | class | from |
|---|---|---|
| `~/.config/gtk-3.0/settings.ini` | owned (the found file kept once) | [`config/gtk-settings.ini`](config/gtk-settings.ini) |
| `~/.config/gtk-4.0/settings.ini` | owned | the same file |
| `~/.config/qt6ct/qt6ct.conf` | owned | [`config/qt6ct.conf`](config/qt6ct.conf) |
| `~/.config/xdg-desktop-portal/portals.conf` | owned | [`config/portals.conf`](config/portals.conf) |
| `~/.icons/default/index.theme` | owned | [`config/cursor-index.theme`](config/cursor-index.theme): the cursor for programs that read neither `XCURSOR_THEME` nor the resources |
**`qt6ct.conf` is the mesh's now.** A change made in qt6ct's own window is replaced at the next push,
and the window's saved geometry goes with it. The theme is this module's to say. Settings will make it
the operator's (issue 168).
## What it improves
- **No package from the user repository.** Today's Qt style, `adwaita-dark` (`QT_STYLE_OVERRIDE` and
qt6ct's `Adwaita-Dark`), comes from the user repository's `adwaita-qt5`/`adwaita-qt6-git`, a
project that is no longer developed. The module uses Qt's own **Fusion** style with qt6ct's
**`darker`** palette, both shipped with Qt and qt6ct. **This is the one visible change:** Qt
programs keep a dark palette, drawn by Fusion instead of Adwaita-Qt.
- **One Qt tool.** `qt5ct` is gone (installed on the desktop only, with a configuration on both).
`QT_SELECT=6` and `qt6ct` cover the Qt 6 programs. A Qt 5 program gets Fusion through
`QT_STYLE_OVERRIDE`, but not the palette.
- **The fonts research 026/04 chose:** Inter 11 for GTK, Qt and GSettings' interface font, and
JetBrains Mono Nerd Font for Qt's fixed font and GSettings' monospace. Today these are Noto Sans 12,
Adwaita Sans 11 and nothing.
- **The cursor said everywhere**: GTK's settings, GSettings, `XCURSOR_*`, the X resources and the
default theme. Today only the resources and GSettings said it.
- **The portal answers secrets.** `portals.conf` routes `org.freedesktop.impl.portal.Secret` to
gnome-keyring. Today `adwaita_portal_check` shows no backend answering it: gtk does not implement
it, and gnome-keyring's backend names only GNOME.
## Tools
| tool | | what |
|---|---|---|
| `adwaita_appearance` | r/a | dark or light as each audience sees it (GSettings, the portal's own answer, the GTK files, Qt's style and palette, the theme words in the user manager). `mode` switches GSettings for the session, and the answer says what follows live (programs asking the portal) and what stays dark (GTK 3 under `GTK_THEME`, the declared files) |
| `adwaita_cursor` | r/a | the cursor in GSettings, the resources and the environment, and the cursor themes installed; set theme or size for new windows (GSettings, and the resources when a session runs) |
| `adwaita_icons` | r | icon themes installed (where, what each inherits, whether it has cursors), and the one GSettings, GTK and Qt use |
| `adwaita_portal_check` | r | the backends installed and what each implements, which `portals.conf` decides (the first that exists, in xdg-desktop-portal's order), the backend answering each interface, what runs on the bus, and the colour scheme the portal answers |
Each reaches GSettings, the portal and the user manager on the account's bus. Only the cursor's X
resources need the session. From the user manager it reads only the theme's own words.
**The appearance is a session's choice.** A persistent dark or light, for the files too, is a setting
and waits for issue 168. Until then the module's default is dark, and `mode` lasts until the next
login.
## What it leaves found
`~/.config/qt5ct/`, `~/.config/gtk-3.0/bookmarks` and everything else in those directories,
`~/scripts/xdg-appearance`, and the user repository's `adwaita-qt*` packages.
## The one-off migration (ADR 0182)
**Once `adwaita` is assigned:**
1. In `~/.xinitrc`, the theme exports and `~/scripts/xdg-appearance || true` go (see `xorg`'s list).
2. Delete `~/scripts/xdg-appearance`.
3. In `~/.Xresources`, delete the two `Xcursor` lines.
4. Delete `~/.config/qt5ct/`.
5. Remove the user repository's packages: `sudo pacman -Rns adwaita-qt5-git adwaita-qt6-git`
(laptop), `sudo pacman -Rns adwaita-qt5 adwaita-qt6-git adwaita-dark qt5ct` (desktop). Check first
that nothing else needs them (`pacman -Qi`).
## What changes when it is assigned
| | g14 | shanks |
|---|---|---|
| packages | none (all present) | the same |
| GTK settings, `portals.conf` | the found files kept once, then the module's: adds the cursor and Inter, keeps Adwaita dark; `portals.conf` adds the Secret line | the same |
| `qt6ct.conf` | Fusion with the `darker` palette, Inter and JetBrains Mono, instead of Adwaita-Dark with Noto Sans | the same |
| `~/.icons/default/index.theme` | new | new |
| environment | `QT_STYLE_OVERRIDE` becomes `Fusion`; `XCURSOR_*` added; the rest as `~/.xinitrc` exported them | the same |
| running programs | **nothing**: settings are read at a program's start, and GSettings is set at the next login | the same |
| next login | GSettings' fonts become Inter and JetBrains Mono. A secret request through the portal finds gnome-keyring | the same |
## Blockers
- **`fonts` first**, for Inter and JetBrains Mono. Without them, GTK and Qt fall back to the nearest
installed face.
- **Light is a session's choice only**, until settings (issue 168).
@@ -0,0 +1,246 @@
package main
import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"adwaita/internal/desktop"
)
type manifest struct {
Capabilities []string `json:"capabilities"`
Claims []any `json:"claims"`
Tools []string `json:"tools"`
Environment struct {
Variables map[string]string `json:"variables"`
} `json:"environment"`
Shell []struct {
For, Slot, Code string
} `json:"shell"`
Resources []map[string]any `json:"resources"`
}
func readManifest(t *testing.T) manifest {
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
if err != nil {
t.Fatal(err)
}
var m manifest
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
return m
}
func TestItContributesTheThemesWordsAndClaimsNoSeat(t *testing.T) {
m := readManifest(t)
if m.Claims != nil {
t.Fatal("a theme is not a seat: several coexist")
}
want := map[string]string{"GTK_THEME": "Adwaita:dark", "GTK2_RC_FILES": "/usr/share/themes/Adwaita-dark/gtk-2.0/gtkrc",
"QT_QPA_PLATFORMTHEME": "qt6ct", "QT_STYLE_OVERRIDE": "Fusion", "QT_SELECT": "6", "XCURSOR_THEME": "Adwaita", "XCURSOR_SIZE": "24"}
if len(m.Environment.Variables) != len(want) {
t.Fatalf("%v", m.Environment.Variables)
}
for k, v := range want {
if m.Environment.Variables[k] != v {
t.Errorf("%s=%q", k, m.Environment.Variables[k])
}
}
served := map[string]bool{}
for _, tool := range tools(adwaita{}) {
served[tool.Name] = true
}
if len(served) != len(m.Tools) {
t.Fatalf("%v %v", served, m.Tools)
}
for _, n := range m.Tools {
if !served[n] {
t.Errorf("%s", n)
}
}
}
func TestTheSessionLinesSetGSettingsAndTheResourcesTheCursor(t *testing.T) {
m := readManifest(t)
if len(m.Shell) != 2 || m.Shell[0].For != "xresources" || m.Shell[1].For != "xinitrc" || m.Shell[0].Slot != "normal" || m.Shell[1].Slot != "normal" {
t.Fatalf("%+v", m.Shell)
}
if m.Shell[0].Code != "! adwaita: the cursor, for X programs that take it from the resources.\nXcursor.theme: Adwaita\nXcursor.size: 24\n" {
t.Fatalf("%q", m.Shell[0].Code)
}
x := m.Shell[1].Code
for _, want := range []string{"color-scheme 'prefer-dark'", "gtk-theme 'Adwaita'", "icon-theme 'Adwaita'", "cursor-theme 'Adwaita'", "font-name 'Inter 11'", "monospace-font-name 'JetBrainsMono Nerd Font 11'"} {
if !strings.Contains(x, want) {
t.Errorf("lacks %s", want)
}
}
for _, l := range strings.Split(strings.TrimSpace(x), "\n") {
if !strings.HasPrefix(l, "#") && !strings.HasSuffix(l, "|| true") {
t.Errorf("a session line that can stop the session's start: %q", l)
}
}
}
func TestItOwnsTheFilesItsSourcesHoldAndNoQt5Duplicate(t *testing.T) {
m := readManifest(t)
sources := map[string]string{"gtk3": "gtk-settings.ini", "gtk4": "gtk-settings.ini", "qt6ct": "qt6ct.conf", "portals": "portals.conf", "cursor": "cursor-index.theme"}
pkgs := []string{}
for _, r := range m.Resources {
id := r["id"].(string)
if r["type"] == "package" {
pkgs = append(pkgs, r["package"].(string))
continue
}
raw, _ := os.ReadFile(filepath.Join("..", "..", "config", sources[id]))
if r["content"] != string(raw) || r["into"] != nil || r["owner"] != "${machine:account}" {
t.Errorf("%s is not config/%s, whole and the account's", id, sources[id])
}
if strings.Contains(r["path"].(string), "qt5ct") {
t.Error("qt5ct: both workstations run Qt 6 programs through qt6ct; a second tool is a duplicate")
}
}
if strings.Join(pkgs, ",") != "gnome-themes-extra,adwaita-icon-theme,adwaita-cursors,qt6ct,xdg-desktop-portal-gtk" {
t.Fatalf("%v", pkgs)
}
qt := readINI(filepath.Join("..", "..", "config", "qt6ct.conf"))
if qt["Appearance"]["style"] != "Fusion" || qt["Appearance"]["custom_palette"] != "true" || !strings.Contains(qt["Fonts"]["general"], "Inter,11") {
t.Fatalf("%v", qt)
}
if _, err := os.Stat("/usr/share/qt6ct/colors"); err == nil {
if _, err := os.Stat(qt["Appearance"]["color_scheme_path"]); err != nil {
t.Fatalf("qt6ct ships no %s", qt["Appearance"]["color_scheme_path"])
}
}
gtk := readINI(filepath.Join("..", "..", "config", "gtk-settings.ini"))["Settings"]
if gtk["gtk-theme-name"] != "Adwaita" || gtk["gtk-application-prefer-dark-theme"] != "1" || gtk["gtk-font-name"] != "Inter 11" {
t.Fatalf("%v", gtk)
}
}
func TestKeyFilesAreReadWithTheirComments(t *testing.T) {
ini := ParseINI("# c\n[A]\nk = v\n; also a comment\n[B]\nx=1=2\n")
if ini["A"]["k"] != "v" || ini["B"]["x"] != "1=2" || len(ini) != 2 {
t.Fatalf("%v", ini)
}
}
func TestThePortalsAnswerIsResolvedAsXdgDesktopPortalDoes(t *testing.T) {
dir := t.TempDir()
os.WriteFile(filepath.Join(dir, "gtk.portal"), []byte("[portal]\nDBusName=org.freedesktop.impl.portal.desktop.gtk\nInterfaces=org.freedesktop.impl.portal.FileChooser;org.freedesktop.impl.portal.Settings;\nUseIn=gnome\n"), 0o644)
os.WriteFile(filepath.Join(dir, "gnome-keyring.portal"), []byte("[portal]\nDBusName=org.freedesktop.secrets\nInterfaces=org.freedesktop.impl.portal.Secret;\nUseIn=gnome\n"), 0o644)
os.WriteFile(filepath.Join(dir, "kde.portal"), []byte("[portal]\nDBusName=org.freedesktop.impl.portal.desktop.kde\nInterfaces=org.freedesktop.impl.portal.FileChooser;\nUseIn=KDE\n"), 0o644)
b := Backends([]string{dir})
if len(b) != 3 || b[0].Name != "gnome-keyring" || len(b[1].Interfaces) != 2 {
t.Fatalf("%+v", b)
}
got := Resolve(b, map[string]string{"default": "gtk", "org.freedesktop.impl.portal.Secret": "gnome-keyring"}, []string{"i3"})
if got["org.freedesktop.impl.portal.FileChooser"] != "gtk" || got["org.freedesktop.impl.portal.Secret"] != "gnome-keyring" || got["org.freedesktop.impl.portal.Settings"] != "gtk" {
t.Fatalf("%v", got)
}
got = Resolve(b, map[string]string{"default": "gtk"}, []string{"i3"})
if got["org.freedesktop.impl.portal.Secret"] != "(none)" {
t.Fatalf("gtk does not implement secrets: %v", got)
}
got = Resolve(b, map[string]string{"default": "none;gtk"}, nil)
if got["org.freedesktop.impl.portal.FileChooser"] != "(none)" {
t.Fatalf("none stops the list: %v", got)
}
got = Resolve(b, nil, []string{"KDE"})
if got["org.freedesktop.impl.portal.FileChooser"] != "kde" || got["org.freedesktop.impl.portal.Settings"] != "(none)" {
t.Fatalf("with no configuration, UseIn decides: %v", got)
}
c := PortalConfigs("/h", []string{"i3", "GNOME"})
if c[0] != "/h/.config/xdg-desktop-portal/i3-portals.conf" || c[1] != "/h/.config/xdg-desktop-portal/gnome-portals.conf" || c[2] != "/h/.config/xdg-desktop-portal/portals.conf" {
t.Fatalf("%v", c[:3])
}
}
func TestThemesAreFoundOnceEachWithCursorsAndIcons(t *testing.T) {
a, b := t.TempDir(), t.TempDir()
os.MkdirAll(filepath.Join(a, "Adwaita", "cursors"), 0o755)
os.MkdirAll(filepath.Join(b, "Adwaita"), 0o755)
os.WriteFile(filepath.Join(b, "Adwaita", "index.theme"), []byte("[Icon Theme]\nName=Adwaita\nInherits=hicolor\nDirectories=16x16\n"), 0o644)
os.MkdirAll(filepath.Join(b, "hicolor"), 0o755)
os.WriteFile(filepath.Join(b, "hicolor", "index.theme"), []byte("[Icon Theme]\nName=Hicolor\nDirectories=16x16\n"), 0o644)
os.MkdirAll(filepath.Join(b, "empty"), 0o755)
got := Themes([]string{a, b})
if len(got) != 2 || got[0].Name != "Adwaita" || !got[0].Cursors || got[0].Icons || got[0].Dir != filepath.Join(a, "Adwaita") || got[1].Name != "hicolor" {
t.Fatalf("the first directory's Adwaita hides the second's: %+v", got)
}
}
type fake struct {
ran []string
get map[string]string
}
func (f *fake) desk() desktop.Desk {
return desktop.Desk{
Find: func() (*desktop.Session, error) { return nil, &desktop.NoSession{Reason: "none"} },
Run: func(_ context.Context, env []string, _ []byte, name string, args ...string) desktop.Result {
line := strings.Join(append([]string{name}, args...), " ")
f.ran = append(f.ran, line)
switch {
case name == "gsettings" && args[0] == "get":
return desktop.Result{Stdout: "'" + f.get[args[2]] + "'\n"}
case name == "gsettings" && args[0] == "set":
f.get[args[2]] = args[3]
case name == "systemctl":
return desktop.Result{Stdout: "GTK_THEME=Adwaita:dark\nNPM_TOKEN=secret\nXDG_CURRENT_DESKTOP=i3\n"}
case name == "busctl" && args[1] == "call":
return desktop.Result{Stdout: "v u 1\n"}
}
return desktop.Result{}
},
}
}
func TestAppearanceSwitchesGSettingsAndSaysWhatFollowsAndWhatStays(t *testing.T) {
f := &fake{get: map[string]string{"color-scheme": "prefer-dark", "gtk-theme": "Adwaita"}}
a := adwaita{d: f.desk(), home: t.TempDir()}
got, err := a.appearance(context.Background(), desktop.Args{"mode": "light"})
if err != nil {
t.Fatal(err)
}
j := asJSON(got)
if f.get["color-scheme"] != "prefer-light" || !strings.Contains(j, `"switched":"light"`) || !strings.Contains(j, "GTK_THEME=Adwaita:dark") || !strings.Contains(j, `"lasts"`) {
t.Fatalf("%s", j)
}
if strings.Contains(j, "secret") || strings.Contains(j, "NPM_TOKEN") {
t.Fatal("only the theme's words are read back from the user manager")
}
if _, err := a.appearance(context.Background(), desktop.Args{"mode": "blue"}); err == nil {
t.Fatal("mode is dark or light")
}
got, _ = a.appearance(context.Background(), desktop.Args{})
if strings.Contains(asJSON(got), "switched") {
t.Fatal("reading changes nothing")
}
}
func TestACursorThemeMustBeInstalledAndWithoutASessionOnlyGSettingsChanges(t *testing.T) {
dir := t.TempDir()
os.MkdirAll(filepath.Join(dir, "Adwaita", "cursors"), 0o755)
f := &fake{get: map[string]string{}}
a := adwaita{d: f.desk(), home: t.TempDir(), iconDirs: []string{dir}}
if _, err := a.cursor(context.Background(), desktop.Args{"theme": "Bibata"}); err == nil {
t.Fatal("a theme that is not installed")
}
got, err := a.cursor(context.Background(), desktop.Args{"theme": "Adwaita", "size": float64(32)})
if err != nil {
t.Fatal(err)
}
if f.get["cursor-theme"] != "Adwaita" || f.get["cursor-size"] != "32" || !strings.Contains(asJSON(got), "no graphical session") {
t.Fatalf("%v %s", f.get, asJSON(got))
}
}
func asJSON(v any) string {
b, _ := json.Marshal(v)
return string(b)
}
+84
View File
@@ -0,0 +1,84 @@
// adwaita's tools (novox/hq ADR 0208, research 026/05): appearance (dark or light for GTK, Qt and the
// portal at once), cursor, icons and portal-check. The predecessor's appearance script is folded into
// the first, and into the module's session line.
//
// None needs the display except setting the cursor's X resources: GSettings, the portal and the user
// manager are reached on the account's own bus, which exists whenever the operator's user manager
// runs.
package main
import (
"context"
"fmt"
"os"
"path/filepath"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
"adwaita/internal/desktop"
)
func main() {
home := desktop.Home()
a := adwaita{
d: desktop.Machine("i3", "sway"), home: home,
iconDirs: []string{filepath.Join(home, ".local", "share", "icons"), filepath.Join(home, ".icons"), "/usr/local/share/icons", "/usr/share/icons"},
portalDirs: []string{"/usr/share/xdg-desktop-portal/portals"},
uid: os.Getuid(),
}
if err := stdio.Serve("", tools(a)); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func call(run func(ctx context.Context, a desktop.Args) (any, error)) func(map[string]any) (any, error) {
return func(args map[string]any) (any, error) {
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
defer cancel()
return run(ctx, desktop.Args(args))
}
}
func tools(a adwaita) []stdio.Tool {
return []stdio.Tool{
{
Name: "adwaita_appearance",
Description: "Dark or light, as each audience sees it now: GSettings (which the portal serves to " +
"Electron, Firefox and flatpaks), the portal's own answer, the GTK settings files, Qt's palette " +
"and the theme words in the user manager's environment. With mode, switch GSettings for the " +
"running session and answer which audiences follow live and which keep the module's dark " +
"default until it is a setting.",
Input: desktop.Schema(map[string]any{"mode": desktop.Enum("switch to (optional)", "dark", "light")}),
Run: call(a.appearance),
},
{
Name: "adwaita_cursor",
Description: "The cursor theme and size in force (GSettings, the X resources, XCURSOR_* in the user " +
"manager) and the cursor themes installed. With theme and/or size, set them for windows opened " +
"from now on; the module's defaults return at the next login.",
Input: desktop.Schema(map[string]any{
"theme": desktop.Str("an installed cursor theme"),
"size": desktop.Int("pixels, 8 to 256"),
}),
Run: call(a.cursor),
},
{
Name: "adwaita_icons",
Description: "The icon themes installed (name, where, what each inherits, whether it carries cursors) " +
"and the one GTK and Qt are set to use.",
Input: desktop.Schema(map[string]any{}),
Run: call(a.icons),
},
{
Name: "adwaita_portal_check",
Description: "Which xdg-desktop-portal backend answers which interface for this desktop: the backends " +
"installed and what they implement, the portals.conf that decides (and which one won), the " +
"resulting backend per interface, whether the portal and each backend are running on the " +
"account's bus, and the colour scheme the portal answers.",
Input: desktop.Schema(map[string]any{}),
Run: call(a.portalCheck),
},
}
}
+434
View File
@@ -0,0 +1,434 @@
package main
import (
"bufio"
"context"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"adwaita/internal/desktop"
)
type adwaita struct {
d desktop.Desk
home string
iconDirs []string
portalDirs []string
uid int
}
const iface = "org.gnome.desktop.interface"
// ParseINI reads a key file (GTK's settings.ini, qt6ct.conf, a .portal, index.theme): sections of
// key=value, `#` and `;` comments.
func ParseINI(text string) map[string]map[string]string {
out := map[string]map[string]string{}
section := ""
sc := bufio.NewScanner(strings.NewReader(text))
for sc.Scan() {
l := strings.TrimSpace(sc.Text())
if l == "" || strings.HasPrefix(l, "#") || strings.HasPrefix(l, ";") {
continue
}
if strings.HasPrefix(l, "[") && strings.HasSuffix(l, "]") {
section = l[1 : len(l)-1]
continue
}
if k, v, ok := strings.Cut(l, "="); ok {
if out[section] == nil {
out[section] = map[string]string{}
}
out[section][strings.TrimSpace(k)] = strings.TrimSpace(v)
}
}
return out
}
func readINI(path string) map[string]map[string]string {
b, err := os.ReadFile(path)
if err != nil {
return nil
}
return ParseINI(string(b))
}
// gsetting is one GSettings value, unquoted.
func (a adwaita) gsetting(ctx context.Context, schema, key string) (string, error) {
r := a.d.AsUser(ctx, "gsettings", "get", schema, key)
if !r.OK() {
return "", r.Err()
}
return strings.Trim(strings.TrimSpace(r.Stdout), "'"), nil
}
// themeWords are the words of the user manager's environment the theme is about; nothing else of it
// is read back.
var themeWords = []string{"GTK_THEME", "GTK2_RC_FILES", "QT_QPA_PLATFORMTHEME", "QT_STYLE_OVERRIDE", "QT_SELECT",
"XCURSOR_THEME", "XCURSOR_SIZE", "XDG_CURRENT_DESKTOP"}
func (a adwaita) userEnvironment(ctx context.Context) map[string]string {
r := a.d.AsUser(ctx, "systemctl", "--user", "show-environment")
all := desktop.ParseProperties(r.Stdout)
out := map[string]string{}
for _, w := range themeWords {
if v, ok := all[w]; ok {
out[w] = v
}
}
return out
}
var portalScheme = regexp.MustCompile(`^v u (\d)`)
// portalColourScheme asks the portal what it tells applications: 0 no preference, 1 dark, 2 light.
func (a adwaita) portalColourScheme(ctx context.Context) string {
r := a.d.AsUser(ctx, "busctl", "--user", "call", "org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop",
"org.freedesktop.portal.Settings", "ReadOne", "ss", "org.freedesktop.appearance", "color-scheme")
m := portalScheme.FindStringSubmatch(strings.TrimSpace(r.Stdout))
if !r.OK() || m == nil {
return "unanswered"
}
return map[string]string{"0": "no-preference", "1": "dark", "2": "light"}[m[1]]
}
func (a adwaita) appearance(ctx context.Context, args desktop.Args) (any, error) {
mode := args.Opt("mode", "")
if mode != "" && mode != "dark" && mode != "light" {
return nil, fmt.Errorf("mode is dark or light")
}
if mode != "" {
scheme := map[string]string{"dark": "prefer-dark", "light": "prefer-light"}[mode]
if r := a.d.AsUser(ctx, "gsettings", "set", iface, "color-scheme", scheme); !r.OK() {
return nil, r.Err()
}
}
scheme, err := a.gsetting(ctx, iface, "color-scheme")
if err != nil {
return nil, fmt.Errorf("GSettings does not answer on the account's bus: %w", err)
}
gtkTheme, _ := a.gsetting(ctx, iface, "gtk-theme")
gtk3 := readINI(filepath.Join(a.home, ".config", "gtk-3.0", "settings.ini"))["Settings"]
gtk4 := readINI(filepath.Join(a.home, ".config", "gtk-4.0", "settings.ini"))["Settings"]
qt := readINI(filepath.Join(a.home, ".config", "qt6ct", "qt6ct.conf"))["Appearance"]
env := a.userEnvironment(ctx)
answer := map[string]any{
"gsettings": map[string]string{"color-scheme": scheme, "gtk-theme": gtkTheme},
"portal": a.portalColourScheme(ctx),
"gtk3": map[string]string{"theme": gtk3["gtk-theme-name"], "prefer-dark": gtk3["gtk-application-prefer-dark-theme"]},
"gtk4": map[string]string{"theme": gtk4["gtk-theme-name"], "prefer-dark": gtk4["gtk-application-prefer-dark-theme"]},
"qt": map[string]string{"style": qt["style"], "palette": filepath.Base(qt["color_scheme_path"])},
"environment": env,
}
if mode != "" {
var stays []string
if strings.HasSuffix(env["GTK_THEME"], ":dark") && mode == "light" {
stays = append(stays, "GTK 3 programs: GTK_THEME="+env["GTK_THEME"]+" in the environment pins them dark")
}
if mode == "light" {
stays = append(stays, "the GTK settings files and Qt's palette, which the module declares dark")
}
answer["switched"] = mode
answer["follows_live"] = "programs asking the portal: Electron, Chromium, Firefox, libadwaita and flatpaks"
if len(stays) > 0 {
answer["stays"] = stays
}
answer["lasts"] = "until the next login, which sets the module's default (dark) again; a persistent choice waits for settings (hq issue 168)"
}
return answer, nil
}
// Theme is one installed icon or cursor theme.
type Theme struct {
Name string `json:"name"`
Dir string `json:"dir"`
Title string `json:"title,omitempty"`
Inherits []string `json:"inherits,omitempty"`
Cursors bool `json:"cursors"`
Icons bool `json:"icons"`
}
// Themes lists the themes in dirs; a name found earlier hides the same name later, as lookups do.
func Themes(dirs []string) []Theme {
seen := map[string]bool{}
var out []Theme
for _, d := range dirs {
entries, _ := os.ReadDir(d)
for _, e := range entries {
if !e.IsDir() && e.Type()&os.ModeSymlink == 0 || seen[e.Name()] {
continue
}
dir := filepath.Join(d, e.Name())
t := Theme{Name: e.Name(), Dir: dir}
if info, err := os.Stat(filepath.Join(dir, "cursors")); err == nil && info.IsDir() {
t.Cursors = true
}
if ini := readINI(filepath.Join(dir, "index.theme")); ini != nil {
th := ini["Icon Theme"]
t.Title = th["Name"]
if th["Directories"] != "" {
t.Icons = true
}
for _, i := range strings.Split(th["Inherits"], ",") {
if i = strings.TrimSpace(i); i != "" {
t.Inherits = append(t.Inherits, i)
}
}
}
if !t.Cursors && !t.Icons && t.Title == "" {
continue
}
seen[e.Name()] = true
out = append(out, t)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
var cursorName = regexp.MustCompile(`^[A-Za-z0-9._ -]{1,64}$`)
func (a adwaita) cursor(ctx context.Context, args desktop.Args) (any, error) {
theme := args.Opt("theme", "")
size, err := args.Whole("size", 0, 8, 256)
if err != nil {
return nil, err
}
var cursors []string
installed := map[string]bool{}
for _, t := range Themes(a.iconDirs) {
if t.Cursors {
cursors = append(cursors, t.Name)
installed[t.Name] = true
}
}
changed := theme != "" || size != 0
if theme != "" && (!cursorName.MatchString(theme) || !installed[theme]) {
return nil, fmt.Errorf("%q is not an installed cursor theme; installed: %s", theme, strings.Join(cursors, ", "))
}
var resources []string
if theme != "" {
if r := a.d.AsUser(ctx, "gsettings", "set", iface, "cursor-theme", theme); !r.OK() {
return nil, r.Err()
}
resources = append(resources, "Xcursor.theme: "+theme)
}
if size != 0 {
if r := a.d.AsUser(ctx, "gsettings", "set", iface, "cursor-size", strconv.Itoa(size)); !r.OK() {
return nil, r.Err()
}
resources = append(resources, "Xcursor.size: "+strconv.Itoa(size))
}
answer := map[string]any{"installed": cursors}
gTheme, _ := a.gsetting(ctx, iface, "cursor-theme")
gSize, _ := a.gsetting(ctx, iface, "cursor-size")
answer["gsettings"] = map[string]string{"cursor-theme": gTheme, "cursor-size": gSize}
env := a.userEnvironment(ctx)
answer["environment"] = map[string]string{"XCURSOR_THEME": env["XCURSOR_THEME"], "XCURSOR_SIZE": env["XCURSOR_SIZE"]}
if s, err := a.d.Find(); err == nil {
senv := s.Env(a.d.Base)
if len(resources) > 0 {
if r := a.d.Run(ctx, senv, []byte(strings.Join(resources, "\n")+"\n"), "xrdb", "-nocpp", "-merge", "-"); !r.OK() {
return nil, r.Err()
}
}
q := a.d.Run(ctx, senv, nil, "xrdb", "-query")
x := map[string]string{}
for _, l := range strings.Split(q.Stdout, "\n") {
if k, v, ok := strings.Cut(l, ":"); ok && strings.HasPrefix(k, "Xcursor.") {
x[k] = strings.TrimSpace(v)
}
}
answer["x_resources"] = x
} else {
answer["x_resources"] = nil
if changed {
answer["note"] = "no graphical session: GSettings changed, the X resources not"
}
}
if changed {
answer["lasts"] = "for windows opened from now on, until the next login"
}
return answer, nil
}
func (a adwaita) icons(ctx context.Context, args desktop.Args) (any, error) {
var themes []Theme
for _, t := range Themes(a.iconDirs) {
if t.Icons {
themes = append(themes, t)
}
}
gtk3 := readINI(filepath.Join(a.home, ".config", "gtk-3.0", "settings.ini"))["Settings"]
qt := readINI(filepath.Join(a.home, ".config", "qt6ct", "qt6ct.conf"))["Appearance"]
g, _ := a.gsetting(ctx, iface, "icon-theme")
return map[string]any{
"installed": themes,
"in_use": map[string]string{"gsettings": g, "gtk": gtk3["gtk-icon-theme-name"], "qt": qt["icon_theme"]},
}, nil
}
// Backend is one installed portal backend.
type Backend struct {
Name string `json:"name"`
DBusName string `json:"dbus_name"`
Interfaces []string `json:"interfaces"`
UseIn []string `json:"use_in,omitempty"`
Running bool `json:"running"`
}
func splitList(v string) []string {
var out []string
for _, x := range strings.Split(v, ";") {
if x = strings.TrimSpace(x); x != "" {
out = append(out, x)
}
}
return out
}
// Backends reads the installed `.portal` files.
func Backends(dirs []string) []Backend {
var out []Backend
for _, d := range dirs {
files, _ := filepath.Glob(filepath.Join(d, "*.portal"))
sort.Strings(files)
for _, f := range files {
p := readINI(f)["portal"]
out = append(out, Backend{Name: strings.TrimSuffix(filepath.Base(f), ".portal"), DBusName: p["DBusName"],
Interfaces: splitList(p["Interfaces"]), UseIn: splitList(p["UseIn"])})
}
}
return out
}
// PortalConfigs are the files xdg-desktop-portal looks for, in its order (portals.conf(5)): for each
// directory, `<desktop>-portals.conf` for each of the desktops named, then `portals.conf`. The first
// that exists decides everything.
func PortalConfigs(home string, desktops []string) []string {
dirs := []string{
filepath.Join(home, ".config", "xdg-desktop-portal"), "/etc/xdg/xdg-desktop-portal", "/etc/xdg-desktop-portal",
filepath.Join(home, ".local", "share", "xdg-desktop-portal"), "/usr/local/share/xdg-desktop-portal", "/usr/share/xdg-desktop-portal",
}
var out []string
for _, d := range dirs {
for _, desk := range desktops {
out = append(out, filepath.Join(d, strings.ToLower(desk)+"-portals.conf"))
}
out = append(out, filepath.Join(d, "portals.conf"))
}
return out
}
// Resolve says which backend answers each interface the backends implement, given the deciding
// file's [preferred] section: an interface's own key first, else `default`; each a list of backend
// names, the first one that implements the interface wins; `none` answers nothing, `*` any.
// With no file, a backend whose UseIn names the desktop answers.
func Resolve(backends []Backend, preferred map[string]string, desktops []string) map[string]string {
out := map[string]string{}
implements := func(b Backend, i string) bool {
for _, x := range b.Interfaces {
if x == i {
return true
}
}
return false
}
var all []string
seen := map[string]bool{}
for _, b := range backends {
for _, i := range b.Interfaces {
if !seen[i] {
seen[i] = true
all = append(all, i)
}
}
}
sort.Strings(all)
for _, i := range all {
var want []string
if preferred != nil {
if v, ok := preferred[i]; ok {
want = splitList(v)
} else {
want = splitList(preferred["default"])
}
} else {
for _, b := range backends {
for _, u := range b.UseIn {
for _, d := range desktops {
if strings.EqualFold(u, d) {
want = append(want, b.Name)
}
}
}
}
}
out[i] = "(none)"
pick:
for _, w := range want {
if w == "none" {
break
}
for _, b := range backends {
if (w == "*" || w == b.Name) && implements(b, i) {
out[i] = b.Name
break pick
}
}
}
}
return out
}
func (a adwaita) portalCheck(ctx context.Context, args desktop.Args) (any, error) {
env := a.userEnvironment(ctx)
desktops := splitColon(env["XDG_CURRENT_DESKTOP"])
backends := Backends(a.portalDirs)
names := map[string]bool{}
if r := a.d.AsUser(ctx, "busctl", "--user", "list", "--no-legend", "--no-pager"); r.OK() {
for _, l := range strings.Split(r.Stdout, "\n") {
if f := strings.Fields(l); len(f) > 1 && f[1] != "-" {
names[f[0]] = true
}
}
}
for i := range backends {
backends[i].Running = names[backends[i].DBusName]
}
var decided string
var preferred map[string]string
looked := PortalConfigs(a.home, desktops)
for _, f := range looked {
if ini := readINI(f); ini != nil {
decided, preferred = f, ini["preferred"]
if preferred == nil {
preferred = map[string]string{}
}
break
}
}
return map[string]any{
"desktop": env["XDG_CURRENT_DESKTOP"],
"portal": map[string]bool{"running": names["org.freedesktop.portal.Desktop"]},
"backends": backends,
"decided_by": decided,
"preferred": preferred,
"answers": Resolve(backends, preferred, desktops),
"colour_scheme": a.portalColourScheme(ctx),
}, nil
}
func splitColon(v string) []string {
var out []string
for _, x := range strings.Split(v, ":") {
if x = strings.TrimSpace(x); x != "" {
out = append(out, x)
}
}
return out
}
@@ -0,0 +1,5 @@
# Written by the mesh (module adwaita, novox/hq ADR 0208): the default cursor theme, for programs that
# read neither XCURSOR_THEME nor the X resources.
[Icon Theme]
Name=Default
Inherits=Adwaita
+9
View File
@@ -0,0 +1,9 @@
# Written by the mesh (module adwaita, novox/hq ADR 0208), for GTK 3 and GTK 4 alike. Replaced at
# every push; adwaita_appearance switches dark and light for the running session.
[Settings]
gtk-theme-name=Adwaita
gtk-icon-theme-name=Adwaita
gtk-cursor-theme-name=Adwaita
gtk-cursor-theme-size=24
gtk-font-name=Inter 11
gtk-application-prefer-dark-theme=1
+11
View File
@@ -0,0 +1,11 @@
# Written by the mesh (module adwaita, novox/hq ADR 0208). Replaced at every push.
#
# Which portal backend answers each interface. i3 is not a desktop xdg-desktop-portal knows, so with
# no preference it uses whichever backend happens to be installed: fine while gtk is the only one,
# wrong the day another arrives as somebody else's dependency. Named instead. gtk also serves
# org.freedesktop.appearance (dark or light) from GSettings, which the session's start sets.
[preferred]
default=gtk
# Secrets for sandboxed programs come from the keyring's backend. Without this line no backend answers
# the interface: gtk does not implement it, and gnome-keyring's names only GNOME as its desktop.
org.freedesktop.impl.portal.Secret=gnome-keyring
+29
View File
@@ -0,0 +1,29 @@
[Appearance]
color_scheme_path=/usr/share/qt6ct/colors/darker.conf
custom_palette=true
icon_theme=Adwaita
standard_dialogs=default
style=Fusion
[Fonts]
fixed="JetBrainsMono Nerd Font,11,-1,5,50,0,0,0,0,0"
general="Inter,11,-1,5,50,0,0,0,0,0"
[Interface]
activate_item_on_single_click=1
buttonbox_layout=0
cursor_flash_time=1000
dialog_buttons_have_icons=1
double_click_interval=400
gui_effects=@Invalid()
keyboard_scheme=2
menus_have_icons=true
show_shortcuts_in_context_menus=true
stylesheets=@Invalid()
toolbutton_style=4
underline_shortcut=1
wheel_scroll_lines=3
[Troubleshooting]
force_raster_widgets=1
ignored_applications=@Invalid()
+5
View File
@@ -0,0 +1,5 @@
module adwaita
go 1.22
require git.novox.be/novox/mesh-sdk/go v0.1.7
+2
View File
@@ -0,0 +1,2 @@
git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w=
git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
+160
View File
@@ -0,0 +1,160 @@
package desktop
import (
"fmt"
"math"
"os"
"path/filepath"
"strings"
)
// Args reads a tool's arguments as JSON decoded them: strings, float64 numbers, booleans.
type Args map[string]any
// Text is a required string, trimmed.
func (a Args) Text(name string) (string, error) {
v, ok := a[name].(string)
if !ok || strings.TrimSpace(v) == "" {
return "", fmt.Errorf("%s is required, as text", name)
}
return strings.TrimSpace(v), nil
}
// Opt is an optional string, trimmed, or def.
func (a Args) Opt(name, def string) string {
if v, ok := a[name].(string); ok && strings.TrimSpace(v) != "" {
return strings.TrimSpace(v)
}
return def
}
// Has is whether the caller gave the argument at all.
func (a Args) Has(name string) bool {
v, ok := a[name]
return ok && v != nil
}
// Bool is an optional boolean: its value, and whether it was given.
func (a Args) Bool(name string) (bool, bool, error) {
v, ok := a[name]
if !ok || v == nil {
return false, false, nil
}
b, isBool := v.(bool)
if !isBool {
return false, false, fmt.Errorf("%s is true or false", name)
}
return b, true, nil
}
// Number is an optional number: its value, and whether it was given.
func (a Args) Number(name string) (float64, bool, error) {
v, ok := a[name]
if !ok || v == nil {
return 0, false, nil
}
f, isNum := v.(float64)
if !isNum || math.IsNaN(f) || math.IsInf(f, 0) {
return 0, false, fmt.Errorf("%s is a number", name)
}
return f, true, nil
}
// Whole is an optional whole number within [lo, hi], or def.
func (a Args) Whole(name string, def, lo, hi int) (int, error) {
f, given, err := a.Number(name)
if err != nil {
return 0, err
}
if !given {
return def, nil
}
if f != math.Trunc(f) || f < float64(lo) || f > float64(hi) {
return 0, fmt.Errorf("%s is a whole number from %d to %d", name, lo, hi)
}
return int(f), nil
}
// OneOf is an optional string that must be one of choices, or def.
func (a Args) OneOf(name, def string, choices ...string) (string, error) {
v := a.Opt(name, def)
for _, c := range choices {
if v == c {
return v, nil
}
}
return "", fmt.Errorf("%s is one of %s", name, strings.Join(choices, ", "))
}
// Strings is an optional list of strings.
func (a Args) Strings(name string) ([]string, error) {
v, ok := a[name]
if !ok || v == nil {
return nil, nil
}
list, isList := v.([]any)
if !isList {
return nil, fmt.Errorf("%s is a list of text", name)
}
out := make([]string, 0, len(list))
for _, x := range list {
s, isText := x.(string)
if !isText {
return nil, fmt.Errorf("%s is a list of text", name)
}
out = append(out, s)
}
return out, nil
}
// Home is the operator account's home: the runtime's word for it, else this process's.
func Home() string {
if h := os.Getenv("MESH_OPERATOR_HOME"); h != "" {
return h
}
if h, err := os.UserHomeDir(); err == nil {
return h
}
return "/"
}
// InHome resolves a path the caller gave: `~/x` and a relative path are under the home. A path
// that leaves the home through `..` is refused, so a tool that writes never writes outside it.
func InHome(path string) (string, error) {
home := Home()
switch {
case path == "~":
path = home
case strings.HasPrefix(path, "~/"):
path = filepath.Join(home, path[2:])
case !filepath.IsAbs(path):
path = filepath.Join(home, path)
}
path = filepath.Clean(path)
if path != home && !strings.HasPrefix(path, home+string(filepath.Separator)) {
return "", fmt.Errorf("%s is outside the account's home", path)
}
return path, nil
}
// Schema builds a tool's input schema from property descriptions; required names those that must
// be given. A property is a string unless its description object says otherwise.
func Schema(props map[string]any, required ...string) map[string]any {
s := map[string]any{"type": "object", "properties": props}
if len(required) > 0 {
s["required"] = required
}
return s
}
// Str, Num, Flag, List and Enum describe one property.
func Str(desc string) map[string]any { return map[string]any{"type": "string", "description": desc} }
func Num(desc string) map[string]any { return map[string]any{"type": "number", "description": desc} }
func Int(desc string) map[string]any { return map[string]any{"type": "integer", "description": desc} }
func Flag(desc string) map[string]any { return map[string]any{"type": "boolean", "description": desc} }
func List(desc string) map[string]any {
return map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": desc}
}
func Enum(desc string, values ...string) map[string]any {
return map[string]any{"type": "string", "enum": values, "description": desc}
}
@@ -0,0 +1,42 @@
package desktop
import (
"bytes"
"os"
"path/filepath"
"testing"
)
// The desktop modules that carry this package. Each builds alone, so each has its own copy; this
// test, itself one of the copied files, holds them to one text wherever the siblings are present.
var carriers = []string{"xorg", "lemurs", "i3", "xterm", "adwaita"}
func TestEveryDesktopModuleCarriesTheSameCopy(t *testing.T) {
mine, err := filepath.Glob("*.go")
if err != nil || len(mine) == 0 {
t.Fatal("no files of this package found", err)
}
compared := 0
for _, module := range carriers {
dir := filepath.Join("..", "..", "..", module, "internal", "desktop")
if _, err := os.Stat(dir); err != nil {
continue
}
theirs, _ := filepath.Glob(filepath.Join(dir, "*.go"))
if len(theirs) != len(mine) {
t.Errorf("%s carries %d files of this package, this copy %d", module, len(theirs), len(mine))
continue
}
for _, f := range mine {
a, _ := os.ReadFile(f)
b, err := os.ReadFile(filepath.Join(dir, f))
if err != nil || !bytes.Equal(a, b) {
t.Errorf("%s's copy of %s differs from this one: change every copy together", module, f)
}
}
compared++
}
if compared == 0 {
t.Log("no sibling copies beside this module")
}
}
+232
View File
@@ -0,0 +1,232 @@
package desktop
import (
"bytes"
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"os"
"os/exec"
"strings"
"syscall"
"time"
)
// Bounds on a command a tool runs: well below the runtime's 30 s call limit, and an answer that
// fits in a tool's reply.
const (
DefaultTimeout = 10 * time.Second
MostOutput = 256 << 10
)
// Result is what one command did.
type Result struct {
Command []string `json:"command"`
Code int `json:"exit_code"`
Stdout string `json:"stdout,omitempty"`
Stderr string `json:"stderr,omitempty"`
Truncated bool `json:"truncated,omitempty"`
TimedOut bool `json:"timed_out,omitempty"`
}
// OK is whether the command ran and exited 0.
func (r Result) OK() bool { return r.Code == 0 && !r.TimedOut }
// Err is the command's failure as an error naming it and what it said, or nil.
func (r Result) Err() error {
if r.OK() {
return nil
}
said := strings.TrimSpace(r.Stderr)
if said == "" {
said = strings.TrimSpace(r.Stdout)
}
if r.TimedOut {
return fmt.Errorf("%s did not finish in time", strings.Join(r.Command, " "))
}
return fmt.Errorf("%s exited %d: %s", strings.Join(r.Command, " "), r.Code, said)
}
// Runner runs a command with an environment and answers what it did. Tools take one, so their
// tests replace the machine with a table of answers.
type Runner func(ctx context.Context, env []string, stdin []byte, name string, args ...string) Result
// Exec is the machine's Runner: the command in its own process group, ended with everything it
// started at the deadline, each stream cut at MostOutput.
func Exec(ctx context.Context, env []string, stdin []byte, name string, args ...string) Result {
if _, ok := ctx.Deadline(); !ok {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, DefaultTimeout)
defer cancel()
}
res := Result{Command: append([]string{name}, args...)}
cmd := exec.Command(name, args...)
cmd.Env = env
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
if stdin != nil {
cmd.Stdin = bytes.NewReader(stdin)
}
out, errb := &capped{}, &capped{}
cmd.Stdout, cmd.Stderr = out, errb
if err := cmd.Start(); err != nil {
res.Code = 127
res.Stderr = err.Error()
return res
}
done := make(chan error, 1)
go func() { done <- cmd.Wait() }()
var err error
select {
case err = <-done:
case <-ctx.Done():
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
err = <-done
res.TimedOut = true
}
res.Stdout, res.Stderr = out.String(), errb.String()
res.Truncated = out.cut || errb.cut
var exit *exec.ExitError
switch {
case err == nil:
case errors.As(err, &exit):
res.Code = exit.ExitCode()
if res.Code < 0 {
res.Code = 128
}
default:
res.Code = 1
if res.Stderr == "" {
res.Stderr = err.Error()
}
}
return res
}
// capped keeps the first MostOutput bytes written to it. Its buffer is a field, not embedded: an
// embedded bytes.Buffer brings ReadFrom along, and io.Copy would use it and never call Write.
type capped struct {
buf bytes.Buffer
cut bool
}
func (c *capped) Write(p []byte) (int, error) {
if room := MostOutput - c.buf.Len(); room < len(p) {
if room > 0 {
c.buf.Write(p[:room])
}
c.cut = true
return len(p), nil
}
return c.buf.Write(p)
}
func (c *capped) String() string { return c.buf.String() }
// Desk is what a desktop tool needs: how to find the session, and how to run a command.
type Desk struct {
Find func() (*Session, error)
Run Runner
// Base is the environment a command starts from, before the session's words.
Base []string
}
// Machine is the real Desk, preferring the named processes as the session's.
func Machine(prefer ...string) Desk {
return Desk{
Find: func() (*Session, error) { return Find(prefer...) },
Run: Exec,
Base: os.Environ(),
}
}
// InSession runs a command in the operator's session, or answers NoSession.
func (d Desk) InSession(ctx context.Context, name string, args ...string) (Result, *Session, error) {
s, err := d.Find()
if err != nil {
return Result{}, nil, err
}
return d.Run(ctx, s.Env(d.Base), nil, name, args...), s, nil
}
// InSessionWith is InSession with standard input.
func (d Desk) InSessionWith(ctx context.Context, stdin []byte, name string, args ...string) (Result, *Session, error) {
s, err := d.Find()
if err != nil {
return Result{}, nil, err
}
return d.Run(ctx, s.Env(d.Base), stdin, name, args...), s, nil
}
// Plain runs a command with the base environment: for what needs no session.
func (d Desk) Plain(ctx context.Context, name string, args ...string) Result {
return d.Run(ctx, d.Base, nil, name, args...)
}
// AsUser runs a command with the account's own runtime directory and bus, and no display.
func (d Desk) AsUser(ctx context.Context, name string, args ...string) Result {
return d.Run(ctx, UserEnv(d.Base, os.Getuid()), nil, name, args...)
}
// Launched is how a program was started in the session.
type Launched struct {
Unit string `json:"unit,omitempty"`
PID int `json:"pid,omitempty"`
How string `json:"how"`
}
// Launch starts a program in the operator's session that outlives the call and the runtime.
//
// **Not as a child of this process.** The runtime is a system service; everything it starts is in
// its control group, and the service manager ends that group whenever the runtime restarts — which
// is every push that changes it. So the program is handed to the account's own service manager as a
// transient unit (`systemd-run --user`), with the session's words set on it, and lives as long as the
// operator's user manager does. Without a user manager it is started detached as a last resort, and
// the answer says it will end with the runtime.
func (d Desk) Launch(ctx context.Context, s *Session, name string, argv ...string) (Launched, error) {
if len(argv) == 0 {
return Launched{}, errors.New("nothing to launch")
}
env := s.Env(d.Base)
unit := "mesh-" + name + "-" + token()
args := []string{"--user", "--collect", "--quiet", "--unit=" + unit}
for _, w := range []string{"DISPLAY", "WAYLAND_DISPLAY", "XAUTHORITY", "XDG_SESSION_TYPE", "XDG_CURRENT_DESKTOP", "XDG_SESSION_DESKTOP", "I3SOCK", "SWAYSOCK"} {
if v := lookup(env, w); v != "" {
args = append(args, "--setenv="+w+"="+v)
}
}
args = append(args, "--")
args = append(args, argv...)
res := d.Run(ctx, env, nil, "systemd-run", args...)
if res.OK() {
return Launched{Unit: unit, How: "a transient unit of the account's service manager; ends when it exits or when the operator logs out"}, nil
}
if s.Bus != "" {
return Launched{}, res.Err()
}
cmd := exec.Command(argv[0], argv[1:]...)
cmd.Env = env
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
if err := cmd.Start(); err != nil {
return Launched{}, err
}
pid := cmd.Process.Pid
go func() { _ = cmd.Wait() }()
return Launched{PID: pid, How: "detached from the runtime with no user manager to hand it to; it ends when the runtime restarts"}, nil
}
func lookup(env []string, name string) string {
for i := len(env) - 1; i >= 0; i-- {
if k, v, ok := strings.Cut(env[i], "="); ok && k == name {
return v
}
}
return ""
}
func token() string {
b := make([]byte, 4)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
+445
View File
@@ -0,0 +1,445 @@
// Package desktop is how a desktop module's tools act in the operator's graphical session
// (novox/hq ADR 0208, research 026/05).
//
// **One question, answered once for every desktop tool.** A tool runs inside the node's runtime: a
// process of node-tools.service, started by the system's service manager as the operator account,
// with no session around it — no DISPLAY, no XAUTHORITY, no session bus. The session it must act in
// was started elsewhere, by the login manager, and the only place its values are written down is
// the environment of the processes it started. So this package finds the session the way a person
// would: it looks at the operator account's own processes, takes the one that is plainly the
// session's (the window manager, or the oldest process carrying a display), confirms with logind
// that its session is a live local one, and checks that the display's socket is really there.
//
// **Only the session's own words are read.** A session's processes also carry whatever its start
// script exported — on the workstations that was a file of secrets — so the environment is filtered
// to a fixed list of names while it is read, and nothing else ever leaves /proc.
//
// The D-Bus address handed on is the user manager's socket, `unix:path=$XDG_RUNTIME_DIR/bus`,
// whenever it exists, because that is where the portal, the notifier and every user service
// listen. A session started on a private bus (a stale session, measured on one workstation) is
// reported as `session_bus` beside it, so the difference is visible rather than guessed at.
//
// The same copy of this package is vendored into every desktop module (xorg, lemurs, i3, xterm,
// adwaita); the catalogue builds each module alone, so it cannot be imported across them. Change
// every copy together — the modules' tests compare them.
package desktop
import (
"bufio"
"bytes"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"os/user"
"path/filepath"
"sort"
"strconv"
"strings"
"syscall"
"time"
)
// SessionWords are the only environment words read from a session's process: the ones that say
// where the session is. Everything else in that environment is the operator's, and is never read.
var SessionWords = []string{
"DISPLAY", "WAYLAND_DISPLAY", "XAUTHORITY",
"XDG_SESSION_ID", "XDG_SESSION_TYPE", "XDG_SESSION_DESKTOP", "XDG_CURRENT_DESKTOP",
"XDG_RUNTIME_DIR", "DBUS_SESSION_BUS_ADDRESS", "XDG_SEAT", "XDG_VTNR",
"I3SOCK", "SWAYSOCK",
}
// Session is the operator's running graphical session, as a tool needs it.
type Session struct {
UID int `json:"uid"`
ID string `json:"session_id,omitempty"`
Type string `json:"type"`
Display string `json:"display,omitempty"`
WaylandDisplay string `json:"wayland_display,omitempty"`
XAuthority string `json:"xauthority,omitempty"`
RuntimeDir string `json:"runtime_dir"`
Bus string `json:"bus,omitempty"`
SessionBus string `json:"session_bus,omitempty"`
Desktop string `json:"desktop,omitempty"`
// FoundIn is the process whose environment named the session.
FoundIn Process `json:"found_in"`
// Active is logind's word on the session, when logind answered.
Active *bool `json:"active,omitempty"`
words map[string]string
}
// Process is one process the search looked at.
type Process struct {
PID int `json:"pid"`
Command string `json:"command"`
start uint64
}
// NoSession is the answer when there is no graphical session to act in. Its text is JSON, so a tool
// that returns it as its error still answers structured data.
type NoSession struct {
Reason string `json:"reason"`
Looked []string `json:"looked"`
}
func (e *NoSession) Error() string {
b, _ := json.Marshal(map[string]any{"error": "no-graphical-session", "reason": e.Reason, "looked": e.Looked})
return string(b)
}
// IsNoSession is whether err says there is no session.
func IsNoSession(err error) bool {
var n *NoSession
return errors.As(err, &n)
}
// Finder holds where the search looks, so a test can point it at a tree of its own.
type Finder struct {
Proc string // the process table: /proc
X11Sockets string // where X servers listen: /tmp/.X11-unix
RuntimeBase string // the parent of every XDG_RUNTIME_DIR: /run/user
UID int // whose session
// Prefer names the processes that are the session's own, best first: the session's holder.
Prefer []string
// Logind answers `loginctl show-session` for one id; nil skips the check.
Logind func(id string) (map[string]string, error)
}
// DefaultFinder is the machine's: the account this process runs as, or — when it runs as root — the
// operator account the runtime names (MESH_OPERATOR_ACCOUNT).
func DefaultFinder(prefer ...string) Finder {
uid := os.Getuid()
if uid == 0 {
if name := os.Getenv("MESH_OPERATOR_ACCOUNT"); name != "" {
if u, err := user.Lookup(name); err == nil {
if n, err := strconv.Atoi(u.Uid); err == nil {
uid = n
}
}
}
}
return Finder{
Proc: "/proc", X11Sockets: "/tmp/.X11-unix", RuntimeBase: "/run/user",
UID: uid, Prefer: prefer, Logind: loginctl,
}
}
// Find is the operator's session on this machine, preferring a process named in prefer.
func Find(prefer ...string) (*Session, error) {
return DefaultFinder(prefer...).Find()
}
type candidate struct {
proc Process
words map[string]string
rank int
logind map[string]string
}
// Find looks for the session.
func (f Finder) Find() (*Session, error) {
entries, err := os.ReadDir(f.Proc)
if err != nil {
return nil, &NoSession{Reason: "the process table cannot be read: " + err.Error(), Looked: []string{f.Proc}}
}
looked := []string{fmt.Sprintf("the processes of uid %d in %s", f.UID, f.Proc)}
var found []candidate
stale := 0
for _, e := range entries {
pid, err := strconv.Atoi(e.Name())
if err != nil {
continue
}
dir := filepath.Join(f.Proc, e.Name())
info, err := os.Stat(dir)
if err != nil {
continue
}
if st, ok := info.Sys().(*syscall.Stat_t); !ok || int(st.Uid) != f.UID {
continue
}
words := readWords(filepath.Join(dir, "environ"))
if words["DISPLAY"] == "" && words["WAYLAND_DISPLAY"] == "" {
continue
}
if !f.reachable(words) {
stale++
continue
}
found = append(found, candidate{proc: Process{PID: pid, Command: comm(dir), start: startTime(dir)}, words: words})
}
if len(found) == 0 {
reason := fmt.Sprintf("no process of uid %d carries a display", f.UID)
if stale > 0 {
reason = fmt.Sprintf("%d process(es) of uid %d name a display whose socket is gone: the session they belonged to has ended", stale, f.UID)
}
return nil, &NoSession{Reason: reason, Looked: append(looked, f.X11Sockets, f.RuntimeBase)}
}
// logind's word on each session the candidates name, asked once per session.
asked := map[string]map[string]string{}
for i := range found {
id := found[i].words["XDG_SESSION_ID"]
if f.Logind == nil || id == "" {
found[i].rank = 1
continue
}
props, done := asked[id]
if !done {
props, _ = f.Logind(id)
asked[id] = props
}
found[i].logind = props
switch {
case props == nil:
found[i].rank = 1
case props["Remote"] == "yes":
found[i].rank = 3
case props["Active"] == "yes" && props["State"] != "closing":
found[i].rank = 0
case props["State"] == "closing":
found[i].rank = 3
default:
found[i].rank = 2
}
}
if f.Logind != nil {
looked = append(looked, "logind's sessions")
}
preferred := func(c candidate) int {
for i, p := range f.Prefer {
if c.proc.Command == p {
return i
}
}
return len(f.Prefer)
}
sort.SliceStable(found, func(i, j int) bool {
a, b := found[i], found[j]
if a.rank != b.rank {
return a.rank < b.rank
}
if pa, pb := preferred(a), preferred(b); pa != pb {
return pa < pb
}
if a.proc.start != b.proc.start {
return a.proc.start < b.proc.start
}
return a.proc.PID < b.proc.PID
})
best := found[0]
if best.rank == 3 {
return nil, &NoSession{Reason: "the only sessions found are remote or closing", Looked: looked}
}
return f.session(best), nil
}
func (f Finder) session(c candidate) *Session {
w := c.words
s := &Session{
UID: f.UID, ID: w["XDG_SESSION_ID"], Display: w["DISPLAY"], WaylandDisplay: w["WAYLAND_DISPLAY"],
XAuthority: w["XAUTHORITY"], RuntimeDir: w["XDG_RUNTIME_DIR"], FoundIn: c.proc, words: w,
}
s.Desktop = w["XDG_CURRENT_DESKTOP"]
if s.Desktop == "" {
s.Desktop = w["XDG_SESSION_DESKTOP"]
}
switch {
case w["XDG_SESSION_TYPE"] != "":
s.Type = w["XDG_SESSION_TYPE"]
case s.WaylandDisplay != "":
s.Type = "wayland"
default:
s.Type = "x11"
}
if s.RuntimeDir == "" {
s.RuntimeDir = filepath.Join(f.RuntimeBase, strconv.Itoa(f.UID))
}
if isSocket(filepath.Join(s.RuntimeDir, "bus")) {
s.Bus = "unix:path=" + filepath.Join(s.RuntimeDir, "bus")
}
if own := w["DBUS_SESSION_BUS_ADDRESS"]; own != "" && own != s.Bus {
s.SessionBus = own
}
if c.logind != nil {
active := c.logind["Active"] == "yes"
s.Active = &active
}
return s
}
// reachable is whether the display a process names is still served: the X server's socket, or the
// Wayland compositor's. A process outliving its session still carries the session's words.
func (f Finder) reachable(w map[string]string) bool {
if d := w["WAYLAND_DISPLAY"]; d != "" {
path := d
if !filepath.IsAbs(d) {
dir := w["XDG_RUNTIME_DIR"]
if dir == "" {
dir = filepath.Join(f.RuntimeBase, strconv.Itoa(f.UID))
}
path = filepath.Join(dir, d)
}
if isSocket(path) {
return true
}
}
n, ok := DisplayNumber(w["DISPLAY"])
return ok && isSocket(filepath.Join(f.X11Sockets, "X"+strconv.Itoa(n)))
}
// DisplayNumber is the server number of a local X display (":1", ":1.0", "unix:1"); a display on
// another host — an ssh session's forwarded one — is not the local session and answers false.
func DisplayNumber(display string) (int, bool) {
host, rest, ok := strings.Cut(display, ":")
if !ok || (host != "" && host != "unix") {
return 0, false
}
num, _, _ := strings.Cut(rest, ".")
n, err := strconv.Atoi(num)
if err != nil || n < 0 {
return 0, false
}
return n, true
}
// Word is one of the session's words as its process had it ("" when it had none).
func (s *Session) Word(name string) string { return s.words[name] }
// Env is base with the session's words in place of whatever base said for them.
func (s *Session) Env(base []string) []string {
drop := map[string]bool{}
for _, w := range SessionWords {
drop[w] = true
}
out := make([]string, 0, len(base)+8)
for _, kv := range base {
k, _, _ := strings.Cut(kv, "=")
if !drop[k] {
out = append(out, kv)
}
}
bus := s.Bus
if bus == "" {
bus = s.SessionBus
}
for _, kv := range [][2]string{
{"DISPLAY", s.Display}, {"WAYLAND_DISPLAY", s.WaylandDisplay}, {"XAUTHORITY", s.XAuthority},
{"XDG_RUNTIME_DIR", s.RuntimeDir}, {"DBUS_SESSION_BUS_ADDRESS", bus},
{"XDG_SESSION_TYPE", s.Type}, {"XDG_SESSION_ID", s.ID},
{"XDG_CURRENT_DESKTOP", s.words["XDG_CURRENT_DESKTOP"]},
{"XDG_SESSION_DESKTOP", s.words["XDG_SESSION_DESKTOP"]},
{"I3SOCK", s.words["I3SOCK"]}, {"SWAYSOCK", s.words["SWAYSOCK"]},
} {
if kv[1] != "" {
out = append(out, kv[0]+"="+kv[1])
}
}
return out
}
// UserEnv is base with the account's own runtime directory and bus, for a tool that talks to the
// user manager or the session bus and needs no display — it works with no session at all.
func UserEnv(base []string, uid int) []string {
dir := filepath.Join("/run/user", strconv.Itoa(uid))
out := make([]string, 0, len(base)+2)
for _, kv := range base {
k, _, _ := strings.Cut(kv, "=")
if k != "XDG_RUNTIME_DIR" && k != "DBUS_SESSION_BUS_ADDRESS" {
out = append(out, kv)
}
}
return append(out, "XDG_RUNTIME_DIR="+dir, "DBUS_SESSION_BUS_ADDRESS=unix:path="+filepath.Join(dir, "bus"))
}
// readWords reads a process's environment and keeps only SessionWords.
func readWords(path string) map[string]string {
raw, err := os.ReadFile(path)
if err != nil {
return nil
}
keep := map[string]bool{}
for _, w := range SessionWords {
keep[w] = true
}
out := map[string]string{}
for _, kv := range bytes.Split(raw, []byte{0}) {
k, v, ok := bytes.Cut(kv, []byte{'='})
if ok && keep[string(k)] {
out[string(k)] = string(v)
}
}
return out
}
func comm(dir string) string {
b, err := os.ReadFile(filepath.Join(dir, "comm"))
if err != nil {
return ""
}
return strings.TrimSpace(string(b))
}
// startTime is field 22 of /proc/<pid>/stat: when the process started, in clock ticks since boot.
// Read after the command's closing parenthesis, because the command may hold spaces.
func startTime(dir string) uint64 {
b, err := os.ReadFile(filepath.Join(dir, "stat"))
if err != nil {
return ^uint64(0)
}
i := bytes.LastIndexByte(b, ')')
if i < 0 {
return ^uint64(0)
}
fields := strings.Fields(string(b[i+1:]))
// fields[0] is the state, field 3 of the line; start time is field 22.
if len(fields) < 20 {
return ^uint64(0)
}
n, err := strconv.ParseUint(fields[19], 10, 64)
if err != nil {
return ^uint64(0)
}
return n
}
func isSocket(path string) bool {
info, err := os.Stat(path)
return err == nil && info.Mode()&os.ModeSocket != 0
}
// loginctl asks logind about one session, by its property lines.
func loginctl(id string) (map[string]string, error) {
cmd := exec.Command("loginctl", "show-session", id, "-p", "Active", "-p", "State", "-p", "Remote", "-p", "Type", "-p", "Class")
var out bytes.Buffer
cmd.Stdout = &out
done := make(chan error, 1)
if err := cmd.Start(); err != nil {
return nil, err
}
go func() { done <- cmd.Wait() }()
select {
case err := <-done:
if err != nil {
return nil, err
}
case <-time.After(3 * time.Second):
_ = cmd.Process.Kill()
return nil, errors.New("loginctl did not answer in 3s")
}
return ParseProperties(out.String()), nil
}
// ParseProperties reads `Key=Value` lines, as loginctl and systemctl show print them.
func ParseProperties(text string) map[string]string {
out := map[string]string{}
sc := bufio.NewScanner(strings.NewReader(text))
for sc.Scan() {
if k, v, ok := strings.Cut(sc.Text(), "="); ok {
out[k] = v
}
}
return out
}
@@ -0,0 +1,255 @@
package desktop
import (
"context"
"encoding/json"
"net"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
)
// A machine in a directory: a process table, the X servers' socket directory and a runtime base.
type fakeMachine struct {
t *testing.T
proc, x11, runtime string
uid int
}
func newMachine(t *testing.T) *fakeMachine {
root, err := os.MkdirTemp("", "desk")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { os.RemoveAll(root) })
m := &fakeMachine{t: t, proc: filepath.Join(root, "p"), x11: filepath.Join(root, "x"), runtime: filepath.Join(root, "r"), uid: os.Getuid()}
for _, d := range []string{m.proc, m.x11, filepath.Join(m.runtime, strconv.Itoa(m.uid))} {
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
}
return m
}
func (m *fakeMachine) socket(path string) {
l, err := net.Listen("unix", path)
if err != nil {
m.t.Fatal(err)
}
m.t.Cleanup(func() { l.Close() })
}
func (m *fakeMachine) process(pid int, comm string, start int, env ...string) {
dir := filepath.Join(m.proc, strconv.Itoa(pid))
if err := os.MkdirAll(dir, 0o755); err != nil {
m.t.Fatal(err)
}
os.WriteFile(filepath.Join(dir, "environ"), []byte(strings.Join(env, "\x00")+"\x00"), 0o600)
os.WriteFile(filepath.Join(dir, "comm"), []byte(comm+"\n"), 0o644)
// pid (comm) state ppid pgrp session tty tpgid flags minflt cminflt majflt cmajflt utime stime
// cutime cstime priority nice threads itrealvalue starttime ...
stat := strconv.Itoa(pid) + " (" + comm + ") S 1 1 1 0 -1 0 0 0 0 0 0 0 0 0 20 0 1 0 " + strconv.Itoa(start) + " 0 0"
os.WriteFile(filepath.Join(dir, "stat"), []byte(stat), 0o644)
}
func (m *fakeMachine) finder(logind func(string) (map[string]string, error), prefer ...string) Finder {
return Finder{Proc: m.proc, X11Sockets: m.x11, RuntimeBase: m.runtime, UID: m.uid, Prefer: prefer, Logind: logind}
}
func active(id string) (map[string]string, error) {
return map[string]string{"Active": "yes", "State": "active", "Remote": "no", "Type": "x11"}, nil
}
func TestTheSessionIsFoundInTheWindowManagersEnvironmentAndOnlyItsWordsAreRead(t *testing.T) {
m := newMachine(t)
m.socket(filepath.Join(m.x11, "X1"))
run := filepath.Join(m.runtime, strconv.Itoa(m.uid))
m.socket(filepath.Join(run, "bus"))
m.process(100, "lemurs-child", 5, "DISPLAY=:1", "XDG_SESSION_ID=1")
m.process(200, "i3", 10, "DISPLAY=:1", "XAUTHORITY=/home/op/.Xauthority", "XDG_SESSION_ID=1",
"XDG_SESSION_TYPE=x11", "XDG_CURRENT_DESKTOP=i3", "XDG_RUNTIME_DIR="+run,
"DBUS_SESSION_BUS_ADDRESS=unix:path=/tmp/dbus-private", "NPM_TOKEN=secret", "OPENAI_API_KEY=secret")
m.process(300, "zsh", 50, "TERM=xterm") // no display: not a candidate
s, err := m.finder(active, "i3").Find()
if err != nil {
t.Fatal(err)
}
if s.FoundIn.PID != 200 || s.Display != ":1" || s.XAuthority != "/home/op/.Xauthority" || s.ID != "1" || s.Type != "x11" || s.Desktop != "i3" {
t.Fatalf("session: %+v", s)
}
if s.Bus != "unix:path="+filepath.Join(run, "bus") || s.SessionBus != "unix:path=/tmp/dbus-private" {
t.Fatalf("the user manager's bus first, the session's private one reported beside it: %q %q", s.Bus, s.SessionBus)
}
if s.Active == nil || !*s.Active {
t.Fatal("logind's word is carried")
}
env := strings.Join(s.Env([]string{"PATH=/usr/bin", "DISPLAY=:9", "HOME=/home/op"}), "\n")
for _, want := range []string{"PATH=/usr/bin", "HOME=/home/op", "DISPLAY=:1", "XAUTHORITY=/home/op/.Xauthority", "DBUS_SESSION_BUS_ADDRESS=unix:path=" + filepath.Join(run, "bus"), "XDG_RUNTIME_DIR=" + run} {
if !strings.Contains(env, want) {
t.Errorf("env lacks %s:\n%s", want, env)
}
}
if strings.Contains(env, ":9") || strings.Contains(env, "secret") || strings.Contains(env, "NPM_TOKEN") {
t.Fatalf("the base's display is replaced and no other word of the session's process passes:\n%s", env)
}
b, _ := json.Marshal(s)
if strings.Contains(string(b), "secret") {
t.Fatal("the answer carries a word outside the session's")
}
}
func TestWithoutAPreferenceTheOldestProcessOfTheLiveSessionWins(t *testing.T) {
m := newMachine(t)
m.socket(filepath.Join(m.x11, "X0"))
m.process(410, "xterm", 90, "DISPLAY=:0", "XDG_SESSION_ID=3")
m.process(400, "openbox", 20, "DISPLAY=:0", "XDG_SESSION_ID=3")
s, err := m.finder(nil).Find()
if err != nil || s.FoundIn.PID != 400 {
t.Fatalf("%+v %v", s, err)
}
if s.RuntimeDir != filepath.Join(m.runtime, strconv.Itoa(m.uid)) || s.Bus != "" {
t.Fatalf("an absent runtime directory word falls back to the account's, and no bus socket means no bus: %+v", s)
}
}
func TestALeftoverProcessOfAnEndedSessionIsNotTheSession(t *testing.T) {
m := newMachine(t)
m.process(500, "i3", 10, "DISPLAY=:2", "XDG_SESSION_ID=7") // no X2 socket
_, err := m.finder(active, "i3").Find()
if !IsNoSession(err) || !strings.Contains(err.Error(), "socket is gone") {
t.Fatalf("%v", err)
}
var answer map[string]any
if json.Unmarshal([]byte(err.Error()), &answer) != nil || answer["error"] != "no-graphical-session" {
t.Fatalf("the refusal is structured: %s", err)
}
}
func TestNoProcessWithADisplayIsAClearNoSession(t *testing.T) {
m := newMachine(t)
m.process(600, "sshd", 1, "SSH_CONNECTION=x")
_, err := m.finder(active).Find()
if !IsNoSession(err) || !strings.Contains(err.Error(), "no process of uid") {
t.Fatalf("%v", err)
}
}
func TestAnActiveLocalSessionBeatsAnInactiveOneAndARemoteOneIsRefused(t *testing.T) {
m := newMachine(t)
m.socket(filepath.Join(m.x11, "X0"))
m.socket(filepath.Join(m.x11, "X1"))
m.process(700, "i3", 5, "DISPLAY=:0", "XDG_SESSION_ID=a")
m.process(800, "i3", 9, "DISPLAY=:1", "XDG_SESSION_ID=b")
logind := func(id string) (map[string]string, error) {
if id == "a" {
return map[string]string{"Active": "no", "State": "online", "Remote": "no"}, nil
}
return map[string]string{"Active": "yes", "State": "active", "Remote": "no"}, nil
}
s, err := m.finder(logind, "i3").Find()
if err != nil || s.FoundIn.PID != 800 || s.Display != ":1" {
t.Fatalf("the active session: %+v %v", s, err)
}
remote := func(string) (map[string]string, error) {
return map[string]string{"Active": "yes", "Remote": "yes"}, nil
}
if _, err := m.finder(remote).Find(); !IsNoSession(err) {
t.Fatalf("a remote session is not the operator's desktop: %v", err)
}
}
func TestAWaylandSessionIsFoundByItsCompositorsSocket(t *testing.T) {
m := newMachine(t)
run := filepath.Join(m.runtime, strconv.Itoa(m.uid))
m.socket(filepath.Join(run, "wayland-1"))
m.process(900, "sway", 3, "WAYLAND_DISPLAY=wayland-1", "XDG_RUNTIME_DIR="+run, "SWAYSOCK=/run/x.sock")
s, err := m.finder(nil, "sway").Find()
if err != nil || s.Type != "wayland" || s.WaylandDisplay != "wayland-1" {
t.Fatalf("%+v %v", s, err)
}
if !strings.Contains(strings.Join(s.Env(nil), " "), "SWAYSOCK=/run/x.sock") {
t.Fatal("the compositor's socket word passes")
}
}
func TestADisplayOnAnotherHostIsNotTheLocalSession(t *testing.T) {
for d, want := range map[string]bool{":0": true, ":1.0": true, "unix:2": true, "localhost:10.0": false, "host:0": false, "": false, ":x": false} {
if _, ok := DisplayNumber(d); ok != want {
t.Errorf("%q: %v", d, ok)
}
}
}
func TestACommandIsBoundedAndItsFailureNamed(t *testing.T) {
r := Exec(context.Background(), os.Environ(), []byte("hello"), "cat")
if !r.OK() || r.Stdout != "hello" {
t.Fatalf("%+v", r)
}
r = Exec(context.Background(), os.Environ(), nil, "sh", "-c", "echo no >&2; exit 3")
if r.OK() || r.Code != 3 || !strings.Contains(r.Err().Error(), "exited 3: no") {
t.Fatalf("%+v", r)
}
r = Exec(context.Background(), os.Environ(), nil, "no-such-program-here")
if r.OK() || r.Code != 127 {
t.Fatalf("%+v", r)
}
r = Exec(context.Background(), os.Environ(), nil, "sh", "-c", "head -c 400000 /dev/zero")
if !r.Truncated || len(r.Stdout) != MostOutput {
t.Fatalf("cut at %d: %d %v", MostOutput, len(r.Stdout), r.Truncated)
}
}
func TestArgumentsAreReadStrictly(t *testing.T) {
a := Args{"name": " x ", "n": float64(3), "f": 1.5, "b": true, "l": []any{"a", "b"}}
if v, err := a.Text("name"); err != nil || v != "x" {
t.Fatal(v, err)
}
if _, err := a.Text("missing"); err == nil {
t.Fatal("a missing required text")
}
if n, err := a.Whole("n", 0, 1, 5); err != nil || n != 3 {
t.Fatal(n, err)
}
if _, err := a.Whole("f", 0, 0, 5); err == nil {
t.Fatal("1.5 is not whole")
}
if _, err := a.Whole("n", 0, 4, 5); err == nil {
t.Fatal("out of range")
}
if b, given, err := a.Bool("b"); !b || !given || err != nil {
t.Fatal("bool")
}
if _, _, err := a.Bool("name"); err == nil {
t.Fatal("text is not a bool")
}
if l, err := a.Strings("l"); err != nil || len(l) != 2 {
t.Fatal(l, err)
}
if _, err := a.OneOf("name", "", "y", "z"); err == nil {
t.Fatal("not one of")
}
}
func TestAPathIsKeptInsideTheHome(t *testing.T) {
t.Setenv("MESH_OPERATOR_HOME", "/home/op")
for in, want := range map[string]string{"~/a.png": "/home/op/a.png", "b/c": "/home/op/b/c", "/home/op/d": "/home/op/d", "~": "/home/op"} {
if got, err := InHome(in); err != nil || got != want {
t.Errorf("%s: %s %v", in, got, err)
}
}
for _, out := range []string{"/etc/passwd", "~/../other", "../x"} {
if _, err := InHome(out); err == nil {
t.Errorf("%s was accepted", out)
}
}
}
func TestPropertiesAreParsed(t *testing.T) {
p := ParseProperties("Active=yes\nState=active\nDisplay=\n")
if p["Active"] != "yes" || p["State"] != "active" || p["Display"] != "" {
t.Fatal(p)
}
}
+118
View File
@@ -0,0 +1,118 @@
{
"module": "adwaita",
"version": "1",
"capabilities": [
"package-manager"
],
"tools": [
"adwaita_appearance",
"adwaita_cursor",
"adwaita_icons",
"adwaita_portal_check"
],
"environment": {
"variables": {
"GTK_THEME": "Adwaita:dark",
"GTK2_RC_FILES": "/usr/share/themes/Adwaita-dark/gtk-2.0/gtkrc",
"QT_QPA_PLATFORMTHEME": "qt6ct",
"QT_STYLE_OVERRIDE": "Fusion",
"QT_SELECT": "6",
"XCURSOR_THEME": "Adwaita",
"XCURSOR_SIZE": "24"
}
},
"shell": [
{
"for": "xresources",
"slot": "normal",
"code": "! adwaita: the cursor, for X programs that take it from the resources.\nXcursor.theme: Adwaita\nXcursor.size: 24\n"
},
{
"for": "xinitrc",
"slot": "normal",
"code": "# The appearance (module adwaita): GSettings is where the portal reads dark or light, and the portal is\n# the only way it reaches Electron, Chromium, Firefox and flatpaks. Set at every session start to the\n# module's default; adwaita_appearance switches it for a session.\ngsettings set org.gnome.desktop.interface color-scheme 'prefer-dark' || true\ngsettings set org.gnome.desktop.interface gtk-theme 'Adwaita' || true\ngsettings set org.gnome.desktop.interface icon-theme 'Adwaita' || true\ngsettings set org.gnome.desktop.interface cursor-theme 'Adwaita' || true\ngsettings set org.gnome.desktop.interface cursor-size 24 || true\ngsettings set org.gnome.desktop.interface font-name 'Inter 11' || true\ngsettings set org.gnome.desktop.interface monospace-font-name 'JetBrainsMono Nerd Font 11' || true\n"
}
],
"resources": [
{
"id": "package-gnome-themes-extra",
"type": "package",
"package": "gnome-themes-extra"
},
{
"id": "package-adwaita-icon-theme",
"type": "package",
"package": "adwaita-icon-theme"
},
{
"id": "package-adwaita-cursors",
"type": "package",
"package": "adwaita-cursors"
},
{
"id": "package-qt6ct",
"type": "package",
"package": "qt6ct"
},
{
"id": "package-xdg-desktop-portal-gtk",
"type": "package",
"package": "xdg-desktop-portal-gtk"
},
{
"id": "gtk3",
"type": "file",
"path": "${machine:account-home}/.config/gtk-3.0/settings.ini",
"owner": "${machine:account}",
"mode": "0644",
"content": "# Written by the mesh (module adwaita, novox/hq ADR 0208), for GTK 3 and GTK 4 alike. Replaced at\n# every push; adwaita_appearance switches dark and light for the running session.\n[Settings]\ngtk-theme-name=Adwaita\ngtk-icon-theme-name=Adwaita\ngtk-cursor-theme-name=Adwaita\ngtk-cursor-theme-size=24\ngtk-font-name=Inter 11\ngtk-application-prefer-dark-theme=1\n"
},
{
"id": "gtk4",
"type": "file",
"path": "${machine:account-home}/.config/gtk-4.0/settings.ini",
"owner": "${machine:account}",
"mode": "0644",
"content": "# Written by the mesh (module adwaita, novox/hq ADR 0208), for GTK 3 and GTK 4 alike. Replaced at\n# every push; adwaita_appearance switches dark and light for the running session.\n[Settings]\ngtk-theme-name=Adwaita\ngtk-icon-theme-name=Adwaita\ngtk-cursor-theme-name=Adwaita\ngtk-cursor-theme-size=24\ngtk-font-name=Inter 11\ngtk-application-prefer-dark-theme=1\n"
},
{
"id": "qt6ct",
"type": "file",
"path": "${machine:account-home}/.config/qt6ct/qt6ct.conf",
"owner": "${machine:account}",
"mode": "0644",
"content": "[Appearance]\ncolor_scheme_path=/usr/share/qt6ct/colors/darker.conf\ncustom_palette=true\nicon_theme=Adwaita\nstandard_dialogs=default\nstyle=Fusion\n\n[Fonts]\nfixed=\"JetBrainsMono Nerd Font,11,-1,5,50,0,0,0,0,0\"\ngeneral=\"Inter,11,-1,5,50,0,0,0,0,0\"\n\n[Interface]\nactivate_item_on_single_click=1\nbuttonbox_layout=0\ncursor_flash_time=1000\ndialog_buttons_have_icons=1\ndouble_click_interval=400\ngui_effects=@Invalid()\nkeyboard_scheme=2\nmenus_have_icons=true\nshow_shortcuts_in_context_menus=true\nstylesheets=@Invalid()\ntoolbutton_style=4\nunderline_shortcut=1\nwheel_scroll_lines=3\n\n[Troubleshooting]\nforce_raster_widgets=1\nignored_applications=@Invalid()\n"
},
{
"id": "portals",
"type": "file",
"path": "${machine:account-home}/.config/xdg-desktop-portal/portals.conf",
"owner": "${machine:account}",
"mode": "0644",
"content": "# Written by the mesh (module adwaita, novox/hq ADR 0208). Replaced at every push.\n#\n# Which portal backend answers each interface. i3 is not a desktop xdg-desktop-portal knows, so with\n# no preference it uses whichever backend happens to be installed: fine while gtk is the only one,\n# wrong the day another arrives as somebody else's dependency. Named instead. gtk also serves\n# org.freedesktop.appearance (dark or light) from GSettings, which the session's start sets.\n[preferred]\ndefault=gtk\n# Secrets for sandboxed programs come from the keyring's backend. Without this line no backend answers\n# the interface: gtk does not implement it, and gnome-keyring's names only GNOME as its desktop.\norg.freedesktop.impl.portal.Secret=gnome-keyring\n"
},
{
"id": "cursor",
"type": "file",
"path": "${machine:account-home}/.icons/default/index.theme",
"owner": "${machine:account}",
"mode": "0644",
"content": "# Written by the mesh (module adwaita, novox/hq ADR 0208): the default cursor theme, for programs that\n# read neither XCURSOR_THEME nor the X resources.\n[Icon Theme]\nName=Default\nInherits=Adwaita\n"
}
],
"build": {
"artifacts": [
{
"name": "tools",
"kind": "bundle",
"language": "go",
"system": "arch",
"from": "cmd/adwaita-tools",
"binary": "adwaita-tools",
"loads": [
"adwaita-tools"
]
}
]
}
}
-22
View File
@@ -1,22 +0,0 @@
# anthropic-consumer's runtime: the tool runtime, carrying this module's compiled code.
#
# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in
# the base images, published like any other artifact — which is what makes this buildable by the
# mesh from a repository and a path (novox/hq ADR 0069) rather than only on a workstation that
# happens to have the siblings.
#
# Two bases, named rather than pinned (novox/hq issue 044): the image this is COMPILED in and the
# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`.
ARG BUILD_BASE
ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build
WORKDIR /app/modules/anthropic-consumer
COPY . .
RUN node /app/node_modules/typescript/bin/tsc apply/index.ts usage/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE}
COPY --from=build /app/modules/anthropic-consumer/dist /app/modules/anthropic-consumer/dist
# No serve-time entrypoints: every container of this module names its command (`run` on a
# schedule), so nothing here serves — deliberately no MESH_TOOL_MODULES.
+30 -67
View File
@@ -9,29 +9,20 @@
"model-access"
],
"binds": {
"model-access": "/var/lib/anthropic-consumer/model.json"
"model-access": "${dir:state}/model.json"
},
"secrets": {
"model-access": "/var/lib/anthropic-consumer/access-token"
},
"own-secrets": {
"broker": "/var/lib/mesh/anthropic-consumer/broker"
"model-access": "${dir:state}/access-token"
},
"emits": [
"usage.session"
],
"resources": [
{
"id": "mesh-state",
"type": "directory",
"path": "/var/lib/mesh/anthropic-consumer",
"mode": "0700"
},
{
"id": "state",
"type": "directory",
"path": "/var/lib/anthropic-consumer",
"mode": "0700"
"mode": "0700",
"place": "."
},
{
"id": "claude-home",
@@ -42,71 +33,43 @@
{
"id": "out",
"type": "directory",
"path": "/var/lib/anthropic-consumer/out",
"mode": "0700"
},
{
"id": "apply",
"type": "container",
"name": "mesh-anthropic-consumer-apply",
"network": "host",
"type": "process",
"name": "anthropic-consumer-apply",
"artifact": "code",
"run": [
"node",
"apply/index.js"
],
"schedule": "*/5 * * * *",
"args": [
"run",
"/app/modules/anthropic-consumer/dist/apply/index.js"
],
"volumes": [
"/var/lib/anthropic-consumer:/run/state"
],
"env": {
"MESH_MODEL_ACCESS_SECRET_FILE": "/run/state/access-token",
"MESH_MODEL_ACCESS_BIND_FILE": "/run/state/model.json",
"MESH_CLAUDE_CREDENTIALS_FILE": "/run/state/claude/.credentials.json",
"MESH_CLAUDE_IDENTITY_FILE": "/run/state/claude/.claude.json"
},
"artifact": "runtime"
},
{
"id": "usage",
"type": "container",
"name": "mesh-anthropic-consumer-usage",
"network": "host",
"schedule": "*/5 * * * *",
"args": [
"run",
"/app/modules/anthropic-consumer/dist/usage/index.js"
],
"volumes": [
"/var/lib/mesh/anthropic-consumer/broker:/run/secrets/broker:ro",
"/var/lib/anthropic-consumer:/run/state"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_CLAUDE_PROJECTS_DIR": "/run/state/claude/projects",
"MESH_ANTHROPIC_USAGE_OUT": "/run/state/out/session-usage.json",
"MESH_TOOLS_MAIN": "/app/dist/main.js"
},
"artifact": "runtime"
"MESH_MODEL_ACCESS_SECRET_FILE": "${dir:state}/access-token",
"MESH_MODEL_ACCESS_BIND_FILE": "${dir:state}/model.json",
"MESH_CLAUDE_CREDENTIALS_FILE": "${dir:state}/claude/.credentials.json",
"MESH_CLAUDE_IDENTITY_FILE": "${dir:state}/claude/.claude.json"
}
}
],
"build": {
"on": [
{
"arg": "BUILD_BASE",
"module": "mesh-tools",
"artifact": "build"
},
{
"arg": "RUNTIME_BASE",
"module": "mesh-tools",
"artifact": "runtime"
}
],
"artifacts": [
{
"name": "runtime",
"kind": "image",
"from": "Dockerfile"
"name": "code",
"kind": "bundle",
"language": "typescript",
"entrypoints": [
"apply/index.js",
"usage/index.js"
],
"loads": [
"usage/index.js"
],
"env": {
"MESH_CLAUDE_PROJECTS_DIR": "${dir:state}/claude/projects",
"MESH_ANTHROPIC_USAGE_OUT": "${dir:state}/out/session-usage.json"
}
}
]
}
+19 -18
View File
@@ -3,12 +3,15 @@
// per session. The consumer IS the (node,module) session's fixed binding, so no per-message account
// attribution is done — just the totals (port map "don't-map" #3).
//
// Runs as `mesh-tools run` (no broker), so events are emitted best-effort via the sibling mesh-tools
// `emit` primitive; the totals are also written to a file so the reading is observable without one.
// Runs in the node's runtime (novox/hq ADR 0198), every five minutes, so events are emitted through
// 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 { join, dirname } from "node:path";
import { emit } from "@novox/mesh-sdk/events";
import { readSessionFile, type SessionUsage } from "../transcript.js";
/** 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);
}
/** 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> {
const main = process.env.MESH_TOOLS_MAIN ?? "/app/dist/main.js";
const { spawn } = await import("node:child_process");
await new Promise<void>((resolve) => {
const child = spawn(
process.execPath,
[main, "emit", "usage.session", JSON.stringify(body)],
{ stdio: "inherit" },
);
child.on("exit", () => resolve());
child.on("error", (err) => {
console.error(`[anthropic-consumer] could not emit usage: ${err}`);
resolve();
});
});
try {
await emit("usage.session", body);
} catch (err) {
console.error(`[anthropic-consumer] could not emit usage: ${err}`);
}
}
await main();
// The cadence the scheduled container had: once at start, then every five minutes. Not awaited, so the
// runtime's handshake is answered while a long first reading is still under way.
const EVERY_MS = 5 * 60 * 1000;
const tick = (): void => {
void main().catch((err) => console.error(`[anthropic-consumer] usage reading failed: ${err}`));
};
tick();
setInterval(tick, EVERY_MS);
+8 -8
View File
@@ -9,13 +9,13 @@
"model-access"
],
"binds": {
"model-access": "/var/lib/mesh/anthropic-manager/model.json"
"model-access": "${dir:mesh-state}/model.json"
},
"secrets": {
"model-access": "/var/lib/mesh/anthropic-manager/refresh-token"
"model-access": "${dir:mesh-state}/refresh-token"
},
"own-secrets": {
"broker": "/var/lib/mesh/anthropic-manager/broker"
"broker": "${dir:mesh-state}/broker"
},
"emits": [
"usage.read"
@@ -24,13 +24,13 @@
{
"id": "mesh-state",
"type": "directory",
"path": "/var/lib/mesh/anthropic-manager",
"mode": "0700"
"mode": "0700",
"place": "mesh"
},
{
"id": "out",
"type": "directory",
"path": "/var/lib/mesh/anthropic-manager/out",
"path": "${dir:mesh-state}/out",
"mode": "0700"
},
{
@@ -45,8 +45,8 @@
"/app/modules/anthropic-manager/dist/refresh/index.js"
],
"volumes": [
"/var/lib/mesh/anthropic-manager/broker:/run/secrets/broker:ro",
"/var/lib/mesh/anthropic-manager:/run/state"
"${dir:mesh-state}/broker:/run/secrets/broker:ro",
"${dir:mesh-state}:/run/state"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
-33
View File
@@ -1,33 +0,0 @@
# audit-logger's runtime: the shared runtime image, carrying this module's compiled code.
#
# **Built from this module's own directory and nothing else.** The toolkit is in the base image, so
# nothing is copied out of a neighbouring checkout — which is what lets the mesh build this from a
# repository and a path (novox/hq ADR 0069) rather than only on a workstation that happens to have
# the siblings laid out beside it.
# Two bases, named rather than pinned: the image this is COMPILED in, and the image it RUNS in.
# They are different images on purpose — the first carries a compiler and the second must not, or
# every running container would carry one it never invokes. The mesh answers both with the copies it
# holds, because a fingerprint written here would name one particular copy and no other mesh has it
# (novox/hq issue 044). Declared in module.json's `build.on`; deliberately no defaults, so a build
# nobody told stops here and says which module to build first.
ARG BUILD_BASE
ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build
# Compiled under /app/modules so `@novox/mesh-sdk` resolves upward into the base's own
# node_modules — the module is compiled against exactly the toolkit it will run against.
WORKDIR /app/modules/audit-logger
COPY . .
# The compiler is invoked by its real path rather than through node_modules/.bin, whose entries are
# symlinks to a launcher that requires its library relatively — resolved away when the base image
# was assembled.
RUN node /app/node_modules/typescript/bin/tsc audit.ts index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE}
COPY --from=build /app/modules/audit-logger/dist /app/modules/audit-logger/dist
# **Served, not run.** This subscribes on import, and the serve mode binds the broker before it
# imports anything — `run` exists for a step that works offline and exits, and would leave this
# with nothing to subscribe to.
ENV MESH_TOOL_MODULES=/app/modules/audit-logger/dist/index.js
+14 -36
View File
@@ -5,27 +5,21 @@
"consumes": [
"**"
],
"own-secrets": {
"broker": "/var/lib/audit-logger/broker"
},
"build": {
"on": [
{
"arg": "BUILD_BASE",
"module": "mesh-tools",
"artifact": "build"
},
{
"arg": "RUNTIME_BASE",
"module": "mesh-tools",
"artifact": "runtime"
}
],
"artifacts": [
{
"name": "runtime",
"kind": "image",
"from": "Dockerfile"
"name": "code",
"kind": "bundle",
"language": "typescript",
"entrypoints": [
"index.js"
],
"loads": [
"index.js"
],
"env": {
"AUDIT_LOG": "${dir:trail}/audit.log"
}
}
]
},
@@ -33,29 +27,13 @@
{
"id": "state",
"type": "directory",
"path": "/var/lib/audit-logger",
"mode": "0700"
"mode": "0700",
"place": "."
},
{
"id": "trail",
"type": "directory",
"path": "/var/lib/audit-logger/trail",
"mode": "0700"
},
{
"id": "run",
"type": "container",
"name": "mesh-audit-logger",
"network": "host",
"volumes": [
"/var/lib/audit-logger/broker:/run/secrets/broker:ro",
"/var/lib/audit-logger/trail:/trail"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"AUDIT_LOG": "/trail/audit.log"
},
"artifact": "runtime"
}
],
"capabilities": [
+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");
// 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_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));
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].node, "anchor");
assert.equal(lines[0].body.domain, "my-app");
+42
View File
@@ -0,0 +1,42 @@
# avahi
The local network's name and service discovery (mDNS/DNS-SD) as a module (novox/hq to-be 42 Phase 1,
research 027).
## What it owns
- The `avahi` package.
- `avahi-daemon.service`, running and enabled.
## What it improves
It was on all four machines and owned by none. It is now declared, and its tools show why discovery
does not work today:
- **The packet filter drops mDNS.** The mesh's filter has no rule for inbound UDP 5353 on any of the
four machines, so avahi announces this machine but hears no other machine's answers. A browse
finds nothing, and resolving even the machine's own `.local` name times out. A module's `listens`
can reach the private network, this machine or anywhere, but not the local link. Opening the port
to anywhere would answer the internet on a public machine, so the module opens nothing. This needs
a decision in novox/hq: a local-link source scope for `listens`. Until then, `avahi_status` reports
`inbound_mdns_accepted: false`, and browse and resolve say so whenever they hear nothing.
## What it leaves found
- **`nss-mdns` and `/etc/nsswitch.conf`.** An ordinary lookup reaches avahi only through the
`hosts:` line. That line is one ordered list shared by every name source: containers, files, DNS,
mDNS and the resolver daemon. The host can write a marked block into a file, but it cannot add a
member to a line. Owning the whole file would make this module the owner of every machine's name
resolution. On 2026-10-04 all four machines had the same file, with `mdns4_minimal` wired by hand
and nss-mdns installed. Both are left as found, and `avahi_status` reports the wiring.
- `/etc/avahi/avahi-daemon.conf`, including each workstation's hand-set `allow-interfaces`, which
names that machine's own network interface.
## Tools
| tool | | answers |
|---|---|---|
| `avahi_status` | r | the daemon, its version and configuration, the `hosts:` line and whether mdns is on it, nss-mdns, whether the filter accepts inbound 5353, systemd-resolved beside it, and notes |
| `avahi_browse` | r | every service announced in a few seconds (`avahi-browse -prt`), resolved where possible, narrowed to a type |
| `avahi_resolve` | r | a `.local` name through avahi and through the name service side by side, or an address to its name |
| `avahi_services` | r | what this machine publishes from `/etc/avahi/services` |
+353
View File
@@ -0,0 +1,353 @@
package main
// Avahi, the local network's name and service discovery (mDNS/DNS-SD), as a module (novox/hq to-be 42
// Phase 1, research 027: "on all four, owned by none"). The module declares the package and the
// daemon. Two things it does not declare, and these tools report instead:
//
// - **The name service switch.** nss-mdns is what lets an ordinary lookup answer `<host>.local`, and
// it works only through the `hosts:` line of /etc/nsswitch.conf. That line is one ordered list
// shared by every name source on the machine (containers, files, DNS, mDNS, the resolver daemon),
// the host can write a marked block into a file but not a member into a line, and owning the whole
// file would make this module the owner of every machine's name resolution. So both stay as found
// (wired by hand, identically, on all four machines on 2026-10-04) and `avahi_status` says whether
// the wiring is there.
// - **The packet filter.** mDNS is multicast to UDP 5353 on the local link. The mesh's filter has no
// source scope for "the local link" — a module's `listens` reach the private network, this machine
// or anywhere — so it drops what other machines announce, and a browse hears nothing. Opening it to
// anywhere would answer the internet on a public machine. `avahi_status` reports whether inbound
// 5353 is accepted; browse and resolve say so when they hear nothing.
import (
"fmt"
"net"
"regexp"
"sort"
"strconv"
"strings"
)
// The files avahi and the name service read.
const (
DaemonConf = "/etc/avahi/avahi-daemon.conf"
ServicesDir = "/etc/avahi/services"
NSSwitch = "/etc/nsswitch.conf"
Daemon = "avahi-daemon.service"
)
// Status is the daemon, its configuration, the name service's wiring and the filter.
type Status struct {
Daemon map[string]string `json:"daemon"`
Version string `json:"version,omitempty"`
Config map[string]map[string]string `json:"config"`
HostsLine string `json:"nsswitch_hosts"`
MDNSWired bool `json:"nss_mdns_wired"`
NSSMDNS string `json:"nss_mdns_package,omitempty"`
InboundMDNS *bool `json:"inbound_mdns_accepted"`
FilterError string `json:"filter_error,omitempty"`
ResolvedOn bool `json:"systemd_resolved_active"`
Notes []string `json:"notes"`
}
// ParseINI reads avahi-daemon.conf's sections and their set keys; commented keys are defaults.
func ParseINI(text string) map[string]map[string]string {
out := map[string]map[string]string{}
section := ""
for _, l := range lines(text) {
l = strings.TrimSpace(l)
switch {
case strings.HasPrefix(l, "#") || strings.HasPrefix(l, ";"):
case strings.HasPrefix(l, "[") && strings.HasSuffix(l, "]"):
section = strings.Trim(l, "[]")
out[section] = map[string]string{}
default:
if k, v, ok := strings.Cut(l, "="); ok && section != "" {
out[section][strings.TrimSpace(k)] = strings.TrimSpace(v)
}
}
}
return out
}
// HostsLine is the `hosts:` line of nsswitch.conf, and whether an mdns source is on it.
func HostsLine(text string) (string, bool) {
for _, l := range lines(text) {
l = strings.TrimSpace(l)
if !strings.HasPrefix(l, "hosts:") {
continue
}
for _, f := range strings.Fields(strings.TrimPrefix(l, "hosts:")) {
if strings.HasPrefix(f, "mdns") {
return l, true
}
}
return l, false
}
return "", false
}
var mdnsAccept = regexp.MustCompile(`(?m)\budp dport (?:\{[^}\n]*\b(?:5353|mdns)\b[^}\n]*\}|(?:5353|mdns)\b)[^\n]*\baccept\b`)
// InboundMDNS is whether a ruleset accepts UDP 5353 coming in.
func InboundMDNS(ruleset string) bool { return mdnsAccept.MatchString(ruleset) }
// GetStatus reads the daemon, its configuration, the name service and the packet filter.
func (m *Machine) GetStatus() (Status, error) {
s := Status{Config: map[string]map[string]string{}, Notes: []string{}}
d, err := m.unitProps(Daemon, "LoadState", "ActiveState", "SubState", "UnitFileState", "MainPID")
if err != nil {
return s, err
}
s.Daemon = d
if v, err := m.Out("avahi-daemon", "--version"); err == nil {
s.Version = strings.TrimSpace(v)
}
if text, err := m.ReadFile(DaemonConf); err == nil {
s.Config = ParseINI(string(text))
}
if text, err := m.ReadFile(NSSwitch); err == nil {
s.HostsLine, s.MDNSWired = HostsLine(string(text))
}
if r := m.Run(bg(), "pacman", "-Q", "nss-mdns"); r.Status == 0 && r.Err == "" {
s.NSSMDNS = strings.TrimSpace(r.Stdout)
}
if rs, err := m.Root("nft", "list", "ruleset"); err == nil {
open := InboundMDNS(rs)
s.InboundMDNS = &open
if !open {
s.Notes = append(s.Notes, "the packet filter drops inbound UDP 5353: this machine announces itself but hears no other machine's mDNS")
}
} else {
s.FilterError = err.Error()
}
if p, err := m.unitProps("systemd-resolved.service", "ActiveState"); err == nil {
s.ResolvedOn = p["ActiveState"] == "active"
}
if s.MDNSWired && s.NSSMDNS == "" {
s.Notes = append(s.Notes, "nsswitch names mdns and nss-mdns is not installed: those lookups fail")
}
if !s.MDNSWired {
s.Notes = append(s.Notes, "nsswitch does not name mdns: ordinary lookups never ask avahi")
}
return s, nil
}
// Service is one service a browse found.
type Service struct {
Interface string `json:"interface"`
Protocol string `json:"protocol"`
Name string `json:"name"`
Type string `json:"type"`
Domain string `json:"domain"`
Host string `json:"host,omitempty"`
Address string `json:"address,omitempty"`
Port int `json:"port,omitempty"`
TXT []string `json:"txt,omitempty"`
Resolved bool `json:"resolved"`
}
// unescape undoes avahi-browse -p's escaping: a special byte as a backslash and three decimals, any
// other character after a backslash as itself. Decoded as bytes, so a name in UTF-8 stays whole.
func unescape(s string) string {
out := make([]byte, 0, len(s))
for i := 0; i < len(s); i++ {
if s[i] == '\\' {
if d := s[i+1 : min(i+4, len(s))]; len(d) == 3 && isDigits(d) {
n, _ := strconv.Atoi(d)
out = append(out, byte(n))
i += 3
continue
}
if i+1 < len(s) {
out = append(out, s[i+1])
i++
continue
}
}
out = append(out, s[i])
}
return string(out)
}
func isDigits(s string) bool {
for _, c := range s {
if c < '0' || c > '9' {
return false
}
}
return true
}
var txtItem = regexp.MustCompile(`"((?:[^"\\]|\\.)*)"`)
// ParseBrowse reads `avahi-browse -p -r`: `+` lines found, `=` lines resolved; a found service
// that resolved is answered once, resolved.
func ParseBrowse(out string) []Service {
byKey := map[string]int{}
services := []Service{}
for _, l := range lines(out) {
f := strings.Split(l, ";")
if len(f) < 6 || (f[0] != "+" && f[0] != "=") {
continue
}
s := Service{Interface: f[1], Protocol: f[2], Name: unescape(f[3]), Type: f[4], Domain: f[5]}
if f[0] == "=" && len(f) >= 9 {
s.Resolved, s.Host, s.Address = true, f[6], f[7]
s.Port, _ = strconv.Atoi(f[8])
if len(f) >= 10 {
for _, t := range txtItem.FindAllStringSubmatch(strings.Join(f[9:], ";"), -1) {
s.TXT = append(s.TXT, t[1])
}
}
}
key := strings.Join([]string{s.Interface, s.Protocol, s.Name, s.Type, s.Domain}, "\x00")
if i, seen := byKey[key]; seen {
if s.Resolved {
services[i] = s
}
continue
}
byKey[key] = len(services)
services = append(services, s)
}
sort.SliceStable(services, func(i, j int) bool {
if services[i].Type != services[j].Type {
return services[i].Type < services[j].Type
}
return services[i].Name < services[j].Name
})
return services
}
var serviceType = regexp.MustCompile(`^_[A-Za-z0-9-]+\._(tcp|udp)$`)
// Browse listens for a few seconds and answers every service announced, resolved where it could be.
func (m *Machine) Browse(seconds int, kind string) (map[string]any, error) {
args := []string{strconv.Itoa(seconds), "avahi-browse", "-p", "-r", "-t"}
if kind == "" {
args = append(args, "-a")
} else {
if !serviceType.MatchString(kind) {
return nil, fmt.Errorf("%q is not a service type such as _ssh._tcp", kind)
}
args = append(args, kind)
}
r := m.Run(bg(), "timeout", args...)
// timeout's 124 is the listening time ending, which is how a browse that keeps hearing ends.
if r.Err != "" || (r.Status != 0 && r.Status != 124) {
return nil, failure("avahi-browse", "avahi-browse", r)
}
services := ParseBrowse(r.Stdout)
out := map[string]any{"seconds": seconds, "count": len(services), "services": services}
if len(services) == 0 {
out["note"] = m.silenceNote()
}
return out, nil
}
// silenceNote says why nothing may have been heard, from the packet filter when it can be read.
func (m *Machine) silenceNote() string {
if rs, err := m.Root("nft", "list", "ruleset"); err == nil && !InboundMDNS(rs) {
return "nothing was heard, and this machine's packet filter drops inbound UDP 5353 (mDNS): other machines' answers do not reach avahi"
}
return "nothing was heard on the local network"
}
// Resolve asks avahi for a name's address (or an address's name), and the name service the same,
// so an answer avahi has and an ordinary lookup does not shows the switch unwired.
func (m *Machine) Resolve(name, address string) (map[string]any, error) {
if (name == "") == (address == "") {
return nil, fmt.Errorf("give a name or an address")
}
out := map[string]any{}
var r Ran
if name != "" {
if !strings.HasSuffix(name, ".local") {
name += ".local"
}
out["name"] = name
r = m.Run(bg(), "avahi-resolve", "-n", name)
} else {
if net.ParseIP(address) == nil {
return nil, fmt.Errorf("%q is not an address", address)
}
out["address"] = address
r = m.Run(bg(), "avahi-resolve", "-a", address)
}
if r.Err != "" {
return nil, failure("avahi-resolve", "avahi-resolve", r)
}
// avahi-resolve says a failure on stderr and exits 0.
avahi := map[string]any{"answers": []string{}}
for _, l := range lines(r.Stdout) {
if f := strings.Fields(l); len(f) >= 2 {
avahi["answers"] = append(avahi["answers"].([]string), f[1])
}
}
if said := firstLine(r.Stderr); said != "" {
avahi["error"] = said
}
avahi["resolved"] = len(avahi["answers"].([]string)) > 0
out["avahi"] = avahi
if name != "" {
nss := map[string]any{"answers": []string{}}
g := m.Run(bg(), "getent", "hosts", name)
for _, l := range lines(g.Stdout) {
if f := strings.Fields(l); len(f) >= 1 {
nss["answers"] = append(nss["answers"].([]string), f[0])
}
}
nss["resolved"] = len(nss["answers"].([]string)) > 0
out["name_service"] = nss
}
if avahi["resolved"] == false {
out["note"] = m.silenceNote()
}
return out, nil
}
// Published is one service this machine announces from a file of /etc/avahi/services.
type Published struct {
File string `json:"file"`
Name string `json:"name,omitempty"`
Types []string `json:"types"`
Ports []int `json:"ports"`
}
var (
xmlName = regexp.MustCompile(`<name[^>]*>([^<]*)</name>`)
xmlType = regexp.MustCompile(`<type>([^<]*)</type>`)
xmlPort = regexp.MustCompile(`<port>(\d+)</port>`)
)
// Services is what this machine publishes from its service files.
func (m *Machine) Services() (map[string]any, error) {
r := m.Run(bg(), "find", ServicesDir, "-mindepth", "1", "-maxdepth", "1", "-name", "*.service", "-printf", "%f\n")
if r.Err != "" || r.Status != 0 {
if strings.Contains(r.Stderr, "No such file") {
return map[string]any{"directory": ServicesDir, "published": []Published{}}, nil
}
return nil, failure("find", "find", r)
}
pub := []Published{}
names := lines(r.Stdout)
sort.Strings(names)
for _, n := range names {
text, err := m.ReadFile(ServicesDir + "/" + n)
if err != nil {
return nil, err
}
p := Published{File: n, Types: []string{}, Ports: []int{}}
if x := xmlName.FindStringSubmatch(string(text)); x != nil {
p.Name = x[1]
}
for _, t := range xmlType.FindAllStringSubmatch(string(text), -1) {
p.Types = append(p.Types, t[1])
}
for _, x := range xmlPort.FindAllStringSubmatch(string(text), -1) {
port, _ := strconv.Atoi(x[1])
p.Ports = append(p.Ports, port)
}
pub = append(pub, p)
}
return map[string]any{"directory": ServicesDir, "published": pub}, nil
}
+165
View File
@@ -0,0 +1,165 @@
package main
import (
"strings"
"testing"
)
const browse = `+;enp6s0;IPv4;home\032server;_ssh._tcp;local
+;enp6s0;IPv4;Printer\046Co;_ipp._tcp;local
=;enp6s0;IPv4;home\032server;_ssh._tcp;local;home-server.local;192.168.1.10;22;
=;enp6s0;IPv4;Printer\046Co;_ipp._tcp;local;printer.local;192.168.1.20;631;"txtvers=1" "rp=ipp/print"
+;enp6s0;IPv6;Kitchen;_spotify-connect._tcp;local
`
func TestABrowseIsReadResolvedOnceAndUnescaped(t *testing.T) {
s := ParseBrowse(browse)
if len(s) != 3 {
t.Fatalf("%+v", s)
}
by := map[string]Service{}
for _, x := range s {
by[x.Name] = x
}
ssh := by["home server"]
if !ssh.Resolved || ssh.Address != "192.168.1.10" || ssh.Port != 22 || ssh.Host != "home-server.local" {
t.Fatalf("%+v", ssh)
}
ipp := by["Printer.Co"]
if strings.Join(ipp.TXT, ",") != "txtvers=1,rp=ipp/print" {
t.Fatalf("%+v", ipp)
}
if k := by["Kitchen"]; k.Resolved || k.Type != "_spotify-connect._tcp" {
t.Fatalf("%+v", k)
}
if unescape(`caf\195\169`) != "café" || unescape(`a\.b`) != "a.b" {
t.Fatal("unescape")
}
}
func TestABrowseThatHearsNothingSaysTheFilterDropsMDNS(t *testing.T) {
var calls []call
m := machine(fake(func(c call) Ran {
switch c.String() {
case "timeout 5 avahi-browse -p -r -t -a":
return Ran{Status: 124}
case "sudo -n nft list ruleset":
return Ran{Stdout: "table inet mesh {\n chain input {\n type filter hook input priority filter; policy drop;\n tcp dport 22 accept\n }\n}\n"}
}
return Ran{Status: 99}
}, &calls), 1000)
r, err := m.Browse(5, "")
if err != nil || r["count"] != 0 || !strings.Contains(r["note"].(string), "drops inbound UDP 5353") {
t.Fatalf("%v %v", r, err)
}
if _, err := m.Browse(5, "ssh; rm"); err == nil {
t.Fatal("not a service type")
}
}
func TestTheFilterIsReadForAnAcceptedInboundMDNS(t *testing.T) {
for rs, want := range map[string]bool{
"\t\tudp dport 5353 accept\n": true,
"\t\tiifname \"enp6s0\" udp dport { 53, 5353 } accept\n": true,
"\t\tudp dport mdns accept\n": true,
"\t\tudp dport 53 accept\n": false,
"\t\tudp dport 5353 drop\n": false,
"\t\tip saddr 10.0.0.0/8 udp dport 15353 accept\n": false,
} {
if InboundMDNS(rs) != want {
t.Errorf("%q: %v", rs, !want)
}
}
}
func TestStatusNamesTheSwitchTheFilterAndTheDaemon(t *testing.T) {
m := machine(fake(func(c call) Ran {
switch {
case c.name == "systemctl" && c.args[1] == Daemon:
return Ran{Stdout: "LoadState=loaded\nActiveState=active\nUnitFileState=enabled\n"}
case c.name == "systemctl":
return Ran{Stdout: "ActiveState=inactive\n"}
case c.String() == "avahi-daemon --version":
return Ran{Stdout: "avahi-daemon 0.9-rc5\n"}
case c.String() == "pacman -Q nss-mdns":
return Ran{Stdout: "nss-mdns 0.15.1-2\n"}
case c.String() == "sudo -n nft list ruleset":
return Ran{Stdout: "udp dport 53 accept\n"}
}
return Ran{Status: 99}
}, nil), 1000)
files := map[string]string{
DaemonConf: "[server]\nuse-ipv4=yes\n#host-name=foo\nallow-interfaces=enp6s0\n[publish]\npublish-hinfo=no\n",
NSSwitch: "passwd: files\nhosts: mymachines files dns mdns4_minimal [NOTFOUND=return] resolve [!UNAVAIL=return]\n",
}
m.ReadFile = func(p string) ([]byte, error) {
if s, ok := files[p]; ok {
return []byte(s), nil
}
return nil, errNoFile
}
s, err := m.GetStatus()
if err != nil {
t.Fatal(err)
}
if !s.MDNSWired || s.NSSMDNS != "nss-mdns 0.15.1-2" || s.InboundMDNS == nil || *s.InboundMDNS || s.Version != "avahi-daemon 0.9-rc5" {
t.Fatalf("%+v", s)
}
if s.Config["server"]["allow-interfaces"] != "enp6s0" || s.Config["server"]["host-name"] != "" || s.Daemon["ActiveState"] != "active" {
t.Fatalf("%+v", s.Config)
}
if len(s.Notes) != 1 || !strings.Contains(s.Notes[0], "drops inbound UDP 5353") {
t.Fatalf("%v", s.Notes)
}
if _, wired := HostsLine("hosts: files dns\n"); wired {
t.Fatal("no mdns on the line")
}
}
func TestResolveAsksAvahiAndTheNameServiceAndReadsAFailureFromStderr(t *testing.T) {
m := machine(byLine(map[string]Ran{
"avahi-resolve -n printer.local": {Stdout: "printer.local\t192.168.1.20\n"},
"getent hosts printer.local": {Status: 2},
"avahi-resolve -n nowhere.local": {Stderr: "Failed to resolve host name 'nowhere.local': Timeout reached\n"},
"getent hosts nowhere.local": {Status: 2},
"sudo -n nft list ruleset": {Stdout: "udp dport 5353 accept\n"},
"avahi-resolve -a 192.168.1.20": {Stdout: "192.168.1.20\tprinter.local\n"},
}, nil), 1000)
r, err := m.Resolve("printer", "")
if err != nil {
t.Fatal(err)
}
if r["avahi"].(map[string]any)["resolved"] != true || r["name_service"].(map[string]any)["resolved"] != false {
t.Fatalf("%v", r)
}
r, _ = m.Resolve("nowhere.local", "")
if a := r["avahi"].(map[string]any); a["resolved"] != false || !strings.Contains(a["error"].(string), "Timeout reached") || r["note"] != "nothing was heard on the local network" {
t.Fatalf("%v", r)
}
r, _ = m.Resolve("", "192.168.1.20")
if r["avahi"].(map[string]any)["answers"].([]string)[0] != "printer.local" {
t.Fatalf("%v", r)
}
for _, bad := range [][2]string{{"", ""}, {"a", "1.2.3.4"}, {"", "not-an-ip"}} {
if _, err := m.Resolve(bad[0], bad[1]); err == nil {
t.Errorf("%v accepted", bad)
}
}
}
func TestPublishedServicesAreReadFromTheirFiles(t *testing.T) {
m := machine(byLine(map[string]Ran{
"find /etc/avahi/services -mindepth 1 -maxdepth 1 -name *.service -printf %f\n": {Stdout: "ssh.service\n"},
}, nil), 1000)
m.ReadFile = func(string) ([]byte, error) {
return []byte(`<service-group><name replace-wildcards="yes">%h</name><service><type>_ssh._tcp</type><port>22</port></service></service-group>`), nil
}
r, err := m.Services()
if err != nil {
t.Fatal(err)
}
p := r["published"].([]Published)
if len(p) != 1 || p[0].Name != "%h" || p[0].Types[0] != "_ssh._tcp" || p[0].Ports[0] != 22 {
t.Fatalf("%+v", p)
}
}
+289
View File
@@ -0,0 +1,289 @@
package main
// The commands this bundle runs on its machine, and who runs them.
//
// Who asks. The node's tool runtime runs as the operator account, not root (novox/hq ADR 0175 §4),
// and launches this binary as a process of its own (ADR 0188, ADR 0193) with the runtime's words —
// HOME, a PATH, MESH_OPERATOR_ACCOUNT — and no session words. Reading needs nothing more; what only
// root may do goes through `sudo -n`, as the packet filter's, the service manager's and the
// intrusion prevention's tools do (to-be 38 WP4), and the `sudo` module is what declares that the
// account may (to-be 42, research 027). A refusal is named by how it failed, never read as an
// empty answer.
//
// The runner is injected, so every tool is tested over a fake one without the machine.
import (
"bytes"
"context"
"errors"
"fmt"
"io/fs"
"os"
"os/exec"
"strings"
"time"
)
// Ran is what one command did: its output, its exit status, and why it never ran to an answer.
type Ran struct {
Stdout string
Stderr string
Status int
// Err is "ENOENT" when the program is not there, or that it was ended for taking too long.
Err string
}
// Runner runs one command, so the tools can be tested without the machine.
type Runner func(ctx context.Context, name string, args ...string) Ran
// CallTimeout is how long one command may take: below the runtime's thirty-second call limit, so a
// command that hangs is answered as such rather than as a call the runtime gave up on.
const CallTimeout = 20 * time.Second
// outputLimit bounds what one command may hand back, so a runaway listing cannot exhaust the
// process; well above anything a tool answers.
const outputLimit = 16 << 20
type bounded struct {
bytes.Buffer
cut bool
}
func (b *bounded) Write(p []byte) (int, error) {
if room := outputLimit - b.Len(); room < len(p) {
if room > 0 {
b.Buffer.Write(p[:room])
}
b.cut = true
return len(p), nil
}
return b.Buffer.Write(p)
}
// ExecRunner runs a command on this machine, in the C locale so what is parsed is one language.
func ExecRunner(ctx context.Context, name string, args ...string) Ran {
ctx, cancel := context.WithTimeout(ctx, CallTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, name, args...)
cmd.Env = append(os.Environ(), "LC_ALL=C")
var out, errb bounded
cmd.Stdout, cmd.Stderr = &out, &errb
err := cmd.Run()
r := Ran{Stdout: out.String(), Stderr: errb.String()}
if ctx.Err() == context.DeadlineExceeded {
r.Status, r.Err = 124, fmt.Sprintf("no answer within %d s", int(CallTimeout.Seconds()))
return r
}
var exit *exec.ExitError
switch {
case err == nil:
case errors.As(err, &exit):
r.Status = exit.ExitCode()
case errors.Is(err, exec.ErrNotFound) || errors.Is(err, fs.ErrNotExist):
r.Status, r.Err = 127, "ENOENT"
default:
r.Status, r.Err = 126, err.Error()
}
return r
}
// Escalated is the command as it is run: as given when this process is root, else through sudo
// without a prompt.
func Escalated(uid int, name string, args ...string) (string, []string) {
if uid == 0 {
return name, args
}
return "sudo", append([]string{"-n", name}, args...)
}
// Machine is this machine as the tools see it: a runner, who this process is, and its files.
type Machine struct {
Run Runner
UID int
User string
Account string
ReadFile func(path string) ([]byte, error)
Now func() time.Time
Sleep func(time.Duration)
}
// ThisMachine is the machine the runtime launched this bundle on.
func ThisMachine() *Machine {
user := os.Getenv("USER")
if user == "" {
user = os.Getenv("LOGNAME")
}
account := strings.TrimSpace(os.Getenv("MESH_OPERATOR_ACCOUNT"))
if account == "" {
account = user
}
return &Machine{Run: ExecRunner, UID: os.Getuid(), User: user, Account: account, ReadFile: os.ReadFile, Now: time.Now, Sleep: time.Sleep}
}
// Out runs a command that only reads, and fails with what went wrong named.
func (m *Machine) Out(name string, args ...string) (string, error) {
r := m.Run(context.Background(), name, args...)
if r.Status == 0 && r.Err == "" {
return r.Stdout, nil
}
return r.Stdout, failure(name, name, r)
}
// Root runs a command that needs root, escalated when this process is not.
func (m *Machine) Root(name string, args ...string) (string, error) {
program, argv := Escalated(m.UID, name, args...)
r := m.Run(context.Background(), program, argv...)
if r.Status == 0 && r.Err == "" {
return r.Stdout, nil
}
return r.Stdout, failure(name, program, r)
}
// RootRan is Root's raw answer, for a command whose non-zero status is itself an answer.
func (m *Machine) RootRan(name string, args ...string) (Ran, error) {
program, argv := Escalated(m.UID, name, args...)
r := m.Run(context.Background(), program, argv...)
if r.Err != "" || (program == "sudo" && sudoRefused(r)) {
return r, failure(name, program, r)
}
return r, nil
}
func sudoRefused(r Ran) bool {
return strings.HasPrefix(strings.TrimSpace(r.Stderr), "sudo:")
}
// failure names what failed by how it failed: the program missing is a spawn error, sudo missing
// or refusing speaks for itself, and the rest is the command's own first line.
func failure(cmd, program string, r Ran) error {
said := strings.TrimSpace(r.Stderr + "\n" + r.Stdout)
if r.Err == "ENOENT" {
if program == "sudo" {
return fmt.Errorf("%s needs root for this, and sudo is not installed here for the runtime's account to escalate with", cmd)
}
return fmt.Errorf("%s is not installed on this machine", cmd)
}
if r.Err != "" {
return fmt.Errorf("%s did not answer: %s", cmd, r.Err)
}
if program == "sudo" && sudoRefused(r) {
if strings.Contains(said, "command not found") {
return fmt.Errorf("%s is not installed on this machine", cmd)
}
return fmt.Errorf("%s needs root for this and the runtime's account may not run it without a prompt: %s", cmd, firstLine(said))
}
if line := firstLine(said); line != "" {
return fmt.Errorf("%s failed (%d): %s", cmd, r.Status, line)
}
return fmt.Errorf("%s failed with status %d", cmd, r.Status)
}
func firstLine(text string) string {
for _, l := range strings.Split(text, "\n") {
if l = strings.TrimSpace(l); l != "" {
return l
}
}
return ""
}
func lines(text string) []string {
var out []string
for _, l := range strings.Split(text, "\n") {
if l = strings.TrimRight(l, "\r"); strings.TrimSpace(l) != "" {
out = append(out, l)
}
}
return out
}
// text is a string argument; required says whether it may be absent. It is never something a
// command would read as an option, which under sudo would be root's option.
func text(args map[string]any, key string, required bool) (string, error) {
raw, present := args[key]
if !present || raw == nil {
if required {
return "", fmt.Errorf("%s is required", key)
}
return "", nil
}
s, ok := raw.(string)
if !ok {
return "", fmt.Errorf("%s must be a string", key)
}
s = strings.TrimSpace(s)
if required && s == "" {
return "", fmt.Errorf("%s is required", key)
}
if strings.HasPrefix(s, "-") || strings.ContainsRune(s, 0) || strings.ContainsAny(s, "\n\r") {
return "", fmt.Errorf("%s %q is not a value this tool passes on", key, s)
}
return s, nil
}
// whole is a whole-number argument with a default, kept within bounds.
func whole(args map[string]any, key string, def, least, most int) (int, error) {
raw, present := args[key]
if !present || raw == nil {
return def, nil
}
f, ok := raw.(float64)
if !ok || f != float64(int(f)) {
return 0, fmt.Errorf("%s must be a whole number", key)
}
n := int(f)
if n < least {
return 0, fmt.Errorf("%s must be at least %d", key, least)
}
if n > most {
n = most
}
return n, nil
}
// flag is a boolean argument, false when absent.
func flag(args map[string]any, key string) (bool, error) {
raw, present := args[key]
if !present || raw == nil {
return false, nil
}
b, ok := raw.(bool)
if !ok {
return false, fmt.Errorf("%s must be true or false", key)
}
return b, nil
}
// schema is a tool's input: its properties and the ones it requires.
func schema(properties map[string]any, required ...string) map[string]any {
s := map[string]any{"type": "object", "properties": properties}
if len(required) > 0 {
s["required"] = required
}
return s
}
// unitProps reads a unit's properties as systemctl shows them.
func (m *Machine) unitProps(unit string, props ...string) (map[string]string, error) {
args := []string{"show", unit, "--no-pager"}
for _, p := range props {
args = append(args, "--property="+p)
}
out, err := m.Out("systemctl", args...)
if err != nil {
return nil, err
}
return keyValues(out, "="), nil
}
// keyValues reads `key<sep>value` lines; a line without the separator is skipped.
func keyValues(out, sep string) map[string]string {
kv := map[string]string{}
for _, l := range strings.Split(out, "\n") {
k, v, ok := strings.Cut(l, sep)
if ok {
kv[strings.TrimSpace(k)] = strings.TrimSpace(v)
}
}
return kv
}
@@ -0,0 +1,107 @@
package main
import (
"context"
"strings"
"testing"
"time"
)
// call is one command a fake runner was asked to run.
type call struct {
name string
args []string
}
func (c call) String() string {
if len(c.args) == 0 {
return c.name
}
return c.name + " " + strings.Join(c.args, " ")
}
// fake is a runner answering by the command line it is given, recording every call.
func fake(answer func(c call) Ran, calls *[]call) Runner {
return func(_ context.Context, name string, args ...string) Ran {
c := call{name, append([]string(nil), args...)}
if calls != nil {
*calls = append(*calls, c)
}
return answer(c)
}
}
// byLine answers from a table keyed by the whole command line, and refuses anything else as a
// command the test did not expect.
func byLine(table map[string]Ran, calls *[]call) Runner {
return fake(func(c call) Ran {
if r, ok := table[c.String()]; ok {
return r
}
return Ran{Status: 99, Stderr: "unexpected command: " + c.String()}
}, calls)
}
func machine(run Runner, uid int) *Machine {
return &Machine{Run: run, UID: uid, User: "operator", Account: "operator",
ReadFile: func(string) ([]byte, error) { return nil, errNoFile },
Now: func() time.Time { return time.Date(2026, 10, 4, 12, 0, 0, 0, time.UTC) },
Sleep: func(time.Duration) {}}
}
type noFile struct{}
func (noFile) Error() string { return "no such file" }
var errNoFile = noFile{}
func TestAnActNeedingRootGoesThroughSudoWithoutAPromptUnlessThisIsRoot(t *testing.T) {
if p, a := Escalated(1000, "visudo", "-c"); p != "sudo" || strings.Join(a, " ") != "-n visudo -c" {
t.Fatalf("not root: %s %v", p, a)
}
if p, a := Escalated(0, "visudo", "-c"); p != "visudo" || strings.Join(a, " ") != "-c" {
t.Fatalf("root: %s %v", p, a)
}
}
func TestFailuresAreNamedNeverReadAsEmpty(t *testing.T) {
cases := []struct {
r Ran
want string
}{
{Ran{Status: 127, Err: "ENOENT"}, "sudo is not installed here"},
{Ran{Status: 1, Stderr: "sudo: a password is required\n"}, "may not run it without a prompt: sudo: a password is required"},
{Ran{Status: 124, Err: "no answer within 20 s"}, "did not answer: no answer within 20 s"},
{Ran{Status: 2, Stderr: "boom\nmore"}, "failed (2): boom"},
}
for _, c := range cases {
m := machine(fake(func(call) Ran { return c.r }, nil), 1000)
if _, err := m.Root("thing"); err == nil || !strings.Contains(err.Error(), c.want) {
t.Errorf("%+v: %v, want %q", c.r, err, c.want)
}
}
m := machine(fake(func(call) Ran { return Ran{Status: 127, Err: "ENOENT"} }, nil), 1000)
if _, err := m.Out("thing"); err == nil || !strings.Contains(err.Error(), "thing is not installed") {
t.Errorf("a missing program: %v", err)
}
}
func TestAnArgumentIsNeverAnOption(t *testing.T) {
for _, bad := range []any{"-rf", "a\nb", 3.0} {
if _, err := text(map[string]any{"x": bad}, "x", true); err == nil {
t.Errorf("%v was accepted", bad)
}
}
if s, err := text(map[string]any{"x": " ok "}, "x", true); err != nil || s != "ok" {
t.Errorf("a plain value: %q %v", s, err)
}
if _, err := text(map[string]any{}, "x", true); err == nil {
t.Error("a missing required value was accepted")
}
if n, _ := whole(map[string]any{"n": 10000.0}, "n", 5, 1, 100); n != 100 {
t.Errorf("not bounded: %d", n)
}
if _, err := whole(map[string]any{"n": 0.0}, "n", 5, 1, 100); err == nil {
t.Error("below the least was accepted")
}
}
+85
View File
@@ -0,0 +1,85 @@
// avahi's tools bundle (novox/hq to-be 42 Phase 1, research 026/05): a process the node's runtime
// launches and speaks MCP over stdio to, through the Go SDK (ADR 0188, ADR 0193). It reads the
// daemon, the name service's wiring and the packet filter's view of mDNS, browses the local network
// for services, resolves a name, and lists what the machine publishes. It changes nothing.
package main
import (
"context"
"fmt"
"os"
stdio "git.novox.be/novox/mesh-sdk/go"
)
// binaryName is what the build names this bundle's executable: the manifest's `binary`.
const binaryName = "avahi-tools"
func bg() context.Context { return context.Background() }
func main() {
// An empty name serves as the module the runtime names (MESH_SERVED_MODULE): avahi.
if err := stdio.Serve("", tools(ThisMachine())); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func tools(m *Machine) []stdio.Tool {
return []stdio.Tool{
{
Name: "avahi_status",
Description: "The daemon's state and version, its configuration as set, the name service switch's hosts line and whether mdns is on it, " +
"whether nss-mdns is installed, whether the packet filter accepts inbound mDNS (UDP 5353), whether systemd-resolved runs beside it, " +
"and notes naming what keeps discovery from working.",
Input: schema(map[string]any{}),
Run: func(map[string]any) (any, error) { return m.GetStatus() },
},
{
Name: "avahi_browse",
Description: "Listen on the local network for a few seconds (avahi-browse -prt) and answer every service announced, with interface, " +
"protocol, name, type, host, address, port and TXT where it resolved; narrowed to one service type when given. Hearing nothing says why it may be.",
Input: schema(map[string]any{
"seconds": map[string]any{"type": "integer", "description": "how long to listen (default 5, at most 15)"},
"type": map[string]any{"type": "string", "description": "one service type, e.g. _ssh._tcp (optional)"},
}),
Run: func(args map[string]any) (any, error) {
n, err := whole(args, "seconds", 5, 1, 15)
if err != nil {
return nil, err
}
kind, err := text(args, "type", false)
if err != nil {
return nil, err
}
return m.Browse(n, kind)
},
},
{
Name: "avahi_resolve",
Description: "Resolve a .local name to its addresses through avahi, and through the name service (getent) beside it, or an address to its name. " +
"An answer from avahi that the name service lacks shows nsswitch unwired; no answer says why it may be.",
Input: schema(map[string]any{
"name": map[string]any{"type": "string", "description": "a host name; .local is added when missing"},
"address": map[string]any{"type": "string", "description": "an address to name instead"},
}),
Run: func(args map[string]any) (any, error) {
name, err := text(args, "name", false)
if err != nil {
return nil, err
}
address, err := text(args, "address", false)
if err != nil {
return nil, err
}
return m.Resolve(name, address)
},
},
{
Name: "avahi_services",
Description: "What this machine publishes from /etc/avahi/services: each file with the service's name, types and ports.",
Input: schema(map[string]any{}),
Run: func(map[string]any) (any, error) { return m.Services() },
},
}
}
@@ -0,0 +1,26 @@
package main
// The module's shape (novox/hq to-be 42 Phase 1, research 027): the package and the daemon, and
// nothing written into the name service switch or opened in the packet filter — avahi.go says why
// neither can be declared safely today, and the tools report both instead.
import "testing"
func TestItDeclaresThePackageAndTheDaemonOnly(t *testing.T) {
m := manifest(t)
if p := m.resource(t, "package"); p["package"] != "avahi" {
t.Fatalf("%v", p)
}
d := m.resource(t, "daemon")
if d["unit"] != Daemon || d["state"] != "running" || d["boot"] != "enabled" {
t.Fatalf("%v", d)
}
for _, r := range m.Resources {
if r["path"] == NSSwitch || r["package"] == "nss-mdns" {
t.Fatalf("%v: the name service switch is left as found", r["id"])
}
}
if len(m.Resources) != 2 {
t.Fatalf("%v", m.Resources)
}
}
@@ -0,0 +1,80 @@
package main
import (
"encoding/json"
"os"
"testing"
)
type resource map[string]any
type manifestShape struct {
Module string `json:"module"`
Version string `json:"version"`
Capabilities []string `json:"capabilities"`
Claims []map[string]any `json:"claims"`
Tools []string `json:"tools"`
Resources []resource `json:"resources"`
Build struct {
Artifacts []map[string]any `json:"artifacts"`
} `json:"build"`
}
func manifest(t *testing.T) manifestShape {
t.Helper()
raw, err := os.ReadFile("../../module.json")
if err != nil {
t.Fatal(err)
}
var m manifestShape
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
return m
}
func (m manifestShape) resource(t *testing.T, id string) resource {
t.Helper()
for _, r := range m.Resources {
if r["id"] == id {
return r
}
}
t.Fatalf("no resource %s", id)
return nil
}
// TestToolsAreTheManifests holds the served tools and the manifest's list to one another, and the
// bundle to the shape the builder compiles and the runtime loads.
func TestToolsAreTheManifests(t *testing.T) {
m := manifest(t)
names := map[string]bool{}
for _, tool := range tools(machine(nil, 1000)) {
if names[tool.Name] {
t.Errorf("%s is served twice", tool.Name)
}
names[tool.Name] = true
}
for _, want := range m.Tools {
if !names[want] {
t.Errorf("the manifest lists %s and the bundle does not serve it", want)
}
delete(names, want)
}
if len(names) != 0 {
t.Errorf("served and not listed: %v", names)
}
var tools map[string]any
for _, a := range m.Build.Artifacts {
if a["name"] == "tools" {
tools = a
}
}
if tools == nil || tools["kind"] != "bundle" || tools["language"] != "go" || tools["system"] != "arch" ||
tools["from"] != "cmd/"+binaryName || tools["binary"] != binaryName {
t.Fatalf("the tools artifact: %v", tools)
}
if loads, _ := tools["loads"].([]any); len(loads) != 1 || loads[0] != binaryName {
t.Fatalf("loads: %v", tools["loads"])
}
}
+5
View File
@@ -0,0 +1,5 @@
module avahi
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=
+43
View File
@@ -0,0 +1,43 @@
{
"module": "avahi",
"version": "1",
"capabilities": [
"package-manager",
"service-manager"
],
"tools": [
"avahi_status",
"avahi_browse",
"avahi_resolve",
"avahi_services"
],
"resources": [
{
"id": "package",
"type": "package",
"package": "avahi"
},
{
"id": "daemon",
"type": "service",
"unit": "avahi-daemon.service",
"state": "running",
"boot": "enabled"
}
],
"build": {
"artifacts": [
{
"name": "tools",
"kind": "bundle",
"language": "go",
"system": "arch",
"from": "cmd/avahi-tools",
"binary": "avahi-tools",
"loads": [
"avahi-tools"
]
}
]
}
}
-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
+55 -24
View File
@@ -2,12 +2,15 @@
// module's tools and anything else baserow-specific import it; nothing outside baserow does.
//
// Baserow authenticates a person with email + password, exchanged for a JWT at /api/user/token-auth/.
// Those credentials are the mesh's own: a person signs up in Baserow (the standard image creates no
// admin from env), and the credential is placed in the runtime config file the mesh mounts. Until
// that happens fromEnv throws and the module simply exposes no tools — the same dormant-until-
// configured shape gitea uses for its token.
// The standard image creates no admin from env, so the account is one a person made in Baserow: its
// password is the module's `admin` secret, accepted from the operator, and its email and the public
// host Baserow answers to reach the runtime config file the mesh mounts (the email from the
// assignment's settings). Until both are there fromEnv throws and the module exposes no tools — the
// same dormant-until-configured shape gitea uses for its token.
import { readFileSync } from "node:fs";
import { request as httpRequest } from "node:http";
import { request as httpsRequest } from "node:https";
export interface BaserowApplication {
id: number;
@@ -68,35 +71,63 @@ export class BaserowClient {
return h;
}
/** Exchange email + password for a JWT, caching it for the client's lifetime. Handles both the
/**
* One HTTP exchange. Not `fetch`: Node's fetch drops a caller's Host header and sends the URL's
* own, and Baserow answers only the host of its BASEROW_PUBLIC_URL — any other Host is looked up
* as a published builder site and gets 404, `/api/_health/` included. A co-located caller reaching
* it by container name must present the public host, so the request is made with node:http, which
* sends the Host it is given.
*/
private send(path: string, method: string, headers: Record<string, string>, body?: string): Promise<{ status: number; text: string }> {
const url = new URL(`${this.baseUrl}${path}`);
const request = url.protocol === "https:" ? httpsRequest : httpRequest;
// A length, never chunked: Baserow's server reads a chunked body as empty.
const sent = body === undefined ? headers : { ...headers, "Content-Length": String(Buffer.byteLength(body)) };
return new Promise((resolve, reject) => {
const req = request(url, { method, headers: sent }, (res) => {
let text = "";
res.setEncoding("utf8");
res.on("data", (chunk: string) => (text += chunk));
res.on("end", () => resolve({ status: res.statusCode ?? 0, text }));
res.on("error", reject);
});
req.on("error", reject);
if (body !== undefined) req.write(body);
req.end();
});
}
/** Exchange email + password for a JWT, caching it until Baserow refuses it. Handles both the
* older `{ token }` and the newer `{ access_token }` response shapes. */
async authenticate(): Promise<string> {
if (this.token) return this.token;
const res = await fetch(`${this.baseUrl}/api/user/token-auth/`, {
method: "POST",
headers: this.headers(),
body: JSON.stringify({ email: this.email, password: this.password }),
});
if (!res.ok) throw new Error(`baserow auth failed: ${res.status} ${await res.text()}`);
const data = (await res.json()) as { token?: string; access_token?: string };
const res = await this.send(
"/api/user/token-auth/",
"POST",
this.headers(),
JSON.stringify({ email: this.email, password: this.password }),
);
if (res.status < 200 || res.status >= 300) throw new Error(`baserow auth failed: ${res.status} ${res.text}`);
const data = JSON.parse(res.text) as { token?: string; access_token?: string };
const token = data.access_token ?? data.token;
if (!token) throw new Error("baserow auth returned no token");
this.token = token;
return token;
}
private async authed<T>(path: string, options: RequestInit = {}): Promise<T> {
const token = await this.authenticate();
const res = await fetch(`${this.baseUrl}${path}`, {
...options,
headers: this.headers({
Authorization: `JWT ${token}`,
...(options.headers as Record<string, string> | undefined),
}),
});
if (!res.ok) throw new Error(`baserow ${path}: ${res.status} ${await res.text()}`);
const text = await res.text();
return (text ? JSON.parse(text) : null) as T;
/** An authenticated GET. A refused token is dropped and the call made once more with a fresh one:
* Baserow's access tokens expire after minutes, and the runtime lives for weeks. */
private async authed<T>(path: string): Promise<T> {
for (let attempt = 0; ; attempt++) {
const token = await this.authenticate();
const res = await this.send(path, "GET", this.headers({ Authorization: `JWT ${token}` }));
if (res.status === 401 && attempt === 0) {
this.token = null;
continue;
}
if (res.status < 200 || res.status >= 300) throw new Error(`baserow ${path}: ${res.status} ${res.text}`);
return (res.text ? JSON.parse(res.text) : null) as T;
}
}
/** The applications (databases) the account can see, across all its workspaces. */
+31 -54
View File
@@ -18,15 +18,14 @@
}
},
"binds": {
"postgres-database": "/var/lib/baserow/database.json",
"route": "/var/lib/baserow/route.json"
"postgres-database": "${dir:state}/database.json",
"route": "${dir:state}/route.json"
},
"secrets": {
"postgres-database": "/var/lib/baserow/database.secret"
"postgres-database": "${dir:state}/database.secret"
},
"own-secrets": {
"secret-key": "/var/lib/baserow/secret-key.secret",
"broker": "/var/lib/mesh/baserow/broker"
"admin": "${dir:state}/admin.secret"
},
"listens": [
{
@@ -34,35 +33,34 @@
"port": 80,
"protocol": "tcp",
"from": "mesh",
"why": "the Baserow web UI and REST API; a public name is a route grant later"
"why": "the Baserow web UI and REST API, served by the image's own Caddy; a public name is the route's"
}
],
"resources": [
{
"id": "mesh-state",
"type": "directory",
"path": "/var/lib/mesh/baserow",
"mode": "0700"
"mode": "0700",
"place": "mesh"
},
{
"id": "state",
"type": "directory",
"path": "/var/lib/baserow",
"mode": "0700"
"mode": "0700",
"place": "."
},
{
"id": "data",
"type": "directory",
"path": "/services/baserow/data",
"mode": "0755",
"owner": "9999:9999"
},
{
"id": "server-env",
"type": "file",
"path": "/var/lib/baserow/server.env",
"path": "${dir:state}/server.env",
"mode": "0600",
"content": "DATABASE_HOST=${bound:postgres-database:at}\nDATABASE_PORT=${bound:postgres-database:port}\nDATABASE_NAME=${bound:postgres-database:as}\nDATABASE_USER=${bound:postgres-database:as}\nDATABASE_PASSWORD=${secret:postgres-database}\nSECRET_KEY=${secret:secret-key}\nBASEROW_PUBLIC_URL=http://localhost\n"
"content": "DATABASE_HOST=${bound:postgres-database:at}\nDATABASE_PORT=${bound:postgres-database:port}\nDATABASE_NAME=${bound:postgres-database:as}\nDATABASE_USER=${bound:postgres-database:as}\nDATABASE_PASSWORD_FILE=/run/secrets/database\nDISABLE_EMBEDDED_PSQL=true\nBASEROW_PUBLIC_URL=https://${bound:route:name}\n"
},
{
"id": "net",
@@ -73,65 +71,44 @@
"id": "server",
"type": "container",
"name": "baserow",
"image": "baserow/baserow@sha256:834424a10413798567f76428f255dc259445b7f8dcec56598c05b4073bb2a124",
"image": "baserow/baserow@sha256:263ea6c4b72c9eccabcd975ffe9fdebf23913a293a514bec6a3897a5e0a5a080",
"network": "baserow",
"env-file": [
"/var/lib/baserow/server.env"
"${dir:state}/server.env"
],
"ports": [
"80"
],
"volumes": [
"/services/baserow/data:/baserow/data"
],
"secrets-in-environment": "baserow reads DATABASE_PASSWORD and SECRET_KEY with os.getenv and has no _FILE twin (settings/base.py); not convertible"
"${dir:data}:/baserow/data",
"${dir:state}/database.secret:/run/secrets/database:ro"
]
},
{
"id": "runtime-config",
"type": "file",
"path": "/var/lib/mesh/baserow/config.json",
"path": "${dir:mesh-state}/config.json",
"mode": "0600",
"content": "{}\n",
"content": "{\n \"password\": \"${secret:admin}\",\n \"host\": \"${bound:route:name}\"\n}\n",
"merge": "json"
},
{
"id": "runtime",
"type": "container",
"name": "mesh-baserow",
"network": "baserow",
"volumes": [
"/var/lib/mesh/baserow/broker:/run/secrets/broker:ro",
"/var/lib/mesh/baserow/config.json:/run/config/config.json:ro"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_BASEROW_URL": "http://baserow:80",
"MESH_BASEROW_CONFIG_FILE": "/run/config/config.json"
},
"restart-on": [
"runtime-config"
],
"artifact": "runtime"
}
],
"build": {
"on": [
{
"arg": "BUILD_BASE",
"module": "mesh-tools",
"artifact": "build"
},
{
"arg": "RUNTIME_BASE",
"module": "mesh-tools",
"artifact": "runtime"
}
],
"artifacts": [
{
"name": "runtime",
"kind": "image",
"from": "Dockerfile"
"name": "tools",
"kind": "bundle",
"language": "typescript",
"entrypoints": [
"tools/index.js"
],
"loads": [
"tools/index.js"
],
"env": {
"MESH_BASEROW_URL": "http://127.0.0.1:${port:80}",
"MESH_BASEROW_CONFIG_FILE": "${dir:mesh-state}/config.json"
}
}
]
}
-24
View File
@@ -1,24 +0,0 @@
# bazarr's runtime: the tool runtime, carrying this module's compiled code.
#
# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in
# the base images, published like any other artifact — which is what makes this buildable by the
# mesh from a repository and a path (novox/hq ADR 0069) rather than only on a workstation that
# happens to have the siblings.
#
# Two bases, named rather than pinned (novox/hq issue 044): the image this is COMPILED in and the
# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`.
ARG BUILD_BASE
ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build
WORKDIR /app/modules/bazarr
COPY . .
RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts tools/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE}
COPY --from=build /app/modules/bazarr/dist /app/modules/bazarr/dist
# Every serve-time entrypoint, loaded by the runtime in serve mode: tools and events serve, and a
# provider's provisioner runs its reconcile loop in the same process, with the broker connected —
# the convention novox/hq issues 060/061 settled.
ENV MESH_TOOL_MODULES=/app/modules/bazarr/dist/index.js,/app/modules/bazarr/dist/tools/index.js
-173
View File
@@ -1,173 +0,0 @@
// The Bazarr API client — bazarr's own code, living in the module (novox/hq ADR 0039). Bazarr
// manages subtitles for a Sonarr/Radarr library: it tracks which episodes and movies are still
// missing subtitles, searches providers for them, and records what it downloaded. This client
// talks its /api surface (keyed by an X-API-KEY header); bazarr's tools and events import it.
import { readFileSync } from "node:fs";
export interface WantedSubtitle {
kind: "episode" | "movie";
title: string; // series + episode, or movie title
path?: string;
seriesId?: number; // sonarr series id (episodes)
episodeId?: number; // sonarr episode id (episodes)
radarrId?: number; // radarr movie id (movies)
missing: string[]; // language names still missing
}
export interface ProviderSubtitle {
provider: string;
language: string;
hearingImpaired: boolean;
forced: boolean;
score?: number;
release?: string;
subtitle: string; // the opaque token Bazarr uses to download this exact result
}
export interface HistoryEntry {
kind: "episode" | "movie";
id: string; // stable dedup key across polls
title: string;
language?: string;
provider?: string;
path?: string;
timestamp?: string;
description?: string;
}
/** The settings-merged config the mesh delivers (novox/hq ADR 0046): { url, apiKey, token, password, user, ... }. */
function meshConfig(file?: string): Record<string, string> {
if (!file) return {};
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
catch { return {}; }
}
/** Read a secret the mesh mounted at a file path (an own-secret delivered by `secret accept`);
* absent or unreadable yields undefined so callers fall back rather than crash. */
function readSecret(file?: string): string | undefined {
if (!file) return undefined;
try { return readFileSync(file, "utf8").trim(); }
catch { return undefined; }
}
export class BazarrClient {
readonly baseUrl: string;
constructor(
url: string,
private readonly apiKey: string,
) {
this.baseUrl = url.replace(/\/$/, "");
}
/** Build from the module's resolved environment. Bazarr's API is keyed; without URL and key
* there is nothing to talk to, so this throws rather than run half-configured. */
static fromEnv(env: NodeJS.ProcessEnv = process.env): BazarrClient {
const cfg = meshConfig(env.MESH_BAZARR_CONFIG_FILE);
const url = cfg.url ?? env.MESH_BAZARR_URL;
const apiKey = cfg.apiKey ?? readSecret(env.MESH_BAZARR_API_KEY_FILE) ?? env.MESH_BAZARR_API_KEY;
if (!url) throw new Error("no Bazarr URL — set MESH_BAZARR_URL");
if (!apiKey) throw new Error("no Bazarr API key — set MESH_BAZARR_API_KEY");
return new BazarrClient(url, apiKey);
}
private async request(method: string, path: string, params: Record<string, string> = {}): Promise<any> {
const url = new URL(`${this.baseUrl}/api${path}`);
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
const res = await fetch(url.toString(), { method, headers: { "X-API-KEY": this.apiKey, Accept: "application/json" } });
if (!res.ok) throw new Error(`Bazarr API ${method} ${path}: ${res.status} ${await res.text()}`);
// Downloads/patches return an empty body; only GETs carry JSON.
const text = await res.text();
return text ? JSON.parse(text) : {};
}
private get(path: string, params?: Record<string, string>): Promise<any> {
return this.request("GET", path, params);
}
private languageNames(missing: any[]): string[] {
return (missing ?? []).map((m: any) => m?.name ?? m?.code2 ?? m?.code3).filter(Boolean);
}
/** Episodes and movies still missing subtitles — Bazarr's core "what's left to do" list. */
async getWanted(limit = 50): Promise<WantedSubtitle[]> {
const [eps, movies] = await Promise.all([
this.get("/episodes/wanted", { start: "0", length: String(limit) }),
this.get("/movies/wanted", { start: "0", length: String(limit) }),
]);
const episodes: WantedSubtitle[] = (eps?.data ?? []).map((e: any) => ({
kind: "episode" as const,
title: `${e.seriesTitle ?? e.series ?? "Unknown"} — ${e.episodeTitle ?? e.episode_title ?? ""}`.trim(),
path: e.path,
seriesId: e.sonarrSeriesId,
episodeId: e.sonarrEpisodeId,
missing: this.languageNames(e.missing_subtitles),
}));
const films: WantedSubtitle[] = (movies?.data ?? []).map((m: any) => ({
kind: "movie" as const,
title: m.title ?? "Unknown",
path: m.path,
radarrId: m.radarrId,
missing: this.languageNames(m.missing_subtitles),
}));
return [...episodes, ...films];
}
/** Ask providers what subtitles are available for one wanted episode — a manual search. */
async searchEpisode(episodeId: number): Promise<ProviderSubtitle[]> {
const raw = await this.get("/providers/episodes", { episodeid: String(episodeId) });
return this.mapProviderResults(raw);
}
/** Ask providers what subtitles are available for one movie — a manual search. */
async searchMovie(radarrId: number): Promise<ProviderSubtitle[]> {
const raw = await this.get("/providers/movies", { radarrid: String(radarrId) });
return this.mapProviderResults(raw);
}
private mapProviderResults(raw: any): ProviderSubtitle[] {
const list = Array.isArray(raw) ? raw : (raw?.data ?? []);
return list.map((r: any) => ({
provider: r.provider,
language: r.language?.name ?? r.language ?? "unknown",
hearingImpaired: Boolean(r.hearing_impaired ?? r.hi),
forced: Boolean(r.forced),
score: r.score,
release: r.release_info?.[0] ?? r.release_info,
subtitle: r.subtitle,
}));
}
/** Recent subtitle-download history, episodes and movies together, newest first. Each entry
* carries a stable id so the events poller can tell a fresh download from one already seen. */
async getHistory(limit = 40): Promise<HistoryEntry[]> {
const [eps, movies] = await Promise.all([
this.get("/episodes/history", { start: "0", length: String(limit) }),
this.get("/movies/history", { start: "0", length: String(limit) }),
]);
const key = (kind: string, r: any): string =>
`${kind}:${r.timestamp ?? r.parsed_timestamp ?? ""}:${r.subtitles_path ?? r.path ?? ""}:${r.language?.code3 ?? r.language ?? ""}`;
const episodes: HistoryEntry[] = (eps?.data ?? []).map((r: any) => ({
kind: "episode" as const,
id: key("episode", r),
title: `${r.seriesTitle ?? "Unknown"} — ${r.episodeTitle ?? ""}`.trim(),
language: r.language?.name ?? r.language,
provider: r.provider,
path: r.subtitles_path,
timestamp: r.timestamp,
description: r.description,
}));
const films: HistoryEntry[] = (movies?.data ?? []).map((r: any) => ({
kind: "movie" as const,
id: key("movie", r),
title: r.title ?? "Unknown",
language: r.language?.name ?? r.language,
provider: r.provider,
path: r.subtitles_path,
timestamp: r.timestamp,
description: r.description,
}));
return [...episodes, ...films];
}
}
-47
View File
@@ -1,47 +0,0 @@
// bazarr's events. The tool runtime imports this once the broker is bound. Bazarr's one genuinely
// observable thing is a subtitle arriving: it works away in the background, searching providers for
// the missing-subtitle list, and when it succeeds a subtitle appears in its history. That is worth
// announcing to the mesh.
//
// Emits (novox/hq ADR 0041/0042):
// module.bazarr.subtitle.downloaded — a subtitle was fetched for an episode or movie
//
// Bazarr has nothing on the mesh it usefully reacts to (a download completing is Sonarr/Radarr's
// business, and they trigger Bazarr directly), so it consumes nothing — a pure emitter.
//
// The event is observation-based: poll history and diff. Primed silently on the first look, or a
// restart would re-announce the whole recent history as freshly downloaded.
import { emit } from "@novox/mesh-sdk/events";
import { BazarrClient } from "./client.js";
const bazarr = BazarrClient.fromEnv();
const seen = new Set<string>();
let primed = false;
async function pollHistory(): Promise<void> {
const entries = await bazarr.getHistory(40);
for (const entry of entries) {
if (seen.has(entry.id)) continue;
if (primed) {
await emit("subtitle.downloaded", {
kind: entry.kind,
title: entry.title,
language: entry.language,
provider: entry.provider,
path: entry.path,
});
}
seen.add(entry.id);
}
primed = true;
}
const tick = (fn: () => Promise<void>, everyMs: number): void => {
const run = (): void => void fn().catch((err) => console.error(`[bazarr] ${err}`));
setInterval(run, everyMs);
run();
};
tick(pollHistory, 60_000);
console.log("[bazarr] watching subtitle-download history");
-141
View File
@@ -1,141 +0,0 @@
{
"module": "bazarr",
"version": "1",
"capabilities": [
"container-runtime"
],
"emits": [
"subtitle.downloaded"
],
"own-secrets": {
"broker": "/var/lib/mesh/bazarr/broker",
"api-key": "/var/lib/mesh/bazarr/api-key"
},
"listens": [
{
"name": "web",
"port": 6767,
"protocol": "tcp",
"from": "mesh",
"why": "managing subtitles"
}
],
"accesses": [
{
"path": "/services/media/movies",
"mode": "read-write"
},
{
"path": "/services/media/series",
"mode": "read-write"
},
{
"path": "/services/media/anime",
"mode": "read-write"
},
{
"path": "/services/media/downloads",
"mode": "read"
}
],
"resources": [
{
"id": "mesh-state",
"type": "directory",
"path": "/var/lib/mesh/bazarr",
"mode": "0700"
},
{
"id": "config",
"type": "directory",
"path": "/services/bazarr/config",
"mode": "0700",
"owner": "1000:1000"
},
{
"id": "server",
"type": "container",
"name": "bazarr",
"image": "lscr.io/linuxserver/bazarr@sha256:3a820372f19fcb2981ea19fe4b5382934d67414afaba974bce831ddda0a64a02",
"env": {
"PUID": "1000",
"PGID": "1000",
"TZ": "Etc/UTC"
},
"ports": [
"6767"
],
"volumes": [
"/services/bazarr/config:/config",
"/services/media/movies:/movies",
"/services/media/series:/series",
"/services/media/anime:/anime",
"/services/media/downloads:/downloads"
]
},
{
"id": "runtime-config",
"type": "file",
"path": "/var/lib/mesh/bazarr/config.json",
"mode": "0600",
"content": "{}\n",
"merge": "json"
},
{
"id": "runtime",
"type": "container",
"name": "mesh-bazarr",
"network": "host",
"volumes": [
"/var/lib/mesh/bazarr/broker:/run/secrets/broker:ro",
"/var/lib/mesh/bazarr/api-key:/run/secrets/api-key:ro",
"/var/lib/mesh/bazarr/config.json:/run/config/config.json:ro",
"/services/bazarr/config:/var/lib/bazarr/config:ro"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_BAZARR_URL": "http://127.0.0.1:6767",
"MESH_BAZARR_API_KEY_FILE": "/run/secrets/api-key",
"MESH_BAZARR_CONFIG_FILE": "/run/config/config.json",
"MESH_BAZARR_CONFIG_DIR": "/var/lib/bazarr/config"
},
"restart-on": [
"runtime-config"
],
"artifact": "runtime"
}
],
"requires": [
"route"
],
"contributes": {
"route": {
"label": "subs",
"endpoint": "web"
}
},
"binds": {
"route": "/var/lib/mesh/bazarr/route.json"
},
"build": {
"on": [
{
"arg": "BUILD_BASE",
"module": "mesh-tools",
"artifact": "build"
},
{
"arg": "RUNTIME_BASE",
"module": "mesh-tools",
"artifact": "runtime"
}
],
"artifacts": [
{
"name": "runtime",
"kind": "image",
"from": "Dockerfile"
}
]
}
}
-14
View File
@@ -1,14 +0,0 @@
{
"name": "@novox/module-bazarr",
"version": "0.1.0",
"description": "bazarr — subtitle management. Its API client, tools and events live here (novox/hq ADR 0039).",
"type": "module",
"private": true,
"dependencies": {
"@novox/mesh-sdk": "^0.1.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.6.0"
}
}
-57
View File
@@ -1,57 +0,0 @@
// bazarr's tools — its own code (novox/hq ADR 0039), importing bazarr's client. They return
// structured data; the mesh serves them through the sdk's tool harness.
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
import { BazarrClient } from "../client.js";
export function getBazarrTools(bazarr: BazarrClient): ToolDefinition[] {
return [
{
name: "bazarr_wanted",
description: "Episodes and movies still missing subtitles, with the languages each still needs.",
input: { limit: { type: "number", description: "max items per kind (default 50)" } },
run: async (args) => {
const wanted = await bazarr.getWanted(args.limit ? Number(args.limit) : 50);
return { count: wanted.length, wanted };
},
},
{
name: "bazarr_search_subtitles",
description: "Manually search subtitle providers for one wanted item — pass an episodeId or a radarrId.",
input: {
episodeId: { type: "number", description: "a Sonarr episode id (from bazarr_wanted)" },
radarrId: { type: "number", description: "a Radarr movie id (from bazarr_wanted)" },
},
run: async (args) => {
if (args.episodeId !== undefined) {
const results = await bazarr.searchEpisode(Number(args.episodeId));
return { kind: "episode", episodeId: Number(args.episodeId), count: results.length, results };
}
if (args.radarrId !== undefined) {
const results = await bazarr.searchMovie(Number(args.radarrId));
return { kind: "movie", radarrId: Number(args.radarrId), count: results.length, results };
}
throw new Error("pass either episodeId or radarrId");
},
},
{
name: "bazarr_history",
description: "Recent subtitle-download history — what was downloaded, for which title, from which provider.",
input: { limit: { type: "number", description: "max entries per kind (default 40)" } },
run: async (args) => {
const history = await bazarr.getHistory(args.limit ? Number(args.limit) : 40);
return { count: history.length, history };
},
},
];
}
// Exposed only when Bazarr is configured; otherwise bazarr contributes no tools rather than
// failing the whole runtime.
registerModuleTools("bazarr", (env) => {
try {
return getBazarrTools(BazarrClient.fromEnv(env));
} catch {
return [];
}
});
+53
View File
@@ -0,0 +1,53 @@
# bluetooth
Bluetooth on the two workstations (novox/hq research 027/02, to-be 42 phase 2 step 9).
## Owns
| what | where |
|---|---|
| the Bluetooth stack and its daemon | package `bluez` |
| `bluetoothctl`, which the tools speak through | package `bluez-utils` |
| the daemon, running and enabled | `bluetooth.service` |
All official. `/etc/bluetooth/main.conf` is the package's file, unchanged on both workstations
(every setting commented out). The module states nothing in it, so it declares nothing there.
## Improves
- **The stack is declared, not a dependency of something else.** On both workstations `bluez` is
installed only as a dependency. Removing the applet that pulled it in would have left it an orphan
for the next clean-up to take, and Bluetooth with it.
- **An owner for the daemon**, running and enabled on both today with nothing recording why.
- **Headphones from the mesh.** `bluetooth_connect` and `bluetooth_devices` (with battery) answer from
any machine, without the applet.
## Tools
All answer JSON; `(r)` reads, `(a)` acts. They run as the operator account. bluez's bus policy lets
the account act; if it ever refuses (`AccessDenied`), the act is repeated through `sudo -n`. An act
whose output says it failed (`Failed to …`, `org.bluez.Error…`, `not available`) is an error, whatever
bluetoothctl's exit status.
| tool | what |
|---|---|
| `bluetooth_controller` (r) | address, name, powered, discoverable, pairable, discovering |
| `bluetooth_power` (r/a) | read, or switch the controller on or off |
| `bluetooth_devices` (r) | all, paired, connected or trusted devices: kind, paired, bonded, trusted, blocked, connected, battery where reported |
| `bluetooth_scan` (r) | discover for 1 to 15 s (default 8); the unpaired devices found, strongest signal first |
| `bluetooth_connect` / `bluetooth_disconnect` (a) | one device; connect waits up to 15 s |
| `bluetooth_trust` (a) | trust, or untrust |
| `bluetooth_pair` (a) | pair with an agent that confirms nothing (headphones, speakers), then trust. A device that shows a code is paired from the desktop |
| `bluetooth_remove` (a) | forget a device |
## What changes when it is assigned
Nothing on disk on either workstation: both packages are installed, and the service is enabled and
running. `bluez` becomes explicitly the mesh's.
## Leaves as found
- The paired devices and their keys under `/var/lib/bluetooth` (bluez's state).
- `blueman` on both workstations, and its applet, which the window manager's configuration starts.
That line is the `i3` module's to keep or drop.
- `bluez-obex` and the AUR terminal client `bluetuith-bin` (with its `-debug`) on the laptop.
@@ -0,0 +1,281 @@
package main
import (
"fmt"
"regexp"
"sort"
"strconv"
"strings"
"time"
)
var macAddress = regexp.MustCompile(`^[0-9A-Fa-f]{2}(:[0-9A-Fa-f]{2}){5}$`)
func addressOf(args map[string]any) (string, error) {
a, err := text(args, "address")
if err != nil {
return "", err
}
if !macAddress.MatchString(a) {
return "", fmt.Errorf("%q is not a Bluetooth address (six hex pairs separated by colons)", a)
}
return strings.ToUpper(a), nil
}
var ansi = regexp.MustCompile(`\x1b\[[0-9;]*[A-Za-z]|\x01|\x02`)
// btFailed are the words bluetoothctl uses for an act that did not happen, whatever its exit status.
var btFailed = regexp.MustCompile(`(?m)(Failed to \w+|not available|org\.bluez\.Error\.\w+|No default controller available)`)
// bt runs bluetoothctl once, non-interactively, as the account; bluez's bus policy lets the
// account act, and if it refuses, the act is run through sudo -n.
func bt(timeout time.Duration, args ...string) (string, error) {
c := Cmd{Name: "bluetoothctl", Args: args, Timeout: timeout}
r := run(c)
if strings.Contains(r.Stdout+r.Stderr, "AccessDenied") || strings.Contains(r.Stdout+r.Stderr, "Not authorized") {
c.Root = true
r = run(c)
}
out := ansi.ReplaceAllString(r.Stdout+"\n"+r.Stderr, "")
if r.Error != "" {
return out, failure(c, r)
}
if strings.Contains(out, "No default controller available") {
return out, fmt.Errorf("this machine has no Bluetooth controller bluez can use: none is present, it is blocked (rfkill), or bluetooth.service is not running")
}
if m := btFailed.FindString(out); m != "" || r.Status != 0 {
said := strings.TrimSpace(out)
if said == "" {
said = fmt.Sprintf("exit status %d", r.Status)
}
return out, fmt.Errorf("bluetoothctl %s: %s", strings.Join(args, " "), tail(said, 1000))
}
return out, nil
}
// fields reads bluetoothctl's "\tKey: value" lines; a key seen twice keeps its first value.
func fields(s string) map[string]string {
out := map[string]string{}
for _, l := range strings.Split(s, "\n") {
if !strings.HasPrefix(l, "\t") {
continue
}
k, v, ok := strings.Cut(strings.TrimSpace(l), ":")
if !ok {
continue
}
if _, seen := out[k]; !seen {
out[k] = strings.TrimSpace(v)
}
}
return out
}
func yes(v string) bool { return v == "yes" }
// ControllerAnswer is what bluetooth_controller answers.
type ControllerAnswer struct {
Address string `json:"address"`
Name string `json:"name"`
Alias string `json:"alias"`
Powered bool `json:"powered"`
PowerState string `json:"power_state,omitempty"`
Discoverable bool `json:"discoverable"`
Pairable bool `json:"pairable"`
Discovering bool `json:"discovering"`
}
// Controller answers the default controller.
func Controller() (ControllerAnswer, error) {
out, err := bt(CallTimeout, "show")
if err != nil {
return ControllerAnswer{}, err
}
c := ControllerAnswer{}
for _, l := range strings.Split(out, "\n") {
if f := strings.Fields(l); len(f) >= 2 && f[0] == "Controller" {
c.Address = f[1]
break
}
}
if c.Address == "" {
return ControllerAnswer{}, fmt.Errorf("bluetoothctl show answered no controller: %s", tail(strings.TrimSpace(out), 500))
}
f := fields(out)
c.Name, c.Alias, c.PowerState = f["Name"], f["Alias"], f["PowerState"]
c.Powered, c.Discoverable, c.Pairable, c.Discovering = yes(f["Powered"]), yes(f["Discoverable"]), yes(f["Pairable"]), yes(f["Discovering"])
return c, nil
}
// Power switches the controller on or off.
func Power(on bool) (map[string]any, error) {
word := "off"
if on {
word = "on"
}
if _, err := bt(CallTimeout, "power", word); err != nil {
return nil, err
}
c, err := Controller()
if err != nil {
return nil, err
}
return map[string]any{"powered": c.Powered, "asked": word}, nil
}
// Device is one device bluez knows.
type Device struct {
Address string `json:"address"`
Name string `json:"name"`
Icon string `json:"kind,omitempty"`
Paired bool `json:"paired"`
Bonded bool `json:"bonded"`
Trusted bool `json:"trusted"`
Blocked bool `json:"blocked"`
Connected bool `json:"connected"`
Battery *int `json:"battery_percent,omitempty"`
RSSI *int `json:"rssi,omitempty"`
}
var inParens = regexp.MustCompile(`\((-?[0-9]+)\)`)
// number reads "0x50 (80)" or "-62" as a number.
func number(v string) *int {
if m := inParens.FindStringSubmatch(v); m != nil {
v = m[1]
}
n, err := strconv.Atoi(strings.TrimSpace(v))
if err != nil {
return nil
}
return &n
}
// deviceInfo asks bluez for one device.
func deviceInfo(address string) (Device, error) {
out, err := bt(CallTimeout, "info", address)
if err != nil {
return Device{}, err
}
f := fields(out)
d := Device{Address: address, Name: f["Name"], Icon: f["Icon"], Paired: yes(f["Paired"]), Bonded: yes(f["Bonded"]),
Trusted: yes(f["Trusted"]), Blocked: yes(f["Blocked"]), Connected: yes(f["Connected"])}
if d.Name == "" {
d.Name = f["Alias"]
}
if v, ok := f["Battery Percentage"]; ok {
d.Battery = number(v)
}
if v, ok := f["RSSI"]; ok {
d.RSSI = number(v)
}
return d, nil
}
// listed reads "Device <address> <name>" lines.
func listed(out string) []string {
seen := map[string]bool{}
addrs := []string{}
for _, l := range strings.Split(out, "\n") {
f := strings.Fields(strings.TrimSpace(l))
if len(f) >= 2 && f[0] == "Device" && macAddress.MatchString(f[1]) && !seen[f[1]] {
seen[f[1]] = true
addrs = append(addrs, f[1])
}
}
return addrs
}
// Devices answers the devices bluez knows, with each one's state.
func Devices(which string) (map[string]any, error) {
if err := oneOf("which", which, "all", "paired", "connected", "trusted"); err != nil {
return nil, err
}
args := []string{"devices"}
if which != "all" {
args = append(args, strings.ToUpper(which[:1])+which[1:])
}
out, err := bt(CallTimeout, args...)
if err != nil {
return nil, err
}
devices := []Device{}
for _, a := range listed(out) {
d, err := deviceInfo(a)
if err != nil {
return nil, err
}
devices = append(devices, d)
}
sort.SliceStable(devices, func(i, k int) bool {
if devices[i].Connected != devices[k].Connected {
return devices[i].Connected
}
return devices[i].Name < devices[k].Name
})
return map[string]any{"which": which, "count": len(devices), "devices": devices}, nil
}
// Scan discovers for a while and answers the devices found that are not paired.
func Scan(seconds int) (map[string]any, error) {
// bluetoothctl's own --timeout ends the scan; the command's bound is a little longer.
limit := time.Duration(seconds+4) * time.Second
if _, err := bt(limit, "--timeout", strconv.Itoa(seconds), "scan", "on"); err != nil {
return nil, err
}
out, err := bt(CallTimeout, "devices")
if err != nil {
return nil, err
}
found := []Device{}
for _, a := range listed(out) {
// A device seen a moment ago may have gone out of reach: it is skipped, not a failure.
d, err := deviceInfo(a)
if err != nil {
continue
}
if !d.Paired {
found = append(found, d)
}
}
sort.SliceStable(found, func(i, k int) bool {
ri, rk := -1000, -1000
if found[i].RSSI != nil {
ri = *found[i].RSSI
}
if found[k].RSSI != nil {
rk = *found[k].RSSI
}
return ri > rk
})
return map[string]any{"seconds": seconds, "count": len(found), "found": found}, nil
}
// Act runs one act on a device and answers the device's state afterwards.
func Act(verb, address string) (map[string]any, error) {
limit := CallTimeout
if verb == "connect" {
// A connect waits for the device; bluetoothctl's own timeout ends it first.
if _, err := bt(limit, "--timeout", "15", verb, address); err != nil {
return nil, err
}
} else if _, err := bt(limit, verb, address); err != nil {
return nil, err
}
if verb == "remove" {
return map[string]any{"act": verb, "address": address, "removed": true}, nil
}
d, err := deviceInfo(address)
if err != nil {
return nil, err
}
return map[string]any{"act": verb, "device": d}, nil
}
// Pair pairs a device with an agent that confirms nothing, then trusts it.
func Pair(address string) (map[string]any, error) {
if _, err := bt(CallTimeout, "--agent", "NoInputNoOutput", "--timeout", "15", "pair", address); err != nil {
return nil, err
}
return Act("trust", address)
}
@@ -0,0 +1,163 @@
package main
import (
"strings"
"testing"
)
func TestTheManifestIsTheStackItsToolsAndTheDaemon(t *testing.T) {
m := readManifest(t)
holdsTheBundle(t, m, "bluetooth")
if got := strings.Join(m.packages(), ","); got != "bluez,bluez-utils" {
t.Errorf("packages %s: the applet and the TUI are the operator's", got)
}
s := m.services()["bluetooth.service"]
if s == nil || s["state"] != "running" || s["boot"] != "enabled" {
t.Errorf("%v", s)
}
if len(m.Resources) != 3 {
t.Errorf("no configuration file: /etc/bluetooth/main.conf is the package's, unchanged on both workstations: %v", m.Resources)
}
}
const show = "Controller 4C:82:A9:97:01:8E (public)\n\tName: g14\n\tAlias: g14\n\tPowered: yes\n\tPowerState: on\n\tDiscoverable: no\n\tPairable: yes\n\tUUID: Headset (00001108-0000-1000-8000-00805f9b34fb)\n\tDiscovering: no\n"
func headphones(connected bool) string {
c := "no"
extra := ""
if connected {
c, extra = "yes", "\tBattery Percentage: 0x50 (80)\n"
}
return "Device 80:99:E7:C2:29:DA (public)\n\tName: WH-1000XM4\n\tAlias: WH-1000XM4\n\tIcon: audio-headset\n\tPaired: yes\n\tBonded: yes\n\tTrusted: yes\n\tBlocked: no\n\tConnected: " + c + "\n" + extra + "\tUUID: Headset (00001108-0000-1000-8000-00805f9b34fb)\n"
}
func TestControllerReadsShow(t *testing.T) {
using(t, func(string, Cmd) Result { return ok("\x1b[0;94m" + show) })
c, err := Controller()
if err != nil || c.Address != "4C:82:A9:97:01:8E" || c.Name != "g14" || !c.Powered || c.Discoverable || !c.Pairable {
t.Fatalf("%+v %v", c, err)
}
using(t, func(string, Cmd) Result { return ok("No default controller available\n") })
if _, err := Controller(); err == nil || !strings.Contains(err.Error(), "no Bluetooth controller") {
t.Fatalf("%v", err)
}
}
func TestDevicesAskEachOneAndReportBatteryWhereGiven(t *testing.T) {
f := using(t, func(line string, c Cmd) Result {
switch line {
case "bluetoothctl devices Paired":
return ok("Device 80:99:E7:C2:29:DA WH-1000XM4\nDevice 2C:41:A1:E4:EC:86 Earmuffs\n")
case "bluetoothctl info 80:99:E7:C2:29:DA":
return ok(headphones(true))
}
return ok("Device 2C:41:A1:E4:EC:86 (public)\n\tName: Earmuffs\n\tPaired: yes\n\tConnected: no\n")
})
got, err := Devices("paired")
devices := got["devices"].([]Device)
if err != nil || got["count"] != 2 || !devices[0].Connected || devices[0].Battery == nil || *devices[0].Battery != 80 || devices[1].Battery != nil {
t.Fatalf("%+v %v", got, err)
}
if f.lines()[0] != "bluetoothctl devices Paired" {
t.Errorf("%v", f.lines())
}
if _, err := Devices("nearby"); err == nil {
t.Error("an unknown which")
}
}
func TestAnActThatFailsIsAnErrorWhateverTheExitStatus(t *testing.T) {
using(t, func(line string, c Cmd) Result {
return ok("Attempting to connect to 80:99:E7:C2:29:DA\nFailed to connect: org.bluez.Error.Failed br-connection-page-timeout\n")
})
if _, err := Act("connect", "80:99:E7:C2:29:DA"); err == nil || !strings.Contains(err.Error(), "page-timeout") {
t.Fatalf("%v", err)
}
using(t, func(string, Cmd) Result { return Result{Status: 1, Stdout: "Device 00:11:22:33:44:55 not available\n"} })
if _, err := Act("trust", "00:11:22:33:44:55"); err == nil || !strings.Contains(err.Error(), "not available") {
t.Fatalf("%v", err)
}
}
func TestConnectWaitsWithBluetoothctlsOwnTimeoutAndAnswersTheState(t *testing.T) {
f := using(t, func(line string, c Cmd) Result {
if strings.Contains(line, "connect") {
return ok("Attempting to connect\n[CHG] Device Connected: yes\nConnection successful\n")
}
return ok(headphones(true))
})
got, err := Act("connect", "80:99:E7:C2:29:DA")
if err != nil || !got["device"].(Device).Connected {
t.Fatalf("%v %v", got, err)
}
if f.lines()[0] != "bluetoothctl --timeout 15 connect 80:99:E7:C2:29:DA" || f.asked[0].Timeout != CallTimeout {
t.Errorf("%v", f.lines())
}
}
func TestAnActBluezRefusesTheAccountIsRetriedThroughSudo(t *testing.T) {
f := using(t, func(line string, c Cmd) Result {
if strings.HasPrefix(line, "sudo") {
return ok("Changing power off succeeded\n" + show)
}
return ok("Failed to set power off: org.freedesktop.DBus.Error.AccessDenied\n")
})
if _, err := Power(false); err != nil {
t.Fatal(err)
}
if l := f.lines(); l[0] != "bluetoothctl power off" || l[1] != "sudo -n bluetoothctl power off" {
t.Errorf("%v", l)
}
}
func TestScanIsBoundedAndAnswersUnpairedDevicesStrongestFirst(t *testing.T) {
f := using(t, func(line string, c Cmd) Result {
switch {
case strings.Contains(line, "scan on"):
return ok("Discovery started\n[NEW] Device AA:BB:CC:DD:EE:01 Speaker\n")
case line == "bluetoothctl devices":
return ok("Device 80:99:E7:C2:29:DA WH-1000XM4\nDevice AA:BB:CC:DD:EE:01 Speaker\nDevice AA:BB:CC:DD:EE:02 Phone\nDevice AA:BB:CC:DD:EE:03 Gone\n")
case strings.HasSuffix(line, "EE:01"):
return ok("Device AA:BB:CC:DD:EE:01\n\tName: Speaker\n\tPaired: no\n\tRSSI: 0xffffffc4 (-60)\n")
case strings.HasSuffix(line, "EE:02"):
return ok("Device AA:BB:CC:DD:EE:02\n\tName: Phone\n\tPaired: no\n\tRSSI: -40\n")
case strings.HasSuffix(line, "EE:03"):
return Result{Status: 1, Stdout: "Device AA:BB:CC:DD:EE:03 not available\n"}
}
return ok(headphones(false))
})
got, err := Scan(8)
found := got["found"].([]Device)
if err != nil || len(found) != 2 || found[0].Name != "Phone" || *found[1].RSSI != -60 {
t.Fatalf("%+v %v", got, err)
}
if f.lines()[0] != "bluetoothctl --timeout 8 scan on" || f.asked[0].Timeout.Seconds() != 12 {
t.Errorf("%v %v", f.lines()[0], f.asked[0].Timeout)
}
}
func TestPairUsesAnAgentThatConfirmsNothingAndThenTrusts(t *testing.T) {
f := using(t, func(line string, c Cmd) Result {
if strings.Contains(line, " pair ") {
return ok("Pairing successful\n")
}
return ok(headphones(false))
})
if _, err := Pair("80:99:e7:c2:29:da"); err != nil {
t.Fatal(err)
}
if l := f.lines(); l[0] != "bluetoothctl --agent NoInputNoOutput --timeout 15 pair 80:99:e7:c2:29:da" || l[1] != "bluetoothctl trust 80:99:e7:c2:29:da" {
t.Errorf("%v", l)
}
}
func TestAnAddressIsSixHexPairs(t *testing.T) {
for _, bad := range []string{"", "80:99:E7:C2:29", "80:99:E7:C2:29:DA; rm", "--help", "GG:99:E7:C2:29:DA"} {
if _, err := addressOf(map[string]any{"address": bad}); err == nil {
t.Errorf("%q accepted", bad)
}
}
if a, err := addressOf(map[string]any{"address": "80:99:e7:c2:29:da"}); err != nil || a != "80:99:E7:C2:29:DA" {
t.Errorf("%s %v", a, err)
}
}
@@ -0,0 +1,352 @@
package main
// kit.go is the same file in each of the workstations' tool bundles (fonts, docker-compose, snapd,
// flatpak, cups, bluetooth, xclip, dmenu): how a tool runs a command, escalates, bounds what it
// keeps, and names a failure. A module is built from its own directory, so the file is copied rather
// than shared; a change to one copy is made to all eight.
//
// The rules it holds (novox/hq research 026/05, to-be 38 WP4):
// - the node's tool runtime runs as the operator account, not root (ADR 0175 §4); a command that
// needs root goes through `sudo -n`, never a prompt, and a refusal is named as such;
// - one command gets 20 s, below the runtime's 30 s call limit, and is ended with everything it
// started when it takes longer;
// - each stream is kept to 256 KiB, and the answer says when it was cut;
// - a failure is an error with what went wrong in it, never an empty answer.
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"strings"
"syscall"
"time"
)
// Bounds every command is held to.
const (
CallTimeout = 20 * time.Second
MostOutput = 256 << 10
)
// Cmd is one command a tool runs.
type Cmd struct {
Name string
Args []string
// Stdin is written to the command's standard input when not empty.
Stdin string
// Env is added to this process's own environment.
Env []string
// Root says the command needs root: it is run through `sudo -n` when this process is not root.
Root bool
// Timeout replaces CallTimeout; only a background job (jobs.go) asks for longer.
Timeout time.Duration
// Detached is for a program that forks a child which outlives it, as xclip does to keep the
// selection: its streams go to files, because a pipe the child inherits would hold the call open
// until the child exits.
Detached bool
}
// Result is what a command did.
type Result struct {
Stdout string `json:"stdout"`
Stderr string `json:"stderr"`
Status int `json:"status"`
// Error is why it did not run to an answer: "not-found" when the program is not there,
// "timeout" when it was ended for taking too long, else the spawn error.
Error string `json:"error,omitempty"`
Truncated bool `json:"truncated,omitempty"`
}
// Runner runs a command. Tests replace it; nothing else does.
type Runner func(Cmd) Result
var (
run Runner = execRun
euid = os.Geteuid
)
// argv is the command as it is run: through sudo without a prompt when it needs root and this
// process is not root.
func argv(c Cmd) (string, []string) {
if c.Root && euid() != 0 {
return "sudo", append([]string{"-n", c.Name}, c.Args...)
}
return c.Name, c.Args
}
// bounded keeps the first MostOutput bytes written to it and notes that more came.
type bounded struct {
b bytes.Buffer
cut bool
}
func (w *bounded) Write(p []byte) (int, error) {
room := MostOutput - w.b.Len()
if room <= 0 {
w.cut = w.cut || len(p) > 0
return len(p), nil
}
if len(p) > room {
w.b.Write(p[:room])
w.cut = true
return len(p), nil
}
return w.b.Write(p)
}
func execRun(c Cmd) Result {
timeout := c.Timeout
if timeout <= 0 {
timeout = CallTimeout
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
name, args := argv(c)
cmd := exec.CommandContext(ctx, name, args...)
cmd.Env = append(append(os.Environ(), "LC_ALL=C"), c.Env...)
if !c.Detached {
// Its own process group, so that ending it on a timeout ends what it started too.
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
cmd.Cancel = func() error {
if cmd.Process != nil {
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
}
return nil
}
}
cmd.WaitDelay = 2 * time.Second
if c.Stdin != "" {
cmd.Stdin = strings.NewReader(c.Stdin)
}
var out, errs bounded
var outFile, errFile *os.File
if c.Detached {
var err error
if outFile, err = os.CreateTemp("", "mesh-tool-out-*"); err != nil {
return Result{Status: 127, Error: err.Error()}
}
defer os.Remove(outFile.Name())
defer outFile.Close()
if errFile, err = os.CreateTemp("", "mesh-tool-err-*"); err != nil {
return Result{Status: 127, Error: err.Error()}
}
defer os.Remove(errFile.Name())
defer errFile.Close()
cmd.Stdout, cmd.Stderr = outFile, errFile
} else {
cmd.Stdout, cmd.Stderr = &out, &errs
}
err := cmd.Run()
if c.Detached {
for _, f := range []struct {
file *os.File
into *bounded
}{{outFile, &out}, {errFile, &errs}} {
if _, e := f.file.Seek(0, io.SeekStart); e == nil {
_, _ = io.Copy(f.into, f.file)
}
}
}
r := Result{Stdout: out.b.String(), Stderr: errs.b.String(), Truncated: out.cut || errs.cut}
var exit *exec.ExitError
switch {
case err == nil:
case ctx.Err() == context.DeadlineExceeded:
r.Status, r.Error = 124, "timeout"
case errors.Is(err, exec.ErrNotFound) || errors.Is(err, os.ErrNotExist):
r.Status, r.Error = 127, "not-found"
case errors.As(err, &exit):
r.Status = exit.ExitCode()
default:
r.Status, r.Error = 127, err.Error()
}
return r
}
// call runs a command and answers its result, or an error naming what went wrong.
func call(c Cmd) (Result, error) {
r := run(c)
if r.Status == 0 && r.Error == "" {
return r, nil
}
return r, failure(c, r)
}
// failure names how a command failed: not installed, refused escalation, too slow, or its exit
// status with the end of what it said.
func failure(c Cmd, r Result) error {
program, _ := argv(c)
switch {
case r.Error == "not-found" && program == "sudo":
return fmt.Errorf("%s needs root, and sudo is not installed here for the runtime's account to escalate with", c.Name)
case r.Error == "not-found":
if hint, ok := providedBy[c.Name]; ok {
return fmt.Errorf("%s is not installed on this machine (%s)", c.Name, hint)
}
return fmt.Errorf("%s is not installed on this machine", c.Name)
case r.Error == "timeout":
limit := c.Timeout
if limit <= 0 {
limit = CallTimeout
}
return fmt.Errorf("%s gave no answer within %s and was ended", c.Name, limit)
case r.Error != "":
return fmt.Errorf("%s did not run: %s", c.Name, r.Error)
case program == "sudo" && strings.Contains(r.Stderr, "command not found"):
if hint, ok := providedBy[c.Name]; ok {
return fmt.Errorf("%s is not installed on this machine (%s)", c.Name, hint)
}
return fmt.Errorf("%s is not installed on this machine", c.Name)
case program == "sudo" && strings.HasPrefix(strings.TrimSpace(r.Stderr), "sudo:"):
return fmt.Errorf("%s needs root, and sudo -n refused the runtime's account: %s (the escalation is the sudo module's to declare)",
c.Name, firstLine(r.Stderr))
}
said := tail(strings.TrimSpace(r.Stderr), 2000)
if said == "" {
said = tail(strings.TrimSpace(r.Stdout), 2000)
}
if said == "" {
said = "and said nothing"
}
return fmt.Errorf("%s %s exited %d: %s", c.Name, strings.Join(c.Args, " "), r.Status, said)
}
func firstLine(s string) string {
s = strings.TrimSpace(s)
if i := strings.IndexByte(s, '\n'); i >= 0 {
return s[:i]
}
return s
}
func tail(s string, n int) string {
if len(s) <= n {
return s
}
return "…" + s[len(s)-n:]
}
// lines are a command's output lines, blank ones dropped.
func lines(s string) []string {
out := []string{}
for _, l := range strings.Split(s, "\n") {
if strings.TrimSpace(l) != "" {
out = append(out, strings.TrimRight(l, "\r"))
}
}
return out
}
// Arguments, read the way a tool's JSON arguments arrive.
func text(args map[string]any, key string) (string, error) {
v, ok := args[key]
if !ok || v == nil {
return "", fmt.Errorf("%s is required", key)
}
s, ok := v.(string)
if !ok {
return "", fmt.Errorf("%s must be a string", key)
}
if strings.TrimSpace(s) == "" {
return "", fmt.Errorf("%s must not be empty", key)
}
return s, nil
}
func optText(args map[string]any, key, def string) (string, error) {
v, ok := args[key]
if !ok || v == nil {
return def, nil
}
s, ok := v.(string)
if !ok {
return "", fmt.Errorf("%s must be a string", key)
}
if strings.TrimSpace(s) == "" {
return def, nil
}
return s, nil
}
// optWhole reads a whole number, defaulted, refused below least and held to most.
func optWhole(args map[string]any, key string, def, least, most int) (int, error) {
v, ok := args[key]
if !ok || v == nil {
return def, nil
}
f, ok := v.(float64)
if !ok {
if i, isInt := v.(int); isInt {
f = float64(i)
} else {
return 0, fmt.Errorf("%s must be a number", key)
}
}
if f != float64(int(f)) {
return 0, fmt.Errorf("%s must be a whole number", key)
}
n := int(f)
if n < least {
return 0, fmt.Errorf("%s must be at least %d", key, least)
}
if n > most {
n = most
}
return n, nil
}
func optFlag(args map[string]any, key string, def bool) (bool, error) {
v, ok := args[key]
if !ok || v == nil {
return def, nil
}
b, ok := v.(bool)
if !ok {
return false, fmt.Errorf("%s must be true or false", key)
}
return b, nil
}
func optList(args map[string]any, key string) ([]string, error) {
v, ok := args[key]
if !ok || v == nil {
return nil, nil
}
items, ok := v.([]any)
if !ok {
return nil, fmt.Errorf("%s must be a list of strings", key)
}
out := make([]string, 0, len(items))
for _, it := range items {
s, ok := it.(string)
if !ok || strings.TrimSpace(s) == "" {
return nil, fmt.Errorf("%s must be a list of non-empty strings", key)
}
out = append(out, s)
}
return out, nil
}
// oneOf refuses a value outside a closed set.
func oneOf(key, value string, allowed ...string) error {
for _, a := range allowed {
if value == a {
return nil
}
}
return fmt.Errorf("%s must be one of %s, not %q", key, strings.Join(allowed, ", "), value)
}
// plainName refuses a name that could be read as an option or carries a path or a space: package,
// snap, application and printer names never do.
func plainName(key, value string) error {
if strings.HasPrefix(value, "-") || strings.ContainsAny(value, " \t\n/\\") {
return fmt.Errorf("%s %q is not a plain name", key, value)
}
return nil
}
@@ -0,0 +1,147 @@
package main
// Tests of kit.go, the same in each workstation module.
import (
"strings"
"testing"
"time"
)
// fake records the commands asked and answers each from a function of the command line.
type fake struct {
asked []Cmd
answer func(line string, c Cmd) Result
}
func (f *fake) runner() Runner {
return func(c Cmd) Result {
f.asked = append(f.asked, c)
name, args := argv(c)
line := strings.TrimSpace(name + " " + strings.Join(args, " "))
if f.answer == nil {
return Result{}
}
return f.answer(line, c)
}
}
func (f *fake) lines() []string {
out := []string{}
for _, c := range f.asked {
name, args := argv(c)
out = append(out, strings.TrimSpace(name+" "+strings.Join(args, " ")))
}
return out
}
// using installs a fake runner and a non-root uid for one test.
func using(t *testing.T, answer func(line string, c Cmd) Result) *fake {
t.Helper()
f := &fake{answer: answer}
wasRun, wasUID := run, euid
run, euid = f.runner(), func() int { return 1000 }
t.Cleanup(func() { run, euid = wasRun, wasUID })
return f
}
func ok(stdout string) Result { return Result{Stdout: stdout} }
func TestKitAnActThatNeedsRootGoesThroughSudoWithoutAPromptUnlessAlreadyRoot(t *testing.T) {
was := euid
defer func() { euid = was }()
euid = func() int { return 1000 }
if name, args := argv(Cmd{Name: "x", Args: []string{"a"}, Root: true}); name != "sudo" || strings.Join(args, " ") != "-n x a" {
t.Fatalf("not root: %s %v", name, args)
}
if name, _ := argv(Cmd{Name: "x"}); name != "x" {
t.Fatalf("a read is run as the account: %s", name)
}
euid = func() int { return 0 }
if name, _ := argv(Cmd{Name: "x", Root: true}); name != "x" {
t.Fatalf("as root no sudo: %s", name)
}
}
func TestKitAFailureIsNamedByHowItFailed(t *testing.T) {
was := euid
defer func() { euid = was }()
euid = func() int { return 1000 }
cases := []struct {
c Cmd
r Result
want string
}{
{Cmd{Name: "nothere"}, Result{Status: 127, Error: "not-found"}, "not installed"},
{Cmd{Name: "x", Root: true}, Result{Status: 127, Error: "not-found"}, "sudo is not installed"},
{Cmd{Name: "x", Root: true}, Result{Status: 1, Stderr: "sudo: a password is required\n"}, "sudo -n refused"},
{Cmd{Name: "x", Root: true}, Result{Status: 1, Stderr: "sudo: x: command not found\n"}, "x is not installed"},
{Cmd{Name: "x"}, Result{Status: 124, Error: "timeout"}, "within 20s"},
{Cmd{Name: "x", Args: []string{"y"}}, Result{Status: 3, Stderr: "boom\n"}, "x y exited 3: boom"},
{Cmd{Name: "x"}, Result{Status: 3}, "said nothing"},
}
for _, k := range cases {
err := failure(k.c, k.r)
if err == nil || !strings.Contains(err.Error(), k.want) {
t.Errorf("%+v: %v, want %q", k.r, err, k.want)
}
}
}
func TestKitOutputIsBoundedAndSaysSo(t *testing.T) {
var w bounded
big := strings.Repeat("a", MostOutput+10)
n, _ := w.Write([]byte(big))
if n != len(big) || w.b.Len() != MostOutput || !w.cut {
t.Fatalf("kept %d of %d, cut %v", w.b.Len(), len(big), w.cut)
}
}
func TestKitTheRealRunnerRunsEndsAndReportsAMissingProgram(t *testing.T) {
r := execRun(Cmd{Name: "sh", Args: []string{"-c", "echo out; echo err >&2; exit 3"}})
if r.Status != 3 || strings.TrimSpace(r.Stdout) != "out" || strings.TrimSpace(r.Stderr) != "err" {
t.Fatalf("%+v", r)
}
r = execRun(Cmd{Name: "sh", Args: []string{"-c", "sleep 5 & sleep 5"}, Timeout: 200 * time.Millisecond})
if r.Error != "timeout" {
t.Fatalf("a slow command: %+v", r)
}
r = execRun(Cmd{Name: "no-such-program-anywhere"})
if r.Error != "not-found" {
t.Fatalf("a missing program: %+v", r)
}
r = execRun(Cmd{Name: "cat", Stdin: "given"})
if r.Stdout != "given" {
t.Fatalf("stdin: %+v", r)
}
start := time.Now()
r = execRun(Cmd{Name: "sh", Args: []string{"-c", "echo kept; (sleep 3 &) ; exit 0"}, Detached: true})
if r.Status != 0 || strings.TrimSpace(r.Stdout) != "kept" || time.Since(start) > 2*time.Second {
t.Fatalf("a detached command returns when it exits, not when its child does: %+v after %s", r, time.Since(start))
}
}
func TestKitArgumentsAreReadStrictly(t *testing.T) {
args := map[string]any{"s": "x", "n": float64(5), "f": 1.5, "b": true, "l": []any{"a", "b"}}
if _, err := text(args, "missing"); err == nil {
t.Error("a missing required string")
}
if n, _ := optWhole(args, "n", 1, 1, 3); n != 3 {
t.Errorf("held to most: %d", n)
}
if _, err := optWhole(args, "n", 1, 6, 9); err == nil {
t.Error("below least")
}
if _, err := optWhole(args, "f", 1, 0, 9); err == nil {
t.Error("a fraction")
}
if l, _ := optList(args, "l"); len(l) != 2 {
t.Errorf("list: %v", l)
}
if b, _ := optFlag(args, "b", false); !b {
t.Error("flag")
}
if err := plainName("name", "--all"); err == nil {
t.Error("an option as a name")
}
}
@@ -0,0 +1,135 @@
// The bluetooth module's tools (novox/hq research 027/02, 026/05): the controller, the devices with
// their state and battery, scanning, and pairing, connecting, trusting and forgetting a device. A Go
// bundle the node's runtime launches over stdio (ADR 0188, ADR 0193); it runs as the operator
// account, and speaks to bluez through bluetoothctl.
package main
import (
"fmt"
"os"
stdio "git.novox.be/novox/mesh-sdk/go"
)
var providedBy = map[string]string{
"bluetoothctl": "the bluez-utils package, which this module installs",
}
func main() {
if err := stdio.Serve("", tools()); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
var addressArg = map[string]any{"type": "string", "description": "the device's address, such as 80:99:E7:C2:29:DA, as bluetooth_devices answers it"}
func withAddress(f func(string) (any, error)) func(map[string]any) (any, error) {
return func(args map[string]any) (any, error) {
a, err := addressOf(args)
if err != nil {
return nil, err
}
return f(a)
}
}
func tools() []stdio.Tool {
return []stdio.Tool{
{
Name: "bluetooth_controller",
Description: "The machine's Bluetooth controller: address, name, powered, discoverable, pairable, discovering. (r)",
Input: map[string]any{},
Run: func(map[string]any) (any, error) { return Controller() },
},
{
Name: "bluetooth_power",
Description: "Whether the controller is powered; with on, switch it on or off. (r/a)",
Input: map[string]any{"on": map[string]any{"type": "boolean", "description": "power the controller on (true) or off (false)"}},
Run: func(args map[string]any) (any, error) {
if _, given := args["on"]; !given {
c, err := Controller()
if err != nil {
return nil, err
}
return map[string]any{"powered": c.Powered}, nil
}
on, err := optFlag(args, "on", true)
if err != nil {
return nil, err
}
return Power(on)
},
},
{
Name: "bluetooth_devices",
Description: "The devices bluez knows: every one, or only the paired, connected or trusted. Each with its " +
"name, kind, paired, bonded, trusted, blocked, connected, and its battery where the device reports it. (r)",
Input: map[string]any{"which": map[string]any{"type": "string", "enum": []string{"all", "paired", "connected", "trusted"}, "description": "which devices (default all)"}},
Run: func(args map[string]any) (any, error) {
which, err := optText(args, "which", "all")
if err != nil {
return nil, err
}
return Devices(which)
},
},
{
Name: "bluetooth_scan",
Description: "Discover devices nearby for a few seconds (default 8, at most 15), and answer the ones not " +
"paired, with their signal strength. (r)",
Input: map[string]any{"seconds": map[string]any{"type": "integer", "description": "how long to scan (default 8, at most 15)"}},
Run: func(args map[string]any) (any, error) {
s, err := optWhole(args, "seconds", 8, 1, 15)
if err != nil {
return nil, err
}
return Scan(s)
},
},
{
Name: "bluetooth_connect",
Description: "Connect a paired device, such as headphones. Answers its state afterwards. (a)",
Input: map[string]any{"address": addressArg},
Run: withAddress(func(a string) (any, error) { return Act("connect", a) }),
},
{
Name: "bluetooth_disconnect",
Description: "Disconnect a device. (a)",
Input: map[string]any{"address": addressArg},
Run: withAddress(func(a string) (any, error) { return Act("disconnect", a) }),
},
{
Name: "bluetooth_trust",
Description: "Trust a device, so it may connect by itself; or with trusted false, stop trusting it. (a)",
Input: map[string]any{"address": addressArg, "trusted": map[string]any{"type": "boolean", "description": "trust (default) or untrust"}},
Run: func(args map[string]any) (any, error) {
a, err := addressOf(args)
if err != nil {
return nil, err
}
trusted, err := optFlag(args, "trusted", true)
if err != nil {
return nil, err
}
if trusted {
return Act("trust", a)
}
return Act("untrust", a)
},
},
{
Name: "bluetooth_pair",
Description: "Pair a device found by a scan, and trust it. Works for a device that needs no code to be " +
"confirmed, such as headphones; one that shows a code is paired from the desktop. (a)",
Input: map[string]any{"address": addressArg},
Run: withAddress(func(a string) (any, error) { return Pair(a) }),
},
{
Name: "bluetooth_remove",
Description: "Forget a device: unpair it and drop what bluez knows of it. (a)",
Input: map[string]any{"address": addressArg},
Run: withAddress(func(a string) (any, error) { return Act("remove", a) }),
},
}
}
@@ -0,0 +1,107 @@
package main
// manifest_kit_test.go is the same file in each workstation module: it reads the module's
// definition so the module's own tests can hold it to what it says.
import (
"encoding/json"
"os"
"path/filepath"
"sort"
"strings"
"testing"
)
type manifest struct {
Module string `json:"module"`
Capabilities []string `json:"capabilities"`
Claims []any `json:"claims"`
Seats []any `json:"seats"`
Tools []string `json:"tools"`
Resources []map[string]any `json:"resources"`
Build struct {
Artifacts []map[string]any `json:"artifacts"`
} `json:"build"`
}
func readManifest(t *testing.T) manifest {
t.Helper()
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
if err != nil {
t.Fatal(err)
}
var m manifest
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatalf("module.json: %v", err)
}
return m
}
func (m manifest) resource(id string) map[string]any {
for _, r := range m.Resources {
if r["id"] == id {
return r
}
}
return nil
}
// packages are the packages the module installs, sorted.
func (m manifest) packages() []string {
out := []string{}
for _, r := range m.Resources {
if r["type"] == "package" && r["absent"] != true {
out = append(out, r["package"].(string))
}
}
sort.Strings(out)
return out
}
// services are the units the module declares, by unit name.
func (m manifest) services() map[string]map[string]any {
out := map[string]map[string]any{}
for _, r := range m.Resources {
if r["type"] == "service" {
out[r["unit"].(string)] = r
}
}
return out
}
// holdsTheBundle holds the manifest to the Go bundle this directory builds: every tool registered
// is listed and nothing else, each named <prefix>_…, and the artifact builds this command.
func holdsTheBundle(t *testing.T, m manifest, prefix string) {
t.Helper()
registered := []string{}
for _, tool := range tools() {
registered = append(registered, tool.Name)
if !strings.HasPrefix(tool.Name, prefix+"_") {
t.Errorf("tool %s is not named %s_…", tool.Name, prefix)
}
if tool.Description == "" || tool.Run == nil || tool.Input == nil {
t.Errorf("tool %s is not described, runnable and given an input schema", tool.Name)
}
}
if strings.Join(registered, ",") != strings.Join(m.Tools, ",") {
t.Errorf("registered %v, listed %v", registered, m.Tools)
}
if len(m.Build.Artifacts) != 1 {
t.Fatalf("one artifact, got %d", len(m.Build.Artifacts))
}
cwd, _ := os.Getwd()
binary := filepath.Base(cwd)
a := m.Build.Artifacts[0]
want := map[string]any{"kind": "bundle", "language": "go", "system": "arch", "from": "cmd/" + binary, "binary": binary}
for k, v := range want {
if a[k] != v {
t.Errorf("artifact %s = %v, want %v", k, a[k], v)
}
}
if loads, _ := a["loads"].([]any); len(loads) != 1 || loads[0] != binary {
t.Errorf("artifact loads %v, want [%s]", a["loads"], binary)
}
if m.Claims != nil || m.Seats != nil {
t.Errorf("claims %v, seats %v: this module holds no seat", m.Claims, m.Seats)
}
}
+5
View File
@@ -0,0 +1,5 @@
module bluetooth
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=
+53
View File
@@ -0,0 +1,53 @@
{
"module": "bluetooth",
"version": "1",
"capabilities": [
"package-manager",
"service-manager"
],
"tools": [
"bluetooth_controller",
"bluetooth_power",
"bluetooth_devices",
"bluetooth_scan",
"bluetooth_connect",
"bluetooth_disconnect",
"bluetooth_trust",
"bluetooth_pair",
"bluetooth_remove"
],
"resources": [
{
"id": "stack",
"type": "package",
"package": "bluez"
},
{
"id": "utilities",
"type": "package",
"package": "bluez-utils"
},
{
"id": "daemon",
"type": "service",
"unit": "bluetooth.service",
"state": "running",
"boot": "enabled"
}
],
"build": {
"artifacts": [
{
"name": "tools",
"kind": "bundle",
"language": "go",
"system": "arch",
"from": "cmd/bluetooth-tools",
"binary": "bluetooth-tools",
"loads": [
"bluetooth-tools"
]
}
]
}
}
-24
View File
@@ -1,24 +0,0 @@
# bookshelf's runtime: the tool runtime, carrying this module's compiled code.
#
# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in
# the base images, published like any other artifact — which is what makes this buildable by the
# mesh from a repository and a path (novox/hq ADR 0069) rather than only on a workstation that
# happens to have the siblings.
#
# Two bases, named rather than pinned (novox/hq issue 044): the image this is COMPILED in and the
# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`.
ARG BUILD_BASE
ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build
WORKDIR /app/modules/bookshelf
COPY . .
RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts tools/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE}
COPY --from=build /app/modules/bookshelf/dist /app/modules/bookshelf/dist
# Every serve-time entrypoint, loaded by the runtime in serve mode: tools and events serve, and a
# provider's provisioner runs its reconcile loop in the same process, with the broker connected —
# the convention novox/hq issues 060/061 settled.
ENV MESH_TOOL_MODULES=/app/modules/bookshelf/dist/index.js,/app/modules/bookshelf/dist/tools/index.js
-136
View File
@@ -1,136 +0,0 @@
// The Bookshelf API client — bookshelf's own code, living in the module (novox/hq ADR 0039).
// Ported from the shared hal `arr` client, but self-contained: in nox each Servarr app owns its own
// copy, so a change to Bookshelf's API rebuilds only bookshelf and nothing else. Both this module's
// tools and its events entrypoint import it, and nothing outside bookshelf does.
//
// Bookshelf is a Readarr fork (ghcr.io/pennydreadful/bookshelf). It speaks the Servarr v1 API; its
// content is "book". Unlike Sonarr/Radarr it exposes no calendar endpoint, so there is no calendar
// tool here — matching hal, which excluded bookshelf from its calendar-capable apps.
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
// Bookshelf speaks the v1 API; its content is "book".
const API_VERSION = "v1";
const CONTENT_ENDPOINT = "book";
const APP_NAME = "Bookshelf";
export interface BookshelfQueueItem {
/** The queue record id — stable while the item is in the queue, so events can diff on it. */
id: number;
title: string;
status: string;
size: string;
sizeleft: string;
timeleft?: string;
}
export interface BookshelfContentItem {
title: string;
author?: string;
year?: number;
status?: string;
monitored: boolean;
}
export class BookshelfClient {
readonly baseUrl: string;
constructor(
url: string,
private readonly apiKey: string,
) {
this.baseUrl = url.replace(/\/$/, "");
}
/**
* Build from the module's resolved environment. The URL defaults to the server on this node (the
* runtime shares its network), and the API key is read from MESH_BOOKSHELF_API_KEY or, failing
* that, discovered from the server's own config.xml under MESH_BOOKSHELF_CONFIG_DIR — the same
* file Bookshelf writes it to, so a running server needs nothing configured by hand. Throws when
* no key can be found, so the tools/events simply do not load (the harness treats the throw as
* "exposes nothing").
*/
static fromEnv(env: NodeJS.ProcessEnv = process.env): BookshelfClient {
const url = env.MESH_BOOKSHELF_URL ?? `http://127.0.0.1:${env.MESH_BOOKSHELF_PORT ?? "8787"}`;
const configDir = env.MESH_BOOKSHELF_CONFIG_DIR ?? "/config";
const apiKey = env.MESH_BOOKSHELF_API_KEY ?? BookshelfClient.detectApiKey(configDir);
if (!apiKey) {
throw new Error("Bookshelf not configured — set MESH_BOOKSHELF_API_KEY or make the config dir readable");
}
return new BookshelfClient(url, apiKey);
}
/** Discover the API key from the server's config.xml, falling back to null. Every Servarr app
* writes <ApiKey> into config.xml at the root of its config directory. */
static detectApiKey(configDir: string): string | null {
const config = join(configDir, "config.xml");
if (existsSync(config)) {
const match = readFileSync(config, "utf8").match(/<ApiKey>([^<]+)<\/ApiKey>/);
if (match) return match[1];
}
return null;
}
private async get(endpoint: string, params?: Record<string, string>): Promise<unknown> {
const url = new URL(`${this.baseUrl}/api/${API_VERSION}/${endpoint}`);
if (params) {
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
}
const res = await fetch(url.toString(), { headers: { "X-Api-Key": this.apiKey } });
if (!res.ok) throw new Error(`${APP_NAME} API /${endpoint}: ${res.status} ${await res.text()}`);
return res.json();
}
async getStatus(): Promise<{ appName: string; version: string }> {
const data = (await this.get("system/status")) as { appName?: string; version?: string };
return { appName: data.appName || APP_NAME, version: data.version ?? "unknown" };
}
async getContent(limit?: number): Promise<BookshelfContentItem[]> {
const data = await this.get(CONTENT_ENDPOINT);
const items: any[] = Array.isArray(data) ? data : ((data as any)?.records ?? []);
const mapped = items.map((item) => ({
title: item.title ?? "Unknown",
author: item.author?.authorName ?? item.authorName,
year: item.releaseDate ? new Date(item.releaseDate).getFullYear() : item.year,
status: item.status,
monitored: item.monitored ?? true,
}));
return limit ? mapped.slice(0, limit) : mapped;
}
/** Library search is a filter over existing content, not an indexer lookup — same as hal's. */
async searchContent(term: string): Promise<BookshelfContentItem[]> {
const all = await this.getContent();
const lower = term.toLowerCase();
return all.filter(
(item) =>
item.title.toLowerCase().includes(lower) ||
(item.author?.toLowerCase().includes(lower) ?? false),
);
}
async getQueue(): Promise<{ totalRecords: number; items: BookshelfQueueItem[] }> {
const data = (await this.get("queue", { pageSize: "50" })) as { totalRecords?: number; records?: any[] };
const records = data.records ?? [];
return {
totalRecords: data.totalRecords ?? records.length,
items: records.map((r) => ({
id: r.id,
title: r.title ?? r.book?.title ?? r.author?.authorName ?? "Unknown",
status: r.status ?? "unknown",
size: formatBytes(r.size ?? 0),
sizeleft: formatBytes(r.sizeleft ?? 0),
timeleft: r.timeleft,
})),
};
}
}
function formatBytes(bytes: number): string {
if (bytes === 0) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`;
}
-74
View File
@@ -1,74 +0,0 @@
// bookshelf's events. The tool runtime imports this once the broker is bound. It watches the
// download queue and turns its comings and goings into mesh events — the same mechanism radarr uses,
// applied to a Servarr book manager.
//
// Emits (novox/hq ADR 0041/0042):
// module.bookshelf.book.grabbed — a release entered the queue (Bookshelf grabbed it)
// module.bookshelf.download.completed — a release left the queue, imported. This routing key is
// what the plex module consumes (module.*.download.completed)
// to rescan, so a new audiobook becomes a visible item.
// Consumes: none.
//
// NOTE: the hal bookshelf module emitted no events (its hooks only did install-time provisioning).
// This queue watcher is new in nox, modelled exactly on radarr's — bookshelf is a Servarr app with
// the same queue semantics, so the diff-and-emit pattern carries over unchanged.
//
// The queue is polled and diffed, primed silently on the first look (like plex's index.ts) so a
// restart mid-download does not re-announce everything already in flight as freshly grabbed.
import { emit } from "@novox/mesh-sdk/events";
import { BookshelfClient, type BookshelfQueueItem } from "./client.js";
// Building the client throws when Bookshelf has no URL/key yet. Like the tools (see tools/index.ts),
// the events entrypoint must not crash the runtime for that — it stays idle until configured.
function buildClient(): BookshelfClient | null {
try {
return BookshelfClient.fromEnv();
} catch {
return null;
}
}
const bookshelf = buildClient();
// Bookshelf removes an item from the queue once it has been imported; a "warning"/"failed" status is
// how a stuck or broken grab shows itself, so we do not call those a completion when they vanish.
const FAILED_STATUSES = new Set(["failed", "warning"]);
const inQueue = new Map<number, BookshelfQueueItem>();
let primed = false;
async function pollQueue(bookshelf: BookshelfClient): Promise<void> {
const { items } = await bookshelf.getQueue();
const now = new Map(items.map((i) => [i.id, i]));
if (primed) {
// Entered the queue since last look — Bookshelf grabbed a release.
for (const [id, item] of now) {
if (!inQueue.has(id)) await emit("book.grabbed", { title: item.title, status: item.status });
}
// Left the queue — imported and done, unless it was last seen failing.
for (const [id, item] of inQueue) {
if (!now.has(id) && !FAILED_STATUSES.has(item.status)) {
await emit("download.completed", { title: item.title });
}
}
}
inQueue.clear();
for (const [id, item] of now) inQueue.set(id, item);
primed = true;
}
const tick = (fn: () => Promise<void>, everyMs: number): void => {
const run = (): void => void fn().catch((err) => console.error(`[bookshelf] ${err}`));
setInterval(run, everyMs);
run();
};
if (bookshelf) {
tick(() => pollQueue(bookshelf), 30_000);
console.log("[bookshelf] watching the download queue, emitting grabs and completions");
} else {
console.log("[bookshelf] not configured — events idle until an API key is available");
}
-118
View File
@@ -1,118 +0,0 @@
{
"module": "bookshelf",
"version": "1",
"slug": "books",
"capabilities": [
"container-runtime"
],
"emits": [
"book.grabbed",
"download.completed"
],
"consumes": [],
"own-secrets": {
"broker": "/var/lib/mesh/bookshelf/broker"
},
"listens": [
{
"name": "web",
"port": 8787,
"protocol": "tcp",
"from": "mesh",
"why": "managing the ebook/audiobook library"
}
],
"accesses": [
{
"path": "/services/media/books",
"mode": "read-write"
},
{
"path": "/services/media/downloads",
"mode": "read-write"
}
],
"resources": [
{
"id": "mesh-state",
"type": "directory",
"path": "/var/lib/mesh/bookshelf",
"mode": "0700"
},
{
"id": "config",
"type": "directory",
"path": "/services/bookshelf/config",
"mode": "0700",
"owner": "1000:1000"
},
{
"id": "server",
"type": "container",
"name": "bookshelf",
"image": "ghcr.io/pennydreadful/bookshelf@sha256:388eecc94362580eae31ee0a454be6af516f8a311f8432a521c202fb475f4359",
"env": {
"PUID": "1000",
"PGID": "1000",
"TZ": "Etc/UTC"
},
"ports": [
"8787"
],
"volumes": [
"/services/bookshelf/config:/config",
"/services/media/books:/books",
"/services/media/downloads:/downloads"
]
},
{
"id": "runtime",
"type": "container",
"name": "mesh-bookshelf",
"network": "host",
"volumes": [
"/var/lib/mesh/bookshelf/broker:/run/secrets/broker:ro",
"/services/bookshelf/config:/var/lib/bookshelf/config:ro"
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_BOOKSHELF_URL": "http://127.0.0.1:8787",
"MESH_BOOKSHELF_CONFIG_DIR": "/var/lib/bookshelf/config"
},
"artifact": "runtime"
}
],
"requires": [
"route"
],
"contributes": {
"route": {
"label": "books",
"endpoint": "web"
}
},
"binds": {
"route": "/var/lib/mesh/bookshelf/route.json"
},
"build": {
"on": [
{
"arg": "BUILD_BASE",
"module": "mesh-tools",
"artifact": "build"
},
{
"arg": "RUNTIME_BASE",
"module": "mesh-tools",
"artifact": "runtime"
}
],
"artifacts": [
{
"name": "runtime",
"kind": "image",
"from": "Dockerfile"
}
]
}
}
-14
View File
@@ -1,14 +0,0 @@
{
"name": "@novox/module-bookshelf",
"version": "0.1.0",
"description": "bookshelf — ebook/audiobook management (Readarr fork). Its API client, tools and events live here (novox/hq ADR 0039).",
"type": "module",
"private": true,
"dependencies": {
"@novox/mesh-sdk": "^0.1.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.6.0"
}
}
-69
View File
@@ -1,69 +0,0 @@
// bookshelf's tools — ported from the shared hal `arr` sdk (novox/hq ADR 0039), importing
// bookshelf's own client. They return structured data (not the pre-formatted text hal returned); the
// mesh serves them through the sdk's tool harness. Bookshelf has no calendar endpoint, so there is
// no calendar tool — matching hal, which excluded it from its calendar-capable apps.
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
import { BookshelfClient } from "../client.js";
export function getBookshelfTools(bookshelf: BookshelfClient): ToolDefinition[] {
return [
{
name: "bookshelf_status",
description: "Bookshelf status overview: version, book count, monitored count, queue size.",
input: {},
run: async () => {
const [status, content, queue] = await Promise.all([
bookshelf.getStatus(),
bookshelf.getContent(),
bookshelf.getQueue(),
]);
return {
app: status.appName,
version: status.version,
books: content.length,
monitored: content.filter((c) => c.monitored).length,
queue: queue.totalRecords,
};
},
},
{
name: "bookshelf_library",
description: "List books from the Bookshelf library.",
input: { limit: { type: "number", description: "max items to return (default 50)" } },
run: async (args) => {
const items = await bookshelf.getContent(args.limit ? Number(args.limit) : 50);
return { count: items.length, books: items };
},
},
{
name: "bookshelf_search",
description:
"Search the Bookshelf library for books by title or author (filters existing content, not indexers).",
input: { query: { type: "string", description: "the search term" } },
run: async (args) => {
const query = String(args.query);
return { query, results: await bookshelf.searchContent(query) };
},
},
{
name: "bookshelf_queue",
description: "Show the Bookshelf download queue — what is downloading and how far along.",
input: {},
run: async () => {
const queue = await bookshelf.getQueue();
return { count: queue.totalRecords, items: queue.items };
},
},
];
}
// The tools exist only when Bookshelf is configured; without a URL and key, bookshelf contributes
// none rather than failing the whole runtime.
registerModuleTools("bookshelf", (env) => {
try {
return getBookshelfTools(BookshelfClient.fromEnv(env));
} catch {
return [];
}
});
@@ -1,6 +1,6 @@
ARG GO_BASE
ARG 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,
# 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",
"slug": "agent",
"capabilities": [
"container-runtime"
],
"claims": [
{
"name": "mesh-build-machine",
"scope": "mesh"
"name": "node-build-agent",
"scope": "node"
}
],
"requires": [
@@ -15,49 +16,48 @@
"npm-package-registry"
],
"binds": {
"npm-package-registry": "/var/lib/mesh/builder/package-registry.json"
"npm-package-registry": "${dir:mesh-state}/package-registry.json"
},
"secrets": {
"npm-package-registry": "/var/lib/mesh/builder/package-registry.secret"
"npm-package-registry": "${dir:mesh-state}/package-registry.secret"
},
"own-secrets": {
"broker": "/var/lib/mesh/builder/broker"
"broker": "${dir:mesh-state}/broker"
},
"resources": [
{
"id": "mesh-state",
"type": "directory",
"path": "/var/lib/mesh/builder",
"mode": "0700"
"mode": "0700",
"place": "mesh"
},
{
"id": "workspace",
"type": "directory",
"path": "/var/lib/builder/workspace",
"mode": "0700"
},
{
"id": "builder-env",
"id": "agent-env",
"type": "file",
"path": "/var/lib/mesh/builder/builder.env",
"path": "${dir:mesh-state}/build-agent.env",
"mode": "0600",
"content": "MESH_BROKER_FILE=/run/mesh/broker\nMESH_NODE=${machine:name}\nMESH_REGISTRY=${bound:artifact-store:at}:${bound:artifact-store:port}\nMESH_PACKAGE_BINDING=/run/mesh/package-registry.json\nMESH_NPM_TOKEN_FILE=/run/mesh/package-registry.secret\nMESH_WORKSPACE=/var/lib/builder/workspace\n"
"content": "MESH_BROKER_FILE=/run/mesh/broker\nMESH_NODE=${machine:name}\nMESH_REGISTRY=${bound:artifact-store:at}:${bound:artifact-store:port}\nMESH_PACKAGE_BINDING=/run/mesh/package-registry.json\nMESH_NPM_TOKEN_FILE=/run/mesh/package-registry.secret\nMESH_WORKSPACE=${dir:workspace}\n"
},
{
"id": "server",
"type": "container",
"name": "mesh-builder",
"name": "mesh-build-agent",
"artifact": "server",
"env-file": [
"/var/lib/mesh/builder/builder.env"
"${dir:mesh-state}/build-agent.env"
],
"volumes": [
"/var/lib/mesh/builder:/run/mesh:ro",
"/var/lib/builder/workspace:/var/lib/builder/workspace",
"${dir:mesh-state}:/run/mesh:ro",
"${dir:workspace}:${dir:workspace}",
"/var/run/docker.sock:/var/run/docker.sock"
],
"restart-on": [
"builder-env"
"agent-env"
],
"network": "host"
}
@@ -69,7 +69,8 @@
"kind": "image",
"from": "Dockerfile",
"context": {
"repository": "https://git.novox.be/novox/mesh-controller.git",
"seat": "git",
"repository": "novox/mesh-controller",
"ref": "main"
}
}
+81
View File
@@ -0,0 +1,81 @@
# 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 |
|---|---|
| what this node holds | the module's `holdings` state, one key for this node — the account, the kind, fingerprints and expiries, never a token — written at start and whenever the credentials file changes (hq ADR 0206) |
| a person ran `/login` here | the credentials file gains a refresh token this module never writes; its next report shows it, and the licence manager asks `claude_code_grant` for it, giving its key — the one time a refresh token leaves the node, for the manager to adopt by refreshing it |
| what this node should hold | the licence manager's `bindings` state, this node's key; on a newer generation this module asks `anthropic-licence-manager.current` for its token, sealed to the key it sends, and writes it access-token-only — so the agent here never refreshes. A node that was off reads its key when it is back |
| 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_grant` (for the licence
manager), `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.
## Code
Go, one binary (`cmd/claude-code`) the node's runtime launches. Tested with `go test ./...`; the managed
instruction file is held to the TypeScript renderer it replaced (`testdata/rendered-by-typescript.json`),
and the sealed box is the licence manager's own format.
@@ -0,0 +1,364 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
)
var now = time.Now().UnixMilli()
func node(t *testing.T, name string) (Paths, map[string]string) {
t.Helper()
root := t.TempDir()
p := Paths{State: filepath.Join(root, "state"), Facts: filepath.Join(root, "state", "facts.json"),
Settings: filepath.Join(root, "state", "settings.json"), Home: filepath.Join(root, "home"), Node: name}
_ = os.MkdirAll(p.State, 0o700)
_ = os.MkdirAll(filepath.Join(p.Home, ".claude"), 0o700)
_ = os.WriteFile(p.Facts, []byte(`{"node":"`+name+`","console":"http://127.0.0.1:4270/mcp"}`), 0o600)
_ = os.WriteFile(p.Settings, []byte(`{"role":"","mcp_servers":{}}`), 0o600)
return p, map[string]string{}
}
func writer(w map[string]string) WriteManaged {
return func(name, content string) (string, error) { w[name] = content; return name + ": written", nil }
}
func writeFile(t *testing.T, path, content string) {
t.Helper()
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
}
func creds(t *testing.T, p Paths) map[string]any {
t.Helper()
var c map[string]any
raw, _ := os.ReadFile(p.credentials())
if err := json.Unmarshal(raw, &c); err != nil {
t.Fatal(err)
}
return c["claudeAiOauth"].(map[string]any)
}
// ---- the renderer, held to the TypeScript it replaced -------------------------------------------------
func TestTheRendererWritesWhatTheTypeScriptOneWrote(t *testing.T) {
raw, err := os.ReadFile("testdata/rendered-by-typescript.json")
if err != nil {
t.Fatal(err)
}
var f struct {
Facts Facts `json:"facts"`
Settings Settings `json:"settings"`
Registered Servers `json:"registered"`
WithKey map[string]string `json:"withKey"`
Plain map[string]string `json:"plain"`
}
if err := json.Unmarshal(raw, &f); err != nil {
t.Fatal(err)
}
same := func(label string, got, want map[string]string) {
if got["CLAUDE.md"] != want["CLAUDE.md"] {
t.Errorf("%s: CLAUDE.md differs from the TypeScript's:\n--- go\n%s\n--- typescript\n%s", label, got["CLAUDE.md"], want["CLAUDE.md"])
}
for _, file := range []string{"managed-mcp.json", "managed-settings.json"} {
var a, b any
_ = json.Unmarshal([]byte(got[file]), &a)
_ = json.Unmarshal([]byte(want[file]), &b)
if !reflect.DeepEqual(a, b) {
t.Errorf("%s: %s means something else:\n--- go\n%s\n--- typescript\n%s", label, file, got[file], want[file])
}
}
}
same("with an API key", Render(f.Facts, f.Settings, &Binding{Licence: "api", Kind: "api-key"}, "/state/api-key-helper", f.Registered), f.WithKey)
same("plain", Render(f.Facts, Settings{}, nil, "/h", Servers{}), f.Plain)
}
func TestASettingCannotReplaceTheMeshsOwnEntryAndABadNameIsLeftOut(t *testing.T) {
out := Render(Facts{Node: "w", Console: "http://127.0.0.1:4270/mcp"},
Settings{MCPServers: map[string]map[string]any{"mesh": {"type": "http", "url": "http://evil"}, "bad name": {}}}, nil, "/h", nil)
var mcp struct {
MCPServers map[string]map[string]any `json:"mcpServers"`
}
_ = json.Unmarshal([]byte(out["managed-mcp.json"]), &mcp)
if mcp.MCPServers["mesh"]["url"] != "http://127.0.0.1:4270/mcp" || mcp.MCPServers["bad name"] != nil {
t.Fatalf("%v", mcp.MCPServers)
}
if !reflect.DeepEqual(Render(Facts{Console: "x"}, Settings{}, nil, "/h", nil), Render(Facts{Console: "x"}, Settings{}, nil, "/h", nil)) {
t.Fatal("rendering is not deterministic")
}
}
// ---- the credentials file -----------------------------------------------------------------------------
func i64(v int64) *int64 { return &v }
func TestTheLineageRules(t *testing.T) {
const hour = 3_600_000
g := func(at string, exp int64, rtExp int64) Grant {
return Grant{AccessToken: at, ExpiresAt: exp, RefreshTokenExpiresAt: i64(rtExp)}
}
month := now + 30*24*hour
if d := DecideApply(&Grant{AccessToken: "A", ExpiresAt: now + hour, RefreshTokenExpiresAt: i64(month)}, g("B", now+2*hour, month), false); !d.Apply {
t.Fatal("a newer rotation was refused")
}
if d := DecideApply(&Grant{AccessToken: "new", ExpiresAt: now + 2*hour, RefreshTokenExpiresAt: i64(month)}, g("old", now+hour, month), false); d.Apply || d.Reason != "not-newer" {
t.Fatalf("a late older rotation: %+v", d)
}
if d := DecideApply(&Grant{AccessToken: "A", ExpiresAt: now + 8*hour, RefreshTokenExpiresAt: i64(month)}, g("re", now+hour, now+5*24*hour), false); !d.Apply || !d.Reissued {
t.Fatalf("a re-issued grant: %+v", d)
}
if d := DecideApply(&Grant{AccessToken: "A", ExpiresAt: now + 8*hour}, g("other", now+hour, month), true); !d.Apply {
t.Fatal("a switch was refused")
}
if d := DecideApply(&Grant{AccessToken: "A"}, Grant{AccessToken: "A"}, true); d.Apply || d.Reason != "already-current" {
t.Fatalf("the same token: %+v", d)
}
}
func TestALoginIsSeenAndStrippedWhenTheNodesOwnGrantIsWritten(t *testing.T) {
p, _ := node(t, "laptop")
writeFile(t, p.credentials(), `{"claudeAiOauth":{"accessToken":"at-login","refreshToken":"rt-login","expiresAt":1700000000000},"other":1}`)
login := ReadCredentials(p.credentials())
if !HoldsLogin(login) {
t.Fatal("a login was not seen")
}
if err := WriteCredentials(p.credentials(), WithGrant(login, Grant{AccessToken: "at-mesh", ExpiresAt: 1, Scopes: []string{"user:inference"}})); err != nil {
t.Fatal(err)
}
back := ReadCredentials(p.credentials())
raw, _ := os.ReadFile(p.credentials())
info, _ := os.Stat(p.credentials())
if HoldsLogin(back) || GrantOf(back).AccessToken != "at-mesh" || back["other"] == nil || strings.Contains(string(raw), "rt-login") || info.Mode().Perm() != 0o600 {
t.Fatalf("written %s (mode %v)", raw, info.Mode())
}
}
func TestTheAccountIsReadFromTheAgentsStateFileAndNeverGuessed(t *testing.T) {
p, _ := node(t, "laptop")
writeFile(t, p.account(), `{"oauthAccount":{"accountUuid":"u-1","emailAddress":"a@example.org"},"other":2}`)
if id := ReadIdentity(p.account()); id == nil || id.AccountUUID != "u-1" || id.EmailAddress != "a@example.org" {
t.Fatalf("%+v", id)
}
if ReadIdentity("/nonexistent/.claude.json") != nil {
t.Fatal("an identity from nothing")
}
writeFile(t, p.account(), `{}`)
if ReadIdentity(p.account()) != nil {
t.Fatal("an identity from an empty file")
}
}
// ---- the licence, ADR 0206 ----------------------------------------------------------------------------
func TestWhatANodeHoldsIsReportedWithFingerprintsAndItsAccountNeverAToken(t *testing.T) {
p, _ := node(t, "laptop")
writeFile(t, p.credentials(), `{"claudeAiOauth":{"accessToken":"at-secret","refreshToken":"rt-secret","expiresAt":2000,"refreshTokenExpiresAt":9000}}`)
writeFile(t, p.account(), `{"oauthAccount":{"accountUuid":"u-1","emailAddress":"a@example.org"}}`)
h := HoldingsOf(p)
if h.Node != "laptop" || h.Identity.AccountUUID != "u-1" || *h.Kind != "subscription" || !h.Refresh.Present ||
!strings.HasPrefix(*h.Refresh.Fingerprint, "sha256:") || h.Access.ExpiresAt != 2000 || h.ChangedAt == nil {
t.Fatalf("%+v", h)
}
raw, _ := json.Marshal(h)
if strings.Contains(string(raw), "at-secret") || strings.Contains(string(raw), "rt-secret") {
t.Fatalf("a token is in the report: %s", raw)
}
var keys map[string]any
_ = json.Unmarshal(raw, &keys)
for k := range keys {
if strings.Contains(strings.ToLower(k), "token") || strings.Contains(strings.ToLower(k), "secret") {
t.Fatalf("a field the runtime would refuse: %s", k)
}
}
// The manager reads exactly this shape.
if _, err := time.Parse(time.RFC3339Nano, *h.ChangedAt); err != nil {
t.Fatalf("the manager cannot read the report's time: %v", err)
}
}
func TestTheGrantAnswersOnlyAWaitingLoginSealedToTheManagersKey(t *testing.T) {
p, _ := node(t, "laptop")
manager, _ := GenerateKeyPair()
if a, _ := GrantFor(p, manager.PublicKey); a.Sealed != nil || a.Waiting == nil || *a.Waiting {
t.Fatalf("%+v", a)
}
writeFile(t, p.credentials(), `{"claudeAiOauth":{"accessToken":"at","refreshToken":"rt-login","expiresAt":1}}`)
writeFile(t, p.account(), `{"oauthAccount":{"accountUuid":"u-9"}}`)
a, err := GrantFor(p, manager.PublicKey)
if err != nil || a.Identity.AccountUUID != "u-9" {
t.Fatalf("%+v %v", a, err)
}
plain, _ := Open(*a.Sealed, manager.PrivateKey)
if !strings.Contains(plain, `"refreshToken":"rt-login"`) {
t.Fatalf("opened %s", plain)
}
raw, _ := json.Marshal(a)
if strings.Contains(string(raw), "rt-login") {
t.Fatal("the refresh token crossed in the clear")
}
}
// seat answers `current` as the manager does: the grant sealed to the key the node sent.
func seat(t *testing.T, licence, token string, gen int64, asked *[]string) Ask {
return func(address string, args any) (json.RawMessage, error) {
*asked = append(*asked, address)
key := args.(map[string]any)["public_key"].(string)
g, _ := json.Marshal(Grant{AccessToken: token, ExpiresAt: now + 3_600_000})
box, err := Seal(string(g), key)
if err != nil {
t.Fatal(err)
}
return json.Marshal(Current{Licence: licence, Kind: "subscription", Generation: gen, Sealed: &box})
}
}
func TestANewerGenerationFetchesTheTokenOnceByTheSeatsVerb(t *testing.T) {
p, w := node(t, "laptop")
var asked []string
ask := seat(t, "personal", "at-1", 3, &asked)
if _, err := OnBinding(p, &BindingState{Licence: "personal", Kind: "subscription", Generation: 3}, ask, writer(w)); err != nil {
t.Fatal(err)
}
if len(asked) != 1 || asked[0] != "seat:anthropic-licence-manager.current" || creds(t, p)["accessToken"] != "at-1" {
t.Fatalf("asked %v, credentials %v", asked, creds(t, p))
}
if done, _ := OnBinding(p, &BindingState{Licence: "personal", Kind: "subscription", Generation: 3}, ask, writer(w)); done != "" || len(asked) != 1 {
t.Fatal("an equal generation asked again")
}
if HoldingsOf(p).Generation != 3 || w["managed-mcp.json"] == "" {
t.Fatal("the generation or the managed files were not written")
}
}
func TestTheTokenANodeIsHandedReplacesALoginsGrantAndLeavesNoRefreshToken(t *testing.T) {
p, w := node(t, "laptop")
writeFile(t, p.credentials(), `{"claudeAiOauth":{"accessToken":"at-old","refreshToken":"rt-spent","expiresAt":`+
strings.TrimSpace(string(mustJSON(now+7_200_000)))+`}}`)
var asked []string
out, err := Pull(p, seat(t, "personal", "at-new", 1, &asked), writer(w))
if err != nil || out["applied"] != true {
t.Fatalf("%v %v", out, err)
}
c := creds(t, p)
if c["accessToken"] != "at-new" || c["refreshToken"] != nil || HoldingsOf(p).Refresh.Present {
t.Fatalf("%v", c)
}
}
func mustJSON(v any) []byte { b, _ := json.Marshal(v); return b }
// ---- MCP servers in state, ADR 0201 -------------------------------------------------------------------
// bus is the `servers` state as every node in a test shares it, with each node's watch.
type bus struct {
kept map[string]map[string]any
watchers []func(ServerChange)
}
func (b *bus) Put(key string, value any) error {
v := value.(map[string]any)
b.kept[key] = v
for _, w := range b.watchers {
w(ServerChange{Key: key, Op: "put", Value: v})
}
return nil
}
func (b *bus) Delete(key string) error {
delete(b.kept, key)
for _, w := range b.watchers {
w(ServerChange{Key: key, Op: "delete"})
}
return nil
}
func (b *bus) Keys() ([]string, error) {
var out []string
for k := range b.kept {
out = append(out, k)
}
return out, nil
}
// join is a node joining: its view takes the current state, then every change.
func (b *bus) join(p Paths, w map[string]string) *ServerView {
v := NewServerView(p)
for k, val := range b.kept {
_, _ = OnServerChange(v, ServerChange{Key: k, Op: "put", Value: val}, p, writer(w))
}
b.watchers = append(b.watchers, func(c ServerChange) { _, _ = OnServerChange(v, c, p, writer(w)) })
return v
}
func noOthers() ([]string, error) { return nil, nil }
func TestRegisteringHerePutsItUnderThisNodesKeyAndAsksAboutTheOthers(t *testing.T) {
p, w := node(t, "laptop")
b := &bus{kept: map[string]map[string]any{}}
v := b.join(p, w)
r, err := RegisterServer(p, Registration{Name: "search", Entry: map[string]any{"type": "http", "url": "https://s.example/mcp"}}, b, v, writer(w),
func() ([]string, error) { return []string{"laptop", "server", "desktop"}, nil })
if err != nil || r["here"] != "changed" || !strings.Contains(r["also"].(string), "server, desktop") || b.kept["laptop.search"] == nil {
t.Fatalf("%v %v %v", r, err, b.kept)
}
if !strings.Contains(w["managed-mcp.json"], `"search"`) {
t.Fatal("not rendered")
}
}
func TestEveryNodeRegistrationReachesTheOthersAndALateNodeReadsIt(t *testing.T) {
a, wa := node(t, "laptop")
s, ws := node(t, "server")
b := &bus{kept: map[string]map[string]any{}}
va := b.join(a, wa)
b.join(s, ws)
_, _ = RegisterServer(a, Registration{Name: "docs", Entry: map[string]any{"type": "stdio", "command": "docs-mcp"}, Nodes: []string{"all"}}, b, va, writer(wa), noOthers)
if Registered(s)["docs"] == nil || !strings.Contains(ws["managed-mcp.json"], "docs-mcp") {
t.Fatalf("the other node did not take it: %v", Registered(s))
}
late, wl := node(t, "desktop")
b.join(late, wl)
if Registered(late)["docs"] == nil {
t.Fatal("a node joining later did not read the current set")
}
_, _ = RegisterServer(a, Registration{Name: "docs", Nodes: []string{"all"}}, b, va, writer(wa), noOthers)
if Registered(s)["docs"] != nil || Registered(late)["docs"] != nil {
t.Fatal("an unregistration did not reach every node")
}
}
func TestANodesOwnRegistrationOverridesTheOneForEveryNode(t *testing.T) {
a, wa := node(t, "laptop")
s, ws := node(t, "server")
b := &bus{kept: map[string]map[string]any{}}
va := b.join(a, wa)
b.join(s, ws)
_, _ = RegisterServer(a, Registration{Name: "x", Entry: map[string]any{"type": "http", "url": "https://all"}, Nodes: []string{"all"}}, b, va, writer(wa), noOthers)
_, _ = RegisterServer(a, Registration{Name: "x", Entry: map[string]any{"type": "http", "url": "https://laptop"}}, b, va, writer(wa), noOthers)
if Registered(a)["x"]["url"] != "https://laptop" || Registered(s)["x"]["url"] != "https://all" {
t.Fatalf("%v %v", Registered(a), Registered(s))
}
r, _ := RegisterServer(a, Registration{Name: "x"}, b, va, writer(wa), noOthers)
if !strings.Contains(r["still"].(string), "still applies here") || Registered(a)["x"]["url"] != "https://all" {
t.Fatalf("%v", r)
}
}
func TestABadEntryIsRefusedBeforeAnythingIsPut(t *testing.T) {
p, w := node(t, "laptop")
b := &bus{kept: map[string]map[string]any{}}
v := b.join(p, w)
r, _ := RegisterServer(p, Registration{Name: "mesh", Entry: map[string]any{"type": "http", "url": "https://x"}}, b, v, writer(w), noOthers)
if r["registered"] != false || len(b.kept) != 0 {
t.Fatalf("%v %v", r, b.kept)
}
if done, _ := OnServerChange(v, ServerChange{Key: "server.b", Op: "put", Value: map[string]any{"type": "http", "url": "https://b"}}, p, writer(w)); done != "" {
t.Fatal("another node's key changed this one")
}
}
@@ -0,0 +1,198 @@
package main
// The agent's credentials file, and whether an offered grant may replace what it holds (novox/hq ADR 0183,
// ADR 0206, 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 bound to a licence never holds a refresh token, so
// the one this module writes never carries one; a refresh token found there is a person's login.
//
// 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 (
"bytes"
"encoding/json"
"math"
"os"
"path/filepath"
)
// Grant is what the manager hands a node: an access token and its expiries, never a refresh token.
type Grant struct {
AccessToken string `json:"accessToken"`
ExpiresAt int64 `json:"expiresAt"`
RefreshTokenExpiresAt *int64 `json:"refreshTokenExpiresAt,omitempty"`
Scopes []string `json:"scopes,omitempty"`
SubscriptionType string `json:"subscriptionType,omitempty"`
RateLimitTier string `json:"rateLimitTier,omitempty"`
}
// Decision is whether a handed grant is applied, and why not.
type Decision struct {
Apply bool
Reissued bool
Reason string // already-current | not-newer
}
// generationTolerance: two refresh-token expiries within a day are one lineage; a login starts a fresh
// window weeks away.
const generationTolerance = 24 * 60 * 60 * 1000
func sameGeneration(a, b *int64) bool {
if a == nil || b == nil {
return true
}
return math.Abs(float64(*a-*b)) <= generationTolerance
}
// DecideApply says whether an offered grant replaces the one held; switch is a move to another licence.
func DecideApply(local *Grant, offered Grant, switching bool) Decision {
if local == nil || local.AccessToken == "" {
return Decision{Apply: true}
}
if local.AccessToken == offered.AccessToken {
return Decision{Reason: "already-current"}
}
reissued := !sameGeneration(local.RefreshTokenExpiresAt, offered.RefreshTokenExpiresAt)
if !switching && !reissued && local.ExpiresAt >= offered.ExpiresAt {
return Decision{Reason: "not-newer"}
}
return Decision{Apply: true, Reissued: reissued}
}
// Credentials is the file as found, every key kept — the vendor's other keys are not this module's.
type Credentials map[string]any
func (c Credentials) oauth() map[string]any {
o, _ := c["claudeAiOauth"].(map[string]any)
return o
}
// ReadCredentials reads the file, keeping numbers as written; nil when there is none.
func ReadCredentials(path string) Credentials {
raw, err := os.ReadFile(path)
if err != nil {
return nil
}
dec := json.NewDecoder(bytes.NewReader(raw))
dec.UseNumber()
var c Credentials
if dec.Decode(&c) != nil {
return nil
}
return c
}
func number(v any) (int64, bool) {
switch n := v.(type) {
case json.Number:
i, err := n.Int64()
if err != nil {
f, err := n.Float64()
return int64(f), err == nil
}
return i, true
case float64:
return int64(n), true
case int64:
return n, true
}
return 0, false
}
// GrantOf is the grant the file holds, or nil.
func GrantOf(c Credentials) *Grant {
o := c.oauth()
at, _ := o["accessToken"].(string)
if at == "" {
return nil
}
g := &Grant{AccessToken: at}
g.ExpiresAt, _ = number(o["expiresAt"])
if v, ok := number(o["refreshTokenExpiresAt"]); ok {
g.RefreshTokenExpiresAt = &v
}
return g
}
// HoldsLogin says the file holds a refresh token — which this module never writes, so a person's login.
func HoldsLogin(c Credentials) bool {
rt, _ := c.oauth()["refreshToken"].(string)
return rt != ""
}
// RefreshTokenOf is the refresh token a login left, or "".
func RefreshTokenOf(c Credentials) string {
rt, _ := c.oauth()["refreshToken"].(string)
return rt
}
// WithGrant lays the handed grant over what is there, and deletes any refresh token.
func WithGrant(local Credentials, g Grant) Credentials {
next := Credentials{}
for k, v := range local {
next[k] = v
}
oauth := map[string]any{}
for k, v := range local.oauth() {
oauth[k] = v
}
oauth["accessToken"] = g.AccessToken
oauth["expiresAt"] = g.ExpiresAt
if g.RefreshTokenExpiresAt != nil {
oauth["refreshTokenExpiresAt"] = *g.RefreshTokenExpiresAt
}
if len(g.Scopes) > 0 {
oauth["scopes"] = g.Scopes
}
if g.SubscriptionType != "" {
oauth["subscriptionType"] = g.SubscriptionType
}
if g.RateLimitTier != "" {
oauth["rateLimitTier"] = g.RateLimitTier
}
delete(oauth, "refreshToken")
next["claudeAiOauth"] = oauth
return next
}
// ReplacedBy is the handed grant in place of the old licence's, whole — scopes and subscription included;
// only keys outside the grant stay. No refresh token survives.
func ReplacedBy(local Credentials, g Grant) Credentials {
next := Credentials{}
for k, v := range local {
if k != "claudeAiOauth" {
next[k] = v
}
}
return WithGrant(next, g)
}
// WriteCredentials writes atomically at 0600: a partial credentials file must never be read as a whole one.
func WriteCredentials(path string, c Credentials) error {
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return err
}
raw, err := indented(c)
if err != nil {
return err
}
tmp := path + ".mesh-tmp"
if err := os.WriteFile(tmp, raw, 0o600); err != nil {
return err
}
return os.Rename(tmp, path)
}
// indented is JSON as the agent's own files are written: two-space indent, a trailing newline, nothing
// escaped that need not be.
func indented(v any) ([]byte, error) {
var b bytes.Buffer
enc := json.NewEncoder(&b)
enc.SetEscapeHTML(false)
enc.SetIndent("", " ")
err := enc.Encode(v)
return b.Bytes(), err
}
@@ -0,0 +1,84 @@
package main
// 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 report and attribute a login;
// written, three keys and nothing else, when a licence is switched, so the account Claude Code shows is
// the one whose token it now holds.
import (
"bytes"
"encoding/json"
"os"
)
// Identity is an account as the agent's state file names it.
type Identity struct {
AccountUUID string `json:"accountUuid"`
EmailAddress string `json:"emailAddress,omitempty"`
OrganizationUUID string `json:"organizationUuid,omitempty"`
}
func readState(path string) (map[string]any, bool) {
raw, err := os.ReadFile(path)
if err != nil {
return nil, false
}
dec := json.NewDecoder(bytes.NewReader(raw))
dec.UseNumber()
var m map[string]any
if dec.Decode(&m) != nil || m == nil {
return nil, false
}
return m, true
}
// ReadIdentity is the account the state file names, or nil — never a guess.
func ReadIdentity(path string) *Identity {
m, ok := readState(path)
if !ok {
return nil
}
a, _ := m["oauthAccount"].(map[string]any)
uuid, _ := a["accountUuid"].(string)
if uuid == "" {
return nil
}
id := &Identity{AccountUUID: uuid}
id.EmailAddress, _ = a["emailAddress"].(string)
id.OrganizationUUID, _ = a["organizationUuid"].(string)
return id
}
// WriteIdentity points the state file's account at id, keeping every other key as found; answers whether
// the file changed. A file that is there and cannot be read as an object is left alone.
func WriteIdentity(path string, id Identity) (bool, error) {
m, ok := readState(path)
if !ok {
if _, err := os.Stat(path); err == nil {
return false, nil
}
m = map[string]any{}
}
current, _ := m["oauthAccount"].(map[string]any)
if current == nil {
current = map[string]any{}
}
e, _ := current["emailAddress"].(string)
o, _ := current["organizationUuid"].(string)
if current["accountUuid"] == id.AccountUUID && e == id.EmailAddress && o == id.OrganizationUUID {
return false, nil
}
current["accountUuid"] = id.AccountUUID
current["emailAddress"] = id.EmailAddress
current["organizationUuid"] = id.OrganizationUUID
m["oauthAccount"] = current
raw, err := indented(m)
if err != nil {
return false, err
}
tmp := path + ".mesh-tmp"
if err := os.WriteFile(tmp, raw, 0o600); err != nil {
return false, err
}
return true, os.Rename(tmp, path)
}
@@ -0,0 +1,12 @@
package main
// instructionsText is the managed instruction file, generated from the TypeScript renderer it replaced so
// the file under the agent's managed directory did not change by a byte when the module moved to Go;
// a test holds it to that renderer's own output (testdata/rendered-by-typescript.json).
func instructionsText(node, role string) string {
return "# This machine is a node of a Novox mesh\n\nWritten by the mesh's `claude-code` module. Edit the module's settings or the catalogue, never this file:\nit is rewritten whenever the module renders.\n\n## Who this node is\n\n- **Node:** `" +
node +
"`\n- **Role:** " +
role +
"\n- The other nodes, their roles and what runs where: ask the controller (`mesh-controller.nodes`,\n `mesh-controller.node`). Nothing here lists them, because a copy drifts.\n\n## How a session on this mesh works\n\nThe console is the only way to the mesh: the MCP server named `mesh`. It offers five tools, and\neverything else is an address you find and call through them:\n\n- `mesh_search` — words in, matching addresses out. `mesh_describe` — one address's arguments.\n- `mesh_call` — call an address. A seat the mesh holds once is `<seat>.<verb>` (the mesh's own verbs\n are `mesh-controller.<verb>`: `status`, `plan`, `node`, `assign`, `push`, `settings`);\n a module on a machine is `<node>/<module>.<tool>`.\n- `mesh_overview` and `mesh_machine` — the mesh's seats and machines, and what one machine runs.\n\n- **Symptom first.** For an error, a failing service or anything unexpected, search the record with the\n literal text before forming a hypothesis: the records module's `records_search`, then\n `records_read`.\n- **Ask the mesh before changing it**, and change it through the controller's verbs or the catalogue.\n- **A licence** through the `anthropic-licence-manager` seat's verbs. Never edit the agent's credentials\n file by hand, never print or ask for a token.\n\n## Hard rules\n\n- A file the mesh manages is changed through the verb or the catalogue that owns it, never on disk. If\n unsure, `mesh-controller.plan` for the node says what the mesh writes there.\n- Never write to a store's database by hand; schema changes are numbered migrations.\n- Never push to a main branch: a branch, a pull request, and a human approval for every merge.\n- The mesh creates no symlinks, and nobody else does either.\n- A package is declared in a module, never installed by hand.\n\n## Conventions\n\n- Commit messages are concise, in the imperative, about why.\n- Test before pushing: nodes update unattended.\n- The playbooks in the record say how research, decisions, designs, issues and hand-offs are done.\n"
}
+365
View File
@@ -0,0 +1,365 @@
// claude-code's bundle (novox/hq design 36, ADR 0183, ADR 0206): a binary the node's runtime launches over
// stdio as the operator account (ADR 0193) and is the bus for (ADR 0198). It is given its state directory
// and two files the mesh renders into it (ADR 0192), beside the runtime's own words.
//
// At start it renders the agent's managed directory, reports what this node holds as the module's
// `holdings` state and again whenever the credentials file changes, watches the licence manager's
// `bindings` state for this node and fetches the token when it says so, and watches the module's
// `servers` state — every node's MCP server registrations (ADR 0201). node.go holds the logic.
//
// stdout is the MCP channel; everything this module says, it says on stderr.
package main
import (
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
)
func say(format string, args ...any) {
fmt.Fprintf(os.Stderr, "[claude-code] "+format+"\n", args...)
}
// writeManaged writes one managed file as root, only when its content changed. From a staged file, never
// /dev/stdin: a child's input may be a socket, which /dev/stdin cannot open (found on the first assignment).
func writeManaged(name, content string) (string, error) {
path := filepath.Join(ManagedDir, name)
if was, err := os.ReadFile(path); err == nil && string(was) == content {
return name + ": unchanged", nil
}
staged, err := os.MkdirTemp("", "claude-code-")
if err != nil {
return "", err
}
defer os.RemoveAll(staged)
source := filepath.Join(staged, name)
if err := os.WriteFile(source, []byte(content), 0o644); err != nil {
return "", err
}
args := []string{"install", "-D", "-m", "0644", source, path}
if os.Geteuid() != 0 {
args = append([]string{"sudo", "-n"}, args...)
}
if out, err := exec.Command(args[0], args[1:]...).CombinedOutput(); err != nil {
return "", fmt.Errorf("%s: could not be written to %s (%s); the module writes there through the operator account's passwordless sudo",
name, ManagedDir, strings.TrimSpace(string(out)))
}
return name + ": written", nil
}
// ask is a tool on the bus, through the runtime: its answer is the tool's value.
func ask(address string, args any) (json.RawMessage, error) { return stdio.Ask(address, args) }
// stateOf adapts the SDK's state to what node.go asks of one.
type stateOf struct{ s stdio.KeptState }
func (s stateOf) Put(key string, value any) error { _, err := s.s.Put(key, value); return err }
func (s stateOf) Delete(key string) error { return s.s.Delete(key) }
func (s stateOf) Keys() ([]string, error) { return s.s.Keys() }
// nodesRunningMe is the nodes claude-code runs on, from the controller's list of modules — for the register
// tool's question.
func nodesRunningMe() ([]string, error) {
raw, err := ask("seat:mesh-controller.modules", map[string]any{})
if err != nil {
return nil, err
}
var answer struct {
Output string `json:"output"`
}
text := string(raw)
if json.Unmarshal(raw, &answer) == nil && answer.Output != "" {
text = answer.Output
}
for _, line := range strings.Split(text, "\n") {
if !strings.HasPrefix(line, "claude-code ") {
continue
}
_, on, ok := strings.Cut(line, " on ")
if !ok || strings.TrimSpace(on) == "nothing" {
return nil, nil
}
var out []string
for _, n := range strings.Split(on, ",") {
if n = strings.TrimSpace(n); n != "" {
out = append(out, n)
}
}
return out, nil
}
return nil, nil
}
func fingerprintOfFile(path string) any {
raw, err := os.ReadFile(path)
if err != nil {
return nil
}
return Fingerprint(string(raw))
}
func status(p Paths) map[string]any {
creds := ReadCredentials(p.credentials())
var token any
if g := GrantOf(creds); g != nil {
token = map[string]any{"fingerprint": Fingerprint(g.AccessToken), "expiresAt": stamp(g.ExpiresAt), "loginWaiting": HoldsLogin(creds)}
}
var managed []map[string]any
for _, f := range []string{"managed-mcp.json", "managed-settings.json", "CLAUDE.md"} {
path := filepath.Join(ManagedDir, f)
managed = append(managed, map[string]any{"file": path, "fingerprint": fingerprintOfFile(path)})
}
var licence any
var b Binding
if readJSON(p.binding(), &b) {
licence = b
}
names := []string{}
for n := range Registered(p) {
names = append(names, n)
}
return map[string]any{"node": p.Node, "licence": licence, "token": token, "holdings": HoldingsOf(p),
"managed": managed, "registered": names}
}
func str(description string) map[string]any {
return map[string]any{"type": "string", "description": description}
}
// nodesOf reads the tools' `nodes` argument: absent is this node, "all" every node, else a list.
func nodesOf(v any) []string {
s, _ := v.(string)
s = strings.TrimSpace(s)
switch s {
case "":
return nil
case "all":
return []string{"all"}
}
var out []string
for _, n := range strings.Split(s, ",") {
if n = strings.TrimSpace(n); n != "" {
out = append(out, n)
}
}
return out
}
func tools(p Paths, servers ServerState, view *ServerView) []stdio.Tool {
nodesArg := str(`more nodes: "all" for every node running claude-code, or a comma-separated list; absent is this node only`)
return []stdio.Tool{
{Name: "claude_code_status",
Description: "Claude Code on this machine as the mesh configured it: the licence it holds and when its token expires, what it reports holding, the managed files, the MCP servers registered here. Fingerprints only, never a token.",
Run: func(map[string]any) (any, error) { return status(p), nil }},
{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.",
Run: func(map[string]any) (any, error) {
out, err := RenderNow(p, writeManaged)
return map[string]any{"rendered": out}, err
}},
{Name: "claude_code_pull",
Description: "Ask the licence manager for this node's current token now and apply it, rather than waiting for its binding to change.",
Run: func(map[string]any) (any, error) { return Pull(p, ask, writeManaged) }},
{Name: "claude_code_grant",
Description: "For the licence manager (ADR 0206): the full grant in this node's credentials file — a login made here — sealed to the public key given, with the account it belongs to. Nothing when no login is waiting. Never answers a token in the clear.",
Input: map[string]any{"public_key": str("the manager's public key, PEM; the grant opens only with its private half")},
Run: func(a map[string]any) (any, error) {
key, _ := a["public_key"].(string)
if !strings.Contains(key, "PUBLIC KEY") {
return nil, errors.New("claude_code_grant seals to a public key, and none was given")
}
return GrantFor(p, key)
}},
{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.",
Run: func(map[string]any) (any, error) {
keys, err := servers.Keys()
return map[string]any{"here": Registered(p), "everywhere": keys}, err
}},
{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: map[string]any{
"name": str("the server's name: letters, digits, - and _"),
"type": str("http, sse or stdio (default stdio when a command is given, http when a url is)"),
"url": str("an http or sse server's url"),
"command": str("a stdio server's program"),
"args": map[string]any{"type": "array", "description": "a stdio server's arguments"},
"env": map[string]any{"type": "object", "description": "a stdio server's environment"},
"headers": map[string]any{"type": "object", "description": "an http server's headers"},
"nodes": nodesArg,
},
Run: func(a map[string]any) (any, error) {
entry := map[string]any{}
if t, _ := a["type"].(string); t != "" {
entry["type"] = t
} else if _, hasURL := a["url"]; hasURL {
entry["type"] = "http"
} else {
entry["type"] = "stdio"
}
for _, k := range []string{"url", "command", "args", "env", "headers"} {
if v, ok := a[k]; ok {
entry[k] = v
}
}
name, _ := a["name"].(string)
return RegisterServer(p, Registration{Name: name, Entry: entry, Nodes: nodesOf(a["nodes"])}, servers, view, writeManaged, nodesRunningMe)
}},
{Name: "claude_code_mcp_unregister",
Description: "Remove an MCP server registered through this module, on this node or more.",
Input: map[string]any{"name": str("the server's name"), "nodes": nodesArg},
Run: func(a map[string]any) (any, error) {
name, _ := a["name"].(string)
return RegisterServer(p, Registration{Name: name, Nodes: nodesOf(a["nodes"])}, servers, view, writeManaged, nodesRunningMe)
}},
}
}
// persist asks the state again until it answers: its bucket or the bus's grant may arrive after the module.
func persist(what string, attempt func() error, done func(refusals int)) {
waits := []time.Duration{2 * time.Second, 5 * time.Second, 10 * time.Second, 30 * time.Second}
for n := 0; ; n++ {
err := attempt()
if err == nil {
done(n)
return
}
pause := time.Minute
if n < len(waits) {
pause = waits[n]
}
say("%s not yet (%v); asking again in %s", what, err, pause)
time.Sleep(pause)
}
}
func main() {
p, launched := PathsFrom(os.Getenv)
if !launched {
// Outside a launch — a build, a check — it serves nothing and says why.
say("not launched by the runtime with this module's words; serving no tools")
if err := stdio.Serve("", nil); err != nil {
os.Exit(1)
}
return
}
if _, err := Keypair(p); err != nil {
say("this module's key: %v", err)
}
if out, err := RenderNow(p, writeManaged); err != nil {
say("%v", err)
} else {
for _, line := range out {
if !strings.HasSuffix(line, "unchanged") {
say("%s", line)
}
}
}
servers := stateOf{stdio.State("servers")}
view := NewServerView(p)
go run(p, view)
if err := stdio.Serve("", tools(p, servers, view)); err != nil {
say("%v", err)
os.Exit(1)
}
}
// run is the module's long-running half, beside the tools (ADR 0198).
func run(p Paths, view *ServerView) {
// Every node's MCP servers: the whole current set first, then each change (ADR 0201).
go persist("watching the MCP servers", func() error {
return stdio.State("servers").Watch("", func(c stdio.StateChange) error {
var value map[string]any
_ = json.Unmarshal(c.Value, &value)
if done, err := OnServerChange(view, ServerChange{Key: c.Key, Op: c.Op, Value: value}, p, writeManaged); err != nil {
say("taking %s %s: %v", c.Op, c.Key, err) // the view took it; the next render writes it
} else if done != "" {
say("%s", done)
}
return nil
})
}, func(n int) { say("watching the MCP servers%s", refusals(n)) })
// What this node holds (ADR 0206): at start — a node already logged in is reported at once — and on
// every change of the credentials file, polled, because the file is replaced by rename and a watch on
// the old inode would go quiet. Fingerprints and expiries only.
holdings := stdio.State("holdings")
reported := ""
report := func() {
now := HoldingsOf(p)
raw, _ := json.Marshal(now)
if string(raw) == reported {
return
}
persist("reporting what this node holds", func() error { _, err := holdings.Put(p.Node, now); return err }, func(int) {
reported = string(raw)
account := "no account"
if now.Identity != nil && now.Identity.EmailAddress != "" {
account = now.Identity.EmailAddress
}
line := "reported: " + account
if now.Kind != nil {
line += ", " + *now.Kind
}
if now.Refresh.Present {
line += ", a login waiting"
}
if now.Licence != nil {
line += fmt.Sprintf(", licence %s g%d", *now.Licence, now.Generation)
}
say("%s", line)
})
}
// What this node should hold (ADR 0206): the manager's `bindings` key for this node; a newer
// generation is fetched with the seat's `current`, sealed to this module's key.
go persist("watching this node's licence binding", func() error {
return stdio.State(Manager+".bindings").Watch(p.Node, func(c stdio.StateChange) error {
if c.Key != p.Node {
return nil
}
var b *BindingState
if c.Op == "put" {
b = &BindingState{}
if err := json.Unmarshal(c.Value, b); err != nil {
return nil
}
}
if done, err := OnBinding(p, b, ask, writeManaged); err != nil {
say("fetching this node's token failed: %v", err)
} else if done != "" {
say("%s", done)
}
go report()
return nil
})
}, func(n int) { say("watching this node's licence binding%s", refusals(n)) })
report()
var last string
for range time.Tick(5 * time.Second) {
info, err := os.Stat(p.credentials())
now := "absent"
if err == nil {
now = fmt.Sprintf("%d/%d", info.ModTime().UnixNano(), info.Size())
}
if now != last {
last = now
report()
}
}
}
func refusals(n int) string {
if n == 0 {
return ""
}
return fmt.Sprintf(" (after %d refusal(s))", n)
}
+529
View File
@@ -0,0 +1,529 @@
package main
// What claude-code does on a node, written against what it is handed — a way to ask a tool on the bus, its
// own state, a way to write a managed file — so every path is tested without a bus (novox/hq design 36,
// ADR 0183, ADR 0201, ADR 0206).
//
// Over NATS, and nothing an event: what is current is state, and a secret only ever travels on a request,
// sealed to its one recipient.
// - What this node holds is the module's `holdings` state, one key per node: the account, the kind,
// fingerprints and expiries — never a token. Written at start and on every change of the credentials
// file, so the licence manager learns a login, or a node already logged in, from the state alone.
// - The grant itself leaves only when the manager asks `claude_code_grant`, sealed to the key it gives.
// - What this node should hold is the manager's `bindings` state; a newer generation for this node is
// fetched with the seat's `current` verb, sealed to this module's key, and written access-token-only.
// - An MCP server registered through this module is a key in its `servers` state — `all.<server>` for
// every node, `<node>.<server>` for one — which every node watches.
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
)
// Seat is the licence manager's role, and Manager the module whose `bindings` state this one reads.
const (
Seat = "anthropic-licence-manager"
Manager = "claude-licence-manager"
)
// SeatVerb is a seat's verb as the runtime addresses it: a role, not a module.
func SeatVerb(verb string) string { return "seat:" + Seat + "." + verb }
// Fingerprint names a token without being one: the first 16 hex of its SHA-256, as the manager computes it.
func Fingerprint(s string) string {
sum := sha256.Sum256([]byte(s))
return "sha256:" + hex.EncodeToString(sum[:])[:16]
}
// Paths are where this node's files are, from the module's words (ADR 0192).
type Paths struct {
State, Facts, Settings, Home, Node string
}
// PathsFrom reads them, or answers false outside a launch.
func PathsFrom(env func(string) string) (Paths, bool) {
p := Paths{State: env("MESH_CLAUDE_CODE_STATE"), Facts: env("MESH_CLAUDE_CODE_FACTS"),
Settings: env("MESH_CLAUDE_CODE_SETTINGS"), Home: env("MESH_OPERATOR_HOME"), Node: env("MESH_NODE")}
return p, p.State != "" && p.Facts != "" && p.Settings != "" && p.Home != "" && p.Node != ""
}
func (p Paths) credentials() string { return filepath.Join(p.Home, ".claude", ".credentials.json") }
func (p Paths) account() string { return filepath.Join(p.Home, ".claude.json") }
func (p Paths) binding() string { return filepath.Join(p.State, "licence.json") }
func (p Paths) apiKey() string { return filepath.Join(p.State, "api-key") }
func (p Paths) helper() string { return filepath.Join(p.State, "api-key-helper") }
func (p Paths) registry() string { return filepath.Join(p.State, "mcp-servers.json") }
// Ask is a tool on the bus: its address and arguments in, its JSON answer out.
type Ask func(address string, args any) (json.RawMessage, error)
// WriteManaged writes one managed file and answers what happened.
type WriteManaged func(name, content string) (string, error)
func readJSON(path string, into any) bool {
raw, err := os.ReadFile(path)
return err == nil && json.Unmarshal(raw, into) == nil
}
// Keypair is this module's own, made once in its state; the TypeScript module's files are kept, so a node
// moving to this binary keeps the key it had.
func Keypair(p Paths) (KeyPair, error) {
priv, pub := filepath.Join(p.State, "key.pem"), filepath.Join(p.State, "key.pub.pem")
if _, err := os.Stat(priv); errors.Is(err, os.ErrNotExist) {
k, err := GenerateKeyPair()
if err != nil {
return KeyPair{}, err
}
if err := os.WriteFile(priv, []byte(k.PrivateKey), 0o600); err != nil {
return KeyPair{}, err
}
if err := os.WriteFile(pub, []byte(k.PublicKey), 0o644); err != nil {
return KeyPair{}, err
}
}
a, err1 := os.ReadFile(priv)
b, err2 := os.ReadFile(pub)
return KeyPair{PrivateKey: string(a), PublicKey: string(b)}, errors.Join(err1, err2)
}
// Registered is what applies here of the servers registered through this module.
func Registered(p Paths) Servers {
s := Servers{}
readJSON(p.registry(), &s)
return s
}
// RenderNow writes the managed directory from the facts, the settings, the licence held and the servers
// registered here.
func RenderNow(p Paths, write WriteManaged) ([]string, error) {
var facts Facts
if !readJSON(p.Facts, &facts) || facts.Console == "" {
return nil, fmt.Errorf("the mesh has not rendered %s yet; nothing to write", p.Facts)
}
var settings Settings
readJSON(p.Settings, &settings)
var binding *Binding
var b Binding
if readJSON(p.binding(), &b) {
binding = &b
}
files := Render(facts, settings, binding, p.helper(), Registered(p))
names := make([]string, 0, len(files))
for n := range files {
names = append(names, n)
}
sort.Strings(names)
var out []string
for _, n := range names {
line, err := write(n, files[n])
if err != nil {
return out, err
}
out = append(out, line)
}
return out, nil
}
// ---- the licence ----------------------------------------------------------------------------------
// BindingState is what the manager's `bindings` state says one consumer should hold (ADR 0206).
type BindingState struct {
Licence string `json:"licence"`
Kind string `json:"kind"`
Generation int64 `json:"generation"`
}
// Current is what the seat answers to `current`: the licence this node is bound to and its token, sealed.
type Current struct {
Licence string `json:"licence"`
Kind string `json:"kind"`
Generation int64 `json:"generation"`
Sealed *SealedBox `json:"sealed"`
Identity *Identity `json:"identity"`
}
// Holdings is what this node holds, as the `holdings` state carries it (ADR 0206): enough for the manager
// to tell a login it has not adopted from one it has, and never a token — fingerprints and expiries only.
type Holdings struct {
Node string `json:"node"`
Identity *Identity `json:"identity"`
Kind *string `json:"kind"`
Refresh struct {
Present bool `json:"present"`
Fingerprint *string `json:"fingerprint"`
ExpiresAt *int64 `json:"expiresAt"`
} `json:"refresh"`
Access *struct {
Fingerprint string `json:"fingerprint"`
ExpiresAt int64 `json:"expiresAt"`
} `json:"access"`
Licence *string `json:"licence"`
Generation int64 `json:"generation"`
ChangedAt *string `json:"changedAt"`
}
// HoldingsOf is what this node holds now.
func HoldingsOf(p Paths) Holdings {
h := Holdings{Node: p.Node, Identity: ReadIdentity(p.account())}
creds := ReadCredentials(p.credentials())
if info, err := os.Stat(p.credentials()); err == nil {
at := info.ModTime().UTC().Format("2006-01-02T15:04:05.000Z")
h.ChangedAt = &at
}
if rt := RefreshTokenOf(creds); rt != "" {
fp := Fingerprint(rt)
h.Refresh.Present, h.Refresh.Fingerprint = true, &fp
}
if v, ok := number(creds.oauth()["refreshTokenExpiresAt"]); ok {
h.Refresh.ExpiresAt = &v
}
if g := GrantOf(creds); g != nil {
h.Access = &struct {
Fingerprint string `json:"fingerprint"`
ExpiresAt int64 `json:"expiresAt"`
}{Fingerprint(g.AccessToken), g.ExpiresAt}
}
kind := ""
if _, err := os.Stat(p.apiKey()); err == nil {
kind = "api-key"
} else if h.Access != nil {
kind = "subscription"
}
if kind != "" {
h.Kind = &kind
}
var applied Binding
if readJSON(p.binding(), &applied) && applied.Licence != "" {
h.Licence, h.Generation = &applied.Licence, applied.Generation
}
return h
}
// GrantAnswer is what `claude_code_grant` answers: a login sealed to the key given, or nothing waiting.
type GrantAnswer struct {
Sealed *SealedBox `json:"sealed,omitempty"`
Identity *Identity `json:"identity,omitempty"`
Fingerprint string `json:"fingerprint,omitempty"`
Waiting *bool `json:"waiting,omitempty"`
}
// GrantFor is the full grant in the credentials file sealed to the manager's key — the one time a refresh
// token leaves this node, for the manager to adopt by refreshing it (ADR 0206). Nothing waiting when the
// file holds no refresh token.
func GrantFor(p Paths, managerPublicKey string) (GrantAnswer, error) {
creds := ReadCredentials(p.credentials())
rt := RefreshTokenOf(creds)
if rt == "" {
no := false
return GrantAnswer{Waiting: &no}, nil
}
raw, err := json.Marshal(creds.oauth())
if err != nil {
return GrantAnswer{}, err
}
box, err := Seal(string(raw), managerPublicKey)
if err != nil {
return GrantAnswer{}, err
}
return GrantAnswer{Sealed: &box, Identity: ReadIdentity(p.account()), Fingerprint: Fingerprint(rt)}, nil
}
// Pull asks the seat for this node's current token and applies it.
func Pull(p Paths, ask Ask, write WriteManaged) (map[string]any, error) {
keys, err := Keypair(p)
if err != nil {
return nil, err
}
raw, err := ask(SeatVerb("current"), map[string]any{"consumer": p.Node, "public_key": keys.PublicKey})
if err != nil {
return nil, err
}
var c Current
if err := json.Unmarshal(raw, &c); err != nil || c.Sealed == nil {
return map[string]any{"applied": false, "reason": "the seat holds no licence for this node"}, nil
}
return Apply(p, c, write)
}
// OnBinding takes a change to this node's key in the manager's `bindings` state (ADR 0206): the token is
// fetched when the generation is newer than the one applied. A released binding keeps the last token,
// which lives hours, and says so.
func OnBinding(p Paths, b *BindingState, ask Ask, write WriteManaged) (string, error) {
if b == nil {
return "this node's binding was released; it keeps its last token until it expires", nil
}
var applied Binding
if readJSON(p.binding(), &applied) && applied.Generation >= b.Generation {
return "", nil
}
out, err := Pull(p, ask, write)
if err != nil {
return "", err
}
raw, _ := json.Marshal(out)
return string(raw), nil
}
// Apply applies what the seat handed over. A switch replaces the grant whole and cleans up after the old
// licence; whatever it is, the file is written without a refresh token, so the agent here never refreshes.
func Apply(p Paths, c Current, write WriteManaged) (map[string]any, error) {
keys, err := Keypair(p)
if err != nil {
return nil, err
}
plain, err := Open(*c.Sealed, keys.PrivateKey)
if err != nil {
return nil, err
}
var previous Binding
had := readJSON(p.binding(), &previous)
switched := !had || previous.Licence != c.Licence
out := map[string]any{"applied": true, "licence": c.Licence, "kind": c.Kind, "switched": switched}
if c.Kind == "api-key" {
if err := os.WriteFile(p.apiKey(), []byte(strings.TrimSpace(plain)+"\n"), 0o600); err != nil {
return nil, err
}
if err := os.WriteFile(p.helper(), []byte("#!/bin/sh\nexec cat '"+p.apiKey()+"'\n"), 0o700); err != nil {
return nil, err
}
_ = os.Chmod(p.helper(), 0o700)
} else {
var g Grant
if err := json.Unmarshal([]byte(plain), &g); err != nil {
return nil, err
}
local := ReadCredentials(p.credentials())
// A login waiting here was handed to the manager first (ADR 0206): what comes back is its successor,
// and the refresh token in the file is the one the manager just spent.
d := DecideApply(GrantOf(local), g, switched || HoldsLogin(local))
if d.Apply {
next := WithGrant(local, g)
if switched {
next = ReplacedBy(local, g)
}
if err := WriteCredentials(p.credentials(), next); err != nil {
return nil, err
}
} else {
out = map[string]any{"applied": false, "licence": c.Licence, "reason": d.Reason}
}
// Away from the API key: it goes, with its helper.
_ = os.Remove(p.apiKey())
_ = os.Remove(p.helper())
}
if switched && c.Identity != nil && c.Identity.AccountUUID != "" {
changed, err := WriteIdentity(p.account(), *c.Identity)
if err == nil {
out["account"] = map[bool]string{true: "updated", false: "unchanged"}[changed]
}
}
gen := c.Generation
if gen == 0 {
gen = previous.Generation
}
raw, _ := json.Marshal(Binding{Licence: c.Licence, Kind: c.Kind, Generation: gen})
if err := os.WriteFile(p.binding(), append(raw, '\n'), 0o600); err != nil {
return nil, err
}
// The key-helper comes or goes with the licence's kind.
if rendered, err := RenderNow(p, write); err != nil {
out["rendered"] = map[string]any{"failed": err.Error()}
} else {
out["rendered"] = rendered
}
return out, nil
}
// ---- MCP servers ----------------------------------------------------------------------------------
// Registration is a server registered (or, with no entry, unregistered) through this module.
type Registration struct {
Name string
Entry map[string]any
// Nodes: nil for this node, ["all"] for every node running the module, or a list.
Nodes []string
}
// ServerState is the `servers` state as this module reaches it through the runtime.
type ServerState interface {
Put(key string, value any) error
Delete(key string) error
Keys() ([]string, error)
}
// ServerChange is one change to the `servers` state, as a watch hands it over.
type ServerChange struct {
Key string
Op string // put | delete
Value map[string]any
}
// KeyOf is the key a registration lives at: `all.<server>` for every node, `<node>.<server>` for one.
func KeyOf(scope, name string) string { return scope + "." + name }
// ServerView is what this node takes from the `servers` state: the entries for every node and for this
// one, kept in memory from the watch and written through to the module's own file whenever what applies
// here changes, so the managed directory renders without the bus.
type ServerView struct {
p Paths
mu sync.Mutex
entries map[string]map[string]any
}
// NewServerView is an empty view for this node.
func NewServerView(p Paths) *ServerView {
return &ServerView{p: p, entries: map[string]map[string]any{}}
}
// Take takes one change, and answers whether what applies to this node changed.
func (v *ServerView) Take(c ServerChange) bool {
scope, name, ok := strings.Cut(c.Key, ".")
if !ok || scope == "" || (scope != "all" && scope != v.p.Node) {
return false
}
v.mu.Lock()
if c.Op == "put" && c.Value != nil && EntryProblem(name, c.Value) == "" {
v.entries[c.Key] = c.Value
} else {
delete(v.entries, c.Key)
}
v.mu.Unlock()
return v.writeThrough()
}
// Effective is what applies here: every node's entries, with this node's own laid over them by name.
func (v *ServerView) Effective() Servers {
v.mu.Lock()
defer v.mu.Unlock()
out := Servers{}
for _, scope := range []string{"all", v.p.Node} {
for key, entry := range v.entries {
if name, ok := strings.CutPrefix(key, scope+"."); ok {
out[name] = entry
}
}
}
return out
}
func (v *ServerView) writeThrough() bool {
now, _ := indented(v.Effective())
before, _ := os.ReadFile(v.p.registry())
if string(before) == string(now) {
return false
}
_ = os.WriteFile(v.p.registry(), now, 0o600)
return true
}
// OnServerChange takes a change from the watch, and renders when what applies here changed.
func OnServerChange(v *ServerView, c ServerChange, p Paths, write WriteManaged) (string, error) {
if !v.Take(c) {
return "", nil
}
if _, err := RenderNow(p, write); err != nil {
return "", err
}
what := "registered"
if c.Op != "put" {
what = "unregistered"
}
return what + " " + c.Key, nil
}
// RegisterServer registers (or, with no entry, unregisters) 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.
func RegisterServer(p Paths, r Registration, servers ServerState, v *ServerView, write WriteManaged,
others func() ([]string, error)) (map[string]any, error) {
if r.Entry != nil {
if problem := EntryProblem(r.Name, r.Entry); problem != "" {
return map[string]any{"registered": false, "reason": problem}, nil
}
}
scopes := r.Nodes
if len(scopes) == 0 {
scopes = []string{p.Node}
}
// 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.
before, _ := json.Marshal(v.Effective())
for _, scope := range scopes {
key := KeyOf(scope, r.Name)
var err error
if r.Entry != nil {
err = servers.Put(key, r.Entry)
} else {
err = servers.Delete(key)
}
if err != nil {
return nil, err
}
op := "put"
if r.Entry == nil {
op = "delete"
}
v.Take(ServerChange{Key: key, Op: op, Value: r.Entry})
}
after, _ := json.Marshal(v.Effective())
changed := string(before) != string(after)
here := false
for _, s := range scopes {
here = here || s == "all" || s == p.Node
}
verb := "registered"
if r.Entry == nil {
verb = "unregistered"
}
answer := map[string]any{verb: r.Name, "on": scopes}
switch {
case !here:
answer["here"] = "not this node"
case changed:
answer["here"] = "changed"
default:
answer["here"] = "already so"
}
if changed {
rendered, err := RenderNow(p, write)
if err != nil {
return nil, err
}
answer["rendered"] = rendered
}
if r.Entry == nil {
if _, still := v.Effective()[r.Name]; still {
answer["still"] = r.Name + " still applies here from another registration (for every node, or for this one); unregister that too"
}
}
if len(r.Nodes) == 0 {
// The question the operator wanted asked: here only, or more?
var elsewhere []string
if nodes, err := others(); err == nil {
for _, n := range nodes {
if n != p.Node {
elsewhere = append(elsewhere, n)
}
}
}
if len(elsewhere) > 0 {
answer["also"] = fmt.Sprintf("claude-code also runs on %s. To %s it there too, call again with nodes: \"all\" or a list of those nodes.",
strings.Join(elsewhere, ", "), map[bool]string{true: "register", false: "unregister"}[r.Entry != nil])
} else {
answer["also"] = "To do the same on every node running claude-code, call again with nodes: \"all\"."
}
}
return answer, nil
}
// stamp is a time as the status answers it.
func stamp(ms int64) string { return time.UnixMilli(ms).UTC().Format(time.RFC3339) }
@@ -0,0 +1,121 @@
package main
// 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 or registered through this module. Exclusive by
// the vendor's rule — a server not listed here does not load (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.
import (
"bytes"
"encoding/json"
"fmt"
"regexp"
"strings"
)
// ManagedDir is the agent's machine-wide managed directory.
const ManagedDir = "/etc/claude-code"
const meshEntry = "mesh"
// Facts are what the mesh rendered for this node.
type Facts struct {
Node string `json:"node"`
Console string `json:"console"`
}
// Settings are the operator's, for the mesh or this node.
type Settings struct {
Role string `json:"role"`
MCPServers map[string]map[string]any `json:"mcp_servers"`
}
// Binding is the licence this node holds, as it was last applied.
type Binding struct {
Licence string `json:"licence"`
Kind string `json:"kind"` // subscription | api-key
Generation int64 `json:"generation,omitempty"`
}
// Servers are tool server entries by name, in the vendor's `.mcp.json` shape.
type Servers map[string]map[string]any
var serverName = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
// EntryProblem says why the vendor's managed file would not take an entry, or "" when it would: a name of
// letters, digits, `-` and `_`, and an http/sse server with a url or a stdio server with a command.
func EntryProblem(name string, entry map[string]any) string {
if !serverName.MatchString(name) {
return fmt.Sprintf("%q is not a name the agent takes: letters, digits, - and _", name)
}
if name == meshEntry {
return fmt.Sprintf("%q is the mesh's own entry", meshEntry)
}
kind, _ := entry["type"].(string)
if kind == "" {
kind = "stdio"
}
switch kind {
case "http", "sse", "streamable-http":
if u, _ := entry["url"].(string); u != "" {
return ""
}
return fmt.Sprintf("an %s server needs a url", kind)
case "stdio":
if c, _ := entry["command"].(string); c != "" {
return ""
}
return "a stdio server needs a command"
}
return fmt.Sprintf("%q is not a server type the agent knows (http, sse, stdio)", kind)
}
// jsonFile is a value as the managed files are written: two-space indent, a trailing newline, nothing
// escaped that need not be.
func jsonFile(v any) string {
var b bytes.Buffer
enc := json.NewEncoder(&b)
enc.SetEscapeHTML(false)
enc.SetIndent("", " ")
_ = enc.Encode(v)
return b.String()
}
// Render composes the three files. registered — what was registered through this module and applies
// here — is laid over the servers the operator set in its settings.
func Render(facts Facts, settings Settings, binding *Binding, helperPath string, registered Servers) map[string]string {
servers := map[string]any{}
for _, layer := range []map[string]map[string]any{settings.MCPServers, registered} {
for name, entry := range layer {
if EntryProblem(name, entry) != "" {
continue // the mesh's own entry, or one the agent would refuse
}
servers[name] = entry
}
}
servers[meshEntry] = map[string]any{"type": "http", "url": facts.Console}
managed := map[string]any{"attribution": map[string]any{"commit": "", "pr": ""}, "allowAllClaudeAiMcps": true}
if binding != nil && binding.Kind == "api-key" {
managed["apiKeyHelper"] = helperPath
}
role := strings.TrimSpace(settings.Role)
if role == "" {
role = "not stated — set it in this module's settings for the node"
}
return map[string]string{
"managed-mcp.json": jsonFile(map[string]any{"mcpServers": servers}),
"managed-settings.json": jsonFile(managed),
"CLAUDE.md": instructionsText(facts.Node, role),
}
}
+189
View File
@@ -0,0 +1,189 @@
package main
// Sealing to one recipient (novox/hq ADR 0183, ADR 0206): the manager seals what it hands a consumer to
// the key that consumer sent, and a node seals a waiting login to the key the manager gives. The same box
// the agent module's TypeScript makes and opens, byte for byte — X25519 for the agreement, HKDF-SHA256 for
// the key, AES-256-GCM for the box — so `testdata/sealed-by-typescript.json` is opened here, and a test
// reopens what this seals with the same derivation.
//
// A box is `{ v: 1, eph, iv, tag, ct }`, every field base64; `eph` is the one-time public key as SPKI DER,
// and the key is bound to it and to the recipient's raw public key, so a box cannot be re-addressed.
import (
"crypto/aes"
"crypto/cipher"
"crypto/ecdh"
"crypto/hkdf"
"crypto/rand"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"errors"
"fmt"
)
// SealedBox is a value sealed to one recipient.
type SealedBox struct {
V int `json:"v"`
Eph string `json:"eph"`
IV string `json:"iv"`
Tag string `json:"tag"`
Ct string `json:"ct"`
}
// KeyPair is a recipient's keypair as the two PEM strings it is kept and sent as.
type KeyPair struct {
PublicKey string `json:"publicKey"`
PrivateKey string `json:"privateKey"`
}
const sealInfo = "novox-mesh sealed box v1"
// GenerateKeyPair makes an X25519 keypair, PEM-encoded as the agent module's are.
func GenerateKeyPair() (KeyPair, error) {
priv, err := ecdh.X25519().GenerateKey(rand.Reader)
if err != nil {
return KeyPair{}, err
}
pubDER, err := x509.MarshalPKIXPublicKey(priv.PublicKey())
if err != nil {
return KeyPair{}, err
}
privDER, err := x509.MarshalPKCS8PrivateKey(priv)
if err != nil {
return KeyPair{}, err
}
return KeyPair{
PublicKey: string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubDER})),
PrivateKey: string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: privDER})),
}, nil
}
func publicFromPEM(p string) (*ecdh.PublicKey, error) {
block, _ := pem.Decode([]byte(p))
if block == nil {
return nil, errors.New("not a PEM public key")
}
k, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
return nil, err
}
pub, ok := k.(*ecdh.PublicKey)
if !ok || pub.Curve() != ecdh.X25519() {
return nil, errors.New("not an X25519 public key")
}
return pub, nil
}
func privateFromPEM(p string) (*ecdh.PrivateKey, error) {
block, _ := pem.Decode([]byte(p))
if block == nil {
return nil, errors.New("not a PEM private key")
}
k, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return nil, err
}
priv, ok := k.(*ecdh.PrivateKey)
if !ok || priv.Curve() != ecdh.X25519() {
return nil, errors.New("not an X25519 private key")
}
return priv, nil
}
func boxKey(secret, ephDER, recipientRaw []byte) ([]byte, error) {
salt := append(append([]byte{}, ephDER...), recipientRaw...)
return hkdf.Key(sha256.New, secret, salt, sealInfo, 32)
}
// Seal seals plaintext to the recipient's public key.
func Seal(plaintext, recipientPEM string) (SealedBox, error) {
recipient, err := publicFromPEM(recipientPEM)
if err != nil {
return SealedBox{}, err
}
eph, err := ecdh.X25519().GenerateKey(rand.Reader)
if err != nil {
return SealedBox{}, err
}
secret, err := eph.ECDH(recipient)
if err != nil {
return SealedBox{}, err
}
ephDER, err := x509.MarshalPKIXPublicKey(eph.PublicKey())
if err != nil {
return SealedBox{}, err
}
key, err := boxKey(secret, ephDER, recipient.Bytes())
if err != nil {
return SealedBox{}, err
}
gcm, err := newGCM(key)
if err != nil {
return SealedBox{}, err
}
iv := make([]byte, 12)
if _, err := rand.Read(iv); err != nil {
return SealedBox{}, err
}
out := gcm.Seal(nil, iv, []byte(plaintext), nil)
ct, tag := out[:len(out)-gcm.Overhead()], out[len(out)-gcm.Overhead():]
b64 := base64.StdEncoding.EncodeToString
return SealedBox{V: 1, Eph: b64(ephDER), IV: b64(iv), Tag: b64(tag), Ct: b64(ct)}, nil
}
// Open opens a box with the recipient's private key; it fails for a box to another key or one tampered with.
func Open(box SealedBox, privatePEM string) (string, error) {
if box.V != 1 {
return "", errors.New("not a sealed box this module can open")
}
priv, err := privateFromPEM(privatePEM)
if err != nil {
return "", err
}
d := base64.StdEncoding.DecodeString
ephDER, err := d(box.Eph)
if err != nil {
return "", fmt.Errorf("the box's eph: %w", err)
}
ephKey, err := x509.ParsePKIXPublicKey(ephDER)
if err != nil {
return "", err
}
eph, ok := ephKey.(*ecdh.PublicKey)
if !ok {
return "", errors.New("the box's eph is not an X25519 key")
}
secret, err := priv.ECDH(eph)
if err != nil {
return "", err
}
key, err := boxKey(secret, ephDER, priv.PublicKey().Bytes())
if err != nil {
return "", err
}
iv, err1 := d(box.IV)
tag, err2 := d(box.Tag)
ct, err3 := d(box.Ct)
if err := errors.Join(err1, err2, err3); err != nil {
return "", err
}
gcm, err := newGCM(key)
if err != nil {
return "", err
}
plain, err := gcm.Open(nil, iv, append(ct, tag...), nil)
if err != nil {
return "", errors.New("the box does not open with this key")
}
return string(plain), nil
}
func newGCM(key []byte) (cipher.AEAD, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
return cipher.NewGCM(block)
}
@@ -0,0 +1,42 @@
{
"facts": {
"node": "workstation",
"console": "http://127.0.0.1:4270/mcp"
},
"settings": {
"role": "the laptop",
"mcp_servers": {
"search": {
"type": "http",
"url": "https://s.example/mcp"
},
"docs": {
"type": "stdio",
"command": "docs-mcp",
"args": [
"--x"
]
}
}
},
"registered": {
"anton": {
"type": "stdio",
"command": "node",
"args": [
"/a/b.js"
],
"env": {}
}
},
"withKey": {
"managed-mcp.json": "{\n \"mcpServers\": {\n \"anton\": {\n \"type\": \"stdio\",\n \"command\": \"node\",\n \"args\": [\n \"/a/b.js\"\n ],\n \"env\": {}\n },\n \"docs\": {\n \"type\": \"stdio\",\n \"command\": \"docs-mcp\",\n \"args\": [\n \"--x\"\n ]\n },\n \"mesh\": {\n \"type\": \"http\",\n \"url\": \"http://127.0.0.1:4270/mcp\"\n },\n \"search\": {\n \"type\": \"http\",\n \"url\": \"https://s.example/mcp\"\n }\n }\n}\n",
"managed-settings.json": "{\n \"attribution\": {\n \"commit\": \"\",\n \"pr\": \"\"\n },\n \"allowAllClaudeAiMcps\": true,\n \"apiKeyHelper\": \"/state/api-key-helper\"\n}\n",
"CLAUDE.md": "# This machine is a node of a Novox mesh\n\nWritten by the mesh's `claude-code` module. Edit the module's settings or the catalogue, never this file:\nit is rewritten whenever the module renders.\n\n## Who this node is\n\n- **Node:** `workstation`\n- **Role:** the laptop\n- The other nodes, their roles and what runs where: ask the controller (`mesh-controller.nodes`,\n `mesh-controller.node`). Nothing here lists them, because a copy drifts.\n\n## How a session on this mesh works\n\nThe console is the only way to the mesh: the MCP server named `mesh`. It offers five tools, and\neverything else is an address you find and call through them:\n\n- `mesh_search` — words in, matching addresses out. `mesh_describe` — one address's arguments.\n- `mesh_call` — call an address. A seat the mesh holds once is `<seat>.<verb>` (the mesh's own verbs\n are `mesh-controller.<verb>`: `status`, `plan`, `node`, `assign`, `push`, `settings`);\n a module on a machine is `<node>/<module>.<tool>`.\n- `mesh_overview` and `mesh_machine` — the mesh's seats and machines, and what one machine runs.\n\n- **Symptom first.** For an error, a failing service or anything unexpected, search the record with the\n literal text before forming a hypothesis: the records module's `records_search`, then\n `records_read`.\n- **Ask the mesh before changing it**, and change it through the controller's verbs or the catalogue.\n- **A licence** through the `anthropic-licence-manager` seat's verbs. Never edit the agent's credentials\n file by hand, never print or ask for a token.\n\n## Hard rules\n\n- A file the mesh manages is changed through the verb or the catalogue that owns it, never on disk. If\n unsure, `mesh-controller.plan` for the node says what the mesh writes there.\n- Never write to a store's database by hand; schema changes are numbered migrations.\n- Never push to a main branch: a branch, a pull request, and a human approval for every merge.\n- The mesh creates no symlinks, and nobody else does either.\n- A package is declared in a module, never installed by hand.\n\n## Conventions\n\n- Commit messages are concise, in the imperative, about why.\n- Test before pushing: nodes update unattended.\n- The playbooks in the record say how research, decisions, designs, issues and hand-offs are done.\n"
},
"plain": {
"managed-mcp.json": "{\n \"mcpServers\": {\n \"mesh\": {\n \"type\": \"http\",\n \"url\": \"http://127.0.0.1:4270/mcp\"\n }\n }\n}\n",
"managed-settings.json": "{\n \"attribution\": {\n \"commit\": \"\",\n \"pr\": \"\"\n },\n \"allowAllClaudeAiMcps\": true\n}\n",
"CLAUDE.md": "# This machine is a node of a Novox mesh\n\nWritten by the mesh's `claude-code` module. Edit the module's settings or the catalogue, never this file:\nit is rewritten whenever the module renders.\n\n## Who this node is\n\n- **Node:** `workstation`\n- **Role:** not stated — set it in this module's settings for the node\n- The other nodes, their roles and what runs where: ask the controller (`mesh-controller.nodes`,\n `mesh-controller.node`). Nothing here lists them, because a copy drifts.\n\n## How a session on this mesh works\n\nThe console is the only way to the mesh: the MCP server named `mesh`. It offers five tools, and\neverything else is an address you find and call through them:\n\n- `mesh_search` — words in, matching addresses out. `mesh_describe` — one address's arguments.\n- `mesh_call` — call an address. A seat the mesh holds once is `<seat>.<verb>` (the mesh's own verbs\n are `mesh-controller.<verb>`: `status`, `plan`, `node`, `assign`, `push`, `settings`);\n a module on a machine is `<node>/<module>.<tool>`.\n- `mesh_overview` and `mesh_machine` — the mesh's seats and machines, and what one machine runs.\n\n- **Symptom first.** For an error, a failing service or anything unexpected, search the record with the\n literal text before forming a hypothesis: the records module's `records_search`, then\n `records_read`.\n- **Ask the mesh before changing it**, and change it through the controller's verbs or the catalogue.\n- **A licence** through the `anthropic-licence-manager` seat's verbs. Never edit the agent's credentials\n file by hand, never print or ask for a token.\n\n## Hard rules\n\n- A file the mesh manages is changed through the verb or the catalogue that owns it, never on disk. If\n unsure, `mesh-controller.plan` for the node says what the mesh writes there.\n- Never write to a store's database by hand; schema changes are numbered migrations.\n- Never push to a main branch: a branch, a pull request, and a human approval for every merge.\n- The mesh creates no symlinks, and nobody else does either.\n- A package is declared in a module, never installed by hand.\n\n## Conventions\n\n- Commit messages are concise, in the imperative, about why.\n- Test before pushing: nodes update unattended.\n- The playbooks in the record say how research, decisions, designs, issues and hand-offs are done.\n"
}
}
+5
View File
@@ -0,0 +1,5 @@
module claude-code
go 1.25.0
require git.novox.be/novox/mesh-sdk/go v0.1.7
+2
View File
@@ -0,0 +1,2 @@
git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w=
git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
+94
View File
@@ -0,0 +1,94 @@
{
"module": "claude-code",
"version": "1",
"slug": "agent",
"capabilities": [
"package-manager"
],
"requires": [
"mcp-endpoint"
],
"binds": {
"mcp-endpoint": "${dir:state}/mcp-endpoint.json"
},
"state": [
"servers",
"holdings"
],
"reads": [
"claude-licence-manager.bindings"
],
"tools": [
"claude_code_status",
"claude_code_render",
"claude_code_pull",
"claude_code_grant",
"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": "go",
"system": "arch",
"from": "cmd/claude-code",
"binary": "claude-code",
"loads": [
"claude-code"
],
"env": {
"MESH_CLAUDE_CODE_STATE": "${dir:state}",
"MESH_CLAUDE_CODE_FACTS": "${dir:state}/facts.json",
"MESH_CLAUDE_CODE_SETTINGS": "${dir:state}/settings.json"
}
}
]
}
}
+55
View File
@@ -0,0 +1,55 @@
# claude-licence-manager
Holds the `anthropic-licence-manager` seat: every Anthropic licence the mesh has, kept alive by one
rotation source, and handed to each consumer sealed (novox/hq ADR 0183, ADR 0206, design 39).
## How a licence comes to exist
Nothing is configured. Every node running `claude-code` reports what it holds as that module's
`holdings` state — the account, fingerprints and expiries, never a token. This module reads every report
when it starts and watches them:
1. A report with a refresh token it does not hold is a **candidate**.
2. It asks that node's `claude_code_grant`, giving its public key, and receives the grant sealed to it.
3. **It refreshes it.** If the vendor exchanges the token, the grant is this module's — encrypted at rest
with the key the vault made for it — and from then on it is the only refresher. If not, the candidate
is recorded dead and nothing is adopted.
4. Several nodes logged in to one account: newest login first; the rest are never exchanged.
5. A node reporting that account and bound to nothing is bound to it.
Each node is then handed an access token only, so the agent there never refreshes, and a refresh token
appearing on a node later can only be a person's login — which wins if it refreshes.
An API key enters through `adopt`, from a file on this module's node.
## What each consumer holds
This module's `bindings` state: one key per consumer (a node's name) with the licence, its kind and a
generation that grows with every rotation and switch. `claude-code` watches its own key and, on a newer
generation, asks `current` with its public key.
## The seat's verbs
`licences`, `bindings`, `bind`, `switch`, `release`, `refresh`, `usage`, `adopt`, `current` — through
the console as `anthropic-licence-manager.<verb>`. No answer carries a token.
## Settings
`settings.json` in the state directory: `cadence_minutes` (240), `floor_minutes` (60),
`failures_to_notify` (3), `cooldown_hours` (24), `refresh_warn_days` (3).
## Events
`licence.adopted`, `licence.refused`, `licence.failing`, `usage.read` — none carries a secret.
## Code and tests
Go, one binary (`cmd/claude-licence-manager`): the seat's verbs and the daemon in one launched bundle,
`prepare` as the run-once preparation step. The sealed box is `claude-code`'s own format, byte for byte —
the two modules carry the same `seal.go` — and a test opens one sealed by the TypeScript agent module the
Go one replaced, so the format is the one already on the machines.
go test ./...
# the store against a real postgres:
docker run -d --rm --name licmgr-pg -e POSTGRES_PASSWORD=t -p 15498:5432 postgres:16-alpine
MESH_TEST_POSTGRES=postgres://postgres:t@127.0.0.1:15498/postgres go test ./...
@@ -0,0 +1,64 @@
package main
// The grants at rest (novox/hq ADR 0183): encrypted with a key the vault made for this module, so the
// store holds ciphertext and only this module, reading its own secret, can open a row. AES-256-GCM, the
// key derived from the vault's secret by SHA-256 so a secret of any length serves.
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"errors"
"os"
"strings"
)
// Crypt seals and opens what the store keeps.
type Crypt struct{ key []byte }
// NewCrypt is the store's cipher from the vault's secret.
func NewCrypt(secret string) (*Crypt, error) {
secret = strings.TrimSpace(secret)
if secret == "" {
return nil, errors.New("the key the grants are encrypted with is empty: the vault has not delivered it yet")
}
sum := sha256.Sum256([]byte(secret))
return &Crypt{key: sum[:]}, nil
}
// CryptFromFile reads the vault's secret from the file the mesh delivered it to.
func CryptFromFile(path string) (*Crypt, error) {
raw, err := os.ReadFile(path)
if err != nil {
return nil, err
}
return NewCrypt(string(raw))
}
// Seal encrypts a plaintext as `v1.<iv>.<ciphertext and tag>`.
func (c *Crypt) Seal(plaintext string) string {
gcm, _ := newGCM(c.key)
iv := make([]byte, 12)
_, _ = rand.Read(iv)
b64 := base64.StdEncoding.EncodeToString
return "v1." + b64(iv) + "." + b64(gcm.Seal(nil, iv, []byte(plaintext), nil))
}
// Open decrypts what Seal made with the same key.
func (c *Crypt) Open(sealed string) (string, error) {
parts := strings.Split(sealed, ".")
if len(parts) != 3 || parts[0] != "v1" {
return "", errors.New("not a grant this module sealed")
}
iv, err1 := base64.StdEncoding.DecodeString(parts[1])
ct, err2 := base64.StdEncoding.DecodeString(parts[2])
if err := errors.Join(err1, err2); err != nil {
return "", err
}
gcm, _ := newGCM(c.key)
plain, err := gcm.Open(nil, iv, ct, nil)
if err != nil {
return "", errors.New("the grant does not open with this module's key")
}
return string(plain), nil
}
@@ -0,0 +1,348 @@
// claude-licence-manager (novox/hq ADR 0183, ADR 0206, design 39): one binary, launched by the control
// node's runtime and speaking MCP to it over stdio through the Go SDK (ADR 0193, ADR 0198). It serves the
// `anthropic-licence-manager` seat's verbs and, beside them, runs long: it watches what every node reports
// holding and adopts a login it does not hold, keeps every grant alive under a lease, and reads usage.
//
// `claude-licence-manager prepare` is the preparation step (ADR 0135): the host runs it once before the
// version that needs it, with the module's words and no bus, and it brings the store's schema to shape.
//
// stdout is the MCP channel; everything this module says, it says on stderr.
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
)
// Seat is the role this module holds.
const Seat = "anthropic-licence-manager"
func say(format string, args ...any) {
fmt.Fprintf(os.Stderr, "[claude-licence-manager] "+format+"\n", args...)
}
func main() {
if len(os.Args) > 1 && os.Args[1] == "prepare" {
if err := prepare(); err != nil {
say("preparing the store failed: %v", err)
os.Exit(1)
}
say("the store's schema is what this version needs")
return
}
go daemon()
if err := stdio.Serve("", tools()); err != nil {
say("%v", err)
os.Exit(1)
}
}
func prepare() error {
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
store, err := PgStoreFromEnv(ctx)
if err != nil {
return err
}
defer store.Close()
return store.Migrate(ctx)
}
// ---- what the binary is handed -----------------------------------------------------------------------
var (
built *Manager
buildMu sync.Mutex
)
// manager builds the rules' dependencies once, from the module's words (ADR 0192): file paths, never
// values. A failure is said and tried again on the next call, so a database that arrives late is not fatal.
func manager() (*Manager, error) {
buildMu.Lock()
defer buildMu.Unlock()
if built != nil {
return built, nil
}
dir, keyFile := os.Getenv("MESH_LICENCE_STATE"), os.Getenv("MESH_LICENCE_KEY_FILE")
if dir == "" || keyFile == "" {
return nil, errors.New("MESH_LICENCE_STATE and MESH_LICENCE_KEY_FILE are not set: the mesh renders them for this module")
}
crypt, err := CryptFromFile(keyFile)
if err != nil {
return nil, err
}
keys, err := keypair(dir)
if err != nil {
return nil, err
}
store, err := PgStoreFromEnv(context.Background())
if err != nil {
return nil, err
}
node, _ := os.Hostname()
if n := os.Getenv("MESH_NODE"); n != "" {
node = n
}
bindings := stdio.State("bindings")
built = &Manager{
Store: store,
Vendor: LiveVendor(),
Crypt: crypt,
Keys: keys,
AskGrant: func(_ context.Context, node, publicKey string) (GrantAnswer, error) {
var a GrantAnswer
raw, err := stdio.Ask("claude-code.claude_code_grant@"+node, map[string]any{"public_key": publicKey})
if err != nil {
return a, err
}
return a, json.Unmarshal(raw, &a)
},
PutBinding: func(_ context.Context, consumer string, b BindingState) error {
_, err := bindings.Put(consumer, b)
return err
},
DeleteBinding: func(_ context.Context, consumer string) error { return bindings.Delete(consumer) },
Emit: func(event string, body map[string]any) error { return stdio.Emit(event, body) },
Now: time.Now,
Log: say,
Holder: fmt.Sprintf("%s:%d", node, os.Getpid()),
Settings: SettingsFrom(os.Getenv("MESH_LICENCE_SETTINGS")),
}
return built, nil
}
// keypair is this module's own, made once in its state directory; the private half never leaves it.
// Written whole, then linked into place, so a second process reads the first's key and never half of it.
func keypair(dir string) (KeyPair, error) {
file := filepath.Join(dir, "manager-key.json")
if _, err := os.Stat(file); errors.Is(err, os.ErrNotExist) {
k, err := GenerateKeyPair()
if err != nil {
return KeyPair{}, err
}
raw, _ := json.Marshal(k)
tmp := filepath.Join(dir, fmt.Sprintf(".manager-key.%d.json", os.Getpid()))
if err := os.WriteFile(tmp, raw, 0o600); err != nil {
return KeyPair{}, err
}
_ = os.Link(tmp, file) // fails when another made it first, which is right
_ = os.Remove(tmp)
}
raw, err := os.ReadFile(file)
if err != nil {
return KeyPair{}, err
}
var k KeyPair
return k, json.Unmarshal(raw, &k)
}
// ---- the long-running half ---------------------------------------------------------------------------
func daemon() {
var mu sync.Mutex
reports := map[string]Holdings{}
var passing sync.Mutex
pass := func() {
passing.Lock() // one pass at a time: each account is leased, and a pass is cheap
defer passing.Unlock()
m, err := manager()
if err != nil {
say("not ready: %v", err)
return
}
mu.Lock()
all := make([]Holdings, 0, len(reports))
for _, r := range reports {
all = append(all, r)
}
mu.Unlock()
sort.Slice(all, func(i, j int) bool { return all[i].Node < all[j].Node })
adopted, err := m.Consider(context.Background(), all)
if err != nil {
say("considering the reports failed: %v", err)
}
if len(adopted) > 0 {
say("adopted %s", strings.Join(adopted, ", "))
}
}
// What every node holds (ADR 0206): the whole current set, then each change. Asked again until it
// answers — the channel to the runtime opens as the bundle starts, and the agent module's state may
// arrive after this one.
go func() {
waits := []time.Duration{2 * time.Second, 5 * time.Second, 10 * time.Second, 30 * time.Second}
for attempt := 0; ; attempt++ {
err := stdio.State("claude-code.holdings").Watch("", func(c stdio.StateChange) error {
mu.Lock()
if c.Op == "put" {
var h Holdings
if json.Unmarshal(c.Value, &h) == nil {
reports[c.Key] = h
}
} else {
delete(reports, c.Key)
}
mu.Unlock()
// During the current values the pass waits for the whole set: newest login first needs all.
if !c.Current {
go pass()
}
return nil
})
if err == nil {
mu.Lock()
n := len(reports)
mu.Unlock()
say("watching what %d node(s) hold", n)
pass()
return
}
pause := time.Minute
if attempt < len(waits) {
pause = waits[attempt]
}
say("what the nodes hold cannot be watched yet (%v); asking again in %s", err, pause)
time.Sleep(pause)
}
}()
refresh := time.NewTicker(time.Minute)
usage := time.NewTicker(5 * time.Minute)
for {
select {
case <-refresh.C:
if m, err := manager(); err == nil {
out, err := m.RotateDue(context.Background())
for _, r := range out {
b, _ := json.Marshal(r)
say("%s", b)
}
if err != nil {
say("refreshing failed: %v", err)
}
}
pass() // a login whose node did not answer last time is asked again
case <-usage.C:
if m, err := manager(); err == nil {
if err := m.ReadUsage(context.Background()); err != nil {
say("reading usage failed: %v", err)
}
}
}
}
}
// ---- the seat's verbs --------------------------------------------------------------------------------
func text(a map[string]any, k string) (string, error) {
v, _ := a[k].(string)
if strings.TrimSpace(v) == "" {
return "", fmt.Errorf("%s is required", k)
}
return strings.TrimSpace(v), nil
}
// verb is one of the seat's verbs: listed as `<seat>.<verb>`, so the runtime serves it on the seat's subject.
func verb(name, description string, input map[string]any, run func(ctx context.Context, m *Manager, a map[string]any) (any, error)) stdio.Tool {
return stdio.Tool{Name: Seat + "." + name, Description: description, Input: input,
Run: func(a map[string]any) (any, error) {
m, err := manager()
if err != nil {
return nil, err
}
return run(context.Background(), m, a)
}}
}
func str(description string) map[string]any {
return map[string]any{"type": "string", "description": description}
}
func two(a map[string]any, k1, k2 string) (string, string, error) {
v1, err1 := text(a, k1)
v2, err2 := text(a, k2)
return v1, v2, errors.Join(err1, err2)
}
func tools() []stdio.Tool {
consumer := str("the node's name")
return []stdio.Tool{
verb("licences", "Every licence the manager holds — account, kind, when its token and its refresh token expire, failures in a row, which consumers are bound to it. Never a token.",
nil, func(ctx context.Context, m *Manager, _ map[string]any) (any, error) { return m.Licences(ctx) }),
verb("bindings", "Which licence each consumer (a node's agent, by the node's name) is bound to, and the generation it was last given.",
nil, func(ctx context.Context, m *Manager, _ map[string]any) (any, error) { return m.Store.Bindings(ctx) }),
verb("bind", "Bind a consumer — a node's agent, by the node's name — to a licence. Its node fetches the licence's token at once.",
map[string]any{"consumer": consumer, "licence": str("a licence, as `licences` names it")},
func(ctx context.Context, m *Manager, a map[string]any) (any, error) {
c, l, err := two(a, "consumer", "licence")
if err != nil {
return nil, err
}
return m.Bind(ctx, c, l, "bind")
}),
verb("switch", "Move a consumer to another licence. Its node fetches the new licence's token at once and points the agent's account at it.",
map[string]any{"consumer": consumer, "licence": str("the licence to move to")},
func(ctx context.Context, m *Manager, a map[string]any) (any, error) {
c, l, err := two(a, "consumer", "licence")
if err != nil {
return nil, err
}
return m.Bind(ctx, c, l, "switch")
}),
verb("release", "Unbind a consumer. Its node keeps its last token, which expires within hours.",
map[string]any{"consumer": consumer},
func(ctx context.Context, m *Manager, a map[string]any) (any, error) {
c, err := text(a, "consumer")
if err != nil {
return nil, err
}
return m.Release(ctx, c, "release")
}),
verb("refresh", "Refresh a licence now, or every due licence when none is named; under each licence's lease, so it never races the daemon. Answers the outcome, never a token.",
map[string]any{"licence": str("a licence; absent for every due one")},
func(ctx context.Context, m *Manager, a map[string]any) (any, error) {
if l, _ := a["licence"].(string); l != "" {
return m.Rotate(ctx, l, true)
}
return m.RotateDue(ctx)
}),
verb("usage", "Usage readings, the latest first, for every licence or one.",
map[string]any{"licence": str("a licence; absent for all"), "limit": map[string]any{"type": "number", "description": "how many readings (default 20)"}},
func(ctx context.Context, m *Manager, a map[string]any) (any, error) {
l, _ := a["licence"].(string)
limit := 20
if v, ok := a["limit"].(float64); ok && v > 0 {
limit = int(v)
}
return m.Store.Usage(ctx, l, limit)
}),
verb("adopt", "Adopt an API key from a file on the manager's node, never as an argument. Subscriptions are adopted from the nodes' logins by themselves.",
map[string]any{"name": str("the licence's name"), "file": str("a file on the manager's node holding the key")},
func(ctx context.Context, m *Manager, a map[string]any) (any, error) {
n, f, err := two(a, "name", "file")
if err != nil {
return nil, err
}
return m.AdoptKey(ctx, n, f)
}),
verb("current", "For a consumer's agent module (ADR 0206): its token, sealed to the public key it sends, with the licence, kind and generation. Null when it is bound to nothing.",
map[string]any{"consumer": consumer, "public_key": str("the consumer's public key, PEM")},
func(ctx context.Context, m *Manager, a map[string]any) (any, error) {
c, k, err := two(a, "consumer", "public_key")
if err != nil {
return nil, err
}
return m.Current(ctx, c, k)
}),
}
}
@@ -0,0 +1,678 @@
package main
// The Anthropic licence manager's rules (novox/hq ADR 0183, ADR 0206, design 39), written against what it
// is handed — a store, the vendor, a way to ask a node, its own state, a way to emit — so every rule is
// tested without a bus, a database or the vendor.
//
// - A licence is an account, learned from what the nodes report (`holdings`, the agent module's state).
// A report with a refresh token the manager does not hold is a candidate.
// - The secret is asked for, sealed to this module's key, never published.
// - Adopting is refreshing: newest login first, once per account; a failure adopts nothing.
// - What each consumer should hold is this module's `bindings` state, with a generation that grows with
// every rotation and switch; the consumer fetches its token by asking `current`.
// - One rotation source: every exchange with the vendor runs under a lease.
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"regexp"
"sort"
"strings"
"time"
)
// Fingerprint names a token without being one: the agent module's own fingerprint, so the two compare.
func Fingerprint(s string) string {
sum := sha256.Sum256([]byte(s))
return "sha256:" + hex.EncodeToString(sum[:])[:16]
}
// Identity is the account a grant belongs to, as the agent's own state file names it.
type Identity struct {
AccountUUID string `json:"accountUuid"`
EmailAddress string `json:"emailAddress,omitempty"`
OrganizationUUID string `json:"organizationUuid,omitempty"`
}
// Holdings is one node's report, as the agent module writes it to its `holdings` state (ADR 0206).
type Holdings struct {
Node string `json:"node"`
Identity *Identity `json:"identity"`
Kind string `json:"kind"`
Refresh struct {
Present bool `json:"present"`
Fingerprint string `json:"fingerprint"`
} `json:"refresh"`
ChangedAt string `json:"changedAt"`
}
// BindingState is what `bindings` holds for one consumer: no secret, only what it should hold and which
// generation.
type BindingState struct {
Licence string `json:"licence"`
Kind string `json:"kind"`
Generation int64 `json:"generation"`
}
// Settings are the manager's own, declared with defaults (design 39 §8).
type Settings struct {
Cadence time.Duration // rotate a grant once older than this
Floor time.Duration // refresh in any case with less than this left
FailuresToNotify int
Cooldown time.Duration // at most one notification per licence in this window
RefreshWarn time.Duration // warn this long before a refresh token itself expires
}
// Defaults are the settings a fresh mesh runs with.
var Defaults = Settings{Cadence: 4 * time.Hour, Floor: time.Hour, FailuresToNotify: 3, Cooldown: 24 * time.Hour, RefreshWarn: 72 * time.Hour}
// SettingsFrom reads the settings file, keeping a default for anything absent or not positive.
func SettingsFrom(path string) Settings {
s := Defaults
raw, err := os.ReadFile(path)
if err != nil {
return s
}
var f map[string]float64
if json.Unmarshal(raw, &f) != nil {
return s
}
set := func(k string, unit time.Duration, into *time.Duration) {
if v := f[k]; v > 0 {
*into = time.Duration(v * float64(unit))
}
}
set("cadence_minutes", time.Minute, &s.Cadence)
set("floor_minutes", time.Minute, &s.Floor)
set("cooldown_hours", time.Hour, &s.Cooldown)
if v := f["refresh_warn_days"]; v > 0 {
s.RefreshWarn = time.Duration(v * float64(24*time.Hour))
}
if v := f["failures_to_notify"]; v > 0 {
s.FailuresToNotify = int(v)
}
return s
}
// GrantAnswer is what a node's `claude_code_grant` answers: a login sealed to the key given, or nothing.
type GrantAnswer struct {
Sealed *SealedBox `json:"sealed"`
Identity *Identity `json:"identity"`
Fingerprint string `json:"fingerprint"`
}
// Manager is the rules and what they are handed.
type Manager struct {
Store Store
Vendor Vendor
Crypt *Crypt
Keys KeyPair
// AskGrant asks a node's agent module for the grant a login left there, sealed to publicKey. An error
// is the node not answering; a nil Sealed is no login waiting.
AskGrant func(ctx context.Context, node, publicKey string) (GrantAnswer, error)
// PutBinding and DeleteBinding are this module's `bindings` state.
PutBinding func(ctx context.Context, consumer string, b BindingState) error
DeleteBinding func(ctx context.Context, consumer string) error
Emit func(event string, body map[string]any) error
Now func() time.Time
Log func(format string, args ...any)
// Holder names this process in a lease, so a second run is told apart.
Holder string
Settings Settings
}
const leaseFor = 2 * time.Minute
// NameFor is a licence's name: the account's address where it has one, else its id.
func NameFor(id Identity) string {
if e := strings.TrimSpace(id.EmailAddress); e != "" {
return e
}
return id.AccountUUID
}
// ---- learning licences from what the nodes hold ------------------------------------------------------
// Candidate is a report the manager should try.
type Candidate struct {
Node string
Identity Identity
Fingerprint string
ChangedAt int64
}
// CandidatesIn is the candidates in a set of reports by account, newest login first (ADR 0206 §2, §4). A
// report without an identity is not one: a grant is filed under its account or not at all.
func (m *Manager) CandidatesIn(ctx context.Context, reports []Holdings) (map[string][]Candidate, error) {
out := map[string][]Candidate{}
for _, r := range reports {
if !r.Refresh.Present || r.Refresh.Fingerprint == "" || r.Identity == nil || r.Identity.AccountUUID == "" {
continue
}
settled, err := m.Store.Outcome(ctx, r.Refresh.Fingerprint)
if err != nil {
return nil, err
}
if settled != "" {
continue // adopted, dead, skipped, refused or gone: settled once
}
held, err := m.Store.LicenceForAccount(ctx, r.Identity.AccountUUID)
if err != nil {
return nil, err
}
if held != nil && held.RefreshFingerprint == r.Refresh.Fingerprint {
continue
}
var changed int64
if t, err := time.Parse(time.RFC3339Nano, r.ChangedAt); err == nil {
changed = t.UnixMilli()
}
// The latest login wins: one no newer than the grant held is not a newer login.
if held != nil && changed <= held.AdoptedAt {
continue
}
out[r.Identity.AccountUUID] = append(out[r.Identity.AccountUUID],
Candidate{Node: r.Node, Identity: *r.Identity, Fingerprint: r.Refresh.Fingerprint, ChangedAt: changed})
}
for _, list := range out {
sort.SliceStable(list, func(i, j int) bool { return list[i].ChangedAt > list[j].ChangedAt })
}
return out, nil
}
// Consider every report (ADR 0206): for each account with candidates, under that account's lease, try them
// newest first; the first that refreshes is adopted and the rest are settled as skipped without being
// exchanged. Answers what was adopted.
func (m *Manager) Consider(ctx context.Context, reports []Holdings) ([]string, error) {
byAccount, err := m.CandidatesIn(ctx, reports)
if err != nil {
return nil, err
}
accounts := make([]string, 0, len(byAccount))
for a := range byAccount {
accounts = append(accounts, a)
}
sort.Strings(accounts)
// A node reporting an account the manager already holds, and bound to nothing, is bound to it
// (ADR 0206 §7) — whenever its report arrives, not only when the licence is adopted: a node whose own
// login is older than the one adopted is never a candidate, and would otherwise never be bound.
if err := m.bindReporters(ctx, reports); err != nil {
return nil, err
}
var adopted []string
for _, account := range accounts {
list := byAccount[account]
key := "account:" + account
ok, err := m.Store.Lease(ctx, key, m.Holder, leaseFor)
if err != nil || !ok {
continue
}
for i, c := range list {
name, err := m.adoptOne(ctx, c, reports)
if err != nil {
m.Log("adopting %s's login failed: %v", c.Node, err)
continue
}
if name != "" {
adopted = append(adopted, name)
for _, rest := range list[i+1:] {
_ = m.Store.RecordOutcome(ctx, rest.Fingerprint, rest.Node, account, Skipped, c.Node+"'s newer login was adopted first")
}
break
}
}
_ = m.Store.Unlease(ctx, key, m.Holder)
}
return adopted, nil
}
// bindReporters binds each reporting node that is bound to nothing to the licence its account already has.
func (m *Manager) bindReporters(ctx context.Context, reports []Holdings) error {
for _, rep := range reports {
if rep.Identity == nil || rep.Identity.AccountUUID == "" {
continue
}
if b, err := m.Store.Binding(ctx, rep.Node); err != nil || b != nil {
if err != nil {
return err
}
continue
}
l, err := m.Store.LicenceForAccount(ctx, rep.Identity.AccountUUID)
if err != nil {
return err
}
if l == nil {
continue
}
b, err := m.Store.Bind(ctx, rep.Node, l.Name)
if err != nil {
return err
}
_ = m.Store.Audit(ctx, "bound", map[string]any{"consumer": rep.Node, "licence": l.Name, "by": "its account's report"})
if err := m.PutBinding(ctx, rep.Node, BindingState{Licence: l.Name, Kind: l.Kind, Generation: b.Generation}); err != nil {
return err
}
m.Log("bound %s to %s, the licence its account already has", rep.Node, l.Name)
}
return nil
}
func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings) (string, error) {
answer, err := m.AskGrant(ctx, c.Node, m.Keys.PublicKey)
if err != nil {
// Not answering is not an answer: asked again on the next pass.
m.Log("%s did not hand over its login: %v", c.Node, err)
return "", nil
}
if answer.Sealed == nil {
return "", m.Store.RecordOutcome(ctx, c.Fingerprint, c.Node, c.Identity.AccountUUID, Gone, "no login was waiting when asked")
}
plain, err := Open(*answer.Sealed, m.Keys.PrivateKey)
if err != nil {
return "", m.Store.RecordOutcome(ctx, c.Fingerprint, c.Node, c.Identity.AccountUUID, Refused, "the grant did not open with this module's key")
}
var offered FullGrant
if err := json.Unmarshal([]byte(plain), &offered); err != nil || offered.RefreshToken == "" {
return "", m.Store.RecordOutcome(ctx, c.Fingerprint, c.Node, c.Identity.AccountUUID, Refused, "the grant holds no refresh token")
}
if Fingerprint(offered.RefreshToken) != c.Fingerprint {
m.Log("%s's login changed while it was asked for; waiting for its next report", c.Node)
return "", nil
}
// Adopting is refreshing (ADR 0206 §4): the exchange is the check, and from here this module is the
// only holder of a live refresh token for the account.
r := m.Vendor.Refresh(ctx, offered)
if !r.OK {
_ = m.Store.RecordOutcome(ctx, c.Fingerprint, c.Node, c.Identity.AccountUUID, Dead, fmt.Sprintf("%d %s", r.Status, r.Reason))
_ = m.Store.Audit(ctx, "refused", map[string]any{"node": c.Node, "account": c.Identity.AccountUUID, "status": r.Status, "reason": r.Reason})
_ = m.Emit("licence.refused", map[string]any{"node": c.Node, "account": NameFor(c.Identity),
"reason": fmt.Sprintf("the login's refresh token did not refresh (%d)", r.Status)})
m.Log("%s's login for %s did not refresh: %d %s", c.Node, NameFor(c.Identity), r.Status, r.Reason)
return "", nil
}
// The identity guard, on two sources (ADR 0206 §8).
if r.Account != "" && r.Account != c.Identity.AccountUUID {
_ = m.Store.RecordOutcome(ctx, c.Fingerprint, c.Node, c.Identity.AccountUUID, Refused,
"the node says "+c.Identity.AccountUUID+", the vendor says "+r.Account)
_ = m.Store.Audit(ctx, "refused", map[string]any{"node": c.Node, "reported": c.Identity.AccountUUID, "vendor": r.Account})
_ = m.Emit("licence.refused", map[string]any{"node": c.Node, "account": NameFor(c.Identity),
"reason": "the account the node reported is not the one the vendor answered for"})
return "", nil
}
held, err := m.Store.LicenceForAccount(ctx, c.Identity.AccountUUID)
if err != nil {
return "", err
}
now := m.Now().UnixMilli()
l := Licence{Name: NameFor(c.Identity), Kind: "subscription", AccountUUID: c.Identity.AccountUUID,
Email: c.Identity.EmailAddress, OrganizationUUID: c.Identity.OrganizationUUID}
if held != nil {
l.Name, l.NotifiedAt = held.Name, held.NotifiedAt
if l.Email == "" {
l.Email = held.Email
}
if l.OrganizationUUID == "" {
l.OrganizationUUID = held.OrganizationUUID
}
}
m.keep(&l, r.Grant)
l.AdoptedAt = max(now, c.ChangedAt)
if err := m.Store.SaveLicence(ctx, l); err != nil {
return "", err
}
why := "a new licence"
if held != nil {
why = "a newer login"
}
_ = m.Store.RecordOutcome(ctx, c.Fingerprint, c.Node, c.Identity.AccountUUID, Adopted, why)
_ = m.Store.Audit(ctx, "adopted", map[string]any{"licence": l.Name, "node": c.Node, "account": c.Identity.AccountUUID,
"vendorNamedAccount": r.Account != ""})
// A first binding follows the login (ADR 0206 §7): every node reporting this account and bound to nothing.
for _, rep := range reports {
if rep.Identity == nil || rep.Identity.AccountUUID != c.Identity.AccountUUID {
continue
}
if b, err := m.Store.Binding(ctx, rep.Node); err != nil || b != nil {
continue
}
if _, err := m.Store.Bind(ctx, rep.Node, l.Name); err == nil {
_ = m.Store.Audit(ctx, "bound", map[string]any{"consumer": rep.Node, "licence": l.Name, "by": "its login"})
}
}
if err := m.publishAdvance(ctx, l); err != nil {
return "", err
}
_ = m.Emit("licence.adopted", map[string]any{"licence": l.Name, "from": c.Node, "replaced": held != nil})
m.Log("adopted %s from %s (%s)", l.Name, c.Node, why)
return l.Name, nil
}
// keep stores a grant on its licence: encrypted, fingerprinted, its expiries, fresh.
func (m *Manager) keep(l *Licence, g FullGrant) {
raw, _ := json.Marshal(g)
l.Sealed = m.Crypt.Seal(string(raw))
l.RefreshFingerprint = Fingerprint(g.RefreshToken)
l.AccessExpiresAt = g.ExpiresAt
if g.RefreshTokenExpiresAt != nil {
l.RefreshExpiresAt = *g.RefreshTokenExpiresAt
}
l.Failures = 0
l.RotatedAt = m.Now().UnixMilli()
}
// publishAdvance gives every consumer of a licence a new generation, and tells each in the state.
func (m *Manager) publishAdvance(ctx context.Context, l Licence) error {
bindings, err := m.Store.Advance(ctx, l.Name)
if err != nil {
return err
}
for _, b := range bindings {
if err := m.PutBinding(ctx, b.Consumer, BindingState{Licence: b.Licence, Kind: l.Kind, Generation: b.Generation}); err != nil {
return err
}
}
return nil
}
// ---- keeping grants alive ----------------------------------------------------------------------------
// Due says whether a licence needs a refresh now: near expiry, or older than the cadence.
func Due(l Licence, now time.Time, s Settings) bool {
if l.Kind != "subscription" || l.Sealed == "" {
return false
}
ms := now.UnixMilli()
if l.AccessExpiresAt != 0 && l.AccessExpiresAt-ms < s.Floor.Milliseconds() {
return true
}
return l.RotatedAt == 0 || ms-l.RotatedAt >= s.Cadence.Milliseconds()
}
// Rotate refreshes one licence under its lease (design 39 §3). A second run started together finds the
// lease live and does nothing; one started just after finds a fresh grant and is not due. force refreshes
// whatever the age — the seat's `refresh` verb.
func (m *Manager) Rotate(ctx context.Context, name string, force bool) (map[string]any, error) {
key := "licence:" + name
ok, err := m.Store.Lease(ctx, key, m.Holder, leaseFor)
if err != nil {
return nil, err
}
if !ok {
return map[string]any{"licence": name, "refreshed": false, "reason": "another run holds its lease"}, nil
}
defer func() { _ = m.Store.Unlease(ctx, key, m.Holder) }()
l, err := m.Store.Licence(ctx, name)
if err != nil {
return nil, err
}
if l == nil {
return map[string]any{"licence": name, "refreshed": false, "reason": "no such licence"}, nil
}
if l.Kind != "subscription" || l.Sealed == "" {
return map[string]any{"licence": name, "refreshed": false, "reason": "an API key does not refresh"}, nil
}
now := m.Now()
if !force && !Due(*l, now, m.Settings) {
return map[string]any{"licence": name, "refreshed": false, "reason": "not due"}, nil
}
plain, err := m.Crypt.Open(l.Sealed)
if err != nil {
return nil, err
}
var g FullGrant
if err := json.Unmarshal([]byte(plain), &g); err != nil {
return nil, err
}
r := m.Vendor.Refresh(ctx, g)
if !r.OK {
l.Failures++
m.Log("%s did not refresh (%d in a row): %d %s", name, l.Failures, r.Status, r.Reason)
_ = m.Store.Audit(ctx, "failed", map[string]any{"licence": name, "status": r.Status, "reason": r.Reason, "failures": l.Failures})
m.notify(l, fmt.Sprintf("refresh failed %d time(s) in a row: %d", l.Failures, r.Status), l.Failures >= m.Settings.FailuresToNotify)
if err := m.Store.SaveLicence(ctx, *l); err != nil {
return nil, err
}
return map[string]any{"licence": name, "refreshed": false, "reason": fmt.Sprintf("%d %s", r.Status, r.Reason), "failures": l.Failures}, nil
}
m.keep(l, r.Grant)
if l.RefreshExpiresAt != 0 {
left := time.Duration(l.RefreshExpiresAt-now.UnixMilli()) * time.Millisecond
m.notify(l, fmt.Sprintf("its refresh token expires in %.1f day(s): a person must log in again", left.Hours()/24),
left < m.Settings.RefreshWarn)
}
if err := m.Store.SaveLicence(ctx, *l); err != nil {
return nil, err
}
_ = m.Store.Audit(ctx, "rotated", map[string]any{"licence": name, "expiresAt": l.AccessExpiresAt})
if err := m.publishAdvance(ctx, *l); err != nil {
return nil, err
}
return map[string]any{"licence": name, "refreshed": true, "expiresAt": time.UnixMilli(l.AccessExpiresAt).UTC().Format(time.RFC3339)}, nil
}
// notify emits `licence.failing` at most once per cooldown (design 39 §3); it carries no secret.
func (m *Manager) notify(l *Licence, why string, when bool) {
if !when {
return
}
now := m.Now().UnixMilli()
if l.NotifiedAt != 0 && now-l.NotifiedAt < m.Settings.Cooldown.Milliseconds() {
return
}
l.NotifiedAt = now
_ = m.Emit("licence.failing", map[string]any{"licence": l.Name, "why": why})
}
// RotateDue refreshes every licence that is due.
func (m *Manager) RotateDue(ctx context.Context) ([]map[string]any, error) {
all, err := m.Store.Licences(ctx)
if err != nil {
return nil, err
}
var out []map[string]any
for _, l := range all {
if Due(l, m.Now(), m.Settings) {
r, err := m.Rotate(ctx, l.Name, false)
if err != nil {
return out, err
}
out = append(out, r)
}
}
return out, nil
}
// ReadUsage reads and records each subscription's usage (ADR 0054); the event names the licence and numbers.
func (m *Manager) ReadUsage(ctx context.Context) error {
all, err := m.Store.Licences(ctx)
if err != nil {
return err
}
for _, l := range all {
if l.Kind != "subscription" || l.Sealed == "" {
continue
}
plain, err := m.Crypt.Open(l.Sealed)
if err != nil {
return err
}
var g FullGrant
_ = json.Unmarshal([]byte(plain), &g)
raw, err := m.Vendor.Usage(ctx, g.AccessToken)
if err != nil || raw == nil {
continue
}
reading := FlattenUsage(raw)
if err := m.Store.RecordUsage(ctx, l.Name, m.Now().UnixMilli(), reading, raw); err != nil {
return err
}
_ = m.Emit("usage.read", map[string]any{"licence": l.Name, "sessionPct": reading.SessionPct,
"weeklyPct": reading.WeeklyPct, "sonnetPct": reading.SonnetPct})
}
return nil
}
// ---- the seat's verbs --------------------------------------------------------------------------------
// Current is a consumer's token sealed to the key it sent (ADR 0206 §6): for a subscription the access
// token and its expiries only — never the refresh token, which no node holds. Nil when it is bound to
// nothing.
func (m *Manager) Current(ctx context.Context, consumer, publicKey string) (map[string]any, error) {
if !strings.Contains(publicKey, "PUBLIC KEY") {
return nil, errors.New("current seals to the consumer's public key, and none was given")
}
b, err := m.Store.Binding(ctx, consumer)
if err != nil || b == nil {
return nil, err
}
l, err := m.Store.Licence(ctx, b.Licence)
if err != nil || l == nil || l.Sealed == "" {
return nil, err
}
plain, err := m.Crypt.Open(l.Sealed)
if err != nil {
return nil, err
}
handed := plain
if l.Kind == "subscription" {
var g FullGrant
if err := json.Unmarshal([]byte(plain), &g); err != nil {
return nil, err
}
access, _ := json.Marshal(map[string]any{"accessToken": g.AccessToken, "expiresAt": g.ExpiresAt,
"refreshTokenExpiresAt": g.RefreshTokenExpiresAt, "scopes": g.Scopes,
"subscriptionType": g.SubscriptionType, "rateLimitTier": g.RateLimitTier})
handed = string(access)
}
box, err := Seal(handed, publicKey)
if err != nil {
return nil, err
}
out := map[string]any{"licence": l.Name, "kind": l.Kind, "generation": b.Generation, "sealed": box}
if l.AccountUUID != "" {
out["identity"] = Identity{AccountUUID: l.AccountUUID, EmailAddress: l.Email, OrganizationUUID: l.OrganizationUUID}
}
return out, nil
}
// Bind binds or switches a consumer: a person's act (ADR 0183), told to the consumer as a new generation.
func (m *Manager) Bind(ctx context.Context, consumer, licence, by string) (map[string]any, error) {
l, err := m.Store.Licence(ctx, licence)
if err != nil {
return nil, err
}
if l == nil {
return nil, fmt.Errorf("there is no licence %s; `licences` lists them", licence)
}
before, err := m.Store.Binding(ctx, consumer)
if err != nil {
return nil, err
}
b, err := m.Store.Bind(ctx, consumer, licence)
if err != nil {
return nil, err
}
from, what := "", "bound"
if before != nil {
from, what = before.Licence, "switched"
}
_ = m.Store.Audit(ctx, what, map[string]any{"consumer": consumer, "licence": licence, "from": from, "by": by})
if err := m.PutBinding(ctx, consumer, BindingState{Licence: licence, Kind: l.Kind, Generation: b.Generation}); err != nil {
return nil, err
}
return map[string]any{"consumer": consumer, "licence": licence, "generation": b.Generation, "from": from}, nil
}
// Release unbinds a consumer; its node keeps its last token, which expires within hours.
func (m *Manager) Release(ctx context.Context, consumer, by string) (map[string]any, error) {
was, err := m.Store.Binding(ctx, consumer)
if err != nil {
return nil, err
}
if ok, err := m.Store.Unbind(ctx, consumer); err != nil || !ok {
return map[string]any{"consumer": consumer, "released": false, "reason": "it was bound to nothing"}, err
}
if err := m.DeleteBinding(ctx, consumer); err != nil {
return nil, err
}
_ = m.Store.Audit(ctx, "released", map[string]any{"consumer": consumer, "licence": was.Licence, "by": by})
return map[string]any{"consumer": consumer, "released": true, "was": was.Licence}, nil
}
var licenceName = regexp.MustCompile(`^[A-Za-z0-9@._-]+$`)
// AdoptKey adopts an API key from a file on this node — never an argument (design 39 §6).
func (m *Manager) AdoptKey(ctx context.Context, name, file string) (map[string]any, error) {
if !licenceName.MatchString(name) {
return nil, fmt.Errorf("%q is not a licence name: letters, digits and @._-", name)
}
raw, err := os.ReadFile(file)
if err != nil {
return nil, err
}
key := strings.TrimSpace(string(raw))
if key == "" {
return nil, fmt.Errorf("%s is empty", file)
}
held, err := m.Store.Licence(ctx, name)
if err != nil {
return nil, err
}
if held != nil && held.Kind != "api-key" {
return nil, fmt.Errorf("%s is a subscription; an API key needs a name of its own", name)
}
l := Licence{Name: name, Kind: "api-key", Sealed: m.Crypt.Seal(key), AdoptedAt: m.Now().UnixMilli()}
if err := m.Store.SaveLicence(ctx, l); err != nil {
return nil, err
}
_ = m.Store.Audit(ctx, "adopted", map[string]any{"licence": name, "kind": "api-key", "from": "a file"})
if err := m.publishAdvance(ctx, l); err != nil {
return nil, err
}
_ = m.Emit("licence.adopted", map[string]any{"licence": name, "from": "a file", "replaced": held != nil})
return map[string]any{"licence": name, "kind": "api-key", "adopted": true, "fingerprint": Fingerprint(key)}, nil
}
// Licences is each licence as a person reads it: health and who is bound, never a token.
func (m *Manager) Licences(ctx context.Context) ([]map[string]any, error) {
all, err := m.Store.Licences(ctx)
if err != nil {
return nil, err
}
bindings, err := m.Store.Bindings(ctx)
if err != nil {
return nil, err
}
stamp := func(ms int64) any {
if ms == 0 {
return nil
}
return time.UnixMilli(ms).UTC().Format(time.RFC3339)
}
out := []map[string]any{}
for _, l := range all {
bound := []string{}
for _, b := range bindings {
if b.Licence == l.Name {
bound = append(bound, b.Consumer)
}
}
account := l.Email
if account == "" {
account = l.AccountUUID
}
out = append(out, map[string]any{"name": l.Name, "kind": l.Kind, "account": account,
"accessExpiresAt": stamp(l.AccessExpiresAt), "refreshExpiresAt": stamp(l.RefreshExpiresAt),
"rotatedAt": stamp(l.RotatedAt), "failures": l.Failures, "bound": bound})
}
return out, nil
}
@@ -0,0 +1,359 @@
package main
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)
var t0 = time.Date(2026, 10, 4, 12, 0, 0, 0, time.UTC)
// stubVendor rotates like the real one is presumed to: each refresh token exchanges once.
type stubVendor struct {
mu sync.Mutex
live map[string]bool
exchanged []string
issued int
account string
now func() time.Time
}
func (v *stubVendor) Refresh(_ context.Context, g FullGrant) Refreshed {
v.mu.Lock()
defer v.mu.Unlock()
v.exchanged = append(v.exchanged, g.RefreshToken)
if !v.live[g.RefreshToken] {
return Refreshed{Status: 400, Reason: `{"error":"invalid_grant"}`}
}
delete(v.live, g.RefreshToken)
v.issued++
next := fmt.Sprintf("rt-%d", v.issued)
v.live[next] = true
g.AccessToken = fmt.Sprintf("at-%d", v.issued)
g.RefreshToken = next
g.ExpiresAt = v.now().Add(8 * time.Hour).UnixMilli()
exp := v.now().Add(30 * 24 * time.Hour).UnixMilli()
g.RefreshTokenExpiresAt = &exp
return Refreshed{OK: true, Grant: g, Account: v.account}
}
func (v *stubVendor) Usage(context.Context, string) (map[string]any, error) {
return map[string]any{"five_hour": map[string]any{"utilization": 12.0}}, nil
}
type miniMesh struct {
m *Manager
store *MemoryStore
vendor *stubVendor
logins map[string]FullGrant // node → what its credentials file holds
state map[string]BindingState
events []string
now time.Time
keys KeyPair
askDown bool
}
func newMesh(t *testing.T) *miniMesh {
t.Helper()
mm := &miniMesh{logins: map[string]FullGrant{}, state: map[string]BindingState{}, now: t0}
now := func() time.Time { return mm.now }
mm.store = NewMemoryStore(now)
mm.vendor = &stubVendor{live: map[string]bool{}, now: now}
crypt, _ := NewCrypt("a key the vault made")
mm.keys, _ = GenerateKeyPair()
mm.m = &Manager{
Store: mm.store, Vendor: mm.vendor, Crypt: crypt, Keys: mm.keys,
AskGrant: func(_ context.Context, node, publicKey string) (GrantAnswer, error) {
if mm.askDown {
return GrantAnswer{}, fmt.Errorf("503 no responders")
}
g, ok := mm.logins[node]
if !ok {
return GrantAnswer{}, nil
}
raw, _ := json.Marshal(g)
box, err := Seal(string(raw), publicKey)
return GrantAnswer{Sealed: &box, Fingerprint: Fingerprint(g.RefreshToken)}, err
},
PutBinding: func(_ context.Context, c string, b BindingState) error { mm.state[c] = b; return nil },
DeleteBinding: func(_ context.Context, c string) error { delete(mm.state, c); return nil },
Emit: func(event string, body map[string]any) error {
raw, _ := json.Marshal(body)
mm.events = append(mm.events, event+" "+string(raw))
return nil
},
Now: now, Log: func(string, ...any) {}, Holder: "test", Settings: Defaults,
}
return mm
}
func (mm *miniMesh) report(node, rt string, at time.Time, account string) Holdings {
h := Holdings{Node: node, Identity: &Identity{AccountUUID: account, EmailAddress: account + "@example.org"},
Kind: "subscription", ChangedAt: at.Format(time.RFC3339Nano)}
if rt != "" {
h.Refresh.Present, h.Refresh.Fingerprint = true, Fingerprint(rt)
}
return h
}
func (mm *miniMesh) login(node, rt string, valid bool, at time.Time) Holdings {
mm.logins[node] = FullGrant{AccessToken: "local-" + node, RefreshToken: rt, ExpiresAt: mm.now.Add(time.Hour).UnixMilli()}
if valid {
mm.vendor.live[rt] = true
}
return mm.report(node, rt, at, "acct-1")
}
const licence1 = "acct-1@example.org"
func TestAManagerWithNoLicenceAdoptsTheNewestLoginThatRefreshesAndNeverExchangesTheRest(t *testing.T) {
mm := newMesh(t)
ctx := context.Background()
older := mm.login("server", "rt-server", true, t0.Add(-time.Hour))
newest := mm.login("laptop", "rt-laptop", true, t0.Add(-time.Minute))
adopted, err := mm.m.Consider(ctx, []Holdings{older, newest})
if err != nil || len(adopted) != 1 || adopted[0] != licence1 {
t.Fatalf("adopted %v, %v", adopted, err)
}
if strings.Join(mm.vendor.exchanged, ",") != "rt-laptop" {
t.Fatalf("exchanged %v: an older login was exchanged although a newer one refreshed", mm.vendor.exchanged)
}
if o, _ := mm.store.Outcome(ctx, Fingerprint("rt-server")); o != Skipped {
t.Fatalf("the older login is %q, not skipped", o)
}
// A first binding follows the login: both nodes reported this account and were bound to nothing.
bs, _ := mm.store.Bindings(ctx)
if len(bs) != 2 || mm.state["laptop"].Licence != licence1 || mm.state["server"].Licence != licence1 {
t.Fatalf("bindings %v, state %v", bs, mm.state)
}
if !strings.HasPrefix(strings.Join(mm.events, "|"), "licence.adopted") {
t.Fatalf("events %v", mm.events)
}
}
func TestALoginThatDoesNotRefreshAdoptsNothingAndIsNotTriedAgain(t *testing.T) {
mm := newMesh(t)
ctx := context.Background()
dead := mm.login("laptop", "rt-dead", false, t0)
if adopted, _ := mm.m.Consider(ctx, []Holdings{dead}); len(adopted) != 0 {
t.Fatalf("adopted %v", adopted)
}
if o, _ := mm.store.Outcome(ctx, Fingerprint("rt-dead")); o != Dead {
t.Fatalf("outcome %q", o)
}
if ls, _ := mm.store.Licences(ctx); len(ls) != 0 {
t.Fatalf("licences %v", ls)
}
if !strings.Contains(strings.Join(mm.events, "|"), "licence.refused") {
t.Fatalf("events %v", mm.events)
}
_, _ = mm.m.Consider(ctx, []Holdings{dead})
if len(mm.vendor.exchanged) != 1 {
t.Fatalf("a dead refresh token was exchanged %d times", len(mm.vendor.exchanged))
}
}
func TestANewerLoginReplacesTheGrantHeldAndAnOlderOneDoesNot(t *testing.T) {
mm := newMesh(t)
ctx := context.Background()
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0.Add(-time.Minute))})
before, _ := mm.store.Licence(ctx, licence1)
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("server", "rt-old", true, t0.Add(-24*time.Hour))})
if strings.Join(mm.vendor.exchanged, ",") != "rt-a" {
t.Fatalf("an older login was exchanged: %v", mm.vendor.exchanged)
}
mm.now = t0.Add(10 * time.Minute)
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("desktop", "rt-new", true, t0.Add(10*time.Minute))})
after, _ := mm.store.Licence(ctx, licence1)
if after.RefreshFingerprint == before.RefreshFingerprint || mm.vendor.exchanged[len(mm.vendor.exchanged)-1] != "rt-new" {
t.Fatalf("a newer login did not win: %v", mm.vendor.exchanged)
}
if ls, _ := mm.store.Licences(ctx); len(ls) != 1 {
t.Fatalf("one account became %d licences", len(ls))
}
}
func TestAReportNamingAnotherAccountThanTheVendorAnsweredForIsRefused(t *testing.T) {
mm := newMesh(t)
ctx := context.Background()
mm.vendor.account = "someone-else"
if adopted, _ := mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)}); len(adopted) != 0 {
t.Fatalf("adopted %v", adopted)
}
if o, _ := mm.store.Outcome(ctx, Fingerprint("rt-a")); o != Refused {
t.Fatalf("outcome %q", o)
}
}
func TestTwoRefreshRunsStartedTogetherRotateAGrantOnce(t *testing.T) {
mm := newMesh(t)
ctx := context.Background()
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
mm.now = t0.Add(5 * time.Hour)
second := *mm.m
second.Holder = "another run"
var wg sync.WaitGroup
results := make([]map[string]any, 2)
for i, m := range []*Manager{mm.m, &second} {
wg.Add(1)
go func() { defer wg.Done(); results[i], _ = m.Rotate(ctx, licence1, false) }()
}
wg.Wait()
n := 0
for _, r := range results {
if r["refreshed"] == true {
n++
}
}
if n != 1 {
t.Fatalf("rotated %d times: %v", n, results)
}
if r, _ := second.Rotate(ctx, licence1, false); r["reason"] != "not due" {
t.Fatalf("a run just after was %v", r)
}
}
func TestARotationAndASwitchEachGiveANewerGeneration(t *testing.T) {
mm := newMesh(t)
ctx := context.Background()
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
g0 := mm.state["laptop"].Generation
_, _ = mm.m.Rotate(ctx, licence1, true)
g1 := mm.state["laptop"].Generation
if g1 <= g0 {
t.Fatalf("a rotation went from %d to %d", g0, g1)
}
file := filepath.Join(t.TempDir(), "key")
_ = os.WriteFile(file, []byte("sk-ant-api-key\n"), 0o600)
if _, err := mm.m.AdoptKey(ctx, "api", file); err != nil {
t.Fatal(err)
}
if _, err := mm.m.Bind(ctx, "laptop", "api", "test"); err != nil {
t.Fatal(err)
}
if mm.state["laptop"].Licence != "api" || mm.state["laptop"].Generation <= g1 {
t.Fatalf("a switch to a licence rotated less often went backwards: %v", mm.state["laptop"])
}
if _, err := mm.m.Release(ctx, "laptop", "test"); err != nil {
t.Fatal(err)
}
if _, ok := mm.state["laptop"]; ok {
t.Fatal("a released consumer still has a binding in the state")
}
}
func TestCurrentHandsAnAccessTokenOnlySealedToTheConsumersKey(t *testing.T) {
mm := newMesh(t)
ctx := context.Background()
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
node, _ := GenerateKeyPair()
answer, err := mm.m.Current(ctx, "laptop", node.PublicKey)
if err != nil || answer["kind"] != "subscription" {
t.Fatalf("%v %v", answer, err)
}
box := answer["sealed"].(SealedBox)
plain, err := Open(box, node.PrivateKey)
if err != nil {
t.Fatal(err)
}
var handed map[string]any
_ = json.Unmarshal([]byte(plain), &handed)
if !strings.HasPrefix(handed["accessToken"].(string), "at-") || handed["refreshToken"] != nil {
t.Fatalf("handed %v", handed)
}
other, _ := GenerateKeyPair()
if _, err := Open(box, other.PrivateKey); err == nil {
t.Fatal("the hand-over opened with another key")
}
if a, _ := mm.m.Current(ctx, "nobody", node.PublicKey); a != nil {
t.Fatalf("a consumer bound to nothing was answered %v", a)
}
}
func TestNothingTheManagerPublishesKeepsOrListsCarriesAToken(t *testing.T) {
mm := newMesh(t)
ctx := context.Background()
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-secret-1", true, t0)})
_, _ = mm.m.Rotate(ctx, licence1, true)
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("server", "rt-secret-dead", false, t0.Add(time.Hour))})
_ = mm.m.ReadUsage(ctx)
ls, _ := mm.m.Licences(ctx)
bs, _ := mm.store.Bindings(ctx)
everything, _ := json.Marshal([]any{mm.events, mm.state, ls, bs})
for _, token := range []string{"rt-secret", "rt-1", "rt-2", "at-1", "at-2", "local-"} {
if strings.Contains(string(everything), token) {
t.Fatalf("%s was published: %s", token, everything)
}
}
row, _ := mm.store.Licence(ctx, licence1)
if strings.Contains(row.Sealed, "rt-") {
t.Fatal("the grant is stored in the clear")
}
}
func TestANodeThatDoesNotAnswerIsAskedAgainAndOneWithNoLoginIsSettled(t *testing.T) {
mm := newMesh(t)
ctx := context.Background()
r := mm.login("laptop", "rt-a", true, t0)
mm.askDown = true
if adopted, _ := mm.m.Consider(ctx, []Holdings{r}); len(adopted) != 0 {
t.Fatalf("adopted %v from a node that did not answer", adopted)
}
if o, _ := mm.store.Outcome(ctx, Fingerprint("rt-a")); o != "" {
t.Fatalf("a node that was down was settled %q", o)
}
mm.askDown = false
if adopted, _ := mm.m.Consider(ctx, []Holdings{r}); len(adopted) != 1 {
t.Fatalf("adopted %v on the next pass", adopted)
}
gone := mm.report("server", "rt-gone", t0.Add(time.Second), "acct-2")
_, _ = mm.m.Consider(ctx, []Holdings{gone})
if o, _ := mm.store.Outcome(ctx, Fingerprint("rt-gone")); o != Gone {
t.Fatalf("outcome %q", o)
}
}
func TestAReportIsReadAsTheAgentModuleWritesIt(t *testing.T) {
// The agent module's own report shape (claude-code's holdingsOf), parsed here.
raw := `{"node":"laptop","identity":{"accountUuid":"u-1","emailAddress":"a@example.org"},"kind":"subscription",
"refresh":{"present":true,"fingerprint":"sha256:0123456789abcdef","expiresAt":null},
"access":{"fingerprint":"sha256:fedcba9876543210","expiresAt":1},"licence":null,"generation":0,
"changedAt":"2026-10-04T11:00:00.000Z"}`
var h Holdings
if err := json.Unmarshal([]byte(raw), &h); err != nil {
t.Fatal(err)
}
if h.Node != "laptop" || h.Identity.AccountUUID != "u-1" || !h.Refresh.Present || h.Refresh.Fingerprint != "sha256:0123456789abcdef" {
t.Fatalf("%+v", h)
}
if _, err := time.Parse(time.RFC3339Nano, h.ChangedAt); err != nil {
t.Fatalf("the report's time does not parse: %v", err)
}
}
// A node whose report arrives after its account was adopted — with an older login, so never a candidate —
// is still bound to that account's licence, once.
func TestANodeReportingAnAdoptedAccountLaterIsBoundToIt(t *testing.T) {
mm := newMesh(t)
ctx := context.Background()
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("novox", "rt-new", true, t0)})
late := mm.login("laptop", "rt-older", true, t0.Add(-24*time.Hour))
_, _ = mm.m.Consider(ctx, []Holdings{late})
if mm.state["laptop"].Licence != licence1 {
t.Fatalf("a node reporting the adopted account later was not bound: %v", mm.state)
}
if strings.Contains(strings.Join(mm.vendor.exchanged, ","), "rt-older") {
t.Fatal("the older login was exchanged")
}
g := mm.state["laptop"].Generation
_, _ = mm.m.Consider(ctx, []Holdings{late})
if mm.state["laptop"].Generation != g {
t.Fatal("a node already bound was bound again")
}
}
@@ -0,0 +1,281 @@
package main
// The store on the mesh's postgres (novox/hq design 39 §1), the database the mesh provisioned for this
// module. The schema is brought to this version's shape by the preparation step (ADR 0135), each
// statement idempotent.
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// Schema is what this version needs.
var Schema = []string{
`create table if not exists licence (
name text primary key,
kind text not null check (kind in ('subscription', 'api-key')),
account_uuid text unique,
email text,
organization_uuid text,
sealed text,
refresh_fingerprint text,
access_expires_at bigint,
refresh_expires_at bigint,
failures integer not null default 0,
notified_at bigint,
adopted_at bigint not null,
rotated_at bigint)`,
`create sequence if not exists binding_generation`,
`create table if not exists binding (
consumer text primary key,
licence text not null references licence(name),
generation bigint not null)`,
`create table if not exists lease (
key text primary key,
holder text not null,
until timestamptz not null)`,
`create table if not exists offered (
fingerprint text primary key,
node text not null,
account_uuid text,
outcome text not null,
why text not null,
at timestamptz not null default now())`,
`create table if not exists usage (
licence text not null,
at bigint not null,
reading jsonb not null,
raw jsonb not null)`,
`create index if not exists usage_by_licence on usage (licence, at desc)`,
`create table if not exists audit (
at timestamptz not null default now(),
what text not null,
detail jsonb not null)`,
}
// PgStore is the store on postgres.
type PgStore struct{ pool *pgxpool.Pool }
// PgStoreFromEnv opens the store the mesh provisioned, its URL in the file DATABASE_URL_FILE names.
func PgStoreFromEnv(ctx context.Context) (*PgStore, error) {
file := os.Getenv("DATABASE_URL_FILE")
if file == "" {
return nil, errors.New("DATABASE_URL_FILE is not set: the manager's database is a requirement the mesh resolves")
}
raw, err := os.ReadFile(file)
if err != nil {
return nil, err
}
return OpenPgStore(ctx, strings.TrimSpace(string(raw)))
}
// OpenPgStore opens a store at a URL.
func OpenPgStore(ctx context.Context, url string) (*PgStore, error) {
pool, err := pgxpool.New(ctx, url)
if err != nil {
return nil, err
}
return &PgStore{pool: pool}, nil
}
// Migrate brings the schema to this version's shape.
func (s *PgStore) Migrate(ctx context.Context) error {
for _, q := range Schema {
if _, err := s.pool.Exec(ctx, q); err != nil {
return fmt.Errorf("%s: %w", strings.Fields(q)[0:6], err)
}
}
return nil
}
const licenceColumns = `name, kind, coalesce(account_uuid,''), coalesce(email,''), coalesce(organization_uuid,''),
coalesce(sealed,''), coalesce(refresh_fingerprint,''), coalesce(access_expires_at,0), coalesce(refresh_expires_at,0),
failures, coalesce(notified_at,0), adopted_at, coalesce(rotated_at,0)`
func scanLicence(row pgx.Row) (*Licence, error) {
var l Licence
err := row.Scan(&l.Name, &l.Kind, &l.AccountUUID, &l.Email, &l.OrganizationUUID, &l.Sealed, &l.RefreshFingerprint,
&l.AccessExpiresAt, &l.RefreshExpiresAt, &l.Failures, &l.NotifiedAt, &l.AdoptedAt, &l.RotatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return &l, err
}
func (s *PgStore) Licences(ctx context.Context) ([]Licence, error) {
rows, err := s.pool.Query(ctx, `select `+licenceColumns+` from licence order by name`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Licence
for rows.Next() {
l, err := scanLicence(rows)
if err != nil {
return nil, err
}
out = append(out, *l)
}
return out, rows.Err()
}
func (s *PgStore) Licence(ctx context.Context, name string) (*Licence, error) {
return scanLicence(s.pool.QueryRow(ctx, `select `+licenceColumns+` from licence where name = $1`, name))
}
func (s *PgStore) LicenceForAccount(ctx context.Context, account string) (*Licence, error) {
return scanLicence(s.pool.QueryRow(ctx, `select `+licenceColumns+` from licence where account_uuid = $1`, account))
}
func nullable(s string) any {
if s == "" {
return nil
}
return s
}
func nullableInt(v int64) any {
if v == 0 {
return nil
}
return v
}
func (s *PgStore) SaveLicence(ctx context.Context, l Licence) error {
_, err := s.pool.Exec(ctx, `insert into licence (name, kind, account_uuid, email, organization_uuid, sealed, refresh_fingerprint,
access_expires_at, refresh_expires_at, failures, notified_at, adopted_at, rotated_at)
values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
on conflict (name) do update set kind = excluded.kind, account_uuid = excluded.account_uuid, email = excluded.email,
organization_uuid = excluded.organization_uuid, sealed = excluded.sealed, refresh_fingerprint = excluded.refresh_fingerprint,
access_expires_at = excluded.access_expires_at, refresh_expires_at = excluded.refresh_expires_at,
failures = excluded.failures, notified_at = excluded.notified_at, adopted_at = excluded.adopted_at,
rotated_at = excluded.rotated_at`,
l.Name, l.Kind, nullable(l.AccountUUID), nullable(l.Email), nullable(l.OrganizationUUID), nullable(l.Sealed),
nullable(l.RefreshFingerprint), nullableInt(l.AccessExpiresAt), nullableInt(l.RefreshExpiresAt), l.Failures,
nullableInt(l.NotifiedAt), l.AdoptedAt, nullableInt(l.RotatedAt))
return err
}
// Lease is taken in the store before a row is read (design 39 §3): a second run started together finds
// it live and does nothing.
func (s *PgStore) Lease(ctx context.Context, key, holder string, d time.Duration) (bool, error) {
tag, err := s.pool.Exec(ctx, `insert into lease (key, holder, until) values ($1, $2, now() + make_interval(secs => $3))
on conflict (key) do update set holder = excluded.holder, until = excluded.until
where lease.until < now() or lease.holder = excluded.holder`, key, holder, d.Seconds())
if err != nil {
return false, err
}
return tag.RowsAffected() == 1, nil
}
func (s *PgStore) Unlease(ctx context.Context, key, holder string) error {
_, err := s.pool.Exec(ctx, `delete from lease where key = $1 and holder = $2`, key, holder)
return err
}
func (s *PgStore) bindingsWhere(ctx context.Context, q string, args ...any) ([]Binding, error) {
rows, err := s.pool.Query(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Binding
for rows.Next() {
var b Binding
if err := rows.Scan(&b.Consumer, &b.Licence, &b.Generation); err != nil {
return nil, err
}
out = append(out, b)
}
return out, rows.Err()
}
func (s *PgStore) Bindings(ctx context.Context) ([]Binding, error) {
return s.bindingsWhere(ctx, `select consumer, licence, generation from binding order by consumer`)
}
func (s *PgStore) Binding(ctx context.Context, consumer string) (*Binding, error) {
out, err := s.bindingsWhere(ctx, `select consumer, licence, generation from binding where consumer = $1`, consumer)
if err != nil || len(out) == 0 {
return nil, err
}
return &out[0], nil
}
func (s *PgStore) Bind(ctx context.Context, consumer, licence string) (Binding, error) {
out, err := s.bindingsWhere(ctx, `insert into binding (consumer, licence, generation) values ($1, $2, nextval('binding_generation'))
on conflict (consumer) do update set licence = excluded.licence, generation = excluded.generation
returning consumer, licence, generation`, consumer, licence)
if err != nil {
return Binding{}, err
}
return out[0], nil
}
func (s *PgStore) Unbind(ctx context.Context, consumer string) (bool, error) {
tag, err := s.pool.Exec(ctx, `delete from binding where consumer = $1`, consumer)
return err == nil && tag.RowsAffected() == 1, err
}
func (s *PgStore) Advance(ctx context.Context, licence string) ([]Binding, error) {
return s.bindingsWhere(ctx, `update binding set generation = nextval('binding_generation') where licence = $1
returning consumer, licence, generation`, licence)
}
func (s *PgStore) Outcome(ctx context.Context, fp string) (Outcome, error) {
var o string
err := s.pool.QueryRow(ctx, `select outcome from offered where fingerprint = $1`, fp).Scan(&o)
if errors.Is(err, pgx.ErrNoRows) {
return "", nil
}
return Outcome(o), err
}
func (s *PgStore) RecordOutcome(ctx context.Context, fp, node, account string, o Outcome, why string) error {
_, err := s.pool.Exec(ctx, `insert into offered (fingerprint, node, account_uuid, outcome, why) values ($1,$2,$3,$4,$5)
on conflict (fingerprint) do update set outcome = excluded.outcome, why = excluded.why, at = now()`,
fp, node, nullable(account), string(o), why)
return err
}
func (s *PgStore) RecordUsage(ctx context.Context, licence string, at int64, r UsageReading, raw map[string]any) error {
reading, _ := json.Marshal(r)
rawJSON, _ := json.Marshal(raw)
_, err := s.pool.Exec(ctx, `insert into usage (licence, at, reading, raw) values ($1,$2,$3,$4)`, licence, at, reading, rawJSON)
return err
}
func (s *PgStore) Usage(ctx context.Context, licence string, limit int) ([]UsageRow, error) {
rows, err := s.pool.Query(ctx, `select licence, at, reading from usage where ($1 = '' or licence = $1) order by at desc limit $2`, licence, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []UsageRow
for rows.Next() {
var u UsageRow
var reading []byte
if err := rows.Scan(&u.Licence, &u.At, &reading); err != nil {
return nil, err
}
_ = json.Unmarshal(reading, &u.Reading)
out = append(out, u)
}
return out, rows.Err()
}
func (s *PgStore) Audit(ctx context.Context, what string, detail map[string]any) error {
raw, _ := json.Marshal(detail)
_, err := s.pool.Exec(ctx, `insert into audit (what, detail) values ($1, $2)`, what, raw)
return err
}
func (s *PgStore) Close() { s.pool.Close() }
@@ -0,0 +1,189 @@
package main
// Sealing to one recipient (novox/hq ADR 0183, ADR 0206): the manager seals what it hands a consumer to
// the key that consumer sent, and a node seals a waiting login to the key the manager gives. The same box
// the agent module's TypeScript makes and opens, byte for byte — X25519 for the agreement, HKDF-SHA256 for
// the key, AES-256-GCM for the box — so `testdata/sealed-by-typescript.json` is opened here, and a test
// reopens what this seals with the same derivation.
//
// A box is `{ v: 1, eph, iv, tag, ct }`, every field base64; `eph` is the one-time public key as SPKI DER,
// and the key is bound to it and to the recipient's raw public key, so a box cannot be re-addressed.
import (
"crypto/aes"
"crypto/cipher"
"crypto/ecdh"
"crypto/hkdf"
"crypto/rand"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"errors"
"fmt"
)
// SealedBox is a value sealed to one recipient.
type SealedBox struct {
V int `json:"v"`
Eph string `json:"eph"`
IV string `json:"iv"`
Tag string `json:"tag"`
Ct string `json:"ct"`
}
// KeyPair is a recipient's keypair as the two PEM strings it is kept and sent as.
type KeyPair struct {
PublicKey string `json:"publicKey"`
PrivateKey string `json:"privateKey"`
}
const sealInfo = "novox-mesh sealed box v1"
// GenerateKeyPair makes an X25519 keypair, PEM-encoded as the agent module's are.
func GenerateKeyPair() (KeyPair, error) {
priv, err := ecdh.X25519().GenerateKey(rand.Reader)
if err != nil {
return KeyPair{}, err
}
pubDER, err := x509.MarshalPKIXPublicKey(priv.PublicKey())
if err != nil {
return KeyPair{}, err
}
privDER, err := x509.MarshalPKCS8PrivateKey(priv)
if err != nil {
return KeyPair{}, err
}
return KeyPair{
PublicKey: string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubDER})),
PrivateKey: string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: privDER})),
}, nil
}
func publicFromPEM(p string) (*ecdh.PublicKey, error) {
block, _ := pem.Decode([]byte(p))
if block == nil {
return nil, errors.New("not a PEM public key")
}
k, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
return nil, err
}
pub, ok := k.(*ecdh.PublicKey)
if !ok || pub.Curve() != ecdh.X25519() {
return nil, errors.New("not an X25519 public key")
}
return pub, nil
}
func privateFromPEM(p string) (*ecdh.PrivateKey, error) {
block, _ := pem.Decode([]byte(p))
if block == nil {
return nil, errors.New("not a PEM private key")
}
k, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return nil, err
}
priv, ok := k.(*ecdh.PrivateKey)
if !ok || priv.Curve() != ecdh.X25519() {
return nil, errors.New("not an X25519 private key")
}
return priv, nil
}
func boxKey(secret, ephDER, recipientRaw []byte) ([]byte, error) {
salt := append(append([]byte{}, ephDER...), recipientRaw...)
return hkdf.Key(sha256.New, secret, salt, sealInfo, 32)
}
// Seal seals plaintext to the recipient's public key.
func Seal(plaintext, recipientPEM string) (SealedBox, error) {
recipient, err := publicFromPEM(recipientPEM)
if err != nil {
return SealedBox{}, err
}
eph, err := ecdh.X25519().GenerateKey(rand.Reader)
if err != nil {
return SealedBox{}, err
}
secret, err := eph.ECDH(recipient)
if err != nil {
return SealedBox{}, err
}
ephDER, err := x509.MarshalPKIXPublicKey(eph.PublicKey())
if err != nil {
return SealedBox{}, err
}
key, err := boxKey(secret, ephDER, recipient.Bytes())
if err != nil {
return SealedBox{}, err
}
gcm, err := newGCM(key)
if err != nil {
return SealedBox{}, err
}
iv := make([]byte, 12)
if _, err := rand.Read(iv); err != nil {
return SealedBox{}, err
}
out := gcm.Seal(nil, iv, []byte(plaintext), nil)
ct, tag := out[:len(out)-gcm.Overhead()], out[len(out)-gcm.Overhead():]
b64 := base64.StdEncoding.EncodeToString
return SealedBox{V: 1, Eph: b64(ephDER), IV: b64(iv), Tag: b64(tag), Ct: b64(ct)}, nil
}
// Open opens a box with the recipient's private key; it fails for a box to another key or one tampered with.
func Open(box SealedBox, privatePEM string) (string, error) {
if box.V != 1 {
return "", errors.New("not a sealed box this module can open")
}
priv, err := privateFromPEM(privatePEM)
if err != nil {
return "", err
}
d := base64.StdEncoding.DecodeString
ephDER, err := d(box.Eph)
if err != nil {
return "", fmt.Errorf("the box's eph: %w", err)
}
ephKey, err := x509.ParsePKIXPublicKey(ephDER)
if err != nil {
return "", err
}
eph, ok := ephKey.(*ecdh.PublicKey)
if !ok {
return "", errors.New("the box's eph is not an X25519 key")
}
secret, err := priv.ECDH(eph)
if err != nil {
return "", err
}
key, err := boxKey(secret, ephDER, priv.PublicKey().Bytes())
if err != nil {
return "", err
}
iv, err1 := d(box.IV)
tag, err2 := d(box.Tag)
ct, err3 := d(box.Ct)
if err := errors.Join(err1, err2, err3); err != nil {
return "", err
}
gcm, err := newGCM(key)
if err != nil {
return "", err
}
plain, err := gcm.Open(nil, iv, append(ct, tag...), nil)
if err != nil {
return "", errors.New("the box does not open with this key")
}
return string(plain), nil
}
func newGCM(key []byte) (cipher.AEAD, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
return cipher.NewGCM(block)
}
@@ -0,0 +1,43 @@
package main
import (
"encoding/json"
"os"
"testing"
)
// A box the agent module's TypeScript sealed opens here: the two implementations are one format.
func TestABoxSealedInTypeScriptOpensInGo(t *testing.T) {
raw, err := os.ReadFile("testdata/sealed-by-typescript.json")
if err != nil {
t.Fatal(err)
}
var f struct {
PrivateKey string `json:"privateKey"`
Box SealedBox `json:"box"`
Plaintext string `json:"plaintext"`
}
if err := json.Unmarshal(raw, &f); err != nil {
t.Fatal(err)
}
got, err := Open(f.Box, f.PrivateKey)
if err != nil || got != f.Plaintext {
t.Fatalf("opened %q, %v", got, err)
}
}
// What Go seals opens with its own key and no other.
func TestABoxOpensOnlyForItsRecipient(t *testing.T) {
a, _ := GenerateKeyPair()
b, _ := GenerateKeyPair()
box, err := Seal("a token", a.PublicKey)
if err != nil {
t.Fatal(err)
}
if got, err := Open(box, a.PrivateKey); err != nil || got != "a token" {
t.Fatalf("opened %q, %v", got, err)
}
if _, err := Open(box, b.PrivateKey); err == nil {
t.Fatal("a box opened for another key")
}
}
@@ -0,0 +1,263 @@
package main
// The manager's store (novox/hq ADR 0183, design 39 §1): licences with their grants encrypted, bindings
// with a generation, what became of each login it was offered, usage readings, and the audit. One
// interface, two implementations — postgres for the mesh (pgstore.go), memory for the tests — so every
// rule is tested without a database, and the database is asked only to keep rows.
import (
"context"
"sort"
"sync"
"time"
)
// Licence is one licence: an account, or an API key.
type Licence struct {
Name string `json:"name"`
Kind string `json:"kind"` // subscription | api-key
AccountUUID string `json:"accountUuid,omitempty"`
Email string `json:"email,omitempty"`
OrganizationUUID string `json:"organizationUuid,omitempty"`
Sealed string `json:"-"` // the grant or the key, encrypted at rest
RefreshFingerprint string `json:"-"`
AccessExpiresAt int64 `json:"accessExpiresAt,omitempty"`
RefreshExpiresAt int64 `json:"refreshExpiresAt,omitempty"`
Failures int `json:"failures"`
NotifiedAt int64 `json:"-"`
AdoptedAt int64 `json:"adoptedAt"`
RotatedAt int64 `json:"rotatedAt,omitempty"`
}
// Binding is one consumer's binding and the generation it was last given (ADR 0206).
type Binding struct {
Consumer string `json:"consumer"`
Licence string `json:"licence"`
Generation int64 `json:"generation"`
}
// Outcome is what became of a login the manager was offered, by its refresh token's fingerprint.
type Outcome string
const (
Adopted Outcome = "adopted"
Dead Outcome = "dead"
Skipped Outcome = "skipped"
Refused Outcome = "refused"
Gone Outcome = "gone"
)
// UsageRow is one usage reading.
type UsageRow struct {
Licence string `json:"licence"`
At int64 `json:"at"`
Reading UsageReading `json:"reading"`
}
// Store is what the manager keeps.
type Store interface {
Licences(ctx context.Context) ([]Licence, error)
Licence(ctx context.Context, name string) (*Licence, error)
LicenceForAccount(ctx context.Context, account string) (*Licence, error)
SaveLicence(ctx context.Context, l Licence) error
// Lease takes a lease for d, or answers false while another holder's is live.
Lease(ctx context.Context, key, holder string, d time.Duration) (bool, error)
Unlease(ctx context.Context, key, holder string) error
Bindings(ctx context.Context) ([]Binding, error)
Binding(ctx context.Context, consumer string) (*Binding, error)
// Bind binds (or switches) a consumer at the next generation.
Bind(ctx context.Context, consumer, licence string) (Binding, error)
Unbind(ctx context.Context, consumer string) (bool, error)
// Advance gives every consumer of a licence a new generation: what a rotation is to them.
Advance(ctx context.Context, licence string) ([]Binding, error)
Outcome(ctx context.Context, fingerprint string) (Outcome, error)
RecordOutcome(ctx context.Context, fingerprint, node, account string, o Outcome, why string) error
RecordUsage(ctx context.Context, licence string, at int64, r UsageReading, raw map[string]any) error
Usage(ctx context.Context, licence string, limit int) ([]UsageRow, error)
Audit(ctx context.Context, what string, detail map[string]any) error
Close()
}
// MemoryStore is the tests' store.
type MemoryStore struct {
mu sync.Mutex
now func() time.Time
rows map[string]Licence
binds map[string]Binding
leases map[string]struct {
holder string
until time.Time
}
outcomes map[string]Outcome
usage []UsageRow
Audits []map[string]any
generation int64
}
// NewMemoryStore is an empty store whose leases age by now.
func NewMemoryStore(now func() time.Time) *MemoryStore {
return &MemoryStore{now: now, rows: map[string]Licence{}, binds: map[string]Binding{},
leases: map[string]struct {
holder string
until time.Time
}{}, outcomes: map[string]Outcome{}}
}
func (m *MemoryStore) Licences(context.Context) ([]Licence, error) {
m.mu.Lock()
defer m.mu.Unlock()
out := make([]Licence, 0, len(m.rows))
for _, l := range m.rows {
out = append(out, l)
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out, nil
}
func (m *MemoryStore) Licence(_ context.Context, name string) (*Licence, error) {
m.mu.Lock()
defer m.mu.Unlock()
if l, ok := m.rows[name]; ok {
return &l, nil
}
return nil, nil
}
func (m *MemoryStore) LicenceForAccount(_ context.Context, account string) (*Licence, error) {
m.mu.Lock()
defer m.mu.Unlock()
for _, l := range m.rows {
if l.AccountUUID == account {
return &l, nil
}
}
return nil, nil
}
func (m *MemoryStore) SaveLicence(_ context.Context, l Licence) error {
m.mu.Lock()
defer m.mu.Unlock()
m.rows[l.Name] = l
return nil
}
func (m *MemoryStore) Lease(_ context.Context, key, holder string, d time.Duration) (bool, error) {
m.mu.Lock()
defer m.mu.Unlock()
if held, ok := m.leases[key]; ok && held.until.After(m.now()) && held.holder != holder {
return false, nil
}
m.leases[key] = struct {
holder string
until time.Time
}{holder, m.now().Add(d)}
return true, nil
}
func (m *MemoryStore) Unlease(_ context.Context, key, holder string) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.leases[key].holder == holder {
delete(m.leases, key)
}
return nil
}
func (m *MemoryStore) Bindings(context.Context) ([]Binding, error) {
m.mu.Lock()
defer m.mu.Unlock()
out := make([]Binding, 0, len(m.binds))
for _, b := range m.binds {
out = append(out, b)
}
sort.Slice(out, func(i, j int) bool { return out[i].Consumer < out[j].Consumer })
return out, nil
}
func (m *MemoryStore) Binding(_ context.Context, consumer string) (*Binding, error) {
m.mu.Lock()
defer m.mu.Unlock()
if b, ok := m.binds[consumer]; ok {
return &b, nil
}
return nil, nil
}
func (m *MemoryStore) Bind(_ context.Context, consumer, licence string) (Binding, error) {
m.mu.Lock()
defer m.mu.Unlock()
m.generation++
b := Binding{Consumer: consumer, Licence: licence, Generation: m.generation}
m.binds[consumer] = b
return b, nil
}
func (m *MemoryStore) Unbind(_ context.Context, consumer string) (bool, error) {
m.mu.Lock()
defer m.mu.Unlock()
_, ok := m.binds[consumer]
delete(m.binds, consumer)
return ok, nil
}
func (m *MemoryStore) Advance(_ context.Context, licence string) ([]Binding, error) {
m.mu.Lock()
defer m.mu.Unlock()
var out []Binding
for c, b := range m.binds {
if b.Licence != licence {
continue
}
m.generation++
b.Generation = m.generation
m.binds[c] = b
out = append(out, b)
}
sort.Slice(out, func(i, j int) bool { return out[i].Consumer < out[j].Consumer })
return out, nil
}
func (m *MemoryStore) Outcome(_ context.Context, fp string) (Outcome, error) {
m.mu.Lock()
defer m.mu.Unlock()
return m.outcomes[fp], nil
}
func (m *MemoryStore) RecordOutcome(_ context.Context, fp, _, _ string, o Outcome, _ string) error {
m.mu.Lock()
defer m.mu.Unlock()
m.outcomes[fp] = o
return nil
}
func (m *MemoryStore) RecordUsage(_ context.Context, licence string, at int64, r UsageReading, _ map[string]any) error {
m.mu.Lock()
defer m.mu.Unlock()
m.usage = append(m.usage, UsageRow{Licence: licence, At: at, Reading: r})
return nil
}
func (m *MemoryStore) Usage(_ context.Context, licence string, limit int) ([]UsageRow, error) {
m.mu.Lock()
defer m.mu.Unlock()
var out []UsageRow
for i := len(m.usage) - 1; i >= 0 && len(out) < limit; i-- {
if licence == "" || m.usage[i].Licence == licence {
out = append(out, m.usage[i])
}
}
return out, nil
}
func (m *MemoryStore) Audit(_ context.Context, what string, detail map[string]any) error {
m.mu.Lock()
defer m.mu.Unlock()
d := map[string]any{"what": what}
for k, v := range detail {
d[k] = v
}
m.Audits = append(m.Audits, d)
return nil
}
func (m *MemoryStore) Close() {}
@@ -0,0 +1,125 @@
package main
import (
"context"
"os"
"strings"
"testing"
"time"
)
// Against a real postgres, because the questions are the database's: does the schema apply twice, does a
// lease refuse a second holder, does a generation only grow. Skipped unless one is named:
//
// docker run -d --rm --name licmgr-pg -e POSTGRES_PASSWORD=t -p 15498:5432 postgres:16-alpine
// MESH_TEST_POSTGRES=postgres://postgres:t@127.0.0.1:15498/postgres go test ./...
func TestTheStoreOnPostgres(t *testing.T) {
url := os.Getenv("MESH_TEST_POSTGRES")
if url == "" {
t.Skip("MESH_TEST_POSTGRES unset")
}
ctx := context.Background()
s, err := OpenPgStore(ctx, url)
if err != nil {
t.Fatal(err)
}
defer s.Close()
for _, table := range []string{"binding", "licence", "lease", "offered", "usage", "audit"} {
_, _ = s.pool.Exec(ctx, "drop table if exists "+table+" cascade")
}
_, _ = s.pool.Exec(ctx, "drop sequence if exists binding_generation")
for i := 0; i < 2; i++ {
if err := s.Migrate(ctx); err != nil {
t.Fatalf("migration %d: %v", i+1, err)
}
}
l := Licence{Name: "a@example.org", Kind: "subscription", AccountUUID: "u-1", Email: "a@example.org", Sealed: "v1.x.y",
RefreshFingerprint: "sha256:1", AccessExpiresAt: 1, RefreshExpiresAt: 2, AdoptedAt: 3}
if err := s.SaveLicence(ctx, l); err != nil {
t.Fatal(err)
}
l.Failures = 2
_ = s.SaveLicence(ctx, l)
if got, _ := s.LicenceForAccount(ctx, "u-1"); got == nil || got.Failures != 2 || got.OrganizationUUID != "" {
t.Fatalf("%+v", got)
}
if none, err := s.Licence(ctx, "nobody"); none != nil || err != nil {
t.Fatalf("%v %v", none, err)
}
lease := func(holder string) bool {
ok, err := s.Lease(ctx, "licence:a", holder, time.Minute)
if err != nil {
t.Fatal(err)
}
return ok
}
if !lease("one") || lease("two") || !lease("one") {
t.Fatal("a lease did not refuse a second holder, or its own holder could not renew it")
}
_ = s.Unlease(ctx, "licence:a", "one")
if !lease("two") {
t.Fatal("a released lease was not taken")
}
b1, _ := s.Bind(ctx, "laptop", l.Name)
adv, _ := s.Advance(ctx, l.Name)
b3, _ := s.Bind(ctx, "laptop", l.Name)
if len(adv) != 1 || !(b1.Generation < adv[0].Generation && adv[0].Generation < b3.Generation) {
t.Fatalf("generations %d %v %d", b1.Generation, adv, b3.Generation)
}
_ = s.RecordOutcome(ctx, "sha256:x", "laptop", "u-1", Dead, "400")
if o, _ := s.Outcome(ctx, "sha256:x"); o != Dead {
t.Fatalf("outcome %q", o)
}
if o, _ := s.Outcome(ctx, "sha256:none"); o != "" {
t.Fatalf("an unknown login is %q", o)
}
pct := 1.0
_ = s.RecordUsage(ctx, l.Name, 5, UsageReading{SessionPct: &pct}, map[string]any{})
if u, _ := s.Usage(ctx, "", 5); len(u) != 1 || *u[0].Reading.SessionPct != 1 {
t.Fatalf("usage %v", u)
}
if err := s.Audit(ctx, "bound", map[string]any{"consumer": "laptop"}); err != nil {
t.Fatal(err)
}
if ok, _ := s.Unbind(ctx, "laptop"); !ok {
t.Fatal("unbind")
}
all, _ := s.Licences(ctx)
if len(all) != 1 || !strings.HasPrefix(all[0].Sealed, "v1.") {
t.Fatalf("%v", all)
}
}
func TestARefreshThatDoesNotRotateKeepsTheRefreshToken(t *testing.T) {
prev := FullGrant{AccessToken: "a", RefreshToken: "r", ExpiresAt: 1}
in := int64(60)
kept := NextGrant(prev, tokenResponse{AccessToken: "a2", ExpiresIn: &in}, 1000)
if !kept.OK || kept.Grant.RefreshToken != "r" || kept.Grant.ExpiresAt != 61_000 {
t.Fatalf("%+v", kept)
}
rotated := NextGrant(prev, tokenResponse{AccessToken: "a3", RefreshToken: "r2"}, 0)
if rotated.Grant.RefreshToken != "r2" {
t.Fatalf("%+v", rotated)
}
if NextGrant(prev, tokenResponse{}, 0).OK {
t.Fatal("an answer with no access token was a grant")
}
}
func TestAGrantAtRestOpensOnlyWithItsKey(t *testing.T) {
a, _ := NewCrypt("one key")
b, _ := NewCrypt("another key")
sealed := a.Seal(`{"refreshToken":"rt"}`)
if strings.Contains(sealed, "rt") {
t.Fatal("stored in the clear")
}
if got, err := a.Open(sealed); err != nil || got != `{"refreshToken":"rt"}` {
t.Fatalf("%q %v", got, err)
}
if _, err := b.Open(sealed); err == nil {
t.Fatal("opened with another key")
}
if _, err := NewCrypt(" "); err == nil {
t.Fatal("an empty key was accepted")
}
}
@@ -0,0 +1,12 @@
{
"privateKey": "-----BEGIN PRIVATE KEY-----\nMC4CAQAwBQYDK2VuBCIEIAi2NK/bN+p7cqYUwv/kz72TgLdmJUfOHCDZTrMpQzpS\n-----END PRIVATE KEY-----\n",
"publicKey": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VuAyEARYvD/w+9ah0KWS9T9pd6Ea6CBymUE48vEVk983QPKyY=\n-----END PUBLIC KEY-----\n",
"box": {
"v": 1,
"eph": "MCowBQYDK2VuAyEAIYlSGrJvF8qSjR1aTFWbEQM/NFbZXrarglN0aRLZZ0E=",
"iv": "ABqT/hMukn6+xpjh",
"tag": "POzmsWTPHSn9xLMMJJ9Akg==",
"ct": "m2u0kuQ0PwrsGelM99Z5aBw6FCd6lFISUbwiK5TzzDw4ZrGtsHEvxytoIeFC"
},
"plaintext": "a grant sealed by the TypeScript agent module"
}
@@ -0,0 +1,187 @@
package main
// The only file that talks to Anthropic (novox/hq ADR 0183): the token endpoint, which this module alone
// calls — one rotation source — and the usage endpoint. Ported from the predecessor's manager, whose
// client id and error handling were each earned by an incident.
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"time"
)
// The public Claude Code client's id: not a secret, and a hard-won constant — a metadata URL in its place
// answers 400, which the predecessor once misdiagnosed as a dead grant.
const clientID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
func tokenEndpoint() string {
if v := os.Getenv("MESH_ANTHROPIC_TOKEN_ENDPOINT"); v != "" {
return v
}
return "https://platform.claude.com/v1/oauth/token"
}
func usageEndpoint() string {
if v := os.Getenv("MESH_ANTHROPIC_USAGE_ENDPOINT"); v != "" {
return v
}
return "https://api.anthropic.com/api/oauth/usage"
}
// FullGrant is a subscription's grant as this module keeps it: what the agent's credentials file calls
// `claudeAiOauth`.
type FullGrant struct {
AccessToken string `json:"accessToken"`
RefreshToken string `json:"refreshToken"`
ExpiresAt int64 `json:"expiresAt"`
RefreshTokenExpiresAt *int64 `json:"refreshTokenExpiresAt,omitempty"`
Scopes []string `json:"scopes,omitempty"`
SubscriptionType string `json:"subscriptionType,omitempty"`
RateLimitTier string `json:"rateLimitTier,omitempty"`
}
// Refreshed is what a refresh came to: the next grant and the account the vendor answered for, or why not.
type Refreshed struct {
OK bool
Grant FullGrant
Account string // empty when the vendor named none
Status int
Reason string
}
// Vendor is the vendor as the manager reaches it; a test stubs it.
type Vendor interface {
Refresh(ctx context.Context, g FullGrant) Refreshed
Usage(ctx context.Context, accessToken string) (map[string]any, error)
}
type tokenResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn *int64 `json:"expires_in"`
RefreshTokenExpiresIn *int64 `json:"refresh_token_expires_in"`
Scope string `json:"scope"`
Scopes []string `json:"scopes"`
SubscriptionType string `json:"subscription_type"`
Account *struct {
UUID string `json:"uuid"`
} `json:"account"`
}
// NextGrant is the grant a refresh answered, laid over the one refreshed: a refresh token the vendor did
// not rotate is kept, so a rotating vendor and one that does not are both handled.
func NextGrant(prev FullGrant, r tokenResponse, nowMs int64) Refreshed {
if r.AccessToken == "" {
return Refreshed{Status: 200, Reason: "the vendor answered without an access token"}
}
g := prev
g.AccessToken = r.AccessToken
if r.RefreshToken != "" {
g.RefreshToken = r.RefreshToken
}
if r.ExpiresIn != nil {
g.ExpiresAt = nowMs + *r.ExpiresIn*1000
}
if r.RefreshTokenExpiresIn != nil {
v := nowMs + *r.RefreshTokenExpiresIn*1000
g.RefreshTokenExpiresAt = &v
}
if len(r.Scopes) > 0 {
g.Scopes = r.Scopes
} else if r.Scope != "" {
g.Scopes = strings.Fields(r.Scope)
}
if r.SubscriptionType != "" {
g.SubscriptionType = r.SubscriptionType
}
out := Refreshed{OK: true, Grant: g}
if r.Account != nil {
out.Account = r.Account.UUID
}
return out
}
type liveVendor struct{ client *http.Client }
// LiveVendor is the vendor over the network.
func LiveVendor() Vendor { return liveVendor{client: &http.Client{Timeout: 30 * time.Second}} }
func (v liveVendor) Refresh(ctx context.Context, g FullGrant) Refreshed {
form := url.Values{"grant_type": {"refresh_token"}, "refresh_token": {g.RefreshToken}, "client_id": {clientID}}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenEndpoint(), strings.NewReader(form.Encode()))
if err != nil {
return Refreshed{Reason: err.Error()}
}
req.Header.Set("content-type", "application/x-www-form-urlencoded")
resp, err := v.client.Do(req)
if err != nil {
return Refreshed{Reason: "the token endpoint did not answer: " + err.Error()}
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode/100 != 2 {
// The body, never only the status: a malformed request and a revoked grant both answer 400.
reason := string(body)
if len(reason) > 400 {
reason = reason[:400]
}
return Refreshed{Status: resp.StatusCode, Reason: reason}
}
var r tokenResponse
if err := json.Unmarshal(body, &r); err != nil {
return Refreshed{Status: resp.StatusCode, Reason: "the vendor's answer is not JSON"}
}
return NextGrant(g, r, time.Now().UnixMilli())
}
func (v liveVendor) Usage(ctx context.Context, accessToken string) (map[string]any, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, usageEndpoint(), nil)
if err != nil {
return nil, err
}
req.Header.Set("authorization", "Bearer "+accessToken)
resp, err := v.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
return nil, fmt.Errorf("the usage endpoint answered %d", resp.StatusCode)
}
var out map[string]any
return out, json.NewDecoder(resp.Body).Decode(&out)
}
// UsageReading is the licence-grain reading (ADR 0054), flattened from the vendor's windows.
type UsageReading struct {
SessionPct *float64 `json:"sessionPct"`
SessionResetsAt string `json:"sessionResetsAt,omitempty"`
WeeklyPct *float64 `json:"weeklyPct"`
SonnetPct *float64 `json:"sonnetPct"`
}
// FlattenUsage reads the windows the predecessor read.
func FlattenUsage(u map[string]any) UsageReading {
window := func(k string) (*float64, string) {
w, ok := u[k].(map[string]any)
if !ok {
return nil, ""
}
pct, ok := w["utilization"].(float64)
resets, _ := w["resets_at"].(string)
if !ok {
return nil, resets
}
return &pct, resets
}
s, resets := window("five_hour")
w, _ := window("seven_day")
so, _ := window("seven_day_sonnet")
return UsageReading{SessionPct: s, SessionResetsAt: resets, WeeklyPct: w, SonnetPct: so}
}
+16
View File
@@ -0,0 +1,16 @@
module claude-licence-manager
go 1.25.0
require (
git.novox.be/novox/mesh-sdk/go v0.1.7
github.com/jackc/pgx/v5 v5.11.0
)
require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/text v0.29.0 // indirect
)
+28
View File
@@ -0,0 +1,28 @@
git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w=
git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg=
github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+130
View File
@@ -0,0 +1,130 @@
{
"module": "claude-licence-manager",
"version": "1",
"slug": "licmgr",
"requires": [
"postgres-database",
"secret"
],
"contributes": {
"postgres-database": {
"name": "claude_licences"
}
},
"binds": {
"postgres-database": "${dir:state}/database.json"
},
"secrets": {
"postgres-database": "${dir:state}/database.secret",
"secret": {
"grant-key": "${dir:state}/grant.key"
}
},
"seats": [
{
"name": "anthropic-licence-manager",
"scope": "mesh",
"serves": [
"licences",
"bindings",
"bind",
"switch",
"release",
"refresh",
"usage",
"adopt",
"current"
]
}
],
"claims": [
{
"name": "anthropic-licence-manager",
"scope": "mesh",
"serves": [
"licences",
"bindings",
"bind",
"switch",
"release",
"refresh",
"usage",
"adopt",
"current"
]
}
],
"emits": [
"licence.adopted",
"licence.refused",
"licence.failing",
"usage.read"
],
"state": [
"bindings"
],
"reads": [
"claude-code.holdings"
],
"resources": [
{
"id": "state",
"type": "directory",
"mode": "0700",
"place": "."
},
{
"id": "database-url",
"type": "file",
"path": "${dir:state}/database.url",
"mode": "0600",
"content": "postgresql://${bound:postgres-database:as}:${secret:postgres-database}@${bound:postgres-database:at}:${bound:postgres-database:port}/${bound:postgres-database:as}\n"
},
{
"id": "settings",
"type": "file",
"path": "${dir:state}/settings.json",
"mode": "0600",
"merge": "json",
"content": "{\n \"cadence_minutes\": 240,\n \"floor_minutes\": 60,\n \"failures_to_notify\": 3,\n \"cooldown_hours\": 24,\n \"refresh_warn_days\": 3\n}\n"
},
{
"id": "prepare",
"type": "process",
"name": "claude-licence-manager-prepare",
"artifact": "code",
"run": [
"./claude-licence-manager",
"prepare"
],
"run-once": true,
"env": {
"DATABASE_URL_FILE": "${dir:state}/database.url"
},
"restart-on": [
"database-url"
]
}
],
"build": {
"artifacts": [
{
"name": "code",
"kind": "bundle",
"language": "go",
"system": "arch",
"from": "cmd/claude-licence-manager",
"binary": "claude-licence-manager",
"loads": [
"claude-licence-manager"
],
"env": {
"DATABASE_URL_FILE": "${dir:state}/database.url",
"MESH_LICENCE_STATE": "${dir:state}",
"MESH_LICENCE_KEY_FILE": "${dir:state}/grant.key",
"MESH_LICENCE_SETTINGS": "${dir:state}/settings.json"
}
}
]
}
}
-24
View File
@@ -1,24 +0,0 @@
# cloudflare-dns's runtime: the tool runtime, carrying this module's compiled code.
#
# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in
# the base images, published like any other artifact — which is what makes this buildable by the
# mesh from a repository and a path (novox/hq ADR 0069) rather than only on a workstation that
# happens to have the siblings.
#
# Two bases, named rather than pinned (novox/hq issue 044): the image this is COMPILED in and the
# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`.
ARG BUILD_BASE
ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build
WORKDIR /app/modules/cloudflare-dns
COPY . .
RUN node /app/node_modules/typescript/bin/tsc client.ts tools/index.ts provisioner/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE}
COPY --from=build /app/modules/cloudflare-dns/dist /app/modules/cloudflare-dns/dist
# Every serve-time entrypoint, loaded by the runtime in serve mode: tools and events serve, and a
# provider's provisioner runs its reconcile loop in the same process, with the broker connected —
# the convention novox/hq issues 060/061 settled.
ENV MESH_TOOL_MODULES=/app/modules/cloudflare-dns/dist/tools/index.js,/app/modules/cloudflare-dns/dist/provisioner/index.js
+22 -48
View File
@@ -12,87 +12,61 @@
"public-dns": {}
},
"grants": {
"public-dns": "/var/lib/cloudflare-dns/grants"
"public-dns": "${dir:grants}"
},
"receives": {
"public-dns": "/var/lib/cloudflare-dns/grants/mesh.json"
"public-dns": "${dir:grants}/mesh.json"
},
"own-secrets": {
"token": "/var/lib/cloudflare-dns/token",
"broker": "/var/lib/mesh/cloudflare-dns/broker"
"token": "${dir:state}/token"
},
"emits": [
"record.created",
"record.removed"
],
"resources": [
{
"id": "mesh-state",
"type": "directory",
"path": "/var/lib/mesh/cloudflare-dns",
"mode": "0700"
},
{
"id": "state",
"type": "directory",
"path": "/var/lib/cloudflare-dns",
"mode": "0700"
"mode": "0700",
"place": "."
},
{
"id": "grants",
"type": "directory",
"path": "/var/lib/cloudflare-dns/grants",
"mode": "0700"
},
{
"id": "config",
"type": "file",
"path": "/var/lib/cloudflare-dns/config.json",
"path": "${dir:state}/config.json",
"merge": "json",
"content": "{}",
"mode": "0600"
},
{
"id": "runtime",
"type": "container",
"name": "mesh-cloudflare-dns",
"network": "host",
"volumes": [
"/var/lib/cloudflare-dns/config.json:/run/config/config.json:ro",
"/var/lib/cloudflare-dns/grants:/grants",
"/var/lib/cloudflare-dns/token:/run/secrets/token:ro",
"/var/lib/mesh/cloudflare-dns/broker:/run/secrets/broker:ro"
],
"env": {
"MESH_CLOUDFLARE_TOKEN_FILE": "/run/secrets/token",
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_CLOUDFLARE_CONFIG_FILE": "/run/config/config.json",
"MESH_RECEIVES": "/var/lib/cloudflare-dns/grants/mesh.json"
},
"artifact": "runtime"
}
],
"capabilities": [
"container-runtime"
],
"build": {
"on": [
{
"arg": "BUILD_BASE",
"module": "mesh-tools",
"artifact": "build"
},
{
"arg": "RUNTIME_BASE",
"module": "mesh-tools",
"artifact": "runtime"
}
],
"artifacts": [
{
"name": "runtime",
"kind": "image",
"from": "Dockerfile"
"name": "code",
"kind": "bundle",
"language": "typescript",
"entrypoints": [
"tools/index.js",
"provisioner/index.js"
],
"loads": [
"tools/index.js",
"provisioner/index.js"
],
"env": {
"MESH_CLOUDFLARE_TOKEN_FILE": "${dir:state}/token",
"MESH_CLOUDFLARE_CONFIG_FILE": "${dir:state}/config.json",
"MESH_RECEIVES": "${dir:grants}/mesh.json"
}
}
]
}
-24
View File
@@ -1,24 +0,0 @@
# confluence's runtime: the tool runtime, carrying this module's compiled code.
#
# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in
# the base images, published like any other artifact — which is what makes this buildable by the
# mesh from a repository and a path (novox/hq ADR 0069) rather than only on a workstation that
# happens to have the siblings.
#
# Two bases, named rather than pinned (novox/hq issue 044): the image this is COMPILED in and the
# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`.
ARG BUILD_BASE
ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build
WORKDIR /app/modules/confluence
COPY . .
RUN node /app/node_modules/typescript/bin/tsc client.ts tools/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE}
COPY --from=build /app/modules/confluence/dist /app/modules/confluence/dist
# Every serve-time entrypoint, loaded by the runtime in serve mode: tools and events serve, and a
# provider's provisioner runs its reconcile loop in the same process, with the broker connected —
# the convention novox/hq issues 060/061 settled.
ENV MESH_TOOL_MODULES=/app/modules/confluence/dist/tools/index.js
+17 -43
View File
@@ -3,69 +3,43 @@
"version": "1",
"slug": "confl",
"own-secrets": {
"token": "/var/lib/confluence/token",
"broker": "/var/lib/mesh/confluence/broker"
"token": "${dir:state}/token"
},
"resources": [
{
"id": "mesh-state",
"type": "directory",
"path": "/var/lib/mesh/confluence",
"mode": "0700"
},
{
"id": "state",
"type": "directory",
"path": "/var/lib/confluence",
"mode": "0700"
"mode": "0700",
"place": "."
},
{
"id": "config",
"type": "file",
"path": "/var/lib/confluence/config.json",
"path": "${dir:state}/config.json",
"merge": "json",
"content": "{}",
"mode": "0600"
},
{
"id": "runtime",
"type": "container",
"name": "mesh-runtime-confluence",
"network": "host",
"volumes": [
"/var/lib/confluence/config.json:/run/config/config.json:ro",
"/var/lib/confluence/token:/run/secrets/token:ro",
"/var/lib/mesh/confluence/broker:/run/secrets/broker:ro"
],
"env": {
"MESH_CONFLUENCE_TOKEN_FILE": "/run/secrets/token",
"MESH_CONFLUENCE_CONFIG_FILE": "/run/config/config.json",
"MESH_BROKER_FILE": "/run/secrets/broker"
},
"artifact": "runtime"
}
],
"capabilities": [
"container-runtime"
],
"build": {
"on": [
{
"arg": "BUILD_BASE",
"module": "mesh-tools",
"artifact": "build"
},
{
"arg": "RUNTIME_BASE",
"module": "mesh-tools",
"artifact": "runtime"
}
],
"artifacts": [
{
"name": "runtime",
"kind": "image",
"from": "Dockerfile"
"name": "tools",
"kind": "bundle",
"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"
}
}
]
}
+84
View File
@@ -0,0 +1,84 @@
# cups
Printing on the two workstations (novox/hq research 027/02: "`cups` with the printer's driver"; to-be 42
phase 2 step 9).
## Owns
| what | where |
|---|---|
| the print scheduler | package `cups` |
| driverless printing: the filters that turn a document into what an IPP Everywhere printer takes | package `cups-filters` |
| the scheduler, started on demand and at boot | `cups.socket` and `cups.service`, running and enabled |
All from the official repositories. The queues (`/etc/cups/printers.conf`, the PPDs CUPS generates)
and the default printer are CUPS's own state, set through its tools. They are found (ADR 0182), and
the module declares none of them.
## The printer's driver: none needed for the Brother
Research 027 asked for "`cups` with the printer's driver". Measured on 2026-10-04:
| workstation | queue | device | prints through | driver package installed |
|---|---|---|---|---|
| laptop | `Brother` (MFC-L8690CDW) | `ipp://` on the LAN | **IPP Everywhere, driverless** | `brother-mfc-l8690cdw` (AUR), unused |
| desktop | `Brother_MFC_Novox` (default) | `ipp://` on the LAN | **IPP Everywhere, driverless** | `brother-mfc-l8390cdw` and its `-debug` (AUR), unused |
| desktop | `Kanjuro` (Canon PIXMA MG4200) | `cnijnet:` | the vendor's PPD and filter | `cnijfilter-mg4200` 3.80 (AUR) |
**Both Brother queues already print without a vendor driver.** CUPS's own IPP Everywhere support,
with `cups-filters`, is the whole driver. The vendor packages installed beside them serve no queue,
and the laptop's pulls in 32-bit glibc from multilib for a filter nothing runs. So the module declares
no driver, and the Brother needs nothing outside the official repositories.
**The Canon is the exception, and it is blocked.** Its driver is a user-repository package from 2012,
with its own network backend. Like snapd, it waits for the mesh's package repository (research 027
question 1, option P2). Until then it stays as found on the desktop. If the printer answers IPP (check
with `cups_drivers` on a fresh queue, or `driverless` from `cups-filters`), a driverless queue replaces
it and the question goes away.
## Improves
- **An owner for the scheduler.** It runs on both workstations today, enabled by nothing the mesh records.
- **No driver package that nothing uses.** The README's one-off step below removes them, once.
- **Supplies and state from anywhere.** `cups_printers` answers each queue's toner levels and flags a
low one. It also answers why a queue stopped, without opening the printer's page.
- **The laptop has no default printer**, so a print without a named printer fails there.
`cups_default` sets one; it is the operator's choice, not declared.
## Tools
All answer JSON; `(r)` reads, `(a)` acts. They run as the operator account. CUPS lets any account
print and cancel its own jobs. Setting the default, resuming a printer, and cancelling another
account's job are kept for its administrators, so those go through `sudo -n`; a cancel tries the
account first.
| tool | what |
|---|---|
| `cups_printers` (r) | every queue: state, enabled, accepting, default, device, make and model, driverless or not, state reasons, supply levels (low flagged) |
| `cups_queue` (r) | jobs waiting or printing, or the completed ones, newest first, bounded |
| `cups_cancel` (a) | one job, or every job on a printer |
| `cups_print` (a) | a file on this machine to a printer or the default, with copies and IPP options; answers the job id |
| `cups_default` (r/a) | read the default, or set it |
| `cups_resume` (a) | enable a stopped printer and make it accept jobs |
| `cups_drivers` (r) | how each queue prints, the packages that bring filters and backends (foreign ones flagged), findings, and the driver models CUPS offers, filtered |
## What changes when it is assigned
Nothing on disk on either workstation: both have `cups` (explicit) and `cups-filters` (as its
dependency), with `cups.socket` and `cups.service` enabled and running. The packages become the
mesh's; `cups-filters` is now declared explicitly.
## The one-off step for the operator (ADR 0182)
The mesh removes nothing it did not install. Once the Brother queues are confirmed printing (they do
today), remove the unused vendor drivers, once:
- **laptop:** `brother-mfc-l8690cdw`, then whatever `pacman -Qdtq` shows it alone pulled in
(`lib32-glibc`).
- **desktop:** `brother-mfc-l8390cdw` and `brother-mfc-l8390cdw-debug`. Keep `cnijfilter-mg4200` while
the Canon queue is used.
## Leaves as found
The queues and their PPDs, the default printer, `cups.path` (enabled by the package's preset),
`system-config-printer` on the desktop, and `cnijfilter-mg4200`.
+560
View File
@@ -0,0 +1,560 @@
package main
import (
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
)
var queueName = regexp.MustCompile(`^[A-Za-z0-9_.@-]{1,127}$`)
func checkPrinter(p string) error {
if !queueName.MatchString(p) || strings.HasPrefix(p, "-") {
return fmt.Errorf("%q is not a printer queue's name", p)
}
return nil
}
var optionName = regexp.MustCompile(`^[a-z][a-z0-9-]{0,63}$`)
var optionValue = regexp.MustCompile(`^[A-Za-z0-9._:-]{1,128}$`)
// optionsOf reads the print options object.
func optionsOf(args map[string]any) (map[string]string, error) {
v, ok := args["options"]
if !ok || v == nil {
return nil, nil
}
m, ok := v.(map[string]any)
if !ok {
return nil, fmt.Errorf("options must be an object of names to values")
}
out := map[string]string{}
for k, val := range m {
s, ok := val.(string)
if !ok || !optionName.MatchString(k) || !optionValue.MatchString(s) {
return nil, fmt.Errorf("option %s=%v is not an IPP option name and a plain value", k, val)
}
out[k] = s
}
return out, nil
}
// lpoptionsOf reads lpoptions' answer: name=value pairs, a value quoted with ' or with backslash escapes.
func lpoptionsOf(s string) map[string]string {
out := map[string]string{}
s = strings.TrimSpace(s)
i := 0
for i < len(s) {
for i < len(s) && s[i] == ' ' {
i++
}
start := i
for i < len(s) && s[i] != '=' && s[i] != ' ' {
i++
}
key := s[start:i]
if i >= len(s) || s[i] != '=' {
if key != "" {
out[key] = ""
}
continue
}
i++
var b strings.Builder
for i < len(s) && s[i] != ' ' {
switch s[i] {
case '\'':
i++
for i < len(s) && s[i] != '\'' {
if s[i] == '\\' && i+1 < len(s) {
i++
}
b.WriteByte(s[i])
i++
}
i++
case '\\':
if i+1 < len(s) {
b.WriteByte(s[i+1])
}
i += 2
default:
b.WriteByte(s[i])
i++
}
}
out[key] = b.String()
}
return out
}
// Marker is one supply the printer reports.
type Marker struct {
Name string `json:"name"`
Type string `json:"type,omitempty"`
Level int `json:"level_percent"`
Low bool `json:"low"`
}
// Printer is one queue.
type Printer struct {
Name string `json:"name"`
State string `json:"state"`
Enabled bool `json:"enabled"`
Accepting bool `json:"accepting"`
Default bool `json:"default"`
URI string `json:"uri"`
MakeModel string `json:"make_and_model"`
Driverless bool `json:"driverless"`
Shared bool `json:"shared"`
Reasons []string `json:"reasons"`
Markers []Marker `json:"markers"`
Since string `json:"since,omitempty"`
Message string `json:"message,omitempty"`
}
// PrintersAnswer is what cups_printers answers.
type PrintersAnswer struct {
Scheduler string `json:"scheduler"`
Default string `json:"default,omitempty"`
Printers []Printer `json:"printers"`
}
func lpstat(args ...string) (string, error) {
r, err := call(Cmd{Name: "lpstat", Args: args})
if err != nil {
if strings.Contains(r.Stderr, "Scheduler is not running") || strings.Contains(r.Stderr, "Connection refused") {
return "", fmt.Errorf("the print scheduler is not running on this machine: %s", firstLine(r.Stderr))
}
// lpstat answers "No destinations added." with a non-zero status: that is no printers.
if strings.Contains(r.Stderr, "No destinations added") {
return "", nil
}
return "", err
}
return r.Stdout, nil
}
func defaultPrinter() (string, error) {
out, err := lpstat("-d")
if err != nil {
return "", err
}
if _, after, ok := strings.Cut(out, "system default destination: "); ok {
return strings.TrimSpace(firstLine(after)), nil
}
return "", nil
}
// Printers answers every queue with its state, device, model and supplies.
func Printers() (PrintersAnswer, error) {
out := PrintersAnswer{Printers: []Printer{}}
sched, err := lpstat("-r")
if err != nil {
return out, err
}
out.Scheduler = strings.TrimSpace(sched)
if out.Default, err = defaultPrinter(); err != nil {
return out, err
}
ps, err := lpstat("-p")
if err != nil {
return out, err
}
var cur *Printer
for _, l := range strings.Split(ps, "\n") {
if strings.HasPrefix(l, "printer ") {
f := strings.Fields(l)
if len(f) < 3 {
continue
}
out.Printers = append(out.Printers, Printer{Name: f[1], Reasons: []string{}, Markers: []Marker{}})
cur = &out.Printers[len(out.Printers)-1]
rest := strings.Join(f[2:], " ")
switch {
case strings.HasPrefix(rest, "is idle"):
cur.State = "idle"
case strings.HasPrefix(rest, "now printing"):
cur.State = "printing"
default:
cur.State = "stopped"
}
// "disabled since …" (stopped), or "enabled since …" after the state.
cur.Enabled = !strings.HasPrefix(rest, "disabled") && !strings.Contains(rest, "disabled since")
if _, since, found := strings.Cut(rest, " since "); found {
cur.Since = strings.TrimSuffix(strings.TrimSpace(since), " -")
}
continue
}
if cur != nil && strings.HasPrefix(l, "\t") && strings.TrimSpace(l) != "" {
cur.Message = strings.TrimSpace(cur.Message + " " + strings.TrimSpace(l))
}
}
acc, err := lpstat("-a")
if err != nil {
return out, err
}
accepting := map[string]bool{}
for _, l := range lines(acc) {
if f := strings.Fields(l); len(f) >= 2 && f[1] == "accepting" {
accepting[f[0]] = true
}
}
for i := range out.Printers {
p := &out.Printers[i]
p.Accepting = accepting[p.Name]
p.Default = p.Name == out.Default
r, err := call(Cmd{Name: "lpoptions", Args: []string{"-p", p.Name}})
if err != nil {
return out, err
}
o := lpoptionsOf(r.Stdout)
p.URI = o["device-uri"]
p.MakeModel = o["printer-make-and-model"]
p.Driverless = driverless(p.MakeModel, p.URI)
p.Shared = o["printer-is-shared"] == "true"
for _, reason := range strings.Split(o["printer-state-reasons"], ",") {
if reason = strings.TrimSpace(reason); reason != "" && reason != "none" {
p.Reasons = append(p.Reasons, reason)
}
}
p.Markers = markersOf(o)
}
return out, nil
}
// driverless says a queue prints without a vendor driver: CUPS's own IPP Everywhere model, or a
// driverless URI.
func driverless(model, uri string) bool {
m := strings.ToLower(model)
return strings.Contains(m, "ipp everywhere") || strings.Contains(m, "driverless") || strings.HasPrefix(uri, "implicitclass:") ||
strings.HasPrefix(uri, "ipp://") && strings.Contains(m, "everywhere")
}
func markersOf(o map[string]string) []Marker {
split := func(k string) []string {
if o[k] == "" {
return nil
}
return strings.Split(o[k], ",")
}
names, levels, lows, types := split("marker-names"), split("marker-levels"), split("marker-low-levels"), split("marker-types")
out := []Marker{}
for i, n := range names {
if i >= len(levels) {
break
}
level, err := strconv.Atoi(strings.TrimSpace(levels[i]))
if err != nil {
continue
}
m := Marker{Name: strings.TrimSpace(n), Level: level}
if i < len(types) {
m.Type = strings.TrimSpace(types[i])
}
if i < len(lows) {
if low, err := strconv.Atoi(strings.TrimSpace(lows[i])); err == nil && level >= 0 && level <= low {
m.Low = true
}
}
out = append(out, m)
}
return out
}
// Job is one print job.
type Job struct {
ID string `json:"id"`
Printer string `json:"printer"`
User string `json:"user"`
Bytes int64 `json:"bytes"`
Submitted string `json:"submitted"`
}
// Queue answers the jobs waiting, or the finished ones.
func Queue(printer string, completed bool, limit int) (map[string]any, error) {
args := []string{}
if completed {
args = append(args, "-W", "completed")
}
args = append(args, "-o")
if printer != "" {
if err := checkPrinter(printer); err != nil {
return nil, err
}
args = append(args, printer)
}
out, err := lpstat(args...)
if err != nil {
return nil, err
}
jobs := []Job{}
for _, l := range lines(out) {
f := strings.Fields(l)
if len(f) < 4 {
continue
}
i := strings.LastIndex(f[0], "-")
if i <= 0 {
continue
}
size, _ := strconv.ParseInt(f[2], 10, 64)
jobs = append(jobs, Job{ID: f[0], Printer: f[0][:i], User: f[1], Bytes: size, Submitted: strings.Join(f[3:], " ")})
}
sort.SliceStable(jobs, func(a, b int) bool { return jobNumber(jobs[a].ID) > jobNumber(jobs[b].ID) })
total := len(jobs)
if len(jobs) > limit {
jobs = jobs[:limit]
}
return map[string]any{"count": total, "jobs": jobs, "completed": completed}, nil
}
func jobNumber(id string) int {
n, _ := strconv.Atoi(id[strings.LastIndex(id, "-")+1:])
return n
}
var jobID = regexp.MustCompile(`^([A-Za-z0-9_.@-]+-)?[0-9]+$`)
// refused says CUPS kept an act for its administrators.
func refused(r Result) bool {
s := r.Stderr + r.Stdout
return strings.Contains(s, "Forbidden") || strings.Contains(s, "not-authorized") || strings.Contains(s, "Not authorized") || strings.Contains(s, "not allowed")
}
// asAccountThenRoot runs an act as the account, and through sudo -n when CUPS refuses the account.
func asAccountThenRoot(c Cmd) (Result, bool, error) {
r := run(c)
if r.Status == 0 && r.Error == "" {
return r, false, nil
}
if !refused(r) {
return r, false, failure(c, r)
}
c.Root = true
r, err := call(c)
return r, true, err
}
// Cancel cancels one job, or every job on a printer.
func Cancel(job, printer string, all bool) (map[string]any, error) {
var c Cmd
switch {
case all:
if printer == "" {
return nil, fmt.Errorf("all needs printer: cancelling every job on every printer is not offered")
}
if err := checkPrinter(printer); err != nil {
return nil, err
}
c = Cmd{Name: "cancel", Args: []string{"-a", printer}}
case job != "":
if !jobID.MatchString(job) {
return nil, fmt.Errorf("%q is not a job id", job)
}
c = Cmd{Name: "cancel", Args: []string{job}}
default:
return nil, fmt.Errorf("give job, or printer with all")
}
_, escalated, err := asAccountThenRoot(c)
if err != nil {
return nil, err
}
return map[string]any{"cancelled": strings.Join(c.Args, " "), "as_root": escalated}, nil
}
var requestID = regexp.MustCompile(`request id is (\S+)`)
// statFile tells a regular file. Tests replace it.
var statFile = func(p string) error {
info, err := os.Stat(p)
if err != nil {
return err
}
if !info.Mode().IsRegular() {
return fmt.Errorf("%s is not a regular file", p)
}
return nil
}
// Print sends a file to a printer.
func Print(file, printer string, copies int, opts map[string]string, title string) (map[string]any, error) {
if !filepath.IsAbs(file) {
return nil, fmt.Errorf("file must be an absolute path, not %q", file)
}
if err := statFile(file); err != nil {
return nil, fmt.Errorf("cannot print %s: %v", file, err)
}
args := []string{}
if printer != "" {
if err := checkPrinter(printer); err != nil {
return nil, err
}
args = append(args, "-d", printer)
}
args = append(args, "-n", strconv.Itoa(copies))
names := make([]string, 0, len(opts))
for k := range opts {
names = append(names, k)
}
sort.Strings(names)
for _, k := range names {
args = append(args, "-o", k+"="+opts[k])
}
if title == "" {
title = filepath.Base(file)
}
args = append(args, "-t", title, "--", file)
r, err := call(Cmd{Name: "lp", Args: args})
if err != nil {
if strings.Contains(r.Stderr, "No default destination") {
return nil, fmt.Errorf("no printer given and this machine has no default printer: name one, or set one with cups_default")
}
return nil, err
}
m := requestID.FindStringSubmatch(r.Stdout)
if m == nil {
return nil, fmt.Errorf("lp answered no job id: %s", strings.TrimSpace(r.Stdout+r.Stderr))
}
return map[string]any{"job": m[1], "file": file, "copies": copies, "follow": "cups_queue"}, nil
}
// Default answers the default printer, or sets it.
func Default(printer string) (map[string]any, error) {
if printer == "" {
d, err := defaultPrinter()
if err != nil {
return nil, err
}
return map[string]any{"default": d}, nil
}
if err := checkPrinter(printer); err != nil {
return nil, err
}
was, err := defaultPrinter()
if err != nil {
return nil, err
}
if _, err := call(Cmd{Name: "lpadmin", Args: []string{"-d", printer}, Root: true}); err != nil {
return nil, err
}
return map[string]any{"default": printer, "was": was}, nil
}
// Resume enables a printer and makes it accept jobs.
func Resume(printer string) (map[string]any, error) {
if err := checkPrinter(printer); err != nil {
return nil, err
}
for _, c := range []string{"cupsenable", "cupsaccept"} {
if _, err := call(Cmd{Name: c, Args: []string{printer}, Root: true}); err != nil {
return nil, err
}
}
return map[string]any{"printer": printer, "enabled": true, "accepting": true}, nil
}
// DriverPackage is a package that brings filters or backends.
type DriverPackage struct {
Package string `json:"package"`
Version string `json:"version"`
Foreign bool `json:"foreign"`
}
// Model is one driver model CUPS offers.
type Model struct {
PPD string `json:"ppd"`
Description string `json:"description"`
}
// QueueDriver is how one queue prints.
type QueueDriver struct {
Printer string `json:"printer"`
MakeModel string `json:"make_and_model"`
Driverless bool `json:"driverless"`
URI string `json:"uri"`
}
// DriversAnswer is what cups_drivers answers.
type DriversAnswer struct {
Queues []QueueDriver `json:"queues"`
Packages []DriverPackage `json:"driver_packages"`
Models []Model `json:"models"`
Matched int `json:"models_matched"`
Findings []string `json:"findings"`
}
// driverDirs are where drivers put what CUPS runs.
var driverDirs = []string{"/usr/lib/cups/filter", "/usr/lib/cups/backend"}
// basePackages bring CUPS's own filters and backends, not a printer's driver.
var basePackages = map[string]bool{"cups": true, "cups-filters": true, "libcups": true, "ghostscript": true, "cups-pdf": false}
// Drivers answers how each queue prints and which packages bring drivers.
func Drivers(match string, limit int) (DriversAnswer, error) {
out := DriversAnswer{Queues: []QueueDriver{}, Packages: []DriverPackage{}, Models: []Model{}, Findings: []string{}}
ps, err := Printers()
if err != nil {
return out, err
}
for _, p := range ps.Printers {
out.Queues = append(out.Queues, QueueDriver{Printer: p.Name, MakeModel: p.MakeModel, Driverless: p.Driverless, URI: p.URI})
}
r := run(Cmd{Name: "pacman", Args: append([]string{"-Qo"}, driverDirs...)})
if r.Error != "" {
return out, failure(Cmd{Name: "pacman", Args: []string{"-Qo"}}, r)
}
seen := map[string]bool{}
for _, l := range lines(r.Stdout) {
if _, after, ok := strings.Cut(l, " is owned by "); ok {
f := strings.Fields(after)
if len(f) >= 2 && !seen[f[0]] && !basePackages[f[0]] {
seen[f[0]] = true
out.Packages = append(out.Packages, DriverPackage{Package: f[0], Version: f[1]})
}
}
}
if len(out.Packages) > 0 {
r := run(Cmd{Name: "pacman", Args: []string{"-Qqm"}})
foreign := map[string]bool{}
for _, l := range lines(r.Stdout) {
foreign[strings.TrimSpace(l)] = true
}
for i := range out.Packages {
out.Packages[i].Foreign = foreign[out.Packages[i].Package]
}
}
sort.Slice(out.Packages, func(i, k int) bool { return out.Packages[i].Package < out.Packages[k].Package })
m, err := call(Cmd{Name: "lpinfo", Args: []string{"-m"}})
if err != nil {
return out, err
}
for _, l := range lines(m.Stdout) {
ppd, desc, _ := strings.Cut(l, " ")
if match != "" && !strings.Contains(strings.ToLower(desc), strings.ToLower(match)) && !strings.Contains(strings.ToLower(ppd), strings.ToLower(match)) {
continue
}
out.Matched++
if len(out.Models) < limit {
out.Models = append(out.Models, Model{PPD: ppd, Description: desc})
}
}
// Which driver packages no queue prints through.
allDriverless := len(out.Queues) > 0
for _, q := range out.Queues {
allDriverless = allDriverless && q.Driverless
}
for _, p := range out.Packages {
if p.Foreign {
out.Findings = append(out.Findings, "driver package "+p.Package+" is not from the official repositories")
}
if allDriverless {
out.Findings = append(out.Findings, "every queue prints driverless, so no queue uses the driver package "+p.Package)
}
}
return out, nil
}
+234
View File
@@ -0,0 +1,234 @@
package main
import (
"strings"
"testing"
)
func TestTheManifestIsTheSchedulerAndDriverlessPrintingAndNoVendorDriver(t *testing.T) {
m := readManifest(t)
holdsTheBundle(t, m, "cups")
if got := strings.Join(m.packages(), ","); got != "cups,cups-filters" {
t.Errorf("packages %s: a vendor driver is from outside the official repositories, and no queue measured needs one but the desktop's Canon", got)
}
s := m.services()
for _, u := range []string{"cups.socket", "cups.service"} {
if s[u] == nil || s[u]["state"] != "running" || s[u]["boot"] != "enabled" {
t.Errorf("%s: %v", u, s[u])
}
}
for _, r := range m.Resources {
if r["type"] == "file" {
t.Errorf("the queues and cupsd's files are CUPS's own: %v", r["id"])
}
}
}
// The desktop's two queues on 2026-10-04.
func theDesktop(t *testing.T, more func(line string, c Cmd) (Result, bool)) *fake {
return using(t, func(line string, c Cmd) Result {
if more != nil {
if r, handled := more(line, c); handled {
return r
}
}
switch line {
case "lpstat -r":
return ok("scheduler is running\n")
case "lpstat -d":
return ok("system default destination: Brother_MFC_Novox\n")
case "lpstat -p":
return ok("printer Brother_MFC_Novox is idle. enabled since Mon Jun 29 21:34:30 2026\n" +
"printer Kanjuro disabled since Sun Jun 9 11:16:03 2024 -\n\tPaused\n")
case "lpstat -a":
return ok("Brother_MFC_Novox accepting requests since Mon Jun 29 21:34:30 2026\nKanjuro not accepting requests since Sun Jun 9 11:16:03 2024 -\n\tRejecting Jobs\n")
case "lpoptions -p Brother_MFC_Novox":
return ok(`copies=1 device-uri=ipp://192.0.2.171/ipp/port1 finishings=3 marker-levels=70,100,8,100 marker-low-levels=10,10,10,10 marker-names='Black\ Toner\ Cartridge,Cyan\ Toner\ Cartridge,Magenta\ Toner\ Cartridge,Yellow\ Toner\ Cartridge' marker-types=toner,toner,toner,toner printer-is-shared=false printer-make-and-model='Printer - IPP Everywhere' printer-state-reasons=none`)
case "lpoptions -p Kanjuro":
return ok(`device-uri=cnijnet:/18-0C-AC-B0-62-83 printer-is-shared=false printer-make-and-model='Canon MG4200 series Ver.3.80' printer-state-reasons=paused`)
}
return Result{Status: 9, Stderr: "unexpected " + line}
})
}
func TestPrintersReadsStateDeviceModelAndSupplies(t *testing.T) {
theDesktop(t, nil)
got, err := Printers()
if err != nil || len(got.Printers) != 2 || got.Default != "Brother_MFC_Novox" || got.Scheduler != "scheduler is running" {
t.Fatalf("%+v %v", got, err)
}
b, k := got.Printers[0], got.Printers[1]
if b.State != "idle" || !b.Enabled || !b.Accepting || !b.Default || !b.Driverless || b.URI != "ipp://192.0.2.171/ipp/port1" || len(b.Reasons) != 0 {
t.Errorf("%+v", b)
}
if len(b.Markers) != 4 || b.Markers[0].Name != "Black Toner Cartridge" || b.Markers[2].Level != 8 || !b.Markers[2].Low || b.Markers[0].Low {
t.Errorf("markers %+v", b.Markers)
}
if k.State != "stopped" || k.Enabled || k.Accepting || k.Driverless || strings.Join(k.Reasons, ",") != "paused" || k.Message != "Paused" {
t.Errorf("%+v", k)
}
}
func TestPrintersWithoutAQueueOrAScheduler(t *testing.T) {
using(t, func(line string, c Cmd) Result {
if line == "lpstat -r" {
return ok("scheduler is running\n")
}
return Result{Status: 1, Stderr: "lpstat: No destinations added.\n"}
})
got, err := Printers()
if err != nil || len(got.Printers) != 0 {
t.Fatalf("no queue is an empty answer, not a failure: %+v %v", got, err)
}
using(t, func(string, Cmd) Result { return Result{Status: 1, Stderr: "lpstat: Scheduler is not running.\n"} })
if _, err := Printers(); err == nil || !strings.Contains(err.Error(), "scheduler is not running") {
t.Fatalf("%v", err)
}
}
func TestLpoptionsQuotingIsRead(t *testing.T) {
o := lpoptionsOf(`a=1 b='x y' c=p\ q d e=`)
if o["a"] != "1" || o["b"] != "x y" || o["c"] != "p q" || o["d"] != "" || o["e"] != "" {
t.Errorf("%v", o)
}
}
func TestQueueReadsJobsNewestFirstAndBounded(t *testing.T) {
f := using(t, func(string, Cmd) Result {
return ok("Brother-6 jochen 1024 Mon Jul 8 12:15:34 2024\nBrother-8 jochen 2048 Mon Jul 8 12:19:56 2024\nBrother-7 other 1024 Mon Jul 8 12:15:23 2024\n")
})
got, err := Queue("Brother", true, 2)
jobs := got["jobs"].([]Job)
if err != nil || got["count"] != 3 || len(jobs) != 2 || jobs[0].ID != "Brother-8" || jobs[0].Printer != "Brother" || jobs[0].Bytes != 2048 || jobs[0].Submitted != "Mon Jul 8 12:19:56 2024" {
t.Fatalf("%+v %v", got, err)
}
if f.lines()[0] != "lpstat -W completed -o Brother" {
t.Errorf("%v", f.lines())
}
if _, err := Queue("-h", false, 5); err == nil {
t.Error("an option as a printer")
}
}
func TestCancelTriesTheAccountThenRootWhenCUPSRefusesIt(t *testing.T) {
f := using(t, func(line string, c Cmd) Result {
if !strings.HasPrefix(line, "sudo") {
return Result{Status: 1, Stderr: "cancel: Forbidden\n"}
}
return ok("")
})
got, err := Cancel("Brother-12", "", false)
if err != nil || got["as_root"] != true {
t.Fatalf("%v %v", got, err)
}
if strings.Join(f.lines(), "|") != "cancel Brother-12|sudo -n cancel Brother-12" {
t.Errorf("%v", f.lines())
}
f = using(t, func(string, Cmd) Result { return ok("") })
if got, err := Cancel("", "Brother", true); err != nil || got["as_root"] != false || f.lines()[0] != "cancel -a Brother" {
t.Fatalf("%v %v %v", got, err, f.lines())
}
using(t, func(string, Cmd) Result { return Result{Status: 1, Stderr: "cancel: Unknown job 99\n"} })
if _, err := Cancel("99", "", false); err == nil || !strings.Contains(err.Error(), "Unknown job") {
t.Errorf("a failure that is not a refusal is not retried as root: %v", err)
}
for _, bad := range [][3]string{{"", "", ""}, {"12; rm", "", ""}, {"", "", "all"}} {
if _, err := Cancel(bad[0], bad[1], bad[2] == "all"); err == nil {
t.Errorf("%v accepted", bad)
}
}
}
func TestPrintChecksTheFileAndOptionsAndAnswersTheJob(t *testing.T) {
was := statFile
defer func() { statFile = was }()
statFile = func(p string) error {
if p == "/home/op/doc.pdf" {
return nil
}
return errString("no such file")
}
f := using(t, func(string, Cmd) Result { return ok("request id is Brother-13 (1 file(s))\n") })
got, err := Print("/home/op/doc.pdf", "Brother", 2, map[string]string{"sides": "two-sided-long-edge", "media": "A4"}, "")
if err != nil || got["job"] != "Brother-13" {
t.Fatalf("%v %v", got, err)
}
if l := f.lines()[0]; l != "lp -d Brother -n 2 -o media=A4 -o sides=two-sided-long-edge -t doc.pdf -- /home/op/doc.pdf" {
t.Errorf("%s", l)
}
if _, err := Print("doc.pdf", "", 1, nil, ""); err == nil {
t.Error("a relative file")
}
if _, err := Print("/home/op/missing.pdf", "", 1, nil, ""); err == nil {
t.Error("a missing file")
}
if _, err := optionsOf(map[string]any{"options": map[string]any{"sides": "x y"}}); err == nil {
t.Error("an option value with a space")
}
if _, err := optionsOf(map[string]any{"options": map[string]any{"-o": "x"}}); err == nil {
t.Error("an option name that is an option")
}
using(t, func(string, Cmd) Result { return Result{Status: 1, Stderr: "lp: Error - No default destination."} })
if _, err := Print("/home/op/doc.pdf", "", 1, nil, ""); err == nil || !strings.Contains(err.Error(), "no default printer") {
t.Errorf("%v", err)
}
}
type errString string
func (e errString) Error() string { return string(e) }
func TestDefaultReadsAndSetsThroughSudo(t *testing.T) {
f := theDesktop(t, func(line string, c Cmd) (Result, bool) {
if strings.HasPrefix(line, "sudo -n lpadmin") {
return ok(""), true
}
return Result{}, false
})
got, err := Default("")
if err != nil || got["default"] != "Brother_MFC_Novox" {
t.Fatalf("%v %v", got, err)
}
got, err = Default("Kanjuro")
if err != nil || got["default"] != "Kanjuro" || got["was"] != "Brother_MFC_Novox" {
t.Fatalf("%v %v", got, err)
}
if l := f.lines(); l[len(l)-1] != "sudo -n lpadmin -d Kanjuro" {
t.Errorf("%v", l)
}
}
func TestResumeEnablesAndAcceptsThroughSudo(t *testing.T) {
f := using(t, func(string, Cmd) Result { return ok("") })
if _, err := Resume("Kanjuro"); err != nil {
t.Fatal(err)
}
if strings.Join(f.lines(), "|") != "sudo -n cupsenable Kanjuro|sudo -n cupsaccept Kanjuro" {
t.Errorf("%v", f.lines())
}
}
func TestDriversNamesForeignDriverPackagesAndOnesNoQueueUses(t *testing.T) {
theDesktop(t, func(line string, c Cmd) (Result, bool) {
switch {
case strings.HasPrefix(line, "pacman -Qo"):
return ok("/usr/lib/cups/filter/ is owned by brother-mfc-l8390cdw 3.5.1-2\n/usr/lib/cups/filter/ is owned by cups 2:2.4.19-1\n/usr/lib/cups/filter/ is owned by cups-filters 2.0.1-2\n/usr/lib/cups/backend/ is owned by cnijfilter-mg4200 3.80-6\n/usr/lib/cups/backend/ is owned by cups 2:2.4.19-1\n"), true
case line == "pacman -Qqm":
return ok("brother-mfc-l8390cdw\ncnijfilter-mg4200\nsnapd\n"), true
case line == "lpinfo -m":
return ok("drv:///sample.drv/dymo.ppd DYMO Label Printer\ncanonmg4200.ppd Canon MG4200 series Ver.3.80\neverywhere IPP Everywhere\n"), true
}
return Result{}, false
})
got, err := Drivers("canon", 10)
if err != nil || len(got.Queues) != 2 || len(got.Packages) != 2 || got.Matched != 1 || got.Models[0].PPD != "canonmg4200.ppd" {
t.Fatalf("%+v %v", got, err)
}
if !got.Packages[0].Foreign || got.Packages[0].Package != "brother-mfc-l8390cdw" {
t.Errorf("%+v", got.Packages)
}
all := strings.Join(got.Findings, ";")
if !strings.Contains(all, "cnijfilter-mg4200 is not from the official") || strings.Contains(all, "every queue prints driverless") {
t.Errorf("the Canon queue uses its driver, so not every queue is driverless: %s", all)
}
}
+352
View File
@@ -0,0 +1,352 @@
package main
// kit.go is the same file in each of the workstations' tool bundles (fonts, docker-compose, snapd,
// flatpak, cups, bluetooth, xclip, dmenu): how a tool runs a command, escalates, bounds what it
// keeps, and names a failure. A module is built from its own directory, so the file is copied rather
// than shared; a change to one copy is made to all eight.
//
// The rules it holds (novox/hq research 026/05, to-be 38 WP4):
// - the node's tool runtime runs as the operator account, not root (ADR 0175 §4); a command that
// needs root goes through `sudo -n`, never a prompt, and a refusal is named as such;
// - one command gets 20 s, below the runtime's 30 s call limit, and is ended with everything it
// started when it takes longer;
// - each stream is kept to 256 KiB, and the answer says when it was cut;
// - a failure is an error with what went wrong in it, never an empty answer.
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"strings"
"syscall"
"time"
)
// Bounds every command is held to.
const (
CallTimeout = 20 * time.Second
MostOutput = 256 << 10
)
// Cmd is one command a tool runs.
type Cmd struct {
Name string
Args []string
// Stdin is written to the command's standard input when not empty.
Stdin string
// Env is added to this process's own environment.
Env []string
// Root says the command needs root: it is run through `sudo -n` when this process is not root.
Root bool
// Timeout replaces CallTimeout; only a background job (jobs.go) asks for longer.
Timeout time.Duration
// Detached is for a program that forks a child which outlives it, as xclip does to keep the
// selection: its streams go to files, because a pipe the child inherits would hold the call open
// until the child exits.
Detached bool
}
// Result is what a command did.
type Result struct {
Stdout string `json:"stdout"`
Stderr string `json:"stderr"`
Status int `json:"status"`
// Error is why it did not run to an answer: "not-found" when the program is not there,
// "timeout" when it was ended for taking too long, else the spawn error.
Error string `json:"error,omitempty"`
Truncated bool `json:"truncated,omitempty"`
}
// Runner runs a command. Tests replace it; nothing else does.
type Runner func(Cmd) Result
var (
run Runner = execRun
euid = os.Geteuid
)
// argv is the command as it is run: through sudo without a prompt when it needs root and this
// process is not root.
func argv(c Cmd) (string, []string) {
if c.Root && euid() != 0 {
return "sudo", append([]string{"-n", c.Name}, c.Args...)
}
return c.Name, c.Args
}
// bounded keeps the first MostOutput bytes written to it and notes that more came.
type bounded struct {
b bytes.Buffer
cut bool
}
func (w *bounded) Write(p []byte) (int, error) {
room := MostOutput - w.b.Len()
if room <= 0 {
w.cut = w.cut || len(p) > 0
return len(p), nil
}
if len(p) > room {
w.b.Write(p[:room])
w.cut = true
return len(p), nil
}
return w.b.Write(p)
}
func execRun(c Cmd) Result {
timeout := c.Timeout
if timeout <= 0 {
timeout = CallTimeout
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
name, args := argv(c)
cmd := exec.CommandContext(ctx, name, args...)
cmd.Env = append(append(os.Environ(), "LC_ALL=C"), c.Env...)
if !c.Detached {
// Its own process group, so that ending it on a timeout ends what it started too.
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
cmd.Cancel = func() error {
if cmd.Process != nil {
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
}
return nil
}
}
cmd.WaitDelay = 2 * time.Second
if c.Stdin != "" {
cmd.Stdin = strings.NewReader(c.Stdin)
}
var out, errs bounded
var outFile, errFile *os.File
if c.Detached {
var err error
if outFile, err = os.CreateTemp("", "mesh-tool-out-*"); err != nil {
return Result{Status: 127, Error: err.Error()}
}
defer os.Remove(outFile.Name())
defer outFile.Close()
if errFile, err = os.CreateTemp("", "mesh-tool-err-*"); err != nil {
return Result{Status: 127, Error: err.Error()}
}
defer os.Remove(errFile.Name())
defer errFile.Close()
cmd.Stdout, cmd.Stderr = outFile, errFile
} else {
cmd.Stdout, cmd.Stderr = &out, &errs
}
err := cmd.Run()
if c.Detached {
for _, f := range []struct {
file *os.File
into *bounded
}{{outFile, &out}, {errFile, &errs}} {
if _, e := f.file.Seek(0, io.SeekStart); e == nil {
_, _ = io.Copy(f.into, f.file)
}
}
}
r := Result{Stdout: out.b.String(), Stderr: errs.b.String(), Truncated: out.cut || errs.cut}
var exit *exec.ExitError
switch {
case err == nil:
case ctx.Err() == context.DeadlineExceeded:
r.Status, r.Error = 124, "timeout"
case errors.Is(err, exec.ErrNotFound) || errors.Is(err, os.ErrNotExist):
r.Status, r.Error = 127, "not-found"
case errors.As(err, &exit):
r.Status = exit.ExitCode()
default:
r.Status, r.Error = 127, err.Error()
}
return r
}
// call runs a command and answers its result, or an error naming what went wrong.
func call(c Cmd) (Result, error) {
r := run(c)
if r.Status == 0 && r.Error == "" {
return r, nil
}
return r, failure(c, r)
}
// failure names how a command failed: not installed, refused escalation, too slow, or its exit
// status with the end of what it said.
func failure(c Cmd, r Result) error {
program, _ := argv(c)
switch {
case r.Error == "not-found" && program == "sudo":
return fmt.Errorf("%s needs root, and sudo is not installed here for the runtime's account to escalate with", c.Name)
case r.Error == "not-found":
if hint, ok := providedBy[c.Name]; ok {
return fmt.Errorf("%s is not installed on this machine (%s)", c.Name, hint)
}
return fmt.Errorf("%s is not installed on this machine", c.Name)
case r.Error == "timeout":
limit := c.Timeout
if limit <= 0 {
limit = CallTimeout
}
return fmt.Errorf("%s gave no answer within %s and was ended", c.Name, limit)
case r.Error != "":
return fmt.Errorf("%s did not run: %s", c.Name, r.Error)
case program == "sudo" && strings.Contains(r.Stderr, "command not found"):
if hint, ok := providedBy[c.Name]; ok {
return fmt.Errorf("%s is not installed on this machine (%s)", c.Name, hint)
}
return fmt.Errorf("%s is not installed on this machine", c.Name)
case program == "sudo" && strings.HasPrefix(strings.TrimSpace(r.Stderr), "sudo:"):
return fmt.Errorf("%s needs root, and sudo -n refused the runtime's account: %s (the escalation is the sudo module's to declare)",
c.Name, firstLine(r.Stderr))
}
said := tail(strings.TrimSpace(r.Stderr), 2000)
if said == "" {
said = tail(strings.TrimSpace(r.Stdout), 2000)
}
if said == "" {
said = "and said nothing"
}
return fmt.Errorf("%s %s exited %d: %s", c.Name, strings.Join(c.Args, " "), r.Status, said)
}
func firstLine(s string) string {
s = strings.TrimSpace(s)
if i := strings.IndexByte(s, '\n'); i >= 0 {
return s[:i]
}
return s
}
func tail(s string, n int) string {
if len(s) <= n {
return s
}
return "…" + s[len(s)-n:]
}
// lines are a command's output lines, blank ones dropped.
func lines(s string) []string {
out := []string{}
for _, l := range strings.Split(s, "\n") {
if strings.TrimSpace(l) != "" {
out = append(out, strings.TrimRight(l, "\r"))
}
}
return out
}
// Arguments, read the way a tool's JSON arguments arrive.
func text(args map[string]any, key string) (string, error) {
v, ok := args[key]
if !ok || v == nil {
return "", fmt.Errorf("%s is required", key)
}
s, ok := v.(string)
if !ok {
return "", fmt.Errorf("%s must be a string", key)
}
if strings.TrimSpace(s) == "" {
return "", fmt.Errorf("%s must not be empty", key)
}
return s, nil
}
func optText(args map[string]any, key, def string) (string, error) {
v, ok := args[key]
if !ok || v == nil {
return def, nil
}
s, ok := v.(string)
if !ok {
return "", fmt.Errorf("%s must be a string", key)
}
if strings.TrimSpace(s) == "" {
return def, nil
}
return s, nil
}
// optWhole reads a whole number, defaulted, refused below least and held to most.
func optWhole(args map[string]any, key string, def, least, most int) (int, error) {
v, ok := args[key]
if !ok || v == nil {
return def, nil
}
f, ok := v.(float64)
if !ok {
if i, isInt := v.(int); isInt {
f = float64(i)
} else {
return 0, fmt.Errorf("%s must be a number", key)
}
}
if f != float64(int(f)) {
return 0, fmt.Errorf("%s must be a whole number", key)
}
n := int(f)
if n < least {
return 0, fmt.Errorf("%s must be at least %d", key, least)
}
if n > most {
n = most
}
return n, nil
}
func optFlag(args map[string]any, key string, def bool) (bool, error) {
v, ok := args[key]
if !ok || v == nil {
return def, nil
}
b, ok := v.(bool)
if !ok {
return false, fmt.Errorf("%s must be true or false", key)
}
return b, nil
}
func optList(args map[string]any, key string) ([]string, error) {
v, ok := args[key]
if !ok || v == nil {
return nil, nil
}
items, ok := v.([]any)
if !ok {
return nil, fmt.Errorf("%s must be a list of strings", key)
}
out := make([]string, 0, len(items))
for _, it := range items {
s, ok := it.(string)
if !ok || strings.TrimSpace(s) == "" {
return nil, fmt.Errorf("%s must be a list of non-empty strings", key)
}
out = append(out, s)
}
return out, nil
}
// oneOf refuses a value outside a closed set.
func oneOf(key, value string, allowed ...string) error {
for _, a := range allowed {
if value == a {
return nil
}
}
return fmt.Errorf("%s must be one of %s, not %q", key, strings.Join(allowed, ", "), value)
}
// plainName refuses a name that could be read as an option or carries a path or a space: package,
// snap, application and printer names never do.
func plainName(key, value string) error {
if strings.HasPrefix(value, "-") || strings.ContainsAny(value, " \t\n/\\") {
return fmt.Errorf("%s %q is not a plain name", key, value)
}
return nil
}
+147
View File
@@ -0,0 +1,147 @@
package main
// Tests of kit.go, the same in each workstation module.
import (
"strings"
"testing"
"time"
)
// fake records the commands asked and answers each from a function of the command line.
type fake struct {
asked []Cmd
answer func(line string, c Cmd) Result
}
func (f *fake) runner() Runner {
return func(c Cmd) Result {
f.asked = append(f.asked, c)
name, args := argv(c)
line := strings.TrimSpace(name + " " + strings.Join(args, " "))
if f.answer == nil {
return Result{}
}
return f.answer(line, c)
}
}
func (f *fake) lines() []string {
out := []string{}
for _, c := range f.asked {
name, args := argv(c)
out = append(out, strings.TrimSpace(name+" "+strings.Join(args, " ")))
}
return out
}
// using installs a fake runner and a non-root uid for one test.
func using(t *testing.T, answer func(line string, c Cmd) Result) *fake {
t.Helper()
f := &fake{answer: answer}
wasRun, wasUID := run, euid
run, euid = f.runner(), func() int { return 1000 }
t.Cleanup(func() { run, euid = wasRun, wasUID })
return f
}
func ok(stdout string) Result { return Result{Stdout: stdout} }
func TestKitAnActThatNeedsRootGoesThroughSudoWithoutAPromptUnlessAlreadyRoot(t *testing.T) {
was := euid
defer func() { euid = was }()
euid = func() int { return 1000 }
if name, args := argv(Cmd{Name: "x", Args: []string{"a"}, Root: true}); name != "sudo" || strings.Join(args, " ") != "-n x a" {
t.Fatalf("not root: %s %v", name, args)
}
if name, _ := argv(Cmd{Name: "x"}); name != "x" {
t.Fatalf("a read is run as the account: %s", name)
}
euid = func() int { return 0 }
if name, _ := argv(Cmd{Name: "x", Root: true}); name != "x" {
t.Fatalf("as root no sudo: %s", name)
}
}
func TestKitAFailureIsNamedByHowItFailed(t *testing.T) {
was := euid
defer func() { euid = was }()
euid = func() int { return 1000 }
cases := []struct {
c Cmd
r Result
want string
}{
{Cmd{Name: "nothere"}, Result{Status: 127, Error: "not-found"}, "not installed"},
{Cmd{Name: "x", Root: true}, Result{Status: 127, Error: "not-found"}, "sudo is not installed"},
{Cmd{Name: "x", Root: true}, Result{Status: 1, Stderr: "sudo: a password is required\n"}, "sudo -n refused"},
{Cmd{Name: "x", Root: true}, Result{Status: 1, Stderr: "sudo: x: command not found\n"}, "x is not installed"},
{Cmd{Name: "x"}, Result{Status: 124, Error: "timeout"}, "within 20s"},
{Cmd{Name: "x", Args: []string{"y"}}, Result{Status: 3, Stderr: "boom\n"}, "x y exited 3: boom"},
{Cmd{Name: "x"}, Result{Status: 3}, "said nothing"},
}
for _, k := range cases {
err := failure(k.c, k.r)
if err == nil || !strings.Contains(err.Error(), k.want) {
t.Errorf("%+v: %v, want %q", k.r, err, k.want)
}
}
}
func TestKitOutputIsBoundedAndSaysSo(t *testing.T) {
var w bounded
big := strings.Repeat("a", MostOutput+10)
n, _ := w.Write([]byte(big))
if n != len(big) || w.b.Len() != MostOutput || !w.cut {
t.Fatalf("kept %d of %d, cut %v", w.b.Len(), len(big), w.cut)
}
}
func TestKitTheRealRunnerRunsEndsAndReportsAMissingProgram(t *testing.T) {
r := execRun(Cmd{Name: "sh", Args: []string{"-c", "echo out; echo err >&2; exit 3"}})
if r.Status != 3 || strings.TrimSpace(r.Stdout) != "out" || strings.TrimSpace(r.Stderr) != "err" {
t.Fatalf("%+v", r)
}
r = execRun(Cmd{Name: "sh", Args: []string{"-c", "sleep 5 & sleep 5"}, Timeout: 200 * time.Millisecond})
if r.Error != "timeout" {
t.Fatalf("a slow command: %+v", r)
}
r = execRun(Cmd{Name: "no-such-program-anywhere"})
if r.Error != "not-found" {
t.Fatalf("a missing program: %+v", r)
}
r = execRun(Cmd{Name: "cat", Stdin: "given"})
if r.Stdout != "given" {
t.Fatalf("stdin: %+v", r)
}
start := time.Now()
r = execRun(Cmd{Name: "sh", Args: []string{"-c", "echo kept; (sleep 3 &) ; exit 0"}, Detached: true})
if r.Status != 0 || strings.TrimSpace(r.Stdout) != "kept" || time.Since(start) > 2*time.Second {
t.Fatalf("a detached command returns when it exits, not when its child does: %+v after %s", r, time.Since(start))
}
}
func TestKitArgumentsAreReadStrictly(t *testing.T) {
args := map[string]any{"s": "x", "n": float64(5), "f": 1.5, "b": true, "l": []any{"a", "b"}}
if _, err := text(args, "missing"); err == nil {
t.Error("a missing required string")
}
if n, _ := optWhole(args, "n", 1, 1, 3); n != 3 {
t.Errorf("held to most: %d", n)
}
if _, err := optWhole(args, "n", 1, 6, 9); err == nil {
t.Error("below least")
}
if _, err := optWhole(args, "f", 1, 0, 9); err == nil {
t.Error("a fraction")
}
if l, _ := optList(args, "l"); len(l) != 2 {
t.Errorf("list: %v", l)
}
if b, _ := optFlag(args, "b", false); !b {
t.Error("flag")
}
if err := plainName("name", "--all"); err == nil {
t.Error("an option as a name")
}
}
+177
View File
@@ -0,0 +1,177 @@
// The cups module's tools (novox/hq research 027/02, 026/05): the printers, their state, supplies and
// driver, the queue, and printing, cancelling and choosing the default. A Go bundle the node's runtime
// launches over stdio (ADR 0188, ADR 0193); it runs as the operator account. An act CUPS keeps for
// its administrators goes through `sudo -n`.
package main
import (
"fmt"
"os"
stdio "git.novox.be/novox/mesh-sdk/go"
)
var providedBy = map[string]string{
"lpstat": "the cups package, which this module installs",
"lpoptions": "the cups package, which this module installs",
"lp": "the cups package, which this module installs",
"cancel": "the cups package, which this module installs",
"lpadmin": "the cups package, which this module installs",
"lpinfo": "the cups package, which this module installs",
"cupsenable": "the cups package, which this module installs",
"cupsaccept": "the cups package, which this module installs",
"pacman": "this is not an Arch machine",
}
func main() {
if err := stdio.Serve("", tools()); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
var printerArg = map[string]any{"type": "string", "description": "the printer's queue name, as cups_printers answers it"}
func tools() []stdio.Tool {
return []stdio.Tool{
{
Name: "cups_printers",
Description: "Every printer queue: state, whether it is enabled and accepting jobs, the default, its device " +
"address, make and model, whether it prints driverless (IPP Everywhere), the reasons for its state, and " +
"supply levels where the printer reports them. (r)",
Input: map[string]any{},
Run: func(map[string]any) (any, error) { return Printers() },
},
{
Name: "cups_queue",
Description: "The jobs waiting or printing, on every printer or one; or, with completed, the finished ones. " +
"Each with its id, printer, owner, size and when it was submitted. (r)",
Input: map[string]any{
"printer": printerArg,
"completed": map[string]any{"type": "boolean", "description": "the finished jobs instead"},
"limit": map[string]any{"type": "integer", "description": "at most this many, newest first (default 50, at most 500)"},
},
Run: func(args map[string]any) (any, error) {
p, err := optText(args, "printer", "")
if err != nil {
return nil, err
}
done, err := optFlag(args, "completed", false)
if err != nil {
return nil, err
}
limit, err := optWhole(args, "limit", 50, 1, 500)
if err != nil {
return nil, err
}
return Queue(p, done, limit)
},
},
{
Name: "cups_cancel",
Description: "Cancel one job by its id (\"Brother-12\" or 12), or every job on a printer with all. Another " +
"account's job is cancelled through sudo -n. (a)",
Input: map[string]any{
"job": map[string]any{"type": "string", "description": "the job id"},
"printer": printerArg,
"all": map[string]any{"type": "boolean", "description": "every job on printer"},
},
Run: func(args map[string]any) (any, error) {
job, err := optText(args, "job", "")
if err != nil {
return nil, err
}
p, err := optText(args, "printer", "")
if err != nil {
return nil, err
}
all, err := optFlag(args, "all", false)
if err != nil {
return nil, err
}
return Cancel(job, p, all)
},
},
{
Name: "cups_print",
Description: "Print a file on this machine, to a printer or the default, with copies and IPP options such as " +
"sides=two-sided-long-edge or media=A4. Answers the job id. (a)",
Input: map[string]any{
"file": map[string]any{"type": "string", "description": "the file's absolute path on this machine"},
"printer": printerArg,
"copies": map[string]any{"type": "integer", "description": "copies (default 1, at most 99)"},
"options": map[string]any{"type": "object", "additionalProperties": map[string]any{"type": "string"}, "description": "IPP options, name to value"},
"title": map[string]any{"type": "string", "description": "the job's title (default the file's name)"},
},
Run: func(args map[string]any) (any, error) {
file, err := text(args, "file")
if err != nil {
return nil, err
}
p, err := optText(args, "printer", "")
if err != nil {
return nil, err
}
copies, err := optWhole(args, "copies", 1, 1, 99)
if err != nil {
return nil, err
}
opts, err := optionsOf(args)
if err != nil {
return nil, err
}
title, err := optText(args, "title", "")
if err != nil {
return nil, err
}
return Print(file, p, copies, opts, title)
},
},
{
Name: "cups_default",
Description: "The machine's default printer; with printer, make that printer the default (through sudo -n). " +
"The default is CUPS's own setting, kept as the operator chose it: the mesh does not declare it. (r/a)",
Input: map[string]any{"printer": printerArg},
Run: func(args map[string]any) (any, error) {
p, err := optText(args, "printer", "")
if err != nil {
return nil, err
}
return Default(p)
},
},
{
Name: "cups_resume",
Description: "Enable a printer and make it accept jobs again, after CUPS stopped it on an error. Through sudo -n. (a)",
Input: map[string]any{"printer": printerArg},
Run: func(args map[string]any) (any, error) {
p, err := text(args, "printer")
if err != nil {
return nil, err
}
return Resume(p)
},
},
{
Name: "cups_drivers",
Description: "What each printer prints through (driverless or a driver's PPD), the packages that bring drivers " +
"and backends, which of them are from outside the official repositories, and the driver models CUPS " +
"offers, filtered by match. (r)",
Input: map[string]any{
"match": map[string]any{"type": "string", "description": "only models whose description contains this, any case"},
"limit": map[string]any{"type": "integer", "description": "at most this many models (default 50, at most 1000)"},
},
Run: func(args map[string]any) (any, error) {
match, err := optText(args, "match", "")
if err != nil {
return nil, err
}
limit, err := optWhole(args, "limit", 50, 0, 1000)
if err != nil {
return nil, err
}
return Drivers(match, limit)
},
},
}
}

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