135 Commits
Author SHA1 Message Date
mesh-admin 1ab84e69f2 Merge pull request 'mailu records rather than rolls out: people's mail, whose containers are recreated together (hq issue 295, ADR 0242)' (#106) from fix/mail-waits-for-a-person into main 2026-10-07 18:13:21 +00:00
mesh-admin 3ad2235663 Merge pull request 'claude-code: read and remove the person's own home items through the mesh' (#108) from feat/claude-code-home-show-remove into main 2026-10-07 17:48:05 +00:00
jochen a0deb90741 claude-code: undo a home removal through the mesh
mesh/merge-gate pass: builds claude-code → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
A removal kept its copy but nothing could put it back without a shell on
the machine. claude_code_home_restore puts a kept copy back when nothing is
at its path and the copy matches the digests recorded at removal;
claude_code_home_removed lists what is kept.
2026-10-07 19:44:07 +02:00
jochen 03728728c3 claude-code: read and remove the person's own home items through the mesh
mesh/merge-gate pass: builds claude-code → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
Hand-written items in an account's ~/.claude could only be removed over a
shell on the machine. claude_code_home_show reads one in full (memory,
~/.claude/CLAUDE.md, included); claude_code_home_remove removes one on the
person's word, with a required reason, refusing what the mesh placed and
symbolic links, keeping a dated copy in the module's state and logging it.
2026-10-07 19:39:27 +02:00
jochen 8150f38bfd Name the hq issue by its number: 294 was taken on an open branch, this is 295
mesh/merge-gate pass: builds mailu → novox; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
2026-10-07 19:28:54 +02:00
jochen 05278d1aa0 Hold mail's builds for a person: a change to how its containers are declared takes everyone's mail down at once (hq issue 294, ADR 0242)
mesh/merge-gate pass: builds mailu → novox; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
On 2026-10-07 adopting its images' health checks recreated nine of mail's
containers in one send and the operator's phone could not reach the mail.
A module people use directly is moved at a moment a person chooses.
2026-10-07 19:25:16 +02:00
mesh-admin c74f407abd Merge pull request 'Say how the first twenty-three long-running resources are ready (hq ADR 0240, to-be 48 Phase E)' (#104) from feat/health-the-first-declarations into main 2026-10-07 16:54:25 +00:00
jochen f87f4cdfe5 Say how the first twenty-three long-running resources are ready (hq ADR 0240, to-be 48 Phase E)
mesh/merge-gate pass: builds baserow, grafana, mailu, matrix, mongodb, mosquitto, nodered, postgres, redis, step-ca, supabase, website → ace, novox; no bus…
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
mesh/delivery-group group feat/health-the-first-declarations delivered: every member is delivered
Seven modules' images ship a check the mesh never read. Adopted by name where
it says healthy on the live mesh today: nine of mail's containers (not its
antivirus, whose six-minute start is past the five-minute bound, nor its cache,
whose image ships none), the certificate authority, the spreadsheet app, four
of the database suite's (the studio among them, with the address it binds
fixed), the flow editor and the chat client. And the endpoints four services
already declare, looked at from the machine: tcp on the database, the cache,
the document store and the broker; http on the website and the dashboards.
The count of undeclared falls from 93 to 70.
2026-10-07 18:47:58 +02:00
mesh-admin 79b384ec17 Merge pull request 'Keep the count of long-running resources without health, and let it only go down (hq ADR 0240 rule 8, to-be 48 Phase E)' (#103) from feat/health-the-count into main 2026-10-07 16:32:36 +00:00
jochen c0f9039695 Keep the count of long-running resources without health, and let it only go down (hq ADR 0240 rule 8)
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
A field that is optional for ever is one half the catalogue never gets. The
catalogue's merge check now holds the controller's count of long-running
resources that do not say how they are ready to the number kept in
health-undeclared: a change that raises it fails, one that lowers it must
write the new number. Until the controller the mesh runs counts (Phase B), the
check says it did not count.
2026-10-07 16:17:53 +02:00
mesh-admin f4c6efaadb Merge pull request 'gitea: never set warning on the merge check's statuses; a required one blocks (hq issue 293)' (#102) from fix/no-warning-on-a-required-check into main 2026-10-07 12:05:41 +00:00
jochen 33857626be Never set warning on the merge check's statuses: the forge blocks a required one
mesh/merge-gate pass: builds gitea → novox; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
Branch protection requires mesh/merge-gate (and mesh/repo-check on the core
repositories) with no admin override, and the forge combines warning as a
failure. A note is now a success that says it; a repository without a
merge-check.sh is a success where repo-check is not required and a failure
for a person where it is; the status tool refuses the merge check's contexts.
2026-10-07 13:55:34 +02:00
mesh-admin e61fc6aede Merge pull request 'Ask the check of a delivery that waits for one nobody asked (hq issue 290)' (#101) from fix/a-delivery-waiting-gets-its-check into main 2026-10-07 11:20:18 +00:00
jochen d1de0edd4a Ask the check of a delivery that waits for one nobody asked (hq issue 290)
mesh/merge-gate pass: builds mesh-delivery → novox; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
A pull request's head announced again from the bus's history at
mesh-delivery's first start was proposed with no verdict and nothing
ever asked its check: the controller had taken that announcement long
before, and stalled raised it after an hour for the operator.

A proposed delivery with no verdict and no check asked is now asked
through delivery-check once it has waited past a grace longer than a
check takes; an announcement carrying its head's decided gate status
takes it. The proposed bound runs from the ask, and H2's close may
re-ask once (a table row) before the delivery is the operator's.
2026-10-07 11:44:21 +02:00
mesh-admin 46481baab7 Merge pull request 'Keep secrets off command lines the runtime records (hq issue 282)' (#100) from fix/no-secret-on-a-command-line into main 2026-10-06 23:40:53 +00:00
jochen 13b7562c47 Keep secrets off command lines the runtime records (hq issue 282)
mesh/merge-gate pass: builds docker, keycloak, minio, mosquitto → ace, g14, novox, shanks; no bus step; 2 wait(s) for a person; every machine composes with…
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
mosquitto passed the broker's admin password to mosquitto_ctrl as -P on
every docker exec, and the container runtime keeps every exec's command
line in its event stream, where docker_events returned it. The admin
credentials now reach mosquitto_ctrl as a 0600 options file fed on
stdin, client passwords at its own prompt, and an argv carrying a secret
is refused before it runs. The admin secret says it is taken at start:
the bootstrap re-runs when the mesh replaces it and re-keys the broker
online from the value it last applied, so it can be rotated.

docker_events redacts what an exec's command line carried, and
docker_secrets_in_events names such secrets by name. keycloak's repair
hands kcadm its passwords through KC_CLI_PASSWORD; minio gives mc its
root alias through MC_HOST_mesh.
2026-10-07 01:37:21 +02:00
mesh-admin 950e52ff64 Merge pull request 'mesh-delivery: the owner of deliveries and delivery groups; the forge's note, view and status tools (hq ADR 0239)' (#99) from feat/mesh-delivery into main 2026-10-06 23:00:36 +00:00
jochen b6f0bc309b Add mesh-delivery, the owner of deliveries and delivery groups (hq ADR 0239)
mesh/merge-gate fail: a manifest the change touches fails the module check: modules/mesh-delivery/module.json: this manifest cannot be used:
mesh/delivery delivered
mesh/delivery-group group feat/mesh-delivery delivered: every member is delivered
One module answers 'did my change go out' for a commit and orders a
cross-repository change: one compiled state table, its state on the bus,
every transition said, noted on the commit and shown on the pull request.
The forge's holder gains the note, view and status tools it asks with, and
says closed pull requests and a merge's head and statuses.
2026-10-06 23:59:54 +02:00
mesh-admin 88135ad0e7 Merge pull request 'gitea: graph-driven announce, both check statuses, the change plan, branch-protection tools (hq ADR 0238)' (#98) from feat/the-graph-decides-what-is-checked into main 2026-10-06 21:00:27 +00:00
jochen 81641a4b22 Test the modules the planner says the change reaches (hq ADR 0238)
mesh/merge-gate pass: the merge check passed
mesh/delivery delivered
mesh/delivery-group group feat/the-graph-decides-what-is-checked delivered: every member is delivered
2026-10-06 22:54:52 +02:00
jochen cc1305a354 gitea: post a pull request's change plan with its verdict (hq ADR 0238)
mesh/merge-gate pass: the merge check passed
mesh/delivery superseded: a newer head of the same pull request
The gate's status says what the change does and how it was judged; a change that builds
something gets its plan as a comment — the tiers, what each machine receives, and what
is not an ordinary send.
2026-10-06 22:50:32 +02:00
jochen dba71a97a8 gitea: tell the controller which files a pull request deletes; say the dependents a merge would build (hq ADR 0238)
The controller asks its planner what a pull request reaches, as if merged: a module whose
manifest the change deletes is one the merge removes, and the plan's dependents are said
on the gate's status beside the modules it moves.
2026-10-06 22:34:53 +02:00
jochen bd7b757dd9 gitea: give the controller what it maps a pull request onto the graph with; set both check statuses; protect a branch by tool (hq ADR 0237)
The controller now decides what a pull request's check runs from the mesh's module
graph, so the announcement carries the changed directories that hold a module at the
head and whether the head has a merge-check.sh. A verdict sets mesh/merge-gate (the
gate, with the modules it judged) and mesh/repo-check (the repository's own tests).
gitea_branch_protection_get/set let the operator's agent make those statuses required.

The catalogue's merge-check.sh leaves the gate to the build seat and keeps its own layer:
every manifest through module check, and the touched Go modules' tests.
2026-10-06 21:56:04 +02:00
mesh-admin b792bf4ba2 Merge pull request 'Phase 5: check every pull request before it merges, and show the verdict on it (hq ADR 0237)' (#97) from feat/merge-gate into main
mesh/delivery delivered
2026-10-06 19:35:57 +00:00
jochen 9951718623 Check every pull request before it merges, and show the verdict on it (hq ADR 0237, to-be 45 §9)
The forge's announcer announces each new head of an open pull request as pull.updated, once,
and marks the head pending; the controller asks the build seat to check it against every
machine of the mesh's facts, and says the verdict as checked, which the forge's holder sets as
the head commit's status mesh/merge-gate - an error never as a success - with the check's own
account as a comment when it is not a pass. merge-check.sh is the catalogue's check: every
manifest through the running controller's module check and merge gate, and the Go tests of each
module the change touches, a module whose dependencies cannot be fetched said as not tested.
2026-10-06 21:16:32 +02:00
mesh-admin 1ba2c0513f Merge pull request 'gitea: say which changed directories hold a module at the merge commit (hq issue 278)' (#96) from fix/a-module-directory-is-never-shared-code into main
mesh/delivery delivered
2026-10-06 18:28:48 +00:00
jochen 1178db279e gitea: say which changed directories hold a module at the merge commit (hq issue 278)
The controller read a changed file as shared code unless its directory was a module it holds or
the merge also changed that directory's manifest. A merge touching modules/showcase/index.ts -
the catalogue's reference module, held by no machine - therefore rebuilt all 103 modules built
from this repository on 2026-10-06, 88 of them byte-identical, with the build agent first only
because everything else is built by it.

Whether a directory is a module is a fact of the repository at the commit, so the announcer now
looks it up: every directory above a changed file (never the root) is asked for its module.json
at the merge commit, and the ones that have one go out as module_dirs, with module_dirs_said.
Not said when the file list is cut, past 300 directories, or when the forge cannot be asked;
the controller then keeps its old rule, which rebuilds too much rather than too little.

modules/showcase stays: TestTheShowcaseModuleIsAValidManifest in mesh-controller parses it and
hq to-be 18 and 20 name it as the reference module.
2026-10-06 19:55:17 +02:00
mesh-admin 9d407248b3 Merge pull request 'Back up the bus by the server's own snapshot of each stream, not its live files (hq ADR 0235)' (#93) from feat/bus-snapshot into main
mesh/delivery superseded: a newer delivery to the same trunk took over its walk
2026-10-06 17:33:13 +00:00
mesh-admin 6e4d12e5a4 Merge pull request 'Say which modules wait for a person's push; announce a merge's deleted files (hq ADR 0236)' (#95) from feat/core-upgrades-that-roll-back into main
mesh/delivery delivered
2026-10-06 17:32:58 +00:00
jochen 1fd2914ae1 Say which modules wait for a person's push, and announce the files a merge deleted (hq ADR 0236)
With a gate on the first machine and a rollback after it, a module's build rolls out by
default. The ones kept back say why: the network path a rollback could not cross, the
providers every consumer on a machine drops with, and the stores holding the photos.
A merge's deleted files are announced, so a module whose manifest went is forgotten
rather than asked to build (the public-acme plan failure).
2026-10-06 18:39:13 +02:00
mesh-admin 7f99fb4a85 Merge pull request 'audit-logger, model-usage: retry a failed write and never lose the event (hq issue 276)' (#94) from fix/audit-and-usage-never-lose-an-event into main
mesh/delivery delivered
2026-10-06 16:38:06 +00:00
jochen 08265a70ca audit-logger, model-usage: retry a failed write and never lose the event (hq issue 276)
Both caught a failed write and took the event, losing it silently; the SDK's rule is to throw when
the work was not done. A failed write now throws so the bus offers the event again, and is spooled
on disk at once; on its last delivery the spooled event is taken, and a background pass replays the
spool once writing works. The runtime does not pass the delivery count, so the spool counts failed
deliveries itself, across restarts. Over its bound (1000 events or 30 minutes) the last delivery is
no longer taken, so the bus gives it up and the controller raises max-deliveries - the one existing
condition that names a consumer which cannot keep up - while the spool still holds it.

Writes are idempotent by event: the trail skips an id it already wrote; the usage upsert keeps the
reading observed latest (migration 2), so a late replay never overwrites a newer one. Each module has
a status tool for the spool, declared as valuable data (ADR 0233). model-usage moves to the bundle
shape (ADR 0198) with its schema in a prepare step and numbered migrations; its old container shape
had no image. Both on mesh-sdk 0.1.13.

The log-only handlers of redis, mssql, mosquitto, mongodb, mesh-vault, showcase and the catalogue no
longer throw a TypeError on an event without a body.
2026-10-06 18:36:16 +02:00
jochen 419e82cded Back up the bus by the server's own snapshot of each stream, not its live files (hq ADR 0235)
The restic holder copied JetStream's store while the server wrote it; such a
copy may not restore. The nats image now carries mesh-nats-snapshot, run by
the declared dump under the module's own bus account (snapshot API only):
every stream one at a time, flow-controlled, into one tar with a manifest of
counts, sequences and checksums. Restore builds a new store beside the live
one with the bus's own server; a person swaps it in. Proven against
throwaway nats 2.11 servers being written to during the snapshot.
2026-10-06 18:20:51 +02:00
mesh-admin f144f6eee8 Merge pull request 'Every module declares its data; the backup holder measures it (hq ADR 0233)' (#92) from feat/a-module-declares-the-data-it-holds into main
mesh/delivery delivered
2026-10-06 15:05:51 +00:00
jochen 7524390cad Bound the holder's measuring; dump the database platform (hq ADR 0233)
Walks run at most daily and stop after ten minutes or two million files; datasets are read from
their counters and large items from their top level only. The database platform's tables are
dumped with pg_dumpall rather than copied as live files.
2026-10-06 17:00:23 +02:00
jochen 685cb1cb1b Declare every module's data; the backup holder measures it (hq ADR 0233)
Backup lines are derived from each module's data section instead of written by hand; the holder
measures declared items, reads the array under them, and deletes a retired item only after a last
restore point; the Go providers say each held consumer's size so an empty replacement is seen.
2026-10-06 16:47:49 +02:00
mesh-admin 8f5a75ab9b Merge pull request 'Fold public-acme into route-proxy; drop dhcpcd and cloudflare-dns (hq ADR 0226)' (#84) from feat/route-proxy-names-its-public-issuer into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-06 13:04:29 +00:00
jochen 57d524f4ef Fold public-acme into route-proxy; drop dhcpcd and cloudflare-dns (hq ADR 0226)
public-acme ran nothing and had one consumer. The proxy now states the issuer itself, byte for byte
what the binding rendered, so its account directory and every certificate stay put. dhcpcd and
cloudflare-dns are assigned nowhere and nothing requires what they provide.
2026-10-06 14:59:43 +02:00
mesh-admin 368fa2f45e Merge pull request 'postgres, keycloak: retire a consumer, delete only on a person's word (hq ADR 0230)' (#91) from feat/retired-consumers into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-06 12:51:35 +00:00
jochen 2c15074a0b Retire only once the same answer has held ten minutes as well as five passes
Five passes are twenty-five seconds, shorter than a controller restart, a store
reconnecting or a file half written; the operator asked for both (hq ADR 0230).
2026-10-06 14:28:21 +02:00
jochen e68ef88333 Retire a consumer the mesh stops asking for, and delete only on a person's word (hq ADR 0230)
The hourly release of ADR 0229's brake still ended in the mesh acting alone on
a mistake. A consumer now stays active until the same unasked set holds for
five passes, waits for a person past three or half of those held, is disabled
and marked rather than withdrawn, comes back as it was when asked again, and is
deleted only through the provider's delete tool. The backend keeps the mark, so
a restart forgets nothing and finds what was withdrawn before.
2026-10-06 13:54:10 +02:00
mesh-admin 4554e18279 Merge pull request 'Brake a withdrawal larger than its bound (hq to-be 45 Phase 2, ADR 0227 rule 4)' (#90) from feat/a-core-that-cannot-fail-silently-phase-2 into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-06 10:43:11 +00:00
jochen 4238dd8616 Name the SDK harness the Go loop follows: 0.1.11, with the withdrawal brake 2026-10-06 12:29:21 +02:00
jochen 2c151e7c21 Brake a withdrawal larger than its bound (hq to-be 45 Phase 2, ADR 0227 rule 4)
Issue 241 withdrew seven consumers in one pass on one misread file; its fix
refuses a file it cannot read, and a file read whole that names nobody
still withdraws everybody. A pass that would withdraw more than one
consumer at once, or more than half of those it holds, now withdraws
nothing: each consumer it kept is announced provisioner.failing with the
class withdrawal-braked, so the controller raises it as a condition and
the operator is told, and one is let go each hour while the mesh goes on
not asking for them. A consumer asked for again is kept and said recovered.
Postgres and keycloak carry the harness identically.
2026-10-06 12:29:21 +02:00
mesh-admin 12569ed085 Merge pull request 'Mark the OpenAI keys letta and supabase hold as issued outside the mesh' (#89) from feat/a-given-secret-lives-until-the-first-good-start into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-06 10:27:21 +00:00
jochen b1bd5c861e Mark the OpenAI keys letta and supabase hold as issued outside the mesh
The controller now replaces a given at-start secret after the module's
first good start and on rotate (hq ADR 0228); a key only OpenAI can
issue must say so, or a fresh random value would take its place.
2026-10-06 12:13:57 +02:00
mesh-admin 8c2c9ace0e Merge pull request 'messenger: history is never news — read the open conditions first, coalesce bursts (hq issue 271)' (#88) from fix/messenger-history-is-never-news into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-06 10:06:27 +00:00
jochen a4b92c1306 messenger: never say history as news — read the open conditions first, coalesce bursts
A consumer made on 2026-10-06 was handed three hours of raised-and-cleared
conditions at once and the holder said each as new: 20 desktop notifications
in a second. What is said is now decided by the controller's open set and by
an event's own time, never by its arrival; bursts are one message, the
desktop gets warnings at most every 15 min, the cap is said once, and the
first minute after start says only the urgent conditions still open.
Replays of that morning's 96 events are tests. Also drops two committed
binaries. (novox/hq issue 271)
2026-10-06 12:03:19 +02:00
mesh-admin aabc8aa039 Merge pull request 'supabase: make logflare's stored key and backends follow its environment' (#87) from fix/supabase-logflare-reconcile into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-06 09:50:28 +00:00
jochen 908d45864a supabase: make logflare's stored key and backends follow its environment
logflare 1.4.0 copies LOGFLARE_API_KEY into its default user and
POSTGRES_BACKEND_URL into every source's backend once, when it creates
them, and never reads either again. On ace every source still points at
db:5432, which resolves nowhere, so analytics stored no logs; and the key
that leaked into the log before #85 could not be replaced, because the
mesh had to mark it "applied".

The start script now runs logflare's migrations and then reconcile.exs
through `logflare eval`, before logflare starts: it uses logflare's own
Users and Backends contexts to set the default user's key to
LOGFLARE_API_KEY (clearing old_api_key) and to point each postgres
source backend at POSTGRES_BACKEND_URL. It changes nothing that already
matches, says what it changed without printing the key or a password,
and stops the start when it cannot finish.

With the key taken at every start, the secret is "at-start" and `secret
rotate` works. analytics restarts on its env file and studio on its env
file too, so a rotated key reaches every reader.
2026-10-06 11:48:57 +02:00
mesh-admin c12d364a68 Merge pull request 'The operator-channel and the watcher's watcher (hq to-be 45 phase 1)' (#86) from feat/the-mesh-says-when-it-is-wrong into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-06 08:37:48 +00:00
jochen 0ae7933d54 Tell the operator what the mesh finds wrong, and watch the watcher (hq to-be 45 phase 1)
The mesh noticed 48 core failures in six days and told nobody (ADR 0227).
messenger holds the operator-channel seat: it consumes the controller's
condition events and sends them to Telegram and the desktop notifier,
deduplicated by key, reminded once, edited on clear, capped at 20 an hour
with the rest folded, and refusing anything carrying an address, a path or
a secret. mesh-watcher, on a machine other than the control node, sends to
Telegram directly when the self-check heartbeat or the bus goes silent.
2026-10-06 09:35:55 +02:00
mesh-admin 495bb88111 Merge pull request 'supabase: keep logflare's API key out of its log (hq issue 268)' (#85) from fix/supabase-logflare-key-out-of-logs into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-06 00:34:13 +00:00
jochen b87dc29706 supabase: keep logflare's API key out of its log (hq issue 268)
vector handed logflare its API key as ?api_key= in every sink URL, and
logflare 1.4.0 prints a failed request's whole URL in its Plug.Cowboy
error report. Its ingest fails on every request here, so the key was in
the analytics log about every ten seconds. The report is an error, so no
log level hides it.

Every sink now sends the key in the x-api-key header, which logflare
reads first. A start script refuses to start logflare while the vector
config it is given still puts the key in a URL.

The key is marked "applied", not "at-start": logflare writes it into its
default user once and never updates it, so the mesh must not rotate it
by restarting.
2026-10-06 02:33:32 +02:00
mesh-admin e5cb7b071c Merge pull request 'State each provision's identity bound (hq issue 263, ADR 0225)' (#83) from fix/263-identity-bounds-per-provision into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-06 00:29:56 +00:00
mesh-admin 7fad19a764 Merge pull request 'letta: keep its passwords out of its log; docker: find and hide secrets a container printed (hq issue 268)' (#82) from fix/letta-secrets-out-of-logs into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-06 00:25:32 +00:00
jochen 4769dadf63 State each provision's identity bound (hq issue 263)
Consumers were held to an S3 access key's 20 characters whatever they
required. Each provider now says what its backend keeps: minio 20,
PostgreSQL and MongoDB and DNS 63, Gitea 40, a mailbox 64, SQL Server 128,
Keycloak 255, unbounded where the store has no limit, and none for the
resolver and route provisions, which keep no name of their consumers.
Needs the controller that reads the field (mesh-controller, ADR 0225).
2026-10-06 02:16:27 +02:00
jochen bbb67e41a0 docker: find and hide secrets a container printed into its log (hq issue 268)
letta printed two passwords into its log for weeks and nothing noticed,
and docker_logs handed them to whoever asked. docker_secrets_in_logs
compares each container's recent lines with the secret-named values of
its environment, the passwords in its URIs, and any URI carrying a
password, and names what it found by container, module and variable -
never the value. docker_logs redacts the same values before answering.
2026-10-06 02:13:42 +02:00
jochen f9f27d4878 letta: keep its database and server passwords out of its log (hq issue 268)
letta 0.6.8 prints LETTA_PG_URI whole (startup.sh, alembic, server.py)
and its server password when it starts in secure mode, so both were in
the container's log on every one of its restarts. Newer letta still
prints both, and neither is a log level.

The URI now names no password: libpq reads it from a mounted pgpass
file (PGPASSFILE). The one print of the server password is rewritten by
a start script before the server starts, and the script refuses to start
letta if that print, or a password in the URI, is still there - a letta
that does not start says why; one that leaks says nothing.

Both own secrets say they are read at start, so `rotate` can replace the
server password the mesh made.
2026-10-06 02:13:42 +02:00
mesh-admin cc2f19123a Merge pull request 'nats: run 2.11.17, which hands a consumer with several filters every message (hq issue 266)' (#81) from fix/nats-multi-filter-consumers-skip into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-05 23:40:11 +00:00
jochen 0275c2eeac nats: run 2.11.17, which hands a consumer with several filters every message (hq issue 266)
On 2.10.29 such a consumer was moved past a message now and then without
handing it over; the controller's events consumer has seven filters, and a
merge on the stream never reached it. The test reproduces the skip on 2.10.29
and keeps the image's release equal to the server it tests.
2026-10-06 01:29:20 +02:00
mesh-admin 78328d4ab2 Merge pull request 'keycloak: port to Go and repair a refused admin; providers announce a failing consumer' (#80) from feat/identity-provider-admin-safety-nets into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-05 22:39:02 +00:00
jochen 48d4188927 keycloak repair: remove the temporary admin even when the bootstrap failed after making it
The bootstrap once created the temporary admin and then failed on a held port; marked only after
it succeeded, the cleanup did not know the admin existed and left it.
2026-10-06 00:17:35 +02:00
mesh-admin 5c2157b81c Merge pull request 'Rename hosts to hostname, which also writes /etc/hostname (hq ADR 0223 part 3)' (#78) from hostname-module into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-05 22:15:29 +00:00
jochen 77fb1ecfb2 keycloak: port to Go and repair an admin that refuses the mesh's secret
Twice the identity provider's admin kept an older password than the one the
mesh minted (an adopted, then a moved database), and the provisioner failed
every consumer until it was repaired by hand (hq issue 179). The module now
checks the admin's login and repairs a refusal itself through the server's
bootstrap command, verifies, brakes a failed repair and announces it, and
stops asking the server while refused. Ported to Go to change it.
2026-10-06 00:13:42 +02:00
jochen 6f1e2f5a0d postgres: announce a consumer failed for minutes, and its recovery
A provider failed every consumer for a day and said so only in its journal
(hq issue 179). The provisioner loop now emits provisioner.failing after five
minutes without a success — create, check or secret — and repeats it every
fifteen; provisioner.recovered on the next success, on withdrawal, and on the
first success after a restart, so the controller can name it in status
(hq ADR 0224).
2026-10-06 00:13:42 +02:00
mesh-admin ed6384feb0 Merge pull request 'Remove resolv-conf (hq ADR 0223 part 2, step 2 of 2)' (#77) from retire-resolv-conf into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-05 22:08:02 +00:00
mesh-admin 0e072b05c0 Merge pull request 'Uplink modules get short slugs (nm, networkd)' (#79) from fix/uplink-modules-have-short-slugs into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-05 22:03:45 +00:00
jochen 60604fcd11 Give the uplink modules short slugs, so their identity fits a backend's limit
Requiring wildcard-resolution gave each a consumer identity; mesh_<machine>_networkmanager is
over the 20 characters a backend keeps, and the anchor's declaration, which carries every
consumer's grant, could not be composed.
2026-10-06 00:03:40 +02:00
mesh-admin ff61578e3e Merge pull request 'Give /etc/resolv.conf to the uplink's holder (hq ADR 0223 part 2, step 1 of 2)' (#76) from resolv-conf-to-uplink into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-05 21:57:03 +00:00
jochen 138d9afd7b Rename hosts to hostname, which also writes /etc/hostname (hq ADR 0223)
Two files say one fact, the machine's name, and nothing owned /etc/hostname.
The name written is the operator's hostname setting, with no default: three
of four machines call themselves something other than their mesh name, and
renaming one is the operator's call. It takes effect at the next boot.
2026-10-05 23:43:14 +02:00
jochen dd124966ad Remove resolv-conf now that the uplink's holder writes resolv.conf (hq ADR 0223)
Merge only once resolv-conf is unassigned on every machine and forgotten.
2026-10-05 23:40:38 +02:00
jochen 73d6a51325 Give /etc/resolv.conf to the uplink's holder (hq ADR 0223)
The program that manages a machine's network is the one that would rewrite
the resolver file, so its module now writes it: networkmanager,
systemd-networkd and dhcpcd render the same template from the resolver's
holders. resolv-conf declares nothing for one release, so every machine
hands the file over in one apply; it is removed once unassigned everywhere.
2026-10-05 23:39:13 +02:00
mesh-admin 7b0b80ee05 Merge pull request 'postgres: port to Go, and install the extensions a consumer asks for (letta: vector)' (#75) from feat/postgres-go-extensions into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-05 21:31:14 +00:00
jochen d8b4d20886 Port postgres to Go and install the extensions a consumer asks for
letta crash-loops on 'type "vector" does not exist': pgvector is not a
trusted extension, so only the provider's superuser can create it, and
the provisioner never did. A contribution may now name extensions; the
provider creates each (IF NOT EXISTS, available ones only) in the
consumer's database on every pass. Go per the standing rule for a
TypeScript module that changes. letta asks for vector.
2026-10-05 23:29:55 +02:00
mesh-admin 0515db043a Merge pull request 'supabase: studio listens on every address, so its health check reaches it' (#74) from fix/studio-listens-where-its-health-check-asks into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-05 21:20:40 +00:00
jochen 7f491fd6bc Studio listens on every address, so its health check reaches it
Next.js binds the address HOSTNAME names; docker sets HOSTNAME to the container's id, so studio
answered only on its network address while its image's health check asks localhost, and it read
unhealthy while working. HOSTNAME=:: as the upstream compose file sets it.
2026-10-05 23:20:35 +02:00
mesh-admin b4b86c1452 Merge pull request 'resolv-conf lists every mesh resolver and no public one (hq ADR 0223)' (#73) from feat/the-mesh-has-two-resolvers into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-05 20:50:53 +00:00
jochen 737f42deb4 List every mesh resolver and no public one in resolv.conf
musl asks every nameserver at once and takes the first reply, so a public
resolver's NXDOMAIN for a mesh name beat the mesh's answer in every Alpine
container (hq ADR 0223). resolv-conf now renders /etc/resolv.conf from the
holders of mesh-dns-resolver, this machine first when it holds one; dnsmasq's
comments say the seat may have several holders.
2026-10-05 22:42:54 +02:00
mesh-admin 5c89eaf9a6 Merge pull request 'docker: trust the mesh's registry from the runtime's own module (hq ADR 0222, issue 190 — 2 of 3)' (#72) from fix/190-docker-insecure-registries into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-05 20:39:05 +00:00
jochen 964a4fdfbc docker: trust the mesh's registry from the runtime's own module (hq issue 190)
The controller's private network writes insecure-registries into daemon.json, a file this
module owns. The runtime's module states it instead, through ${seat:mesh-artifact-store:reach}
(hq ADR 0222), so the controller can stop generating its registry-trust resources.
2026-10-05 22:17:16 +02:00
mesh-admin 20603b63e6 Merge pull request 'A machine's mesh name has no IPv6 address rather than no name (hq issue 262)' (#71) from fix/a-mesh-name-has-no-ipv6-address-rather-than-no-name into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-05 20:07:33 +00:00
jochen 4bf5eef2fd A machine's mesh name has no IPv6 address rather than no name (hq issue 262)
The resolver answered a machine's name only by wildcard, which says there is no such name when
asked for an IPv6 address; musl reads that as final, so Alpine containers could not find a
machine at all. A host record per machine answers that the name exists and has none, as the
hosts file the per-machine resolvers read used to.
2026-10-05 22:07:28 +02:00
mesh-admin 4cac44face Merge pull request 'Retire resolved-split-dns (hq ADR 0220)' (#70) from feat/retire-resolved-split-dns into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-05 20:03:01 +00:00
jochen 875d2a0554 Retire resolved-split-dns: ADR 0196 chose no stub, and nothing assigns it
The resolv-conf comment pointed operators at it and at NetworkManager as
alternative claimants; it now says the uplink's holder is required beside it
(hq ADR 0220).
2026-10-05 21:57:04 +02:00
mesh-admin 76707bbe0e Merge pull request 'The mesh's one resolver, what every node asks, and a node's hosts file (hq ADR 0194, 0196, 0199)' (#69) from feat/the-mesh-has-one-resolver into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-05 18:44:15 +00:00
jochen 076455ec78 hosts in Go, with the machine's own name in its block (ADR 0199)
The tools were TypeScript; the mesh's modules are Go. The write to /etc/hosts is now staged and
moved into place rather than written over the live file.
2026-10-05 20:42:58 +02:00
jochen f20c4b749b The runtime's file is written by the runtime's module, not by what decides how a machine resolves (issue 190)
resolv-conf would have taken over dnsmasq's write into daemon.json — the same defect issue 190
names. docker, on every machine, now writes live-restore and reloads its own service. Log rotation
is left as each machine has it.
2026-10-05 20:42:58 +02:00
jschoubben 69d6b9066f The mesh's one resolver, what every node asks, and a node's hosts file (hq ADR 0194, 0196, 0199)
- dnsmasq holds mesh-dns-resolver: provides wildcard-resolution mesh-wide, forwards every declared
  zone (zones fact), listens on the private address and loopback only, reads no hosts file and no
  operator's files, and no longer writes the container runtime's dns.
- resolv-conf names the mesh's resolver by address, then 1.1.1.1, timeout 1, one attempt; it now
  holds the runtime's live-restore, which dnsmasq held and every node needs.
- resolved-split-dns routes the suffix to the mesh's resolver by address, not 127.0.0.1.
- hosts: new module holding node-hosts-file — the machine's own lines in its block of /etc/hosts,
  the operator's lines kept, changed by entries/add/remove through sudo -n.
2026-10-05 20:37:59 +02:00
mesh-admin 2cc27e2a74 Merge pull request 'build-agent serves its seat's verbs: current, kill, pause, resume (hq ADR 0219)' (#68) from feat/build-agent-serves-its-seat into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-05 18:31:28 +00:00
jochen 0581256905 build-agent serves its seat's verbs: current, kill, pause, resume (hq ADR 0219)
The node-build-agent seat now promises these verbs, and a holder that does not
name them cannot hold it. The binary serving them is mesh-controller's
cmd/mesh-builder; merge only once a controller carrying the seat's new verbs runs,
since an older one refuses a claim naming verbs its row lacks.
2026-10-05 19:23:30 +02:00
mesh-admin 082f8de32f Merge pull request 'distribution: collect for real now that every kept archive is held (hq issue 253)' (#67) from fix/the-collector-collects-for-real into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-05 16:44:30 +00:00
jochen 428f5b8864 distribution: collect for real now that every kept archive is held
The dry run stood until the controller held each kept archive by a manifest
(mesh-controller #53) and an apply stopped reopening the collection window
(mesh-host #23, hq issue 224). The controller's collection command now
reports 134 of 134 kept archives held. hq issue 253.
2026-10-05 18:34:37 +02:00
mesh-admin 579d21a209 Merge pull request 'gitea: a merge poll looks only at repositories that moved, one pass at a time (hq issue 250)' (#66) from fix/the-merge-poll-looks-only-at-what-moved into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-05 16:12:13 +00:00
jochen 52b81d524c gitea: a merge poll looks only at repositories that moved, one pass at a time
With the poll the only announcer of a merge, its cost showed: every 30 s it
asked every repository for its pull requests, a pass outlasted the tick, and
passes piled up beside each other — a merge was announced four and a half
minutes late, and two passes at once could each announce it. A pass now asks
only repositories updated since a minute before the last look, and the next
pass starts when this one ends. hq issue 250.
2026-10-05 18:12:11 +02:00
mesh-admin b651137d95 Merge pull request 'systemd: port to Go, and read a system unit's journal as root (hq issue 255)' (#65) from feat/systemd-in-go into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-05 16:10:03 +00:00
jochen c42f1ce45b systemd: port to Go, and read a system unit's journal as root
The journal verb ran journalctl as the operator account, which outside the
journal's group sees only its own entries: every system service read
'-- No entries --', and a person reached for a shell. The read now
escalates with sudo -n like the acts; ported to Go with every test. hq
issue 255.
2026-10-05 18:09:42 +02:00
mesh-admin 6a42bafb1c Merge pull request 'records: port to Go, and keep the checkout in a directory it owns (hq issue 251)' (#64) from feat/records-in-go into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-05 15:55:58 +00:00
mesh-admin 053eba6950 Merge pull request 'gitea: announce a merge once, and read every page of its changed files (hq issues 250, 252)' (#63) from fix/a-merge-is-announced-once into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-05 15:55:54 +00:00
jochen 45507c3d5c gitea: test that a pull request's files are read past the forge's page cap 2026-10-05 17:47:36 +02:00
jochen 63e53f4622 gitea: read every page of a pull request's changed files
The forge caps a page at fifty when asked for a hundred, so a large merge's
file list was cut short and reported whole: a module whose own files moved
was not rebuilt. Read until a page comes back short. hq issue 252.
2026-10-05 17:46:54 +02:00
mesh-admin ebacf79c91 Merge pull request 'URGENT distribution: the nightly collector dry-runs until every kept bundle is held (first run tonight 03:30)' (#62) from fix/the-collector-dry-runs-until-bundles-are-held into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-05 15:45:00 +00:00
jochen a66a23582f distribution: collect as a dry run until every kept bundle is held by a manifest
The stock collector keeps only what a manifest names, and the mesh's bundles
and archives are bare blobs no manifest names: its first run, tonight at
03:30, would delete every one, the current ones included. It now reports
what it would delete and deletes nothing, until the controller holds each
kept archive with a manifest. hq issue 253.
2026-10-05 17:43:11 +02:00
jochen db9a5bff0c records: port to Go, and keep the checkout in a directory it owns
Every sync had failed since a container that ran as root left the checkout
root's: git refused it as dubious ownership, and records answered from a
stale copy. The Go bundle clones into repository/ under its directory, clears
the old layout where it can and names what it cannot. Drops the container-
runtime capability the move into the runtime left behind. hq issue 251.
2026-10-05 17:42:08 +02:00
jochen 85352c8d6f gitea: announce a merge once, from the poll
The merge tool announced pull.merged and so did the poll added for issue
131, so every merge made through the tool reached the controller twice.
The poll sees every path and carries the clone url; it is now the only
emitter. hq issue 250.
2026-10-05 17:39:01 +02:00
mesh-admin f74e1f0309 Merge pull request 'claude-code: say what a registration wrote here, whichever took it first' (#58) from fix/register-answers-what-changed into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-05 13:17:39 +00:00
mesh-admin 97b1b2b36b Merge pull request 'Tray applets as modules: openrazer, polychromatic, forticlient, nm-applet' (#60) from feat/tray-applets into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-05 13:10:50 +00:00
jochen d0d5546088 Give the remaining tray applets their modules, each with one start
openrazer (the official driver, daemon and library), polychromatic (the
AUR tray, kept as found; its i3 line moves out of the i3 module into its
own node-display-session contribution), forticlient (the AUR VPN client:
its service declared, its configuration never read) and nm-applet (the
desktop half of NetworkManager, apart from the server-side module).

The openrazer daemon fails on both workstations because the account is
not in the openrazer group; openrazer_check names it and the README
carries the one-off step, since the account's user resource is zsh's.

desktop.go learns to tell a program from another sharing its 15-character
command name, so polychromatic's tools never count or end themselves, and
a copies test holds the six carriers to one text.
2026-10-05 15:10:30 +02:00
mesh-admin 2a99b3c3e9 Merge pull request 'Remove jetbrains-toolbox: the operator retired JetBrains' (#61) from chore/remove-jetbrains into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-05 13:09:53 +00:00
jochen ade4901d7f Remove jetbrains-toolbox: the operator retired JetBrains
The module goes, its link handler leaves xdg's default applications, and the shared desktop helper's
header names the three bundles left.
2026-10-05 15:09:14 +02:00
mesh-admin f2b5168647 Merge pull request 'Add slack and jetbrains-toolbox: one start each, the apps kept as found' (#59) from feat/slack-and-jetbrains-toolbox into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-05 13:07:29 +00:00
jochen 75f25fca21 Add slack and jetbrains-toolbox: one start each, the apps kept as found
Slack comes from the AUR and Toolbox from JetBrains' self-updating tarball,
so neither is declared: the host installs official packages only, and a
pinned archive would fight Toolbox's own updates. Slack's start and the
operator's i3 window rules become one node-display-session contribution,
because Slack's own launch-on-login is a symlink in ~/.config/autostart.
Toolbox keeps its own autostart entry as its one start. The shared
desktop.go header now names all four bundles.
2026-10-05 15:05:55 +02:00
jochen 1fc10b0323 claude-code: say what a registration wrote here, whichever took it first
On g14 the node's own watch took a home registration before the tool did,
and the answer said 'already so' though the file had just been placed.
Compare the view before and after, and render whenever the item applies
here, as the MCP server registration already does.
2026-10-05 14:33:53 +02:00
mesh-admin 4f3e52b47a Merge pull request 'The store collects nightly again (hq ADR 0189)' (#57) from feat/the-store-collects-nightly-again into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-05 12:33:35 +00:00
jschoubben 1a03caf8a5 The store collects nightly again (hq ADR 0189)
Withdrawn 2026-10-04 to unblock novox: while-stopped named the module-local
id and the host refuses a declaration naming a container it does not have,
whole. mesh-controller#259 fixed the namespacing and it has been live since,
so the window composes as distribution.store and the host will take it.

Unchanged from before: plain garbage-collect at 03:30 with the server held
still. The store is at 40G and nothing reclaims it until this runs.
2026-10-05 14:33:14 +02:00
mesh-admin 54ba97f901 Merge pull request 'claude-code: register the agent's configuration at three scopes, served as the nox-mesh plugin (hq ADR 0216)' (#52) from feat/nox-mesh-plugin into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-05 12:30:10 +00:00
jochen 92f78db970 claude-code: act on the review of the nox-mesh plugin
Refuse paths with empty, dot or parent segments and a file that is also a
directory; take an item only when its key says what it is; write the view
under its lock; expand nodes all and check node names; merge the plugin's
entries into the operator's own; render every file past one that fails;
in the home, never take over the person's file, never write through a
symbolic link, keep a deleted file deleted, keep the kind's directory; and
refuse settings that would deny the console or the marketplace.
2026-10-05 14:29:53 +02:00
jochen 1d8f1ceff8 claude-code: drop what was removed while away, render one at a time, refuse an empty settings set
Review of the nox-mesh plugin: a watch hands over what is there, not what
went, so the view is pruned to the state's keys before it starts; the
watches and the tools render one at a time, as the home's record is read
and written whole; the register answer names how an item is offered.
2026-10-05 14:25:10 +02:00
jschoubben 83bd2afe66 Merge pull request 'restic: restore a single kept file, not only a directory' (#56) from fix/restore-a-file into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-05 12:22:17 +00:00
jschoubben 46e3a5a6b7 restic: restore a single kept file, not only a directory
The first restore of an arr app refused its settings file with "not a directory": restic restores
a snapshot's subfolder, not a file. A file is restored with its full path into a scratch directory
beside the target, moved into place, and the scratch removed.
2026-10-05 14:05:52 +02:00
jschoubben d67e786bf9 Merge pull request 'restic: declare sqlite, the tool SQLite stores are copied with' (#55) from feat/arr-backups into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-05 11:49:35 +00:00
jschoubben 7786507d45 restic: declare sqlite, the tool SQLite stores are copied with
The arr apps keep SQLite databases; a consistent copy of a live one is sqlite3's .backup. Declared
once, by the holder that runs the copies, rather than by each app.
2026-10-05 13:39:55 +02:00
jschoubben a70227a40a Merge pull request 'restic: ask as root whether a directory is there' (#54) from fix/backup-sees-as-root into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-05 10:38:25 +00:00
jschoubben 08c7a79f79 restic: ask as root whether a directory is there
The first run on the control node called postgres's dumps missing: the holder looked as the
runtime's account, which cannot see inside a store's 0700 data directory. Every other act already
runs as root; the existence checks do too now.
2026-10-05 12:31:07 +02:00
jschoubben d9120c6848 Merge pull request 'fail2ban: its tools in Go' (#53) from feat/fail2ban-in-go into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-05 10:24:17 +00:00
jschoubben 96ee0d7ad6 Merge pull request 'Back up every store: the restic module and the stores' contributions (hq ADR 0214, to-be 43)' (#49) from feat/node-backup into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-05 10:13:48 +00:00
jschoubben d43de93e49 fail2ban: its tools in Go
Go is the default for module code. One binary, fail2ban-tools, serving the node-intrusion-prevention
seat's four verbs and fail2ban_settings over the SDK, with the same parsing and the same tests; read
back against the control node's live daemon.
2026-10-05 12:01:25 +02:00
jschoubben 3a88bca21a restic: the holder in Go
Go is the default for new module code; the holder is one binary like the licence manager, serving
the seat's verbs over the SDK and running the nights beside them. Same behaviour and tests.
2026-10-05 11:58:18 +02:00
jochen dd1035a27d claude-code: register the agent's configuration at three scopes, served as the nox-mesh plugin
Skills, subagents, commands and hooks had no machine-wide place, so they were
copied into homes by hand and drifted. Each is now registered once through the
module's tools, kept in its config state, and written per node: the plugin in
the managed directory, settings and instructions in the managed files, or the
account's own directory, touching only what the module placed. hq ADR 0216.
2026-10-05 11:57:35 +02:00
mesh-admin 37bb53ab95 Merge pull request 'xdg: the account's folders and the machine's default applications' (#50) from feat/xdg-module into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-05 09:53:30 +00:00
mesh-admin 45f27eb300 Merge pull request 'dbus: hold node-message-bus, and never restart the bus live (hq ADR 0215)' (#51) from feat/dbus-module into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-05 09:52:32 +00:00
jochen 785d32407b xdg: own the account's folders and the machine's default applications
The workstations' XDG conventions had no owner: xdg-user-dirs-update rewrote
the folders file at every login, both machines' default-application lists
named an editor neither has, and the laptop kept two dead links of the
retired predecessor. The module adopts the operator's folders (three as
settings, as the machines differ), disables the update so it cannot undo the
mesh's file, and writes the machine's own mimeapps list, the last one read,
so the person's choices in their own list always win. Autostart is listed,
not owned: each entry is its application's module's.
2026-10-05 11:50:34 +02:00
mesh-admin 170b3296e9 Merge pull request 'nextcloud-client and blueman: the two tray apps as modules, each with one start' (#48) from feat/nextcloud-client-and-blueman into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on
2026-10-05 09:48:43 +00:00
jschoubben 32cd92baeb Back up every store: the restic module holds node-backup, the stores contribute their dumps
ADR 0214 / to-be 43. restic keeps one repository per machine and takes a nightly snapshot per
module — 14 daily, 8 weekly, 6 monthly — and restores beside the live data, never over it. postgres,
mssql and mongodb contribute a consistent dump; minio, influxdb, the vault, mailu, gitea and
nextcloud the directories that hold their data.
2026-10-05 11:47:59 +02:00
jochen 9582208984 Add nextcloud-client and blueman: the two tray apps get an owner and tools, each with one start
Both are official packages already on both workstations, started once by dex from an XDG
autostart entry (the client's own, the package's). The modules declare the package, add no
second start, own none of the apps' files, and give the mesh status, log, restart and check.
2026-10-05 11:47:38 +02:00
385 changed files with 52223 additions and 4373 deletions
+9
View File
@@ -1,2 +1,11 @@
node_modules/ node_modules/
dist/ dist/
# Go tool bundles built in place (go build in a module's cmd/<name>-tools) are build output.
modules/slack/cmd/slack-tools/slack-tools
modules/jetbrains-toolbox/cmd/toolbox-tools/toolbox-tools
modules/messenger/cmd/messenger/messenger
modules/mesh-watcher/cmd/mesh-watcher/mesh-watcher
# ...and the same built from the module's root (go build ./cmd/<name>), which names it after the module.
modules/messenger/messenger
modules/mesh-watcher/mesh-watcher
+5
View File
@@ -0,0 +1,5 @@
70
The long-running resources of this catalogue that do not say how they are ready (`health`, novox/hq ADR 0240
rule 8), as `module check` counts them. It may only go down: merge-check.sh fails a change that raises it, and
one that lowers it writes the new number on the first line. From 2026-11-18, or once it is 0, a long-running
resource without `health` is refused.
Executable
+73
View File
@@ -0,0 +1,73 @@
#!/bin/sh
# mesh-check-toolchain: go
#
# The catalogue's own check (novox/hq ADR 0237 as amended): the second layer of a pull request's merge
# check, `mesh/repo-check`, run by the build seat in the mesh's Go toolchain.
#
# The gate — the manifests the change touches through the running controller's module check, every machine
# of the facts snapshot composed with them and validated by the node-engine's own validator, the replays
# against this tree — is the build seat's first layer (`mesh/merge-gate`), run because the mesh's module
# graph builds these modules from here. It is not repeated here. This is the repository's own:
#
# 1. every manifest through the controller's module check, so one module's change cannot leave another
# it shares a rule with refused (MESH_GATE is the controller the mesh runs) — and the count of
# long-running resources without `health` held to the number in health-undeclared, which only goes down;
# 2. the Go tests of every module the change touches that has them, under the race detector when the
# toolchain has a C compiler — and a module whose dependencies cannot be fetched here, or that is
# written in TypeScript, is said as not tested, never passed silently.
set -eu
if [ -n "${MESH_GATE:-}" ]; then
checked=$("$MESH_GATE" module check modules/*/module.json) || { printf '%s\n' "$checked"; exit 1; }
# **The count only goes down** (novox/hq ADR 0240 rule 8): the long-running resources that do not say how
# they are ready, as the controller counts them, against the number kept in health-undeclared. A change
# that raises it fails; one that lowers it writes the new number there, so it can never rise again.
kept=$(sed -n 's/^\([0-9][0-9]*\).*/\1/p' health-undeclared | head -n 1)
counted=$(printf '%s\n' "$checked" | sed -n 's/^long-running resources without health: \([0-9][0-9]*\)$/\1/p')
if [ -z "$counted" ]; then
echo "NOT COUNTED: the controller the mesh runs is older than the count of resources without health (ADR 0240 Phase B)"
elif [ "$counted" -gt "$kept" ]; then
echo "the long-running resources without health rose from $kept to $counted: declare how each new one is ready (ADR 0240 rule 8)"
exit 1
elif [ "$counted" -lt "$kept" ]; then
echo "the long-running resources without health fell from $kept to $counted: write $counted in health-undeclared, so the count cannot rise again"
exit 1
else
echo "long-running resources without health: $counted, as kept"
fi
else
echo "NOT CHECKED: no controller was built beside this check, so the manifests were not read by one"
fi
race=""
if command -v gcc >/dev/null 2>&1; then race="-race"; else echo "NOT RACE-CHECKED: the toolchain holds no C compiler"; fi
# The modules the change reaches are the controller's planner's answer (MESH_CHECK_MODULES, hq ADR 0238):
# a module by its name, a new one by its directory. By hand, without it, the directories the changed files
# are in.
if [ -n "${MESH_CHECK_MODULES:-}" ]; then
touched=$(printf '%s\n' "$MESH_CHECK_MODULES" | tr ',' '\n' | sed -e 's#^modules/##' -e '/\//d' | sort -u)
else
touched=$(printf '%s\n' "${MESH_CHECK_CHANGED:-}" | tr ',' '\n' | sed -n 's#^modules/\([^/]*\)/.*#\1#p' | sort -u)
fi
for m in $touched; do
[ -d "modules/$m" ] || continue
if [ ! -f "modules/$m/go.mod" ]; then
[ -f "modules/$m/package.json" ] && echo "NOT TESTED HERE: modules/$m is TypeScript; the gate judges its manifest"
continue
fi
if ! (cd "modules/$m" && GOPRIVATE=git.novox.be go mod download >/dev/null 2>&1); then
echo "NOT TESTED: modules/$m — its dependencies cannot be fetched by the build seat"
continue
fi
echo "testing modules/$m"
unformatted=$(cd "modules/$m" && gofmt -l .)
if [ -n "$unformatted" ]; then
echo "modules/$m is not gofmt'd: $unformatted"
exit 1
fi
cgo=0
[ -n "$race" ] && cgo=1
(cd "modules/$m" && CGO_ENABLED=$cgo GOPRIVATE=git.novox.be go vet ./... &&
CGO_ENABLED=$cgo GOPRIVATE=git.novox.be go test $race -count=1 ./...)
done
+96 -9
View File
@@ -1,20 +1,33 @@
// The audit handler — appends one line per event to an append-only audit log. It is the whole of // The audit handler — appends one line per event to an append-only audit log. It is the whole of
// the module's code: an audit logger is not a privileged component, only a module that listens to // the module's code: an audit logger is not a privileged component, only a module that listens to
// everything and writes it down (novox/hq ADR 0041). // everything and writes it down (novox/hq ADR 0041).
//
// **Written once per event.** Delivery is at-least-once, and since novox/hq issue 276 a failed write
// is asked for again and an event that keeps failing is replayed from the spool — so the same event can
// reach the trail more than once. An append-only file has no ON CONFLICT; its equivalent is the SDK's
// other answer ("Taking an event"): skip an event id already written, remembering it only once the line
// is on disk. The ids remembered are the most recent ones, seeded from the end of the trail on start,
// which covers every redelivery (seconds apart) and a restart between a write and its answer.
import { appendFile, mkdir } from "node:fs/promises"; import { mkdir, open, stat } from "node:fs/promises";
import { dirname } from "node:path"; import { dirname } from "node:path";
import type { Event } from "@novox/mesh-sdk/events"; import type { Event } from "@novox/mesh-sdk/events";
import { keyOf } from "./spool.js";
/** Where the trail is written. A directory the host applies; one file per node. */ /** Where the trail is written. A directory the host applies; one file per node. */
export function auditLogPath(env: NodeJS.ProcessEnv = process.env): string { export function auditLogPath(env: NodeJS.ProcessEnv = process.env): string {
return env.AUDIT_LOG ?? "/var/lib/audit-logger/audit.log"; return env.AUDIT_LOG ?? "/var/lib/audit-logger/audit.log";
} }
/** Append an event to the trail as one JSON line, keeping the metadata an audit needs first. The id /** Where an event that could not be written waits (see spool.ts). Beside the trail unless said. */
* is the event's own x-event-id (ADR 0042) — the handle a reader dedups the at-least-once trail on. */ export function auditSpoolPath(env: NodeJS.ProcessEnv = process.env): string {
export async function record(event: Event, path: string): Promise<void> { return env.AUDIT_SPOOL ?? `${dirname(auditLogPath(env))}/spool`;
const line = }
/** One event as the trail's line, keeping the metadata an audit needs first. The id is the event's own
* x-event-id (ADR 0042) — the handle a reader dedups the at-least-once trail on. */
export function lineOf(event: Event): string {
return (
JSON.stringify({ JSON.stringify({
id: event.id, id: event.id,
type: event.type, type: event.type,
@@ -23,8 +36,82 @@ export async function record(event: Event, path: string): Promise<void> {
at: event.at, at: event.at,
...(event.causationId ? { causationId: event.causationId } : {}), ...(event.causationId ? { causationId: event.causationId } : {}),
...(event.schema ? { schema: event.schema } : {}), ...(event.schema ? { schema: event.schema } : {}),
body: event.body, body: event.body ?? null,
}) + "\n"; }) + "\n"
await mkdir(dirname(path), { recursive: true }).catch(() => {}); );
await appendFile(path, line, { mode: 0o600 }); }
/** How many recent event ids a trail remembers, and how much of its end it reads to seed them. */
const REMEMBERED = 50_000;
const SEED_BYTES = 8 * 1024 * 1024;
export class Trail {
private readonly seen = new Map<string, true>();
private constructor(readonly path: string) {}
/** Open the trail at `path`, remembering the ids at its end. */
static async open(path: string): Promise<Trail> {
const t = new Trail(path);
await t.seed();
return t;
}
has(event: Event): boolean {
return this.seen.has(keyOf(event));
}
/** Append the event as one line and sync it, unless this trail already holds it. Throws when the
* line could not be written — the handler's cue to ask for the event again. */
async record(event: Event): Promise<void> {
const key = keyOf(event);
if (this.seen.has(key)) return;
await mkdir(dirname(this.path), { recursive: true }).catch(() => {});
const fh = await open(this.path, "a", 0o600);
try {
await fh.appendFile(lineOf(event));
await fh.datasync();
} finally {
await fh.close();
}
this.remember(key);
}
private remember(key: string): void {
this.seen.set(key, true);
if (this.seen.size > REMEMBERED) {
const first = this.seen.keys().next().value;
if (first !== undefined) this.seen.delete(first);
}
}
private async seed(): Promise<void> {
let size: number;
try {
size = (await stat(this.path)).size;
} catch {
return; // no trail yet
}
const from = Math.max(0, size - SEED_BYTES);
const fh = await open(this.path, "r");
let text: string;
try {
const buf = Buffer.alloc(size - from);
await fh.read(buf, 0, buf.length, from);
text = buf.toString("utf8");
} finally {
await fh.close();
}
const lines = text.split("\n");
if (from > 0) lines.shift(); // the first is cut
for (const line of lines) {
if (!line) continue;
try {
const l = JSON.parse(line);
this.remember(keyOf({ id: l.id ?? "", type: l.type, source: l.source, node: l.node, at: l.at, body: l.body }));
} catch {
// a line cut short by a failed write: its event was asked for again
}
}
}
} }
+18 -12
View File
@@ -1,20 +1,26 @@
// The audit-logger's entrypoint. The per-node module runtime imports this once the broker is // The audit-logger's entrypoint. The per-node module runtime imports this once the broker is
// bound; the on("#") subscription is the whole handshake — it consumes every event on the mesh // bound; the on("#") subscription is the whole handshake — it consumes every event on the mesh
// (module.*, mesh.*, node.*) and writes each to the trail. // (module.*, mesh.*, node.*) and writes each to the trail.
//
// **No event is lost** (novox/hq issue 276). A line that cannot be written is thrown, so the bus offers
// the event again; the first failure spools it on disk, its last delivery is taken from the spool, and
// the spool is replayed into the trail once writing works again (spool.ts). The trail skips an event id
// it already holds, so a redelivery or a replay never writes it twice (audit.ts).
import { on } from "@novox/mesh-sdk/events"; import { on } from "@novox/mesh-sdk/events";
import { auditLogPath, record } from "./audit.js"; import { auditLogPath, auditSpoolPath, Trail } from "./audit.js";
import { replayEvery, Spool, takeOrSpool } from "./spool.js";
const path = auditLogPath(); const say = (line: string) => console.error(`[audit-logger] ${line}`);
const trail = await Trail.open(auditLogPath());
const spool = await Spool.open(auditSpoolPath());
const write = (event: Parameters<Trail["record"]>[0]) => trail.record(event);
await on("#", async (event) => { replayEvery(spool, write, 30_000, say);
try {
await record(event, path);
} catch (err) {
// A failure to write the audit trail is worth a loud line, never a swallowed one — but it must
// not throw back into the broker and wedge the subscription.
console.error(`[audit-logger] could not record ${event.type}: ${err}`);
}
});
console.log(`[audit-logger] recording all mesh events to ${path}`); await on("#", (event) => takeOrSpool(event, write, spool, say));
console.log(
`[audit-logger] recording all mesh events to ${trail.path}` +
(spool.size > 0 ? `; ${spool.size} spooled event(s) wait in ${spool.dir}` : ""),
);
+27 -3
View File
@@ -12,17 +12,36 @@
"kind": "bundle", "kind": "bundle",
"language": "typescript", "language": "typescript",
"entrypoints": [ "entrypoints": [
"index.js" "index.js",
"tools/index.js"
], ],
"loads": [ "loads": [
"index.js" "index.js",
"tools/index.js"
], ],
"env": { "env": {
"AUDIT_LOG": "${dir:trail}/audit.log" "AUDIT_LOG": "${dir:trail}/audit.log",
"AUDIT_SPOOL": "${dir:spool}"
} }
} }
] ]
}, },
"data": {
"own": [
{
"id": "trail",
"path": "${dir:trail}",
"class": "valuable",
"why": "the audit trail, written nowhere else"
},
{
"id": "spool",
"path": "${dir:spool}",
"class": "valuable",
"why": "events the trail could not take yet; after the bus gives one up, the only copy"
}
]
},
"resources": [ "resources": [
{ {
"id": "state", "id": "state",
@@ -34,6 +53,11 @@
"id": "trail", "id": "trail",
"type": "directory", "type": "directory",
"mode": "0700" "mode": "0700"
},
{
"id": "spool",
"type": "directory",
"mode": "0700"
} }
], ],
"capabilities": [ "capabilities": [
+5 -1
View File
@@ -4,8 +4,12 @@
"description": "audit-logger — records every event on the mesh (module, mesh and node) to an append-only trail.", "description": "audit-logger — records every event on the mesh (module, mesh and node) to an append-only trail.",
"type": "module", "type": "module",
"private": true, "private": true,
"scripts": {
"build": "tsc audit.ts spool.ts index.ts tools/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --strict --skipLibCheck --outDir dist",
"test": "npm run build && node --test --experimental-strip-types 'test/*.test.ts'"
},
"dependencies": { "dependencies": {
"@novox/mesh-sdk": "^0.1.0" "@novox/mesh-sdk": "^0.1.13"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
+336
View File
@@ -0,0 +1,336 @@
// The spool — where an event this module could not write waits until it can (novox/hq issue 276).
//
// **A handler takes an event by returning and asks for it again by throwing** (mesh-sdk README,
// "Taking an event"). A write that failed is therefore thrown, and the bus offers the event again five
// seconds later, up to the consumer's maximum deliveries — after which the bus gives it up. For a
// module whose whole job is to keep what it is sent, giving up is losing, so:
//
// - **The first failed write puts the event in the spool**, one file per event, written and synced
// before the handler throws. From then on the event is on this machine's disk, whatever the bus does.
// - **The spool counts the failed deliveries**, because the runtime does not hand a handler the bus's
// delivery count. On the last one the event is **taken**: it is in the spool, and the bus giving it
// up would only raise a condition about an event that is not lost.
// - **A background pass replays the spool** into the store once writes work again, oldest first, and
// stops at the first failure. A write that succeeds — by a redelivery or by a replay — removes the
// event from the spool. The module's writes are idempotent by the event's id, so a replay and a
// redelivery of the same event write it once.
// - **Over its bound** — too many events, or one waiting too long — the spool still keeps every event,
// but the last delivery is no longer taken: the bus gives it up and the controller raises
// `max-deliveries` for this module's consumer (to-be 45, S9). That is the one condition the mesh
// has today that names a consumer which cannot keep up; a module has no standing of its own to report
// (ADR 0224's is a provider's), so the bound borrows that one rather than inventing a second.
//
// The same file lives in audit-logger and model-usage; a third copy belongs in the SDK instead.
import { mkdir, open, readdir, readFile, rename, unlink } from "node:fs/promises";
import { createHash } from "node:crypto";
import { join } from "node:path";
import type { Event } from "@novox/mesh-sdk/events";
/** The controller's maximum deliveries for a module's consumer (mesh-controller
* internal/broker/derived.go). The spool takes an event on this, its last, failed delivery. */
export const MAX_DELIVERIES = 5;
/** More events spooled than this is over the bound. */
export const BOUND_COUNT = 1000;
/** An event spooled longer than this is over the bound. */
export const BOUND_AGE_MS = 30 * 60 * 1000;
/** One event waiting in the spool. */
export interface SpoolEntry {
key: string;
event: Event;
/** Failed deliveries seen so far. */
attempts: number;
/** When it first failed, and last. */
first: string;
last: string;
/** The last failure's words. */
error: string;
/** Taken on its last delivery: only the spool holds it now. */
held: boolean;
}
/** What the spool holds, for the module's status tool and its log. */
export interface Standing {
dir: string;
spooled: number;
held: number;
oldest?: string;
lastError?: string;
overBound: boolean;
why?: string;
bound: { count: number; ageMinutes: number };
}
export interface SpoolOptions {
maxDeliveries?: number;
boundCount?: number;
boundAgeMs?: number;
now?: () => Date;
}
/** What a failed write became: asked for again, held and taken, or asked for again past the bound. */
export type Verdict = "retry" | "held" | "over-bound";
/** The key an event is spooled and deduplicated by: its x-event-id when it is a safe file name, else a
* hash of what identifies it (an event without an id is still one event). */
export function keyOf(event: Pick<Event, "id" | "type" | "source" | "node" | "at" | "body">): string {
if (event.id && /^[A-Za-z0-9_-][A-Za-z0-9._-]{0,127}$/.test(event.id)) return event.id;
const h = createHash("sha256");
for (const part of [event.id ?? "", event.type, event.source, event.node, event.at, JSON.stringify(event.body ?? null)]) {
h.update(String(part));
h.update("\0");
}
return "h-" + h.digest("hex");
}
export class Spool {
private readonly entries = new Map<string, SpoolEntry>();
private chain: Promise<unknown> = Promise.resolve();
private readonly max: number;
private readonly boundCount: number;
private readonly boundAgeMs: number;
private readonly now: () => Date;
private constructor(readonly dir: string, opts: SpoolOptions) {
this.max = opts.maxDeliveries ?? MAX_DELIVERIES;
this.boundCount = opts.boundCount ?? BOUND_COUNT;
this.boundAgeMs = opts.boundAgeMs ?? BOUND_AGE_MS;
this.now = opts.now ?? (() => new Date());
}
/** Open the spool in `dir`, creating it, and read what it already holds. */
static async open(dir: string, opts: SpoolOptions = {}): Promise<Spool> {
const s = new Spool(dir, opts);
await mkdir(dir, { recursive: true, mode: 0o700 });
await s.load(true);
return s;
}
/** Read a spool another process writes, without changing it — for the status tool. */
static async read(dir: string, opts: SpoolOptions = {}): Promise<Spool> {
const s = new Spool(dir, opts);
await s.load(false);
return s;
}
get size(): number {
return this.entries.size;
}
has(key: string): boolean {
return this.entries.has(key);
}
list(): SpoolEntry[] {
return [...this.entries.values()].sort((a, b) => a.first.localeCompare(b.first));
}
/** Record a failed write of `event`. Synced to disk before it returns; throws if it could not be. */
failed(event: Event, err: unknown): Promise<Verdict> {
return this.serial(async () => {
const key = keyOf(event);
const prev = this.entries.get(key);
const at = this.now().toISOString();
const entry: SpoolEntry = {
key,
event,
attempts: (prev?.attempts ?? 0) + 1,
first: prev?.first ?? at,
last: at,
error: String(err instanceof Error ? err.message : err).slice(0, 500),
held: false,
};
const last = entry.attempts >= this.max;
let oldest = entry.first;
for (const e of this.entries.values()) if (e.first < oldest) oldest = e.first;
const over = this.over(prev ? this.entries.size : this.entries.size + 1, oldest);
entry.held = last && !over;
await this.persist(entry);
this.entries.set(key, entry);
return last ? (over ? "over-bound" : "held") : "retry";
});
}
/** The event was written: it need not wait any longer. */
done(key: string): Promise<void> {
if (!this.entries.has(key)) return Promise.resolve();
return this.serial(async () => {
if (!this.entries.has(key)) return;
await unlink(this.file(key)).catch((e: NodeJS.ErrnoException) => {
if (e.code !== "ENOENT") throw e;
});
this.entries.delete(key);
});
}
/** Write every spooled event again, oldest first, stopping at the first that still fails. */
async replay(write: (event: Event) => Promise<void>): Promise<{ replayed: number; left: number; error?: string }> {
let replayed = 0;
for (const entry of this.list()) {
try {
await write(entry.event);
} catch (err) {
return { replayed, left: this.entries.size, error: String(err instanceof Error ? err.message : err) };
}
await this.done(entry.key);
replayed++;
}
return { replayed, left: this.entries.size };
}
standing(): Standing {
const list = this.list();
const oldest = list[0]?.first;
const newest = list.reduce<SpoolEntry | undefined>((n, e) => (!n || e.last > n.last ? e : n), undefined);
const over = this.over(list.length, oldest);
return {
dir: this.dir,
spooled: list.length,
held: list.filter((e) => e.held).length,
...(oldest ? { oldest } : {}),
...(newest ? { lastError: newest.error } : {}),
overBound: over,
...(over ? { why: this.why(list.length, oldest) } : {}),
bound: { count: this.boundCount, ageMinutes: Math.round(this.boundAgeMs / 60000) },
};
}
private over(count: number, oldest: string | undefined): boolean {
return this.why(count, oldest) !== undefined;
}
private why(count: number, oldest: string | undefined): string | undefined {
if (count > this.boundCount) return `${count} events spooled, more than ${this.boundCount}`;
if (oldest && this.now().getTime() - Date.parse(oldest) > this.boundAgeMs) {
return `an event has waited since ${oldest}, longer than ${Math.round(this.boundAgeMs / 60000)} minutes`;
}
return undefined;
}
private file(key: string): string {
return join(this.dir, `${key}.json`);
}
private async load(tidy: boolean): Promise<void> {
let names: string[];
try {
names = await readdir(this.dir);
} catch (e) {
if ((e as NodeJS.ErrnoException).code === "ENOENT") return;
throw e;
}
for (const name of names) {
if (name.endsWith(".tmp")) {
// A write cut short: the event it was for was never answered as taken, so the bus has it.
if (tidy) await unlink(join(this.dir, name)).catch(() => {});
continue;
}
if (!name.endsWith(".json")) continue;
try {
const entry = JSON.parse(await readFile(join(this.dir, name), "utf8")) as SpoolEntry;
if (entry?.key && entry.event) this.entries.set(entry.key, entry);
} catch (err) {
// Kept on disk for a person, never deleted: it may be the only copy of an event.
console.error(`[spool] ${join(this.dir, name)} cannot be read and is left as it is: ${err}`);
}
}
}
/** Written to a temporary file, synced, renamed over the entry, and the directory synced. */
private async persist(entry: SpoolEntry): Promise<void> {
const final = this.file(entry.key);
const tmp = `${final}.tmp`;
const fh = await open(tmp, "w", 0o600);
try {
await fh.writeFile(JSON.stringify(entry) + "\n");
await fh.sync();
} finally {
await fh.close();
}
await rename(tmp, final);
const dh = await open(this.dir, "r");
try {
await dh.sync();
} catch {
// Not every filesystem syncs a directory; the rename stands either way.
} finally {
await dh.close();
}
}
private serial<T>(fn: () => Promise<T>): Promise<T> {
const run = this.chain.then(fn, fn);
this.chain = run.catch(() => {});
return run;
}
}
/**
* The handler's whole discipline: write the event; if that fails, spool it and ask for it again —
* or, on its last delivery, take it, because the spool now holds it. A write that cannot even be
* spooled is asked for again, which is all that is left.
*/
export async function takeOrSpool(
event: Event,
write: (event: Event) => Promise<void>,
spool: Spool,
say: (line: string) => void = (l) => console.error(l),
): Promise<void> {
const what = `${event.type} (${event.id || "no id"})`;
try {
await write(event);
} catch (err) {
let verdict: Verdict;
try {
verdict = await spool.failed(event, err);
} catch (spoolErr) {
say(`could not write ${what}: ${err}; and could not spool it either: ${spoolErr}; asking for it again`);
throw err;
}
if (verdict === "held") {
say(`could not write ${what} on its last delivery: ${err}; spooled and taken, written when writing works again`);
return;
}
say(
verdict === "over-bound"
? `could not write ${what} on its last delivery: ${err}; spooled, and NOT taken because the spool is over its bound (${spool.standing().why}) — the bus gives it up and the controller raises max-deliveries; the spool still writes it when it can`
: `could not write ${what}: ${err}; spooled, asking for it again`,
);
throw err;
}
// Written: a copy spooled by an earlier failed delivery is no longer needed. Failing to remove it
// costs one idempotent write on the next replay, so it is said, never thrown.
await spool.done(keyOf(event)).catch((e) => say(`wrote ${what} but could not remove its spooled copy: ${e}`));
}
/** Replay the spool every `everyMs` while it holds anything, and say loudly — every fifteen minutes —
* while it is over its bound. Returns a stop. */
export function replayEvery(
spool: Spool,
write: (event: Event) => Promise<void>,
everyMs = 30_000,
say: (line: string) => void = (l) => console.error(l),
): () => void {
let running = false;
let loudAt = 0;
const timer = setInterval(async () => {
if (running || spool.size === 0) return;
running = true;
try {
const r = await spool.replay(write);
if (r.replayed > 0) say(`replayed ${r.replayed} spooled event(s); ${r.left} left`);
const st = spool.standing();
if (st.overBound && Date.now() - loudAt > 15 * 60_000) {
loudAt = Date.now();
say(`THE SPOOL IS OVER ITS BOUND: ${st.why}; ${st.spooled} event(s) wait in ${st.dir}, last error: ${r.error ?? st.lastError}`);
}
} catch (err) {
say(`replaying the spool failed: ${err}`);
} finally {
running = false;
}
}, everyMs);
timer.unref?.();
return () => clearInterval(timer);
}
+160 -12
View File
@@ -1,40 +1,188 @@
import { test } from "node:test"; import { test } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { mkdtemp, readFile } from "node:fs/promises"; import { mkdtemp, readFile, readdir } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { useBroker } from "@novox/mesh-sdk/messaging"; import { useBroker } from "@novox/mesh-sdk/messaging";
import { emit, on } from "@novox/mesh-sdk/events"; import { emit, on, type Event } from "@novox/mesh-sdk/events";
import { record } from "../audit.ts"; import { Trail } from "../dist/audit.js";
import { Spool, takeOrSpool } from "../dist/spool.js";
import { getAuditTools } from "../dist/tools/index.js";
async function lines(path: string): Promise<Record<string, any>[]> {
const text = await readFile(path, "utf8").catch(() => "");
return text.trim() === "" ? [] : text.trim().split("\n").map((l) => JSON.parse(l));
}
function ev(id: string, body: unknown = { n: 1 }): Event {
return { type: "umami.site.created", id, source: "umami", node: "anchor", at: "2026-10-06T10:00:00.000Z", body };
}
async function setup() {
const dir = await mkdtemp(join(tmpdir(), "audit-"));
const trail = await Trail.open(join(dir, "audit.log"));
const spool = await Spool.open(join(dir, "spool"));
const store = { broken: false };
const write = async (e: Event) => {
if (store.broken) throw new Error("ENOSPC: no space left on device");
await trail.record(e);
};
const said: string[] = [];
const handle = (e: Event) => takeOrSpool(e, write, spool, (l) => said.push(l));
return { dir, trail, spool, store, write, handle, said, path: join(dir, "audit.log") };
}
test("audit-logger records every event to the trail as one line each", async () => { test("audit-logger records every event to the trail as one line each", async () => {
const broker = memBroker(); const broker = memBroker();
useBroker(() => broker); useBroker(() => broker);
const dir = await mkdtemp(join(tmpdir(), "audit-")); const { handle, path } = await setup();
const path = join(dir, "audit.log");
// The audit-logger's whole behaviour: consume everything, record it. // The audit-logger's whole behaviour: consume everything, record it.
await on("#", async (event) => record(event, path)); // the pattern index.ts subscribes await on("#", handle); // the pattern index.ts subscribes
process.env.MESH_MODULE = "umami"; process.env.MESH_MODULE = "umami";
process.env.MESH_NODE = "anchor"; process.env.MESH_NODE = "anchor";
await emit("site.created", { domain: "my-app" }); await emit("site.created", { domain: "my-app" });
await emit("node.anchor.joined", { role: "worker" }); // a node event, not a module one await emit("node.anchor.joined", { role: "worker" }); // a node event, not a module one
const lines = (await readFile(path, "utf8")).trim().split("\n").map((l) => JSON.parse(l)); const got = await lines(path);
assert.equal(lines.length, 2); assert.equal(got.length, 2);
// A module names its events locally (design 29); the module is the `source`, which together with // 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. // 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.deepEqual(got.map((l) => l.type), ["site.created", "node.anchor.joined"]);
assert.equal(lines[0].source, "umami"); assert.equal(got[0].source, "umami");
assert.equal(lines[0].node, "anchor"); assert.equal(got[0].node, "anchor");
assert.equal(lines[0].body.domain, "my-app"); assert.equal(got[0].body.domain, "my-app");
delete process.env.MESH_MODULE; delete process.env.MESH_MODULE;
delete process.env.MESH_NODE; delete process.env.MESH_NODE;
}); });
test("a write that fails is thrown, so the bus offers the event again — and it is spooled at once", async () => {
const broker = memBroker();
useBroker(() => broker);
const { handle, store, spool, path } = await setup();
await on("#", handle);
store.broken = true;
await assert.rejects(emit("site.created", { domain: "x" }), /ENOSPC/);
assert.equal(spool.size, 1, "spooled on the first failure");
assert.equal(spool.list()[0].attempts, 1);
assert.equal(spool.list()[0].held, false);
assert.deepEqual(await lines(path), []);
});
test("a redelivered event is written once — also after a restart, and also when its earlier failure was spooled", async () => {
const { handle, store, spool, path, dir } = await setup();
await handle(ev("e1"));
await handle(ev("e1")); // the answer was lost; the bus offers it again
assert.equal((await lines(path)).length, 1);
// A restart between writing and answering: the trail remembers the ids at its end.
const again = await Trail.open(path);
await again.record(ev("e1"));
assert.equal((await lines(path)).length, 1);
// Failed once, then written: the spooled copy goes.
store.broken = true;
await assert.rejects(handle(ev("e2")));
assert.equal(spool.size, 1);
store.broken = false;
await handle(ev("e2"));
assert.equal(spool.size, 0);
assert.deepEqual(await readdir(join(dir, "spool")), []);
assert.deepEqual((await lines(path)).map((l) => l.id), ["e1", "e2"]);
});
test("on its last delivery a failed event is spooled and taken", async () => {
const { handle, store, spool, said } = await setup();
store.broken = true;
for (let i = 1; i < 5; i++) await assert.rejects(handle(ev("e3")), /ENOSPC/, `delivery ${i} asks again`);
await handle(ev("e3")); // the fifth — the controller's max-deliver — returns: taken
const [entry] = spool.list();
assert.equal(entry.attempts, 5);
assert.equal(entry.held, true);
assert.match(said.at(-1)!, /spooled and taken/);
});
test("the delivery count survives a restart, because it is the spool's", async () => {
const { handle, store, dir } = await setup();
store.broken = true;
for (let i = 0; i < 3; i++) await assert.rejects(handle(ev("e4")));
const reopened = await Spool.open(join(dir, "spool"));
assert.equal(reopened.list()[0].attempts, 3);
});
test("the spool replays into the trail once writing works, once each", async () => {
const { handle, store, spool, write, path } = await setup();
store.broken = true;
for (let i = 0; i < 5; i++) await handle(ev("e5")).catch(() => {});
for (let i = 0; i < 5; i++) await handle(ev("e6")).catch(() => {});
assert.equal(spool.size, 2);
// Still broken: the pass stops at the first failure and keeps everything.
let r = await spool.replay(write);
assert.equal(r.replayed, 0);
assert.equal(spool.size, 2);
store.broken = false;
r = await spool.replay(write);
assert.deepEqual(r, { replayed: 2, left: 0 });
await handle(ev("e5")); // a late redelivery after the replay
assert.deepEqual((await lines(path)).map((l) => l.id), ["e5", "e6"]);
});
test("over its bound the spool keeps the event but does not take it, so the bus gives it up and says so", async () => {
const dir = await mkdtemp(join(tmpdir(), "audit-"));
const spool = await Spool.open(join(dir, "spool"), { boundCount: 1 });
const failing = async () => {
throw new Error("down");
};
for (let i = 0; i < 5; i++) await takeOrSpool(ev("a"), failing, spool, () => {}).catch(() => {});
assert.equal(spool.list()[0].held, true, "within the bound: taken");
for (let i = 1; i < 5; i++) await assert.rejects(takeOrSpool(ev("b"), failing, spool, () => {}));
await assert.rejects(takeOrSpool(ev("b"), failing, spool, () => {}), /down/, "over the bound: asked again on its last");
assert.equal(spool.size, 2, "and still spooled");
const st = spool.standing();
assert.equal(st.overBound, true);
assert.equal(st.spooled, 2);
assert.equal(st.held, 1);
assert.match(st.why!, /more than 1/);
});
test("an event without a body, or without an id, is recorded once", async () => {
const { handle, path } = await setup();
await handle(ev("e7", null));
await handle(ev("", undefined));
await handle(ev("", undefined));
const got = await lines(path);
assert.equal(got.length, 2);
assert.equal(got[0].body, null);
});
test("an event waiting longer than the bound puts the spool over it", async () => {
const dir = await mkdtemp(join(tmpdir(), "audit-"));
let now = new Date("2026-10-06T10:00:00Z");
const spool = await Spool.open(join(dir, "spool"), { now: () => now });
await takeOrSpool(ev("old"), async () => { throw new Error("down"); }, spool, () => {}).catch(() => {});
assert.equal(spool.standing().overBound, false);
now = new Date("2026-10-06T10:31:00Z");
assert.match(spool.standing().why!, /longer than 30 minutes/);
});
test("audit_status says what waits in the spool, read from disk by the tool's own process", async () => {
const { handle, store, dir } = await setup();
store.broken = true;
for (let i = 0; i < 5; i++) await handle(ev("s1")).catch(() => {});
await handle(ev("s2")).catch(() => {});
const [tool] = getAuditTools({ AUDIT_LOG: join(dir, "audit.log"), AUDIT_SPOOL: join(dir, "spool") });
assert.equal(tool.name, "audit_status");
const got = (await tool.run({}, {} as never)) as { spool: { spooled: number; held: number; lastError: string } };
assert.equal(got.spool.spooled, 2);
assert.equal(got.spool.held, 1);
assert.match(got.spool.lastError, /ENOSPC/);
});
function memBroker() { function memBroker() {
const subs: { pattern: string; handler: (env: { key: string; node: string; body: unknown }) => Promise<void> }[] = []; const subs: { pattern: string; handler: (env: { key: string; node: string; body: unknown }) => Promise<void> }[] = [];
return { return {
+27
View File
@@ -0,0 +1,27 @@
// The audit-logger's one tool — its standing: where the trail is, and what waits in the spool
// (novox/hq issue 276). Launched as its own process beside the handler, so it reads the spool from
// disk rather than asking the handler.
import { stat } from "node:fs/promises";
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
import { auditLogPath, auditSpoolPath } from "../audit.js";
import { Spool } from "../spool.js";
export function getAuditTools(env: NodeJS.ProcessEnv = process.env): ToolDefinition[] {
return [
{
name: "audit_status",
description:
"The audit trail's standing: its path and size, and the events that could not be written yet — how many wait in the spool, how many were taken from the bus and are held only there, the oldest, the last error, and whether the spool is over its bound (then the bus gives events up and max-deliveries is raised).",
input: {},
run: async () => {
const path = auditLogPath(env);
const size = await stat(path).then((s) => s.size, () => null);
const spool = await Spool.read(auditSpoolPath(env));
return { trail: { path, bytes: size }, spool: spool.standing() };
},
},
];
}
registerModuleTools("audit-logger", (env) => getAuditTools(env));
+6 -1
View File
@@ -8,5 +8,10 @@
"skipLibCheck": true, "skipLibCheck": true,
"noEmit": true "noEmit": true
}, },
"include": ["audit.ts", "index.ts"] "include": [
"audit.ts",
"spool.ts",
"index.ts",
"tools/index.ts"
]
} }
+13
View File
@@ -36,6 +36,16 @@
"why": "the Baserow web UI and REST API, served by the image's own Caddy; a public name is the route's" "why": "the Baserow web UI and REST API, served by the image's own Caddy; a public name is the route's"
} }
], ],
"data": {
"own": [
{
"id": "data",
"path": "${dir:data}",
"class": "valuable",
"why": "uploaded files and media; the tables are in the database"
}
]
},
"resources": [ "resources": [
{ {
"id": "mesh-state", "id": "mesh-state",
@@ -72,6 +82,9 @@
"type": "container", "type": "container",
"name": "baserow", "name": "baserow",
"image": "baserow/baserow@sha256:263ea6c4b72c9eccabcd975ffe9fdebf23913a293a514bec6a3897a5e0a5a080", "image": "baserow/baserow@sha256:263ea6c4b72c9eccabcd975ffe9fdebf23913a293a514bec6a3897a5e0a5a080",
"health": {
"kind": "runtime"
},
"network": "baserow", "network": "baserow",
"env-file": [ "env-file": [
"${dir:state}/server.env" "${dir:state}/server.env"
+82
View File
@@ -0,0 +1,82 @@
# blueman
The Bluetooth tray applet on the workstations, as a module (novox/hq ADR 0208). It requires
`x11-display`, so it is assigned only where a display server is held on the same machine.
## Owns
| what | where |
|---|---|
| the applet, the manager window, send-to | package `blueman`, from the official repositories |
Nothing else. It holds no seat, makes no contribution and writes no file.
- **No AUR.** Both workstations run the official package (`extra`), installed explicitly.
- **The Bluetooth stack is not this module's.** `bluez`, `bluez-utils` and `bluetooth.service` belong
to the `bluetooth` module. This module declares none of them (ADR 0210 §4: one package, one module).
The devices, pairing and power are that module's tools (`bluetooth_*`). This module's tools are
about the applet.
- **The operator's applet settings are found.** blueman keeps them in dconf (`org.blueman.*`): the
plugin switches, the recent connections, auto-connect and auto-power-on. The module neither sets nor
resets them. `blueman_status` shows the plugin switches.
## How it starts: the package's autostart entry, and nothing else
One process has one starter (the rule `picom` states for the desktop modules). The package ships
`/etc/xdg/autostart/blueman.desktop` (`blueman-applet`). The session runs it once at login through
the `i3` module's `dex --autostart --environment i3`. **That entry is the applet's one start.** The
module adds no `xinitrc` slot and no `node-display-session` exec, because either would start it a
second time.
- **Excluded:** the window manager's `exec … blueman-applet`, which the `i3` module's configuration
dropped.
- **Not a start:** the package's user unit `blueman-applet.service` is static. It exists for D-Bus
activation of `org.blueman.Applet`. A program that calls the applet's bus name while no applet runs
starts one through it. The applet is single-instance on that name, so this never makes a second
one. The tools always ask the bus with `--auto-start=no`, so asking never starts it.
- The applet starts its tray icon, `blueman-tray`, itself.
## Tools
They are served by the node's runtime as the operator account (ADR 0175).
| tool | does |
|---|---|
| `blueman_status` (r) | <ul><li>whether the applet and its tray icon run: pid, since, and the scope or unit they run in</li><li>the installed version, and what starts it at login</li><li>the plugins the running applet has loaded and those it has not (asked of the applet on the session bus)</li><li>the plugin switches in `org.blueman.general plugin-list`</li><li>whether the applet sees Bluetooth on</li></ul> |
| `blueman_restart` (a) | asks the applet and the tray icon to end (SIGTERM), forces them after 5 s, and starts `blueman-applet` in the operator's session. The start is a transient user unit `mesh-blueman-applet`, so it outlives the tools runtime. Answers the pids. Refused plainly when nobody is logged in to the desktop |
| `blueman_check` (r) | <ul><li>the package is installed</li><li>exactly one start: the package's entry is present and not hidden by an entry of the account, and `dex` is installed</li><li>no window-manager exec</li><li>one applet runs in a desktop session</li><li>`bluetooth.service` is active</li></ul>Each finding says what to do |
The tools find the session's `DISPLAY` and `XAUTHORITY` from the window manager's own environment,
as `clipmenu` and `screen-lock` do. Every command has a timeout and capped output. Everything runs
through an injected runner and a fake root in the tests.
## What changes when it is assigned
| | g14 | shanks |
|---|---|---|
| package | none: `blueman` 2.4.6 is installed, explicitly, from `extra` | the same |
| start | none: dex starts the applet from the package's entry, in the login session's scope; the tray icon with it | none on disk. **The applet running now came from the predecessor's window-manager line** (a child of i3, since the session of 2026-10-04 16:00). That session began before the `i3` module dropped the line and installed `dex`, so the next login is the first that starts it from the entry |
| settings (dconf) | defaults, no plugin switched; recent connections only | no plugin switched; auto-connect for one headset, auto-power-on set |
The workstations are already in the state this module describes.
## Migration (ADR 0182)
Nothing is required on either machine. On shanks, log out and in once, or run `blueman_restart`, and
the applet runs from its one start. `blueman_check` then answers `ok`.
## Leaves as found
- The applet's dconf settings (`/org/blueman/`).
- `/etc/xdg/autostart/blueman.desktop`, the package's own file.
## Relies on
- **The `bluetooth` module, for bluez and its daemon.** Without them the applet has nothing to
manage. There is no dependency mechanism between two modules that hold no seat. So nothing refuses
`blueman` on a node without `bluetooth`. `blueman_check` reports it instead, from
`bluetooth.service`. **Assign both.** When the stack becomes a seat (`node-bluetooth`), this module
depends on the seat.
- **`i3`'s `dex` line for the start**, which is equally undeclared: XDG autostart has no seat.
Assigned without `i3`, the applet is installed and does not start. `blueman_check` says so.
- A display server on the same machine (`x11-display`, ADR 0208 §3).
+97
View File
@@ -0,0 +1,97 @@
// Reading a tool's arguments: JSON numbers arrive as float64, and a missing argument is its default.
// The same in every desktop module that carries it.
package main
import (
"fmt"
"math"
"strings"
"time"
)
// text is a string argument, trimmed; required says an empty one is refused.
func text(args map[string]any, key string, required bool) (string, error) {
v, present := args[key]
if !present || v == nil {
if required {
return "", fmt.Errorf("%s is required", key)
}
return "", nil
}
s, ok := v.(string)
if !ok {
return "", fmt.Errorf("%s is a string, not %T", key, v)
}
s = strings.TrimSpace(s)
if s == "" && required {
return "", fmt.Errorf("%s is required", key)
}
return s, nil
}
// whole is a whole-number argument within [least, most], or def when absent.
func whole(args map[string]any, key string, def, least, most int) (int, error) {
v, present := args[key]
if !present || 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 is a number, not %T", key, v)
}
}
if f != math.Trunc(f) {
return 0, fmt.Errorf("%s is a whole number, not %v", key, f)
}
n := int(f)
if n < least || n > most {
return 0, fmt.Errorf("%s is %d; it is between %d and %d", key, n, least, most)
}
return n, nil
}
// flag is a boolean argument, or def when absent.
func flag(args map[string]any, key string, def bool) (bool, error) {
v, present := args[key]
if !present || v == nil {
return def, nil
}
b, ok := v.(bool)
if !ok {
return false, fmt.Errorf("%s is true or false, not %T", key, v)
}
return b, nil
}
// texts is a list-of-strings argument.
func texts(args map[string]any, key string) ([]string, error) {
v, present := args[key]
if !present || v == nil {
return nil, nil
}
list, ok := v.([]any)
if !ok {
if ss, isStrings := v.([]string); isStrings {
return ss, nil
}
return nil, fmt.Errorf("%s is a list of strings, not %T", key, v)
}
out := make([]string, 0, len(list))
for i, item := range list {
s, ok := item.(string)
if !ok {
return nil, fmt.Errorf("%s[%d] is a string, not %T", key, i, item)
}
out = append(out, s)
}
return out, nil
}
// seconds is a timeout argument in seconds, defaulted and bounded below the runtime's call limit.
func seconds(args map[string]any, key string, def, most int) (time.Duration, error) {
n, err := whole(args, key, def, 1, most)
return time.Duration(n) * time.Second, err
}
@@ -0,0 +1,221 @@
package main
// blueman's applet as the tools see it: its processes, its XDG autostart entry (the package's), the
// applet's own answers on the session bus, and its settings in gsettings. Every bus call is made with
// --auto-start=no: the applet is D-Bus activatable, and a question must never start it.
import (
"encoding/json"
"fmt"
"sort"
"strings"
"time"
)
const (
appletComm = "blueman-applet"
trayComm = "blueman-tray"
appletBin = "/usr/bin/blueman-applet"
entryName = "blueman.desktop"
restartAs = "mesh-blueman-applet"
packageFor = "blueman"
busName = "org.blueman.Applet"
busPath = "/org/blueman/Applet"
stackUnit = "bluetooth.service"
)
// appletCall asks the running applet one question over the session bus and answers busctl's JSON.
func (m *Machine) appletCall(method string) (json.RawMessage, error) {
args := []string{"--user", "--auto-start=no", "--json=short", "call", busName, busPath, busName, method}
o := m.cmd(5*time.Second, m.bus(), "busctl", args...)
if err := failed(o, "busctl", args...); err != nil {
return nil, err
}
var doc struct {
Data []json.RawMessage `json:"data"`
}
if err := json.Unmarshal([]byte(o.Stdout), &doc); err != nil || len(doc.Data) != 1 {
return nil, fmt.Errorf("the applet's answer to %s is not busctl's JSON: %q", method, tail(o.Stdout, 200))
}
return doc.Data[0], nil
}
func (m *Machine) appletStrings(method string) ([]string, error) {
raw, err := m.appletCall(method)
if err != nil {
return nil, err
}
var out []string
if err := json.Unmarshal(raw, &out); err != nil {
return nil, fmt.Errorf("the applet's %s is not a list of names: %w", method, err)
}
sort.Strings(out)
return out, nil
}
// gvariantStrings reads gsettings' printed array of strings: "@as []" or "['a', '!b']".
func gvariantStrings(s string) []string {
s = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(s), "@as"))
s = strings.TrimSuffix(strings.TrimPrefix(s, "["), "]")
out := []string{}
for _, item := range strings.Split(s, ",") {
item = strings.Trim(strings.TrimSpace(item), `'"`)
if item != "" {
out = append(out, item)
}
}
return out
}
// Plugins is which applet plugins run, and what the operator's settings say about them.
type Plugins struct {
// Loaded are the plugins the running applet answers it has loaded.
Loaded []string `json:"loaded"`
// NotLoaded are the plugins it knows but did not load: switched off, or not fit for this machine.
NotLoaded []string `json:"not_loaded"`
// Switched is the operator's plugin-list setting: a name to load it, !name to keep it off. Empty
// is blueman's defaults.
Switched []string `json:"switched"`
}
// AppletStatus is what blueman_status answers.
type AppletStatus struct {
Installed string `json:"installed,omitempty"`
Applet []Proc `json:"applet"`
Tray []Proc `json:"tray"`
StartedBy Autostart `json:"started_by"`
// Bluetooth is the applet's own view of the adapter's power, when it runs.
Bluetooth *bool `json:"bluetooth_on,omitempty"`
Plugins Plugins `json:"plugins"`
// Unanswered says why the running applet's view is missing.
Unanswered string `json:"unanswered,omitempty"`
}
// Status reads the applet: running or not, how it starts, and its plugins.
func (m *Machine) Status() (AppletStatus, error) {
s := AppletStatus{Applet: m.procs(appletComm), Tray: m.procs(trayComm), StartedBy: m.autostart(entryName),
Plugins: Plugins{Loaded: []string{}, NotLoaded: []string{}, Switched: []string{}}}
if s.Applet == nil {
s.Applet = []Proc{}
}
if s.Tray == nil {
s.Tray = []Proc{}
}
if v, err := m.installed(packageFor); err == nil {
s.Installed = v
}
o := m.cmd(5*time.Second, m.bus(), "gsettings", "get", "org.blueman.general", "plugin-list")
if o.Err == nil && o.Code == 0 {
s.Plugins.Switched = gvariantStrings(o.Stdout)
}
if len(s.Applet) == 0 {
s.Unanswered = "the applet is not running"
return s, nil
}
loaded, err := m.appletStrings("QueryPlugins")
if err != nil {
s.Unanswered = err.Error()
return s, nil
}
s.Plugins.Loaded = loaded
if all, err := m.appletStrings("QueryAvailablePlugins"); err == nil {
in := map[string]bool{}
for _, p := range loaded {
in[p] = true
}
for _, p := range all {
if !in[p] {
s.Plugins.NotLoaded = append(s.Plugins.NotLoaded, p)
}
}
}
if raw, err := m.appletCall("GetBluetoothStatus"); err == nil {
var on bool
if json.Unmarshal(raw, &on) == nil {
s.Bluetooth = &on
}
}
return s, nil
}
// RestartAnswer is what blueman_restart answers.
type RestartAnswer struct {
Ended []int `json:"ended"`
Killed []int `json:"killed,omitempty"`
Running []Proc `json:"running"`
Session Session `json:"session"`
Unit string `json:"unit"`
}
// Restart ends the applet and its tray icon, and starts the applet again in the operator's session
// under the account's service manager. The applet starts its tray icon itself.
func (m *Machine) Restart() (RestartAnswer, error) {
s, err := m.session()
if err != nil {
return RestartAnswer{}, err
}
a := RestartAnswer{Session: s, Unit: restartAs + ".service"}
a.Ended, a.Killed = m.stop(5*time.Second, appletComm, trayComm)
if err := m.detach(s, restartAs, appletBin); err != nil {
return a, err
}
a.Running = m.waitFor(appletComm, 4*time.Second)
if len(a.Running) == 0 {
return a, fmt.Errorf("the applet was started as %s but no %s process appeared within 4 s: "+
"see `journalctl --user -u %s`", a.Unit, appletComm, a.Unit)
}
return a, nil
}
// CheckAnswer is what blueman_check answers.
type CheckAnswer struct {
OK bool `json:"ok"`
Findings []Finding `json:"findings"`
Starts []string `json:"starts"`
}
// Check verifies what the module promises and relies on: the package; one start (the package's
// autostart entry, which the session's dex runs); the applet running once in a session; and the
// Bluetooth daemon it manages, which is the bluetooth module's.
func (m *Machine) Check() (CheckAnswer, error) {
a := CheckAnswer{Findings: []Finding{}, Starts: []string{}}
add := func(what, do string) { a.Findings = append(a.Findings, Finding{what, do}) }
v, err := m.installed(packageFor)
if err != nil {
return a, err
}
if v == "" {
add("the package blueman is not installed", "push the module to the node")
}
entry := m.autostart(entryName)
if entry.Starts {
a.Starts = append(a.Starts, "XDG autostart: "+entry.From)
if entry.From != "/etc/xdg/autostart/"+entryName {
add("the account's own "+entry.From+" replaces the package's entry", "remove it, so the package's entry is the one start")
}
} else {
add("the applet does not start with the session ("+entry.Because+")", "remove ~/.config/autostart/"+entryName+" if it hides the package's entry")
}
if o := m.cmd(0, nil, "dex", "--version"); o.Err != nil {
add("dex, which runs the XDG autostart entries at login, is not installed", "assign the i3 module, which installs it and runs it")
}
for _, l := range m.i3Starts(appletComm) {
a.Starts = append(a.Starts, "window manager: "+l)
add("a second start: "+l, "remove the line; the package's autostart entry is the applet's one start")
}
if o := m.cmd(0, nil, "systemctl", "is-active", stackUnit); o.Err != nil || strings.TrimSpace(o.Stdout) != "active" {
add("the Bluetooth daemon ("+stackUnit+") is not running: the applet has nothing to manage",
"assign the bluetooth module, which owns bluez and its daemon")
}
running := m.procs(appletComm)
if _, err := m.session(); err == nil {
switch {
case len(running) == 0:
add("no applet runs in the desktop session", "blueman_restart")
case len(running) > 1:
add(fmt.Sprintf("%d applets run", len(running)), "blueman_restart ends them all and starts one")
}
}
a.OK = len(a.Findings) == 0
return a, nil
}
@@ -0,0 +1,126 @@
package main
import (
"strings"
"testing"
)
func newApplet(t *testing.T, running bool) *fake {
f := newFake(t)
f.write("/etc/xdg/autostart/blueman.desktop", "[Desktop Entry]\nName=Blueman Applet\nExec=blueman-applet\nType=Application\n")
if running {
f.proc(3859, 1000, appletComm, []string{"/usr/bin/python", "/usr/bin/blueman-applet"}, "session-c1.scope")
f.proc(4084, 1000, trayComm, []string{"/usr/bin/python", "/usr/bin/blueman-tray"}, "session-c1.scope")
}
f.answer = func(name string, args []string) Output {
call := name + " " + strings.Join(args, " ")
switch {
case name == "pacman":
return Output{Stdout: "blueman 2.4.6-2\n"}
case name == "gsettings":
return Output{Stdout: "['!NetUsage', 'DhcpClient']\n"}
case strings.HasSuffix(call, " QueryPlugins"):
return Output{Stdout: `{"type":"as","data":[["StatusIcon","AuthAgent","PowerManager"]]}`}
case strings.HasSuffix(call, " QueryAvailablePlugins"):
return Output{Stdout: `{"type":"as","data":[["StatusIcon","AuthAgent","PowerManager","NetUsage"]]}`}
case strings.HasSuffix(call, " GetBluetoothStatus"):
return Output{Stdout: `{"type":"b","data":[true]}`}
case name == "systemctl" && len(args) > 1 && args[0] == "is-active":
return Output{Stdout: "active\n"}
}
return Output{}
}
return f
}
func TestStatusAsksTheRunningAppletAndNeverStartsIt(t *testing.T) {
f := newApplet(t, true)
s, err := f.Status()
if err != nil {
t.Fatal(err)
}
if s.Installed != "2.4.6-2" || len(s.Applet) != 1 || len(s.Tray) != 1 || !s.StartedBy.Starts || s.Bluetooth == nil || !*s.Bluetooth {
t.Fatalf("%+v", s)
}
if strings.Join(s.Plugins.Loaded, ",") != "AuthAgent,PowerManager,StatusIcon" || strings.Join(s.Plugins.NotLoaded, ",") != "NetUsage" ||
strings.Join(s.Plugins.Switched, ",") != "!NetUsage,DhcpClient" {
t.Fatalf("%+v", s.Plugins)
}
for _, c := range f.calls {
if strings.HasPrefix(c, "busctl") && !strings.Contains(c, "--auto-start=no") {
t.Fatalf("a bus call that could start the applet: %s", c)
}
}
stopped := newApplet(t, false)
s, err = stopped.Status()
if err != nil || s.Unanswered != "the applet is not running" || len(s.Applet) != 0 || s.Bluetooth != nil {
t.Fatalf("%+v %v", s, err)
}
if stopped.called("busctl") {
t.Fatalf("asked the bus with no applet running: %q", stopped.calls)
}
}
func TestSettingsListsAreReadAsGSettingsPrintsThem(t *testing.T) {
for in, want := range map[string]string{"@as []\n": "", "['a']": "a", "['!a', 'b']\n": "!a,b"} {
if got := strings.Join(gvariantStrings(in), ","); got != want {
t.Errorf("%q: %q, not %q", in, got, want)
}
}
}
func TestRestartEndsAppletAndTrayAndStartsTheApplet(t *testing.T) {
f := newApplet(t, true)
if _, err := f.Restart(); err == nil || !strings.Contains(err.Error(), "no graphical session") {
t.Fatalf("without a desktop: %v", err)
}
f.desktopSession()
f.onStart = func(argv []string) { f.proc(9100, 1000, appletComm, argv, "app.slice/"+restartAs+".service") }
a, err := f.Restart()
if err != nil {
t.Fatal(err)
}
if len(a.Ended) != 2 || len(a.Running) != 1 || a.Running[0].PID != 9100 || a.Running[0].Command != appletBin {
t.Fatalf("%+v", a)
}
if !f.called("systemd-run --user --collect --quiet --unit=" + restartAs + " --setenv=DISPLAY=:1") {
t.Fatalf("%q", f.calls)
}
}
func TestCheckPassesThePackagesOneStartAndNamesEveryOther(t *testing.T) {
f := newApplet(t, true)
f.desktopSession()
c, err := f.Check()
if err != nil || !c.OK || len(c.Starts) != 1 || c.Starts[0] != "XDG autostart: /etc/xdg/autostart/blueman.desktop" {
t.Fatalf("%+v %v", c, err)
}
f.write(testHome+"/.config/i3/config", "exec --no-startup-id blueman-applet\n")
f.write(testHome+"/.config/autostart/blueman.desktop", "[Desktop Entry]\nExec=blueman-applet\nHidden=true\n")
f.proc(3860, 1000, appletComm, []string{"blueman-applet"}, "session-c1.scope")
f.answer = func(name string, args []string) Output {
switch name {
case "pacman":
return Output{Code: 1}
case "systemctl":
return Output{Stdout: "inactive\n", Code: 3}
case "dex":
return Output{Code: 127, Err: ErrNotInstalled}
}
return Output{}
}
c, _ = f.Check()
var all []string
for _, x := range c.Findings {
all = append(all, x.What)
}
got := strings.Join(all, "\n")
for _, want := range []string{"not installed", "does not start with the session (Hidden=true)", "dex", "a second start: ~/.config/i3/config:1",
"bluetooth.service", "2 applets run"} {
if !strings.Contains(got, want) {
t.Errorf("no finding %q in\n%s", want, got)
}
}
}
@@ -0,0 +1,37 @@
package main
// The desktop applications whose bundles carry desktop.go. 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.
import (
"bytes"
"os"
"path/filepath"
"testing"
)
var carriers = []string{"blueman", "forticlient", "nextcloud-client", "nm-applet", "openrazer", "polychromatic", "slack"}
func TestEveryDesktopApplicationCarriesTheSameCopy(t *testing.T) {
compared := 0
for _, module := range carriers {
dir := filepath.Join("..", "..", "..", module, "cmd", module+"-tools")
if _, err := os.Stat(dir); err != nil {
continue
}
for _, f := range []string{"desktop.go", "desktop_test.go", "copies_test.go"} {
mine, err := os.ReadFile(f)
if err != nil {
t.Fatal(err)
}
theirs, err := os.ReadFile(filepath.Join(dir, f))
if err != nil || !bytes.Equal(mine, theirs) {
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")
}
}
@@ -0,0 +1,601 @@
package main
// desktop.go is the same file in every desktop application's bundle (copies_test.go names them and
// holds them to one text): a tray application of the operator's graphical session, seen from the
// node's tool runtime (novox/hq ADR 0208).
//
// The runtime is a system service running as the operator account (ADR 0175): it has the account's
// uid and none of the session's environment. A tool that starts something on the desktop finds the
// session from a process of the account that carries DISPLAY (the window manager first), and starts
// the program under the account's own service manager with `systemd-run --user`, never as its own
// child: the runtime's unit is a cgroup that is emptied whenever the runtime restarts.
//
// Everything a tool touches goes through a Machine: its filesystem root, its commands (a Runner) and
// its signals are injected, so the tests run against a fake /proc and a fake home.
//
// Bounds: one command gets at most CallTimeout (below the runtime's 30 s call limit) and is ended
// with everything it started when it takes longer; each stream is kept to MostOutput; a file is read
// to at most MostRead.
import (
"bufio"
"bytes"
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
"syscall"
"time"
)
// Bounds every command and read is held to.
const (
CallTimeout = 10 * time.Second
MostOutput = 256 << 10
MostRead = 16 << 20
)
// Output is what a command did.
type Output struct {
Stdout string
Stderr string
Code int
// Err is why it did not run to an answer: not installed, ended on its timeout, or the spawn error.
Err error
Cut bool
}
// ErrNotInstalled and ErrTimedOut are what a Runner answers in Output.Err.
var (
ErrNotInstalled = errors.New("not installed")
ErrTimedOut = errors.New("timed out")
// ErrNoSession is answered by a tool that needs the desktop when nobody is logged in to it.
ErrNoSession = errors.New("no graphical session")
)
// Runner runs one command with extra environment, within the context's deadline. Tests replace it.
type Runner func(ctx context.Context, env []string, name string, args ...string) Output
// Machine is what the tools read and act on.
type Machine struct {
Root string // "" on the machine; a fake root in tests
Home string // the operator's home, as the machine names it
UID int
Run Runner
Kill func(pid int, sig syscall.Signal) error
Sleep func(time.Duration)
Now func() time.Time
Timeout time.Duration
}
// NewMachine is the machine the bundle runs on.
func NewMachine() *Machine {
return &Machine{Home: operatorHome(), UID: os.Getuid(), Run: execRun, Kill: syscall.Kill,
Sleep: time.Sleep, Now: time.Now, Timeout: CallTimeout}
}
// operatorHome is the account's home: what the runtime was told, else the process's own.
func operatorHome() string {
if h := strings.TrimSpace(os.Getenv("MESH_OPERATOR_HOME")); h != "" {
return h
}
h, _ := os.UserHomeDir()
return h
}
func (m *Machine) path(p string) string { return filepath.Join(m.Root, p) }
// home is a path under the operator's home, on this machine's filesystem.
func (m *Machine) home(rel ...string) string {
return filepath.Join(append([]string{m.Root, m.Home}, rel...)...)
}
// tilde shows a path under the home as ~/…, so an answer does not carry the account's name.
func (m *Machine) tilde(p string) string {
if m.Home != "" && m.Home != "/" {
h := strings.TrimSuffix(m.Home, "/")
if p == h {
return "~"
}
if strings.HasPrefix(p, h+"/") {
return "~/" + strings.TrimPrefix(p, h+"/")
}
}
return p
}
// cmd runs a command within the machine's timeout (or a shorter one).
func (m *Machine) cmd(timeout time.Duration, env []string, name string, args ...string) Output {
if timeout <= 0 || timeout > m.Timeout {
timeout = m.Timeout
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
return m.Run(ctx, env, name, args...)
}
// failed names how a command failed, or answers nil when it ran and exited 0.
func failed(o Output, name string, args ...string) error {
switch {
case errors.Is(o.Err, ErrNotInstalled):
return fmt.Errorf("%s is not installed on this machine", name)
case errors.Is(o.Err, ErrTimedOut):
return fmt.Errorf("%s gave no answer in time and was ended", name)
case o.Err != nil:
return fmt.Errorf("%s did not run: %v", name, o.Err)
case o.Code != 0:
said := strings.TrimSpace(o.Stderr)
if said == "" {
said = strings.TrimSpace(o.Stdout)
}
if said == "" {
said = "and said nothing"
}
return fmt.Errorf("%s %s exited %d: %s", name, strings.Join(args, " "), o.Code, tail(said, 1000))
}
return nil
}
func tail(s string, n int) string {
if len(s) <= n {
return s
}
return "…" + s[len(s)-n:]
}
type capped struct {
b bytes.Buffer
cut bool
}
func (c *capped) Write(p []byte) (int, error) {
if room := MostOutput - c.b.Len(); room < len(p) {
if room > 0 {
c.b.Write(p[:room])
}
c.cut = true
return len(p), nil
}
return c.b.Write(p)
}
func execRun(ctx context.Context, env []string, name string, args ...string) Output {
path, err := exec.LookPath(name)
if err != nil {
return Output{Code: 127, Err: ErrNotInstalled}
}
cmd := exec.CommandContext(ctx, path, args...)
cmd.Env = append(append(os.Environ(), "LC_ALL=C"), env...)
// 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
var out, errs capped
cmd.Stdout, cmd.Stderr = &out, &errs
err = cmd.Run()
o := Output{Stdout: out.b.String(), Stderr: errs.b.String(), Cut: out.cut || errs.cut}
var exit *exec.ExitError
switch {
case err == nil:
case ctx.Err() == context.DeadlineExceeded:
o.Code, o.Err = 124, ErrTimedOut
case errors.As(err, &exit):
o.Code = exit.ExitCode()
default:
o.Code, o.Err = 127, err
}
return o
}
// readBounded reads a file to at most MostRead bytes.
func readBounded(path string) ([]byte, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
return io.ReadAll(io.LimitReader(f, MostRead))
}
// Proc is one process of the account.
type Proc struct {
PID int `json:"pid"`
Command string `json:"command"`
// StartedIn is the unit or scope it runs in: the login session's scope when the session's start
// (dex, the window manager) started it, a mesh-… unit when a tool restarted it.
StartedIn string `json:"started_in,omitempty"`
Since string `json:"since,omitempty"`
}
// procs are this account's processes named comm, oldest first.
func (m *Machine) procs(comm string) []Proc {
entries, err := os.ReadDir(m.path("/proc"))
if err != nil {
return nil
}
boot := m.bootTime()
var out []Proc
for _, e := range entries {
pid, err := strconv.Atoi(e.Name())
if err != nil {
continue
}
dir := m.path(filepath.Join("/proc", e.Name()))
if readTrimmed(filepath.Join(dir, "comm")) != comm || m.uidOf(dir) != m.UID {
continue
}
p := Proc{PID: pid, Command: strings.TrimSpace(strings.ReplaceAll(readTrimmed(filepath.Join(dir, "cmdline")), "\x00", " "))}
if p.Command == "" {
p.Command = comm
}
if cg := readTrimmed(filepath.Join(dir, "cgroup")); cg != "" {
line := strings.Split(cg, "\n")[0]
p.StartedIn = filepath.Base(line[strings.LastIndexByte(line, ':')+1:])
}
if t, ok := startOf(readTrimmed(filepath.Join(dir, "stat")), boot); ok {
p.Since = t.UTC().Format(time.RFC3339)
}
out = append(out, p)
}
sort.Slice(out, func(i, j int) bool { return out[i].PID < out[j].PID })
return out
}
// procsOf are the account's processes named comm whose program is word. The kernel keeps 15
// characters of a command name, so a longer name can share them with another program's: this bundle's
// own binary among them (polychromatic-tools and polychromatic-tray-applet are both polychromatic-t).
// The program is the first word of the command line, or the second for a script run by its
// interpreter. An empty word keeps every process named comm.
func (m *Machine) procsOf(comm, word string) []Proc {
var out []Proc
for _, p := range m.procs(comm) {
f := strings.Fields(p.Command)
if word == "" || (len(f) > 0 && filepath.Base(f[0]) == word) || (len(f) > 1 && filepath.Base(f[1]) == word) {
out = append(out, p)
}
}
return out
}
// uidOf is the real uid on a process's status, -1 when unreadable.
func (m *Machine) uidOf(dir string) int {
for _, l := range strings.Split(readTrimmed(filepath.Join(dir, "status")), "\n") {
if f := strings.Fields(l); len(f) > 1 && f[0] == "Uid:" {
if n, err := strconv.Atoi(f[1]); err == nil {
return n
}
}
}
return -1
}
func (m *Machine) bootTime() int64 {
for _, l := range strings.Split(readTrimmed(m.path("/proc/stat")), "\n") {
if f := strings.Fields(l); len(f) == 2 && f[0] == "btime" {
n, _ := strconv.ParseInt(f[1], 10, 64)
return n
}
}
return 0
}
// startOf reads a process's start from its stat line (field 22, in clock ticks of 1/100 s since boot).
func startOf(stat string, boot int64) (time.Time, bool) {
i := strings.LastIndexByte(stat, ')')
if i < 0 || boot == 0 {
return time.Time{}, false
}
f := strings.Fields(stat[i+1:])
if len(f) < 20 {
return time.Time{}, false
}
ticks, err := strconv.ParseInt(f[19], 10, 64)
if err != nil {
return time.Time{}, false
}
return time.Unix(boot+ticks/100, 0), true
}
func readTrimmed(path string) string {
b, err := os.ReadFile(path)
if err != nil {
return ""
}
return strings.TrimSpace(string(b))
}
func exists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
// Session is what a tool needs to start something on the operator's desktop.
type Session struct {
Display string `json:"display"`
XAuthority string `json:"xauthority,omitempty"`
Bus string `json:"bus,omitempty"`
RuntimeDir string `json:"runtime_dir,omitempty"`
From string `json:"found_in"`
}
// sessionHolders are the processes whose environment is the session's, best first.
var sessionHolders = []string{"i3", "sway", "i3bar", "picom", "dunst", "xterm"}
// session finds the account's graphical session, or ErrNoSession saying what it looked at.
func (m *Machine) session() (Session, error) {
entries, _ := os.ReadDir(m.path("/proc"))
best, bestRank := -1, len(sessionHolders)+1
var env map[string]string
var from string
for _, e := range entries {
pid, err := strconv.Atoi(e.Name())
if err != nil {
continue
}
dir := m.path(filepath.Join("/proc", e.Name()))
if m.uidOf(dir) != m.UID {
continue
}
raw, err := os.ReadFile(filepath.Join(dir, "environ"))
if err != nil {
continue
}
vars := parseEnviron(raw)
if vars["DISPLAY"] == "" {
continue
}
comm := readTrimmed(filepath.Join(dir, "comm"))
rank := len(sessionHolders)
for i, h := range sessionHolders {
if h == comm {
rank = i
}
}
if rank < bestRank || (rank == bestRank && pid > best) {
best, bestRank, env, from = pid, rank, vars, fmt.Sprintf("process %s (pid %d)", comm, pid)
}
}
if env == nil {
return Session{}, fmt.Errorf("%w for uid %d on this machine: no process of the account carries DISPLAY. "+
"Is anyone logged in to the desktop?", ErrNoSession, m.UID)
}
s := Session{Display: env["DISPLAY"], XAuthority: env["XAUTHORITY"], Bus: env["DBUS_SESSION_BUS_ADDRESS"],
RuntimeDir: env["XDG_RUNTIME_DIR"], From: from}
if s.RuntimeDir == "" {
s.RuntimeDir = fmt.Sprintf("/run/user/%d", m.UID)
}
if s.Bus == "" && exists(m.path(filepath.Join(s.RuntimeDir, "bus"))) {
s.Bus = "unix:path=" + filepath.Join(s.RuntimeDir, "bus")
}
return s, nil
}
// bus is the account's session bus environment, which a logged-in account has with or without a
// desktop: what a command needs to reach the user's service manager or a bus name.
func (m *Machine) bus() []string {
runtime := fmt.Sprintf("/run/user/%d", m.UID)
return []string{"XDG_RUNTIME_DIR=" + runtime, "DBUS_SESSION_BUS_ADDRESS=unix:path=" + runtime + "/bus"}
}
// Env is the session's variables, for a command that draws or speaks to the desktop.
func (s Session) Env() []string {
var env []string
for _, kv := range [][2]string{{"DISPLAY", s.Display}, {"XAUTHORITY", s.XAuthority},
{"DBUS_SESSION_BUS_ADDRESS", s.Bus}, {"XDG_RUNTIME_DIR", s.RuntimeDir}} {
if kv[1] != "" {
env = append(env, kv[0]+"="+kv[1])
}
}
return env
}
func parseEnviron(raw []byte) map[string]string {
env := map[string]string{}
for _, kv := range bytes.Split(raw, []byte{0}) {
if i := bytes.IndexByte(kv, '='); i > 0 {
env[string(kv[:i])] = string(kv[i+1:])
}
}
return env
}
// detach starts a long-lived program under the account's service manager, as a transient unit that
// carries the session's display. A unit left by an earlier start under the same name is stopped
// first, so the fixed name means at most one.
func (m *Machine) detach(s Session, unit string, argv ...string) error {
_ = m.cmd(5*time.Second, s.Env(), "systemctl", "--user", "stop", unit+".service")
call := []string{"--user", "--collect", "--quiet", "--unit=" + unit}
for _, kv := range [][2]string{{"DISPLAY", s.Display}, {"XAUTHORITY", s.XAuthority}} {
if kv[1] != "" {
call = append(call, "--setenv="+kv[0]+"="+kv[1])
}
}
call = append(append(call, "--"), argv...)
return failed(m.cmd(8*time.Second, s.Env(), "systemd-run", call...), "systemd-run", call...)
}
// stop ends every process of the account named in comms: SIGTERM, then SIGKILL for what is still
// there after grace. It answers the pids that ended and those that had to be killed.
func (m *Machine) stop(grace time.Duration, comms ...string) (ended, killed []int) {
var ps []Proc
for _, c := range comms {
ps = append(ps, m.procs(c)...)
}
return m.stopProcs(grace, ps)
}
// stopProcs ends the processes given, as stop does.
func (m *Machine) stopProcs(grace time.Duration, ps []Proc) (ended, killed []int) {
var pids []int
for _, p := range ps {
if m.Kill(p.PID, syscall.SIGTERM) == nil {
pids = append(pids, p.PID)
}
}
alive := func() []int {
var left []int
for _, pid := range pids {
if exists(m.path(filepath.Join("/proc", strconv.Itoa(pid)))) {
left = append(left, pid)
}
}
return left
}
step := 200 * time.Millisecond
for waited := time.Duration(0); waited < grace && len(alive()) > 0; waited += step {
m.Sleep(step)
}
left := alive()
for _, pid := range left {
if m.Kill(pid, syscall.SIGKILL) == nil {
killed = append(killed, pid)
}
}
gone := map[int]bool{}
for _, pid := range left {
gone[pid] = true
}
for _, pid := range pids {
if !gone[pid] {
ended = append(ended, pid)
}
}
return ended, killed
}
// waitFor waits up to d for a process of the account named comm, and answers what it found.
func (m *Machine) waitFor(comm string, d time.Duration) []Proc { return m.waitForOf(comm, "", d) }
// waitForOf waits up to d for a process of the account named comm whose program is word (procsOf).
func (m *Machine) waitForOf(comm, word string, d time.Duration) []Proc {
step := 250 * time.Millisecond
for waited := time.Duration(0); ; waited += step {
if p := m.procsOf(comm, word); len(p) > 0 || waited >= d {
return p
}
m.Sleep(step)
}
}
// desktopEntry reads the [Desktop Entry] group of an XDG desktop file; nil when there is none.
func desktopEntry(path string) map[string]string {
raw, err := readBounded(path)
if err != nil {
return nil
}
out := map[string]string{}
in := false
s := bufio.NewScanner(bytes.NewReader(raw))
for s.Scan() {
l := strings.TrimSpace(s.Text())
switch {
case strings.HasPrefix(l, "["):
in = l == "[Desktop Entry]"
case in && l != "" && !strings.HasPrefix(l, "#"):
if i := strings.IndexByte(l, '='); i > 0 {
out[strings.TrimSpace(l[:i])] = strings.TrimSpace(l[i+1:])
}
}
}
return out
}
// Autostart is what XDG autostart does with one entry: the account's file overrides the system's
// of the same name, and Hidden=true (or the GNOME switch off) means it is not started.
type Autostart struct {
Entry string `json:"entry"`
From string `json:"from"`
Exec string `json:"exec,omitempty"`
Starts bool `json:"starts"`
Because string `json:"because,omitempty"`
}
// autostart resolves one XDG autostart entry by its file name, the account's directory first.
func (m *Machine) autostart(name string) Autostart {
a := Autostart{Entry: name}
user := m.home(".config", "autostart", name)
system := m.path(filepath.Join("/etc/xdg/autostart", name))
var e map[string]string
switch {
case exists(user):
e, a.From = desktopEntry(user), m.tilde(filepath.Join(m.Home, ".config/autostart", name))
case exists(system):
e, a.From = desktopEntry(system), filepath.Join("/etc/xdg/autostart", name)
default:
a.Because = "no such entry in ~/.config/autostart or /etc/xdg/autostart"
return a
}
a.Exec = e["Exec"]
switch {
case strings.EqualFold(e["Hidden"], "true"):
a.Because = "Hidden=true"
case strings.EqualFold(e["X-GNOME-Autostart-enabled"], "false"):
a.Because = "X-GNOME-Autostart-enabled=false"
case a.Exec == "":
a.Because = "the entry has no Exec"
default:
a.Starts = true
}
return a
}
// i3Starts are the window manager's start-up lines (exec, exec_always) that run a program named
// word, in the configuration and its config.d: a second start beside an autostart entry.
func (m *Machine) i3Starts(word string) []string {
files := []string{m.home(".config", "i3", "config")}
more, _ := filepath.Glob(m.home(".config", "i3", "config.d", "*.conf"))
files = append(files, more...)
var out []string
for _, f := range files {
raw, err := readBounded(f)
if err != nil {
continue
}
for n, l := range strings.Split(string(raw), "\n") {
t := strings.TrimSpace(l)
if !strings.HasPrefix(t, "exec ") && !strings.HasPrefix(t, "exec_always ") {
continue
}
for _, w := range strings.Fields(t)[1:] {
if filepath.Base(strings.Trim(w, `"'`)) == word {
out = append(out, fmt.Sprintf("%s:%d: %s", m.tilde(strings.TrimPrefix(f, m.Root)), n+1, t))
break
}
}
}
}
return out
}
// installed asks the package manager for one package's version; "" when it is not installed.
func (m *Machine) installed(pkg string) (string, error) {
o := m.cmd(0, nil, "pacman", "-Q", pkg)
if o.Err != nil {
return "", failed(o, "pacman", "-Q", pkg)
}
if o.Code != 0 {
return "", nil
}
f := strings.Fields(o.Stdout)
if len(f) < 2 {
return "", fmt.Errorf("pacman -Q %s answered %q", pkg, o.Stdout)
}
return f[1], nil
}
// Finding is one thing a check found wrong, and what to do about it.
type Finding struct {
What string `json:"what"`
Do string `json:"do,omitempty"`
}
@@ -0,0 +1,219 @@
package main
// The fake machine the tests run against, and the tests of desktop.go. The same in every desktop
// application's bundle (copies_test.go).
import (
"context"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"syscall"
"testing"
"time"
)
const testHome = "/home/operator"
// fake is a machine with a fake root, a scripted Runner and signals that end fake processes.
type fake struct {
*Machine
t *testing.T
mu sync.Mutex
calls []string
answer func(name string, args []string) Output
// onStart is run when systemd-run starts something, to let a fake process appear.
onStart func(argv []string)
// stubborn pids ignore SIGTERM.
stubborn map[int]bool
signals []string
}
func newFake(t *testing.T) *fake {
t.Helper()
root := t.TempDir()
f := &fake{t: t, stubborn: map[int]bool{}}
f.Machine = &Machine{Root: root, Home: testHome, UID: 1000, Timeout: CallTimeout,
Sleep: func(time.Duration) {}, Now: func() time.Time { return time.Unix(1_800_000_000, 0) }}
f.Run = func(_ context.Context, env []string, name string, args ...string) Output {
f.mu.Lock()
f.calls = append(f.calls, strings.TrimSpace(name+" "+strings.Join(args, " ")))
f.mu.Unlock()
if name == "systemd-run" && f.onStart != nil {
for i, a := range args {
if a == "--" {
f.onStart(args[i+1:])
}
}
}
if f.answer != nil {
return f.answer(name, args)
}
return Output{}
}
f.Kill = func(pid int, sig syscall.Signal) error {
f.signals = append(f.signals, strconv.Itoa(pid)+":"+sig.String())
if sig == syscall.SIGKILL || !f.stubborn[pid] {
return os.RemoveAll(filepath.Join(root, "proc", strconv.Itoa(pid)))
}
return nil
}
f.write("/proc/stat", "cpu 1 2 3\nbtime 1799990000\n")
return f
}
func (f *fake) write(path, content string) {
f.t.Helper()
p := filepath.Join(f.Root, path)
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
f.t.Fatal(err)
}
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
f.t.Fatal(err)
}
}
// proc adds a process of uid with a command name, argv, cgroup and environment.
func (f *fake) proc(pid, uid int, comm string, argv []string, cgroup string, env ...string) {
d := "/proc/" + strconv.Itoa(pid) + "/"
f.write(d+"comm", comm+"\n")
f.write(d+"status", "Name:\t"+comm+"\nUid:\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\n")
f.write(d+"cmdline", strings.Join(argv, "\x00")+"\x00")
f.write(d+"cgroup", "0::/user.slice/user-"+strconv.Itoa(uid)+".slice/"+cgroup+"\n")
f.write(d+"environ", strings.Join(env, "\x00")+"\x00")
// starttime (field 22) is 1000 ticks: 10 s after boot.
f.write(d+"stat", strconv.Itoa(pid)+" ("+comm+") S 1 1 1 0 -1 0 0 0 0 0 0 0 0 0 20 0 1 0 1000 0 0\n")
}
func (f *fake) desktopSession() {
f.proc(3700, 1000, "i3", []string{"i3"}, "session-c1.scope", "DISPLAY=:1", "XAUTHORITY="+testHome+"/.Xauthority")
f.write("/run/user/1000/bus", "")
}
func (f *fake) called(prefix string) bool {
for _, c := range f.calls {
if strings.HasPrefix(c, prefix) {
return true
}
}
return false
}
func TestProcessesAreTheAccountsOwnWithWhereAndWhenTheyStarted(t *testing.T) {
f := newFake(t)
f.proc(10, 1000, "worker", []string{"/usr/bin/worker", "--background"}, "session-c1.scope")
f.proc(11, 1001, "worker", []string{"/usr/bin/worker"}, "session-c2.scope")
f.proc(12, 1000, "other", []string{"other"}, "x.scope")
got := f.procs("worker")
if len(got) != 1 || got[0].PID != 10 || got[0].Command != "/usr/bin/worker --background" ||
got[0].StartedIn != "session-c1.scope" || got[0].Since != time.Unix(1799990010, 0).UTC().Format(time.RFC3339) {
t.Fatalf("%+v", got)
}
}
func TestAProgramIsToldFromAnotherSharingItsCutName(t *testing.T) {
f := newFake(t)
f.proc(10, 1000, "polychromatic-t", []string{"/usr/bin/python", "/usr/bin/polychromatic-tray-applet"}, "s.scope")
f.proc(11, 1000, "polychromatic-t", []string{"polychromatic-tray-applet"}, "s.scope")
f.proc(12, 1000, "polychromatic-t", []string{"/usr/lib/mesh/polychromatic-tools"}, "s.scope")
if got := f.procsOf("polychromatic-t", "polychromatic-tray-applet"); len(got) != 2 || got[0].PID != 10 || got[1].PID != 11 {
t.Fatalf("%+v", got)
}
if got := f.procsOf("polychromatic-t", ""); len(got) != 3 {
t.Fatalf("%+v", got)
}
ended, _ := f.stopProcs(time.Second, f.procsOf("polychromatic-t", "polychromatic-tray-applet"))
if len(ended) != 2 || len(f.procs("polychromatic-t")) != 1 {
t.Fatalf("ended %v; the tools' own process must stay", ended)
}
}
func TestTheSessionIsTheWindowManagersAndNoneIsSaidPlainly(t *testing.T) {
f := newFake(t)
if _, err := f.session(); err == nil || !strings.Contains(err.Error(), "no graphical session") {
t.Fatalf("%v", err)
}
f.proc(50, 1000, "xterm", []string{"xterm"}, "s.scope", "DISPLAY=:9")
f.desktopSession()
f.proc(60, 1001, "i3", []string{"i3"}, "s.scope", "DISPLAY=:5")
s, err := f.session()
if err != nil || s.Display != ":1" || s.XAuthority != testHome+"/.Xauthority" || s.Bus != "unix:path=/run/user/1000/bus" ||
!strings.Contains(s.From, "i3") {
t.Fatalf("%+v %v", s, err)
}
}
func TestStopAsksThenForcesAndDetachStartsUnderTheServiceManager(t *testing.T) {
f := newFake(t)
f.desktopSession()
f.proc(20, 1000, "app", []string{"app"}, "s.scope")
f.proc(21, 1000, "app", []string{"app"}, "s.scope")
f.stubborn[21] = true
ended, killed := f.stop(time.Second, "app")
if len(ended) != 1 || ended[0] != 20 || len(killed) != 1 || killed[0] != 21 {
t.Fatalf("ended %v killed %v (%v)", ended, killed, f.signals)
}
s, _ := f.session()
if err := f.detach(s, "mesh-app", "/usr/bin/app", "--background"); err != nil {
t.Fatal(err)
}
want := "systemd-run --user --collect --quiet --unit=mesh-app --setenv=DISPLAY=:1 --setenv=XAUTHORITY=" + testHome +
"/.Xauthority -- /usr/bin/app --background"
if !f.called("systemctl --user stop mesh-app.service") || !f.called(want) {
t.Fatalf("%q", f.calls)
}
}
func TestAnAutostartEntryOfTheAccountOverridesTheSystemsAndHiddenStartsNothing(t *testing.T) {
f := newFake(t)
if a := f.autostart("x.desktop"); a.Starts || a.Because == "" {
t.Fatalf("%+v", a)
}
f.write("/etc/xdg/autostart/x.desktop", "[Desktop Entry]\nExec=x-applet\n[Desktop Action y]\nExec=other\n")
if a := f.autostart("x.desktop"); !a.Starts || a.Exec != "x-applet" || a.From != "/etc/xdg/autostart/x.desktop" {
t.Fatalf("%+v", a)
}
f.write(testHome+"/.config/autostart/x.desktop", "[Desktop Entry]\nExec=x-applet\nHidden=true\n")
if a := f.autostart("x.desktop"); a.Starts || a.Because != "Hidden=true" || a.From != "~/.config/autostart/x.desktop" {
t.Fatalf("%+v", a)
}
}
func TestAWindowManagerStartIsFoundInTheConfigurationAndItsDropIns(t *testing.T) {
f := newFake(t)
f.write(testHome+"/.config/i3/config", "exec --no-startup-id dex --autostart --environment i3\n# exec app\nbindsym $mod+a exec app\n")
f.write(testHome+"/.config/i3/config.d/50-x.conf", "exec_always --no-startup-id /usr/bin/app --flag\n")
got := f.i3Starts("app")
if len(got) != 1 || got[0] != "~/.config/i3/config.d/50-x.conf:1: exec_always --no-startup-id /usr/bin/app --flag" {
t.Fatalf("%q", got)
}
}
func TestACommandThatFailsIsNamed(t *testing.T) {
if err := failed(Output{Code: 127, Err: ErrNotInstalled}, "dex"); err == nil || !strings.Contains(err.Error(), "dex is not installed") {
t.Fatal(err)
}
if err := failed(Output{Code: 1, Stderr: "nope"}, "pacman", "-Q", "x"); err == nil || !strings.Contains(err.Error(), "pacman -Q x exited 1: nope") {
t.Fatal(err)
}
if err := failed(Output{}, "true"); err != nil {
t.Fatal(err)
}
}
func TestTheRealRunnerBoundsTimeAndOutput(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
if o := execRun(ctx, nil, "sleep", "5"); o.Err != ErrTimedOut {
t.Fatalf("%+v", o)
}
if o := execRun(context.Background(), nil, "no-such-program-here"); o.Err != ErrNotInstalled {
t.Fatalf("%+v", o)
}
o := execRun(context.Background(), nil, "head", "-c", strconv.Itoa(MostOutput+10), "/dev/zero")
if !o.Cut || len(o.Stdout) != MostOutput {
t.Fatalf("cut %v, %d bytes", o.Cut, len(o.Stdout))
}
}
+48
View File
@@ -0,0 +1,48 @@
// The blueman module's Go tools bundle (novox/hq ADR 0188, ADR 0193, ADR 0208): the Bluetooth tray
// applet in the operator's session, served by the node's runtime as the operator account. The module
// holds no seat, so every tool is its own. The devices themselves are the bluetooth module's tools.
package main
import (
"fmt"
"os"
stdio "git.novox.be/novox/mesh-sdk/go"
)
func main() {
if err := stdio.Serve("", tools()); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
var machine = NewMachine()
func tools() []stdio.Tool {
return []stdio.Tool{
{
Name: "blueman_status",
Description: "The Bluetooth applet: whether it and its tray icon run (pid, since, and the unit or " +
"session scope they run in), the installed version, what starts it at login, the plugins the " +
"running applet has loaded and those it has not, the plugin switches in the operator's settings, " +
"and whether the applet sees Bluetooth on. Never starts the applet. (r)",
Run: func(map[string]any) (any, error) { return machine.Status() },
},
{
Name: "blueman_restart",
Description: "End the applet and its tray icon (asked first, then forced after 5 s) and start the " +
"applet again in the operator's desktop session, under the account's service manager. Answers " +
"the pids ended and the new one. Needs someone logged in to the desktop. (a)",
Run: func(map[string]any) (any, error) { return machine.Restart() },
},
{
Name: "blueman_check",
Description: "Check what the module promises and relies on: the package is installed; the applet has " +
"exactly one start (the package's XDG autostart entry, which the session's dex runs; no " +
"window-manager exec); it runs once in a desktop session; and the Bluetooth daemon is running " +
"(the bluetooth module's). Answers ok and each finding with what to do. (r)",
Run: func(map[string]any) (any, error) { return machine.Check() },
},
}
}
@@ -0,0 +1,109 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
)
// blueman's shape (novox/hq ADR 0208, ADR 0210): one official package, no seat, the X display on its
// own machine, no start of its own (the package's autostart entry is the one start), nothing of the
// bluetooth module's (bluez, its utilities, its daemon), and the Go bundle serving exactly the listed
// blueman_ tools.
type manifest struct {
Module string `json:"module"`
Version string `json:"version"`
Capabilities []string `json:"capabilities"`
Requires []string `json:"requires"`
Tools []string `json:"tools"`
Resources []map[string]any `json:"resources"`
Claims []any `json:"claims"`
Seats []any `json:"seats"`
Shell []any `json:"shell"`
Contributions []any `json:"contributions"`
Environment any `json:"environment"`
Build struct {
Artifacts []map[string]any `json:"artifacts"`
} `json:"build"`
}
func readManifest(t *testing.T) (manifest, string) {
t.Helper()
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
if err != nil {
t.Fatal(err)
}
dec := json.NewDecoder(strings.NewReader(string(raw)))
dec.DisallowUnknownFields()
var m manifest
if err := dec.Decode(&m); err != nil {
t.Fatalf("module.json: %v", err)
}
return m, string(raw)
}
func TestItInstallsTheAppletAndNothingElse(t *testing.T) {
m, _ := readManifest(t)
if m.Module != "blueman" || !reflect.DeepEqual(m.Requires, []string{"x11-display"}) ||
!reflect.DeepEqual(m.Capabilities, []string{"package-manager"}) {
t.Fatalf("%+v", m)
}
if len(m.Resources) != 1 || m.Resources[0]["type"] != "package" || m.Resources[0]["package"] != packageFor {
t.Fatalf("resources: %v", m.Resources)
}
if m.Claims != nil || m.Seats != nil || m.Environment != nil {
t.Fatal("it holds no seat and sets no environment")
}
}
func TestItAddsNoSecondStartAndDeclaresNothingOfTheBluetoothModule(t *testing.T) {
m, raw := readManifest(t)
// The package ships its XDG autostart entry, which the session's dex runs: an xinitrc slot or a
// window-manager exec would start it twice.
if m.Shell != nil || m.Contributions != nil {
t.Fatalf("a second start: shell %v, contributions %v", m.Shell, m.Contributions)
}
for _, never := range []string{"autostart", "service", "bluez", "/etc/bluetooth"} {
if strings.Contains(raw, never) {
t.Errorf("module.json names %q: the start is the package's, the stack the bluetooth module's", never)
}
}
}
func TestTheToolsAgreeWithTheManifest(t *testing.T) {
m, raw := readManifest(t)
served := map[string]bool{}
for _, tool := range tools() {
served[tool.Name] = true
if !strings.HasPrefix(tool.Name, "blueman_") || strings.TrimSpace(tool.Description) == "" {
t.Errorf("%s: prefixed blueman_ and described", tool.Name)
}
}
for _, name := range m.Tools {
if !served[name] {
t.Errorf("module.json lists %s, which the bundle does not serve", name)
}
delete(served, name)
}
for name := range served {
t.Errorf("the bundle serves %s, which module.json does not list", name)
}
if len(m.Build.Artifacts) != 1 {
t.Fatalf("%v", m.Build.Artifacts)
}
b := m.Build.Artifacts[0]
if b["kind"] != "bundle" || b["language"] != "go" || b["system"] != "arch" ||
b["from"] != "cmd/blueman-tools" || b["binary"] != "blueman-tools" {
t.Errorf("the Go tools bundle: %v", b)
}
s := strings.ToLower(raw)
for _, never := range []string{"/home/", "jochen", "g14", "shanks", "novox.be", "http", "password", "token"} {
if strings.Contains(s, never) {
t.Errorf("module.json names %q", never)
}
}
}
+5
View File
@@ -0,0 +1,5 @@
module blueman
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=
+37
View File
@@ -0,0 +1,37 @@
{
"module": "blueman",
"version": "1",
"capabilities": [
"package-manager"
],
"requires": [
"x11-display"
],
"tools": [
"blueman_status",
"blueman_restart",
"blueman_check"
],
"resources": [
{
"id": "package",
"type": "package",
"package": "blueman"
}
],
"build": {
"artifacts": [
{
"name": "tools",
"kind": "bundle",
"language": "go",
"system": "arch",
"from": "cmd/blueman-tools",
"binary": "blueman-tools",
"loads": [
"blueman-tools"
]
}
]
}
}
+2 -2
View File
@@ -48,6 +48,6 @@ running. `bluez` becomes explicitly the mesh's.
## Leaves as found ## Leaves as found
- The paired devices and their keys under `/var/lib/bluetooth` (bluez's state). - 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. - `blueman` and its applet: the `blueman` module's, which relies on this one for the stack and
That line is the `i3` module's to keep or drop. declares none of its packages. The applet starts from the package's XDG autostart entry.
- `bluez-obex` and the AUR terminal client `bluetuith-bin` (with its `-debug`) on the laptop. - `bluez-obex` and the AUR terminal client `bluetuith-bin` (with its `-debug`) on the laptop.
+12 -1
View File
@@ -8,7 +8,8 @@
"claims": [ "claims": [
{ {
"name": "node-build-agent", "name": "node-build-agent",
"scope": "node" "scope": "node",
"serves": ["current", "kill", "pause", "resume"]
} }
], ],
"requires": [ "requires": [
@@ -24,6 +25,16 @@
"own-secrets": { "own-secrets": {
"broker": "${dir:mesh-state}/broker" "broker": "${dir:mesh-state}/broker"
}, },
"data": {
"own": [
{
"id": "workspace",
"path": "${dir:workspace}",
"class": "cache",
"why": "a build's working copy, cloned again for every build"
}
]
},
"resources": [ "resources": [
{ {
"id": "mesh-state", "id": "mesh-state",
+41 -4
View File
@@ -22,11 +22,17 @@ whenever the node's tool runtime collects the module's tools:
| file | holds | | 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-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 keys set in this module's `managed_settings` setting, under the mesh's own: 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 | | `managed-settings.json` | the keys set in this module's `managed_settings` setting, then the settings registered through this module (the mesh's, then this node's), under the mesh's own keys: the repositories' attribution convention, the claude.ai connectors kept beside the managed servers, the key-helper while the node holds an API-key licence, and the two that name the `nox-mesh` marketplace and enable its plugin |
| `CLAUDE.md` | how a session on this mesh works, this node's name and role, the conventions | | `CLAUDE.md` | how a session on this mesh works, this node's name and role, the conventions — then the instruction sections registered for every node and for this one |
| `marketplace/` | the `nox-mesh` plugin (hq ADR 0216): the skills, subagents, commands, hooks and output styles registered for every node and for this one, offered in a session as `nox-mesh:<name>`. Replaced whole, staged beside and swapped in |
Under the operator's home, only `~/.claude/.credentials.json`, and only when the licence manager hands Under the operator's home: `~/.claude/.credentials.json`, only when the licence manager hands this node a
this node a subscription token. Nothing else under the home is read or written. subscription token; and what is registered at the **home** scope for this node — a skill, subagent,
command, output style, or instructions as a rule file — each path recorded in the module's state
(`home-placed.json`). It writes, changes and removes only those — never a path the person made, even one with the
same content, and never through a directory that is a symbolic link. A placed file changed by hand is left
alone, and one the person deleted stays deleted until the item is unregistered (hq ADR 0182). The status tool reads the rest of the home's
items to report them; nothing else is read or written.
## Over NATS ## Over NATS
@@ -41,6 +47,7 @@ must see, a node that joins later included — kept, so it carries no secret eit
| 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 | | 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 | | 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 | | 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 |
| the agent's configuration (hq ADR 0216) | a key in the module's `config` state per registration — `mesh.<kind>.<name>` for every node, `node.<node>.<kind>.<name>` for one, `home.<node>.<kind>.<name>` for one account's own directory — the item and its files in one value, at most 256 KiB. Every node watches it and renders what applies to it, a node item over a mesh item of the same kind and name |
## Tools ## Tools
@@ -49,6 +56,36 @@ manager), `claude_code_mcp_list`,
`claude_code_mcp_register` (this node by default; `nodes: "all"` or a list for more — called for this `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`. node alone, its answer names the other nodes running claude-code), `claude_code_mcp_unregister`.
The agent's configuration (hq ADR 0216), each registered at a **scope** — `mesh` (the default), `node`
(`nodes`: a list, or `"all"` for every node running claude-code; absent is this node) or `home` (the
operator account's own `~/.claude` on those nodes):
- for each kind — `skill`, `agent`, `command`, `hook`, `output_style`, `instructions` —
`claude_code_<kind>_list`, `_register`, `_unregister`. A skill is its files (`files`, or `content` for a
lone SKILL.md); a hook is an `event`, a `matcher`, a `command` and its scripts as `files`, with
`${HOOK_DIR}` in the command naming their directory. Hooks and settings take no home scope;
- `claude_code_settings_get`, `_set` (merged into the scope, or `replace`), `_clear`;
`claude_code_permission_add` and `_remove` for one allow, ask or deny rule. The agent refuses to loosen
its own settings: these are the operator's to call;
- `claude_code_config_list`, `_show` (one registration in full), `_status` (what applies here, the plugin
as written, and the home's own items — which the mesh placed, which share a name with a mesh item, which
call a tool server not loaded here) and `_import` (an item of this node's home, registered at a scope;
the original stays);
- `claude_code_home_show` (`kind`, `name`): one item of this node's home in full — a skill, subagent,
command, output style, rule file (`instructions`), or the account's own memory `~/.claude/CLAUDE.md`
(`memory`) — and whether the mesh placed it;
- `claude_code_home_remove` (`kind`, `name`, `why`): removes one item the person made, on their word —
removing it is the person's act, and this tool is that act made explicit. `why` is required; what the
mesh placed is refused (its `_unregister` owns it), and so is a symbolic link. A copy is kept first in
the module's state, `removed-from-home/<date>/<time>-<kind>-<name>/`, with a `removal.json` note, and
the removal and its reason are appended to `removed-from-home/removed.log`; the answer names the copy;
- `claude_code_home_removed`: every kept removal, newest first, with its `keptAt` and whether it was put back;
- `claude_code_home_restore` (`kept`): puts a removal's copy back at its path — refused when something is
there now, or when the copy is not, file by file, what its `removal.json` digests say was removed.
Logged to `removed.log` and the journal; the copy stays, marked with a `restored.json`.
A new session takes a change; a running one at `/reload-plugins`.
## Settings ## Settings
Per node or for the whole mesh, through `mesh-controller.settings module=claude-code`: Per node or for the whole mesh, through `mesh-controller.settings module=claude-code`:
@@ -69,19 +69,25 @@ func TestTheRendererWritesWhatTheTypeScriptOneWrote(t *testing.T) {
for _, file := range []string{"managed-mcp.json", "managed-settings.json"} { for _, file := range []string{"managed-mcp.json", "managed-settings.json"} {
var a, b any var a, b any
_ = json.Unmarshal([]byte(got[file]), &a) _ = json.Unmarshal([]byte(got[file]), &a)
// The plugin's two keys are new since the TypeScript (ADR 0216); everything else means the same.
if m, ok := a.(map[string]any); ok && file == "managed-settings.json" {
for k := range MarketplaceKeys() {
delete(m, k)
}
}
_ = json.Unmarshal([]byte(want[file]), &b) _ = json.Unmarshal([]byte(want[file]), &b)
if !reflect.DeepEqual(a, 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]) 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("with an API key", Render(f.Facts, f.Settings, &Binding{Licence: "api", Kind: "api-key"}, "/state/api-key-helper", f.Registered, Config{}), f.WithKey)
same("plain", Render(f.Facts, Settings{}, nil, "/h", Servers{}), f.Plain) same("plain", Render(f.Facts, Settings{}, nil, "/h", Servers{}, Config{}), f.Plain)
} }
func TestASettingCannotReplaceTheMeshsOwnEntryAndABadNameIsLeftOut(t *testing.T) { func TestASettingCannotReplaceTheMeshsOwnEntryAndABadNameIsLeftOut(t *testing.T) {
out := Render(Facts{Node: "w", Console: "http://127.0.0.1:4270/mcp"}, 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) Settings{MCPServers: map[string]map[string]any{"mesh": {"type": "http", "url": "http://evil"}, "bad name": {}}}, nil, "/h", nil, Config{})
var mcp struct { var mcp struct {
MCPServers map[string]map[string]any `json:"mcpServers"` MCPServers map[string]map[string]any `json:"mcpServers"`
} }
@@ -89,7 +95,7 @@ func TestASettingCannotReplaceTheMeshsOwnEntryAndABadNameIsLeftOut(t *testing.T)
if mcp.MCPServers["mesh"]["url"] != "http://127.0.0.1:4270/mcp" || mcp.MCPServers["bad name"] != nil { if mcp.MCPServers["mesh"]["url"] != "http://127.0.0.1:4270/mcp" || mcp.MCPServers["bad name"] != nil {
t.Fatalf("%v", mcp.MCPServers) t.Fatalf("%v", mcp.MCPServers)
} }
if !reflect.DeepEqual(Render(Facts{Console: "x"}, Settings{}, nil, "/h", nil), Render(Facts{Console: "x"}, Settings{}, nil, "/h", nil)) { if !reflect.DeepEqual(Render(Facts{Console: "x"}, Settings{}, nil, "/h", nil, Config{}), Render(Facts{Console: "x"}, Settings{}, nil, "/h", nil, Config{})) {
t.Fatal("rendering is not deterministic") t.Fatal("rendering is not deterministic")
} }
} }
@@ -104,7 +110,7 @@ func TestTheOperatorsManagedSettingsAreLaidUnderTheMeshsOwnKeys(t *testing.T) {
}} }}
read := func(binding *Binding) map[string]any { read := func(binding *Binding) map[string]any {
var m map[string]any var m map[string]any
out := Render(Facts{Console: "x"}, settings, binding, "/h", nil) out := Render(Facts{Console: "x"}, settings, binding, "/h", nil, Config{})
if err := json.Unmarshal([]byte(out["managed-settings.json"]), &m); err != nil { if err := json.Unmarshal([]byte(out["managed-settings.json"]), &m); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -0,0 +1,947 @@
package main
// The agent's configuration, registered through this module at three scopes (novox/hq ADR 0216, to-be 36 §8).
//
// Every registration is one key in the module's `config` state (ADR 0201):
//
// mesh.<kind>.<name> every node running the agent
// node.<node>.<kind>.<name> one node — a list of nodes is one key each
// home.<node>.<kind>.<name> the operator account's own agent directory on one node
//
// Every instance watches the state and takes what applies to it. What it takes lands in one place per kind,
// the one place the vendor honours for it:
//
// skill, agent, command, hook, output-style the plugin `nox-mesh`, in a marketplace in the managed directory
// (at the home scope: the home's own directories)
// instructions sections of the managed instruction file (home: a rule file)
// settings the managed settings file, mesh then node, under the mesh's keys
//
// Tool servers keep their own state and file (`servers`, the managed tool-server file): the exclusive file
// would block a plugin's.
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"path"
"path/filepath"
"regexp"
"sort"
"strings"
"sync"
"time"
)
// Plugin is the plugin's name, and its marketplace's: what its items are called in a session, `nox-mesh:<name>`.
const Plugin = "nox-mesh"
// MarketplaceDir is where the marketplace is written, inside the managed directory.
const MarketplaceDir = "marketplace"
// MaxItemBytes is the most one registration may carry, its files included: well under the bus's message limit.
const MaxItemBytes = 256 << 10
// The kinds a registration may be.
const (
KindSkill = "skill"
KindAgent = "agent"
KindCommand = "command"
KindHook = "hook"
KindOutputStyle = "output-style"
KindInstructions = "instructions"
KindSettings = "settings"
)
// Kinds is every kind, in the order a list shows them.
var Kinds = []string{KindSkill, KindAgent, KindCommand, KindHook, KindOutputStyle, KindInstructions, KindSettings}
// The scopes.
const (
ScopeMesh = "mesh"
ScopeNode = "node"
ScopeHome = "home"
)
// SettingsName is the one name a settings registration has: a scope holds one set of settings.
const SettingsName = "settings"
var itemName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,63}$`)
// nodeName is what a node may be called in a key.
var nodeName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}$`)
// meshOwnedKeys are the settings a registration may not set: the mesh's own, and those that would deny
// the mesh's console or its marketplace by another way.
var meshOwnedKeys = []string{"attribution", "allowAllClaudeAiMcps", "apiKeyHelper", "extraKnownMarketplaces", "enabledPlugins",
"allowedMcpServers", "deniedMcpServers", "allowManagedMcpServersOnly", "strictKnownMarketplaces", "blockedMarketplaces"}
// hookEvents are the events a hook may be registered for.
var hookEvents = map[string]bool{"PreToolUse": true, "PostToolUse": true, "UserPromptSubmit": true, "Notification": true,
"Stop": true, "SubagentStop": true, "SessionStart": true, "SessionEnd": true, "PreCompact": true}
// Item is one registration, as the state keeps it.
type Item struct {
Kind string `json:"kind"`
Name string `json:"name"`
Scope string `json:"scope"`
// Files are the item's files by path relative to the item: a skill's SKILL.md and whatever sits beside
// it; for the one-file kinds, `<name>.md`; a hook's scripts.
Files map[string]string `json:"files,omitempty"`
// A hook's event, matcher and command. In the command, ${HOOK_DIR} is the directory its files are in.
Event string `json:"event,omitempty"`
Matcher string `json:"matcher,omitempty"`
Command string `json:"command,omitempty"`
// Settings, for the settings kind: keys of the vendor's settings file.
Settings map[string]any `json:"settings,omitempty"`
// Where it was registered from, and when.
By string `json:"by,omitempty"`
At string `json:"at,omitempty"`
}
// Key is where an item lives in the state, for one node (ignored at the mesh scope).
func (it Item) Key(node string) string {
if it.Scope == ScopeMesh {
return ScopeMesh + "." + it.Kind + "." + it.Name
}
return it.Scope + "." + node + "." + it.Kind + "." + it.Name
}
// ParseKey reads a key back: its scope, the node it is for ("" at the mesh scope), the kind and the name.
func ParseKey(key string) (scope, node, kind, name string, ok bool) {
parts := strings.Split(key, ".")
switch {
case len(parts) == 3 && parts[0] == ScopeMesh:
return ScopeMesh, "", parts[1], parts[2], true
case len(parts) == 4 && (parts[0] == ScopeNode || parts[0] == ScopeHome):
return parts[0], parts[1], parts[2], parts[3], true
}
return "", "", "", "", false
}
func knownKind(k string) bool {
for _, x := range Kinds {
if x == k {
return true
}
}
return false
}
// Problem says why an item cannot be registered, or "".
func (it Item) Problem() string {
if !knownKind(it.Kind) {
return fmt.Sprintf("%q is not a kind: %s", it.Kind, strings.Join(Kinds, ", "))
}
if it.Scope != ScopeMesh && it.Scope != ScopeNode && it.Scope != ScopeHome {
return fmt.Sprintf("%q is not a scope: mesh, node or home", it.Scope)
}
if it.Kind == KindSettings {
if it.Scope == ScopeHome {
return "settings take the mesh and node scopes only: the home's settings file is the person's"
}
if it.Name != SettingsName {
return "a scope holds one set of settings, named " + SettingsName
}
if len(it.Settings) == 0 {
return "no settings given"
}
for _, key := range meshOwnedKeys {
if _, ok := it.Settings[key]; ok {
return fmt.Sprintf("%q is one of the mesh's own keys, or would turn off the mesh's console or plugin; a registration cannot set it", key)
}
}
} else if !itemName.MatchString(it.Name) {
return fmt.Sprintf("%q is not a name: lower-case letters, digits and -, at most 64", it.Name)
}
if it.Kind == KindHook {
if it.Scope == ScopeHome {
return "a hook takes the mesh and node scopes only: at home it would live in the person's settings file"
}
if !hookEvents[it.Event] {
return fmt.Sprintf("%q is not a hook event the agent knows", it.Event)
}
if strings.TrimSpace(it.Command) == "" {
return "a hook needs a command"
}
}
for rel := range it.Files {
if problem := pathProblem(rel); problem != "" {
return problem
}
for other := range it.Files {
if strings.HasPrefix(other, rel+"/") {
return fmt.Sprintf("%q is a file and also the directory of %q", rel, other)
}
}
}
switch it.Kind {
case KindSkill:
if _, ok := it.Files["SKILL.md"]; !ok {
return "a skill needs a SKILL.md"
}
case KindAgent, KindCommand, KindOutputStyle, KindInstructions:
if len(it.Files) != 1 || strings.TrimSpace(it.Files[it.Name+".md"]) == "" {
return "this kind is one file, " + it.Name + ".md, with content"
}
}
if n := it.size(); n > MaxItemBytes {
return fmt.Sprintf("%d bytes: more than the %d one registration may carry", n, MaxItemBytes)
}
return ""
}
// pathProblem says why a file's path is not one inside the item, or "": relative, slash-separated, every
// segment a real name — never empty, `.` or `..`.
func pathProblem(rel string) string {
if rel == "" || path.IsAbs(rel) || strings.ContainsAny(rel, "\\\x00") {
return fmt.Sprintf("%q is not a path inside the item", rel)
}
for _, seg := range strings.Split(rel, "/") {
if seg == "" || seg == "." || seg == ".." {
return fmt.Sprintf("%q is not a path inside the item", rel)
}
}
return ""
}
func (it Item) size() int {
raw, _ := json.Marshal(it)
return len(raw)
}
// ---- the view -------------------------------------------------------------------------------------
// ConfigView is what this node takes from the `config` state: every mesh item, and the node and home items
// for this node — kept in memory from the watch and written through to the module's own file, so the
// managed directory renders without the bus.
type ConfigView struct {
p Paths
mu sync.Mutex
items map[string]Item
}
// NewConfigView is the view as last written through, or empty.
func NewConfigView(p Paths) *ConfigView {
v := &ConfigView{p: p, items: map[string]Item{}}
_ = readJSON(p.config(), &v.items)
return v
}
// Applies says whether a key is this node's to take.
func (v *ConfigView) Applies(key string) bool {
scope, node, _, _, ok := ParseKey(key)
return ok && (scope == ScopeMesh || node == v.p.Node)
}
// Take takes one change, and answers whether what applies to this node changed.
func (v *ConfigView) Take(key, op string, item *Item) bool {
if !v.Applies(key) {
return false
}
_, node, _, _, _ := ParseKey(key)
v.mu.Lock()
defer v.mu.Unlock()
// Only an item that is what its key says: a key decides which nodes take an item, the item where it
// lands, and the two must agree — a mesh key holding a home item would land in every home.
if op == "put" && item != nil && item.Problem() == "" && item.Key(node) == key {
v.items[key] = *item
} else {
delete(v.items, key)
}
return v.writeThroughLocked()
}
// Prune drops what the view holds and the state no longer does: a registration removed while this node
// was away is never handed over by the watch, which hands over what is there, not what went.
func (v *ConfigView) Prune(present []string) bool {
keep := map[string]bool{}
for _, k := range present {
keep[k] = true
}
v.mu.Lock()
defer v.mu.Unlock()
for k := range v.items {
if !keep[k] {
delete(v.items, k)
}
}
return v.writeThroughLocked()
}
// Items is what applies here, by key.
func (v *ConfigView) Items() map[string]Item {
v.mu.Lock()
defer v.mu.Unlock()
out := make(map[string]Item, len(v.items))
for k, it := range v.items {
out[k] = it
}
return out
}
// writeThroughLocked writes the view to its file, with v.mu held: the snapshot and the write are one step, so
// an older snapshot never lands after a newer one.
func (v *ConfigView) writeThroughLocked() bool {
now, _ := indented(v.items)
before, _ := os.ReadFile(v.p.config())
if string(before) == string(now) {
return false
}
_ = os.WriteFile(v.p.config(), now, 0o600)
return true
}
// Config is what applies to this node, sorted for rendering.
type Config struct {
Mesh, Node, Home []Item
}
// ConfigOf sorts the items that apply here by scope, each scope by kind and name.
func ConfigOf(items map[string]Item) Config {
var c Config
for _, it := range items {
switch it.Scope {
case ScopeMesh:
c.Mesh = append(c.Mesh, it)
case ScopeNode:
c.Node = append(c.Node, it)
case ScopeHome:
c.Home = append(c.Home, it)
}
}
for _, list := range [][]Item{c.Mesh, c.Node, c.Home} {
sort.Slice(list, func(i, j int) bool {
if list[i].Kind != list[j].Kind {
return list[i].Kind < list[j].Kind
}
return list[i].Name < list[j].Name
})
}
return c
}
// ---- rendering --------------------------------------------------------------------------------------
// PluginFile is one file of the marketplace: its content and whether it is run.
type PluginFile struct {
Content string
Executable bool
}
// Marketplace is the marketplace directory's whole content, by path inside it. A node item of a name laid
// over a mesh item of the same kind and name: the node's wins.
func Marketplace(c Config) map[string]PluginFile {
root := "plugins/" + Plugin + "/"
out := map[string]PluginFile{
".claude-plugin/marketplace.json": {Content: jsonFile(map[string]any{
"name": Plugin,
"owner": map[string]any{"name": "the mesh"},
"plugins": []any{map[string]any{"name": Plugin, "source": "./plugins/" + Plugin,
"description": "What the mesh registered for the agent: written by the claude-code module, never by hand."}},
})},
root + ".claude-plugin/plugin.json": {Content: jsonFile(map[string]any{
"name": Plugin, "version": "1.0.0",
"description": "What the mesh registered for the agent: written by the claude-code module, never by hand.",
"author": map[string]any{"name": "the mesh"},
})},
}
chosen := map[string]Item{}
for _, layer := range [][]Item{c.Mesh, c.Node} {
for _, it := range layer {
chosen[it.Kind+"/"+it.Name] = it
}
}
keys := make([]string, 0, len(chosen))
for k := range chosen {
keys = append(keys, k)
}
sort.Strings(keys)
hooks := map[string][]any{}
for _, k := range keys {
it := chosen[k]
switch it.Kind {
case KindSkill:
for rel, content := range it.Files {
out[root+"skills/"+it.Name+"/"+rel] = PluginFile{Content: content, Executable: isScript(rel, content)}
}
case KindAgent:
out[root+"agents/"+it.Name+".md"] = PluginFile{Content: it.Files[it.Name+".md"]}
case KindCommand:
out[root+"commands/"+it.Name+".md"] = PluginFile{Content: it.Files[it.Name+".md"]}
case KindOutputStyle:
out[root+"output-styles/"+it.Name+".md"] = PluginFile{Content: it.Files[it.Name+".md"]}
case KindHook:
for rel, content := range it.Files {
out[root+"hooks/"+it.Name+"/"+rel] = PluginFile{Content: content, Executable: true}
}
command := strings.ReplaceAll(it.Command, "${HOOK_DIR}", `"${CLAUDE_PLUGIN_ROOT}/hooks/`+it.Name+`"`)
entry := map[string]any{"hooks": []any{map[string]any{"type": "command", "command": command}}}
if it.Matcher != "" {
entry["matcher"] = it.Matcher
}
hooks[it.Event] = append(hooks[it.Event], entry)
}
}
if len(hooks) > 0 {
out[root+"hooks/hooks.json"] = PluginFile{Content: jsonFile(map[string]any{"hooks": hooks})}
}
return out
}
func isScript(rel, content string) bool {
return strings.HasPrefix(content, "#!") || strings.HasSuffix(rel, ".sh")
}
// MarketplaceKeys are the two managed settings keys that name the marketplace and enable the plugin.
func MarketplaceKeys() map[string]any {
return map[string]any{
"extraKnownMarketplaces": map[string]any{Plugin: map[string]any{
"source": map[string]any{"source": "directory", "path": filepath.Join(ManagedDir, MarketplaceDir)}}},
"enabledPlugins": map[string]any{Plugin + "@" + Plugin: true},
}
}
// RegisteredSettings is the settings registered for the mesh, with those registered for this node laid over.
func RegisteredSettings(c Config) map[string]any {
out := map[string]any{}
for _, layer := range [][]Item{c.Mesh, c.Node} {
for _, it := range layer {
if it.Kind == KindSettings {
out = mergeSettings(out, it.Settings)
}
}
}
return out
}
// mergeSettings lays b over a: objects are merged key by key, lists are joined without repeats (a
// permission rule or an auto-mode rule added for a node adds to the mesh's), and anything else is b's.
func mergeSettings(a, b map[string]any) map[string]any {
out := map[string]any{}
for k, v := range a {
out[k] = v
}
for k, v := range b {
switch nb := v.(type) {
case map[string]any:
if na, ok := out[k].(map[string]any); ok {
out[k] = mergeSettings(na, nb)
continue
}
case []any:
if la, ok := out[k].([]any); ok {
joined := append([]any{}, la...)
seen := map[string]bool{}
for _, x := range la {
raw, _ := json.Marshal(x)
seen[string(raw)] = true
}
for _, x := range nb {
raw, _ := json.Marshal(x)
if !seen[string(raw)] {
seen[string(raw)] = true
joined = append(joined, x)
}
}
out[k] = joined
continue
}
}
out[k] = v
}
return out
}
// InstructionSections is what the managed instruction file adds after the mesh's own text: the sections
// registered for the mesh, then those for this node.
func InstructionSections(c Config) string {
var b strings.Builder
for _, part := range []struct {
title string
items []Item
}{{"Instructions for every node", c.Mesh}, {"Instructions for this node", c.Node}} {
var sections []Item
for _, it := range part.items {
if it.Kind == KindInstructions {
sections = append(sections, it)
}
}
if len(sections) == 0 {
continue
}
fmt.Fprintf(&b, "\n## %s\n\nRegistered through the `claude-code` module's instruction tools; change them there.\n", part.title)
for _, it := range sections {
fmt.Fprintf(&b, "\n### %s\n\n%s\n", it.Name, strings.TrimSpace(it.Files[it.Name+".md"]))
}
}
return b.String()
}
// HomeFiles is what the home scope places in the operator account's agent directory, by path relative to
// that directory.
func HomeFiles(c Config) map[string]string {
out := map[string]string{}
for _, it := range c.Home {
switch it.Kind {
case KindSkill:
for rel, content := range it.Files {
out["skills/"+it.Name+"/"+rel] = content
}
case KindAgent:
out["agents/"+it.Name+".md"] = it.Files[it.Name+".md"]
case KindCommand:
out["commands/"+it.Name+".md"] = it.Files[it.Name+".md"]
case KindOutputStyle:
out["output-styles/"+it.Name+".md"] = it.Files[it.Name+".md"]
case KindInstructions:
out["rules/"+it.Name+".md"] = it.Files[it.Name+".md"]
}
}
return out
}
// ---- the home ---------------------------------------------------------------------------------------
// Placed is what this module placed in the home, by path relative to the agent directory, with the digest
// of what it wrote (ADR 0182: the mesh owns what it places, and only that).
type Placed map[string]string
func digest(s string) string {
sum := sha256.Sum256([]byte(s))
return hex.EncodeToString(sum[:])
}
// deletedByHand marks, in the record, a path the mesh placed and the person then deleted: their choice,
// kept until the item is unregistered.
const deletedByHand = "deleted-by-hand"
// PlaceHome brings the home in line with what the home scope wants (ADR 0182): it writes what is wanted and
// absent, or its own; it never writes a path the person made, nor through a directory that is a symbolic
// link; it removes what it placed and is no longer wanted, unless the person changed it since; and a file it
// placed that the person deleted stays deleted until the item is unregistered. Answers what it did and what
// it left alone, and why.
func PlaceHome(p Paths, want map[string]string) (done []string, left []string) {
dir := filepath.Join(p.Home, ".claude")
var placed Placed
if !readJSON(p.placed(), &placed) {
placed = Placed{}
}
paths := make([]string, 0, len(want))
for rel := range want {
paths = append(paths, rel)
}
sort.Strings(paths)
for _, rel := range paths {
full := filepath.Join(dir, filepath.FromSlash(rel))
content := want[rel]
if why := linkedParent(dir, rel); why != "" {
left = append(left, rel+": "+why+", left alone")
continue
}
current, err := os.ReadFile(full)
exists := err == nil
ours, wasPlaced := placed[rel]
switch {
case !exists && wasPlaced && ours == deletedByHand:
continue
case !exists && wasPlaced:
placed[rel] = deletedByHand
left = append(left, rel+": deleted by hand, left deleted until the item is unregistered")
continue
case exists && !wasPlaced:
left = append(left, rel+": the person's own, left alone")
continue
case exists && ours == deletedByHand:
left = append(left, rel+": made again by hand after the mesh's was deleted, left alone")
continue
case exists && string(current) == content:
continue
case exists && digest(string(current)) != ours:
left = append(left, rel+": changed by hand since it was placed, left alone")
continue
}
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
left = append(left, rel+": "+err.Error())
continue
}
mode := os.FileMode(0o644)
if isScript(rel, content) {
mode = 0o755
}
if err := os.WriteFile(full, []byte(content), mode); err != nil {
left = append(left, rel+": "+err.Error())
continue
}
placed[rel] = digest(content)
done = append(done, rel+": placed")
}
for rel, ours := range placed {
if _, wanted := want[rel]; wanted {
continue
}
full := filepath.Join(dir, filepath.FromSlash(rel))
if ours == deletedByHand {
delete(placed, rel)
continue
}
if why := linkedParent(dir, rel); why != "" {
left = append(left, rel+": no longer registered, but "+why+", left alone")
continue
}
current, err := os.ReadFile(full)
if err == nil && digest(string(current)) != ours {
left = append(left, rel+": no longer registered, but changed by hand, left alone")
delete(placed, rel)
continue
}
if err := os.Remove(full); err != nil && !os.IsNotExist(err) {
left = append(left, rel+": no longer registered, and could not be removed: "+err.Error())
continue
}
// Up to the kind's own directory, never it: `skills/<name>` goes when empty, `skills` stays.
removeEmptyParents(filepath.Join(dir, strings.SplitN(rel, "/", 2)[0]), filepath.Dir(full))
delete(placed, rel)
done = append(done, rel+": removed")
}
raw, _ := indented(placed)
if err := os.WriteFile(p.placed(), raw, 0o600); err != nil {
left = append(left, "the record of what was placed could not be saved: "+err.Error())
}
return done, left
}
// linkedParent says, when a directory between the agent directory and a file is a symbolic link, which one:
// writing through it would write wherever it points.
func linkedParent(dir, rel string) string {
parts := strings.Split(rel, "/")
at := dir
for _, seg := range parts[:len(parts)-1] {
at = filepath.Join(at, seg)
if info, err := os.Lstat(at); err == nil && info.Mode()&os.ModeSymlink != 0 {
return at + " is a symbolic link"
}
}
return ""
}
// removeEmptyParents removes empty directories from dir up to, not including, root.
func removeEmptyParents(root, dir string) {
for dir != root && strings.HasPrefix(dir, root+string(filepath.Separator)) {
if err := os.Remove(dir); err != nil {
return
}
dir = filepath.Dir(dir)
}
}
// HomeConflict says whether registering an item at the home scope here would collide with something the
// person made: "" when it would not.
func HomeConflict(p Paths, it Item) string {
var placed Placed
_ = readJSON(p.placed(), &placed)
for rel := range HomeFiles(Config{Home: []Item{it}}) {
if _, ours := placed[rel]; ours {
continue
}
if _, err := os.Stat(filepath.Join(p.Home, ".claude", filepath.FromSlash(rel))); err == nil {
return fmt.Sprintf("%s already exists in this home and the mesh did not place it; pick another name, or import it", rel)
}
}
return ""
}
// ---- what the module did not place ------------------------------------------------------------------
// HomeItem is one item found in the home.
type HomeItem struct {
Kind string `json:"kind"`
Name string `json:"name"`
Path string `json:"path"`
Placed bool `json:"placedByTheMesh"`
Notes []string `json:"notes,omitempty"`
}
var mcpToolRef = regexp.MustCompile(`mcp__([A-Za-z0-9_-]+)__[A-Za-z0-9_-]+`)
// HomeItems lists the home's skills, subagents, commands, output styles, rule files and its own memory (CLAUDE.md), says which the mesh
// placed, and notes those that share a name with a mesh item or call a tool server that is not loaded here.
func HomeItems(p Paths, c Config, loaded Servers) []HomeItem {
dir := filepath.Join(p.Home, ".claude")
var placed Placed
_ = readJSON(p.placed(), &placed)
inPlugin := map[string]bool{}
for _, layer := range [][]Item{c.Mesh, c.Node} {
for _, it := range layer {
inPlugin[it.Kind+"/"+it.Name] = true
}
}
var out []HomeItem
add := func(kind, name, rel, body string) {
h := HomeItem{Kind: kind, Name: name, Path: filepath.Join(dir, filepath.FromSlash(rel))}
_, h.Placed = placed[rel]
if inPlugin[kind+"/"+name] {
h.Notes = append(h.Notes, "the mesh also registers a "+kind+" of this name, offered as "+Plugin+":"+name)
}
seen := map[string]bool{}
for _, m := range mcpToolRef.FindAllStringSubmatch(body, -1) {
if server := m[1]; !seen[server] && server != meshEntry && loaded[server] == nil {
seen[server] = true
h.Notes = append(h.Notes, "calls tools of `"+server+"`, a tool server not loaded on this node")
}
}
out = append(out, h)
}
if entries, err := os.ReadDir(filepath.Join(dir, "skills")); err == nil {
for _, e := range entries {
if !e.IsDir() {
continue
}
body, err := os.ReadFile(filepath.Join(dir, "skills", e.Name(), "SKILL.md"))
if err != nil {
continue // the vendor's own synced folders carry none at their top
}
add(KindSkill, e.Name(), "skills/"+e.Name()+"/SKILL.md", string(body))
}
}
for kind, sub := range map[string]string{KindAgent: "agents", KindCommand: "commands", KindOutputStyle: "output-styles", KindInstructions: "rules"} {
entries, err := os.ReadDir(filepath.Join(dir, sub))
if err != nil {
continue
}
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") {
continue
}
body, _ := os.ReadFile(filepath.Join(dir, sub, e.Name()))
add(kind, strings.TrimSuffix(e.Name(), ".md"), sub+"/"+e.Name(), string(body))
}
}
if body, err := os.ReadFile(filepath.Join(dir, "CLAUDE.md")); err == nil {
add(KindMemory, memoryName, "CLAUDE.md", string(body))
}
sort.Slice(out, func(i, j int) bool {
if out[i].Kind != out[j].Kind {
return out[i].Kind < out[j].Kind
}
return out[i].Name < out[j].Name
})
return out
}
// ImportFromHome reads one item of this node's home as an item to register. A rule file becomes instructions.
func ImportFromHome(p Paths, kind, name string) (Item, error) {
dir := filepath.Join(p.Home, ".claude")
it := Item{Kind: kind, Name: name, Files: map[string]string{}}
switch kind {
case KindSkill:
root := filepath.Join(dir, "skills", name)
err := filepath.WalkDir(root, func(full string, d os.DirEntry, err error) error {
if err != nil || d.IsDir() {
return err
}
rel, _ := filepath.Rel(root, full)
raw, err := os.ReadFile(full)
if err != nil {
return err
}
it.Files[filepath.ToSlash(rel)] = string(raw)
return nil
})
if err != nil {
return Item{}, fmt.Errorf("the skill %s in this home: %w", name, err)
}
case KindAgent, KindCommand, KindOutputStyle, KindInstructions:
sub := map[string]string{KindAgent: "agents", KindCommand: "commands", KindOutputStyle: "output-styles", KindInstructions: "rules"}[kind]
raw, err := os.ReadFile(filepath.Join(dir, sub, name+".md"))
if err != nil {
return Item{}, fmt.Errorf("the %s %s in this home: %w", kind, name, err)
}
it.Files[name+".md"] = string(raw)
default:
return Item{}, fmt.Errorf("a %s is not imported from a home", kind)
}
return it, nil
}
// ---- registering ------------------------------------------------------------------------------------
// ConfigState is the `config` state as this module reaches it through the runtime.
type ConfigState interface {
Put(key string, value any) error
Delete(key string) error
Keys() ([]string, error)
Get(key string) (json.RawMessage, bool, error)
}
// Register puts an item (or, with unregister, removes it) at its scope: at the mesh scope one key, at the
// node and home scopes one key per node — this node when none is given. Taken into this node's view at once,
// so the answer says what it did here.
func Register(p Paths, it Item, nodes []string, unregister bool, state ConfigState, v *ConfigView, write WriteManaged) (map[string]any, error) {
if !unregister {
it.By, it.At = p.Node, time.Now().UTC().Format(time.RFC3339)
if problem := it.Problem(); problem != "" {
return map[string]any{"registered": false, "reason": problem}, nil
}
} else if !knownKind(it.Kind) {
return map[string]any{"unregistered": false, "reason": fmt.Sprintf("%q is not a kind", it.Kind)}, nil
}
if it.Scope == ScopeMesh {
nodes = []string{""}
} else if len(nodes) == 0 {
nodes = []string{p.Node}
} else if problem := nodesProblem(nodes); problem != "" {
return map[string]any{verbOf(unregister): false, "reason": problem}, nil
}
if !unregister && it.Scope == ScopeHome {
for _, n := range nodes {
if n == p.Node {
if conflict := HomeConflict(p, it); conflict != "" {
return map[string]any{"registered": false, "reason": conflict}, nil
}
}
}
}
// Compared before and after rather than read from Take: this node's own watch may take the same change
// first, and then Take here finds nothing new although this call made it.
before, _ := json.Marshal(v.Items())
var keys []string
for _, n := range nodes {
key := it.Key(n)
keys = append(keys, key)
var err error
if unregister {
err = state.Delete(key)
} else {
err = state.Put(key, it)
}
if err != nil {
return nil, err
}
op := "put"
if unregister {
op = "delete"
}
item := it
v.Take(key, op, &item)
}
after, _ := json.Marshal(v.Items())
here := string(before) != string(after)
for _, k := range keys {
here = here || v.Applies(k)
}
verb := map[bool]string{false: "registered", true: "unregistered"}[unregister]
answer := map[string]any{verb: it.Kind + " " + it.Name, "keys": keys}
if !unregister {
switch {
case it.Scope == ScopeHome && it.Kind == KindCommand:
answer["offered as"] = "/" + it.Name
case it.Scope == ScopeHome:
answer["offered as"] = it.Name + ", from the home"
case it.Kind == KindSkill || it.Kind == KindAgent || it.Kind == KindOutputStyle:
answer["offered as"] = Plugin + ":" + it.Name
case it.Kind == KindCommand:
answer["offered as"] = "/" + Plugin + ":" + it.Name
}
}
if here {
// Rendered whenever it applies here, so the answer says what this node wrote, whichever of the
// watch and this call took the change.
rendered, err := RenderNow(p, write)
answer["rendered here"] = rendered
if err != nil {
answer["not written here"] = err.Error() // kept on the bus all the same; the next render tries again
}
answer["sessions"] = "a new session takes it; a running one at /reload-plugins"
} else {
answer["here"] = "not this node: each node it is for takes it from the bus"
}
return answer, nil
}
func verbOf(unregister bool) string {
return map[bool]string{false: "registered", true: "unregistered"}[unregister]
}
// nodesProblem says why a list of nodes cannot name keys, or "". "all" has been expanded before this.
func nodesProblem(nodes []string) string {
for _, n := range nodes {
if !nodeName.MatchString(n) {
return fmt.Sprintf("%q is not a node's name", n)
}
}
return ""
}
// ExpandNodes turns `all` into every node running the module; anything else is kept.
func ExpandNodes(nodes []string, running func() ([]string, error)) ([]string, error) {
if len(nodes) != 1 || nodes[0] != "all" {
return nodes, nil
}
all, err := running()
if err != nil {
return nil, fmt.Errorf("which nodes run claude-code: %w", err)
}
if len(all) == 0 {
return nil, fmt.Errorf("no node is known to run claude-code")
}
return all, nil
}
// List is every registration of a kind ("" for every kind) on the mesh, by key, read from the state.
func List(state ConfigState, kind string) (map[string]any, error) {
keys, err := state.Keys()
if err != nil {
return nil, err
}
sort.Strings(keys)
out := map[string]any{}
for _, key := range keys {
_, _, k, _, ok := ParseKey(key)
if !ok || (kind != "" && k != kind) {
continue
}
raw, found, err := state.Get(key)
if err != nil || !found {
continue
}
var it Item
if json.Unmarshal(raw, &it) != nil {
continue
}
files := make([]string, 0, len(it.Files))
for f := range it.Files {
files = append(files, f)
}
sort.Strings(files)
summary := map[string]any{"by": it.By, "at": it.At}
if len(files) > 0 {
summary["files"] = files
}
if it.Kind == KindHook {
summary["event"], summary["matcher"], summary["command"] = it.Event, it.Matcher, it.Command
}
if it.Kind == KindSettings {
summary["settings"] = it.Settings
}
out[key] = summary
}
return out, nil
}
// Show is one registration in full: its files' content included.
func Show(state ConfigState, key string) (any, error) {
raw, found, err := state.Get(key)
if err != nil {
return nil, err
}
if !found {
return nil, fmt.Errorf("nothing is registered at %s", key)
}
var it Item
if err := json.Unmarshal(raw, &it); err != nil {
return nil, err
}
return it, nil
}
@@ -0,0 +1,470 @@
package main
// The agent's configuration registered at three scopes (novox/hq ADR 0216): each test is one row of the
// record's "How it is checked".
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
// memConfig is the `config` state as a map, shared by the nodes of a test the way the bus shares it.
type memConfig map[string]json.RawMessage
func (m memConfig) Put(key string, value any) error {
raw, err := json.Marshal(value)
m[key] = raw
return err
}
func (m memConfig) Delete(key string) error { delete(m, key); return nil }
func (m memConfig) Keys() ([]string, error) {
out := []string{}
for k := range m {
out = append(out, k)
}
return out, nil
}
func (m memConfig) Get(key string) (json.RawMessage, bool, error) {
raw, ok := m[key]
return raw, ok, nil
}
// deliver hands every key of the state to a node's view, as its watch would.
func deliver(m memConfig, v *ConfigView) {
for key, raw := range m {
var it Item
_ = json.Unmarshal(raw, &it)
v.Take(key, "put", &it)
}
}
func one(name, content string) map[string]string { return map[string]string{name + ".md": content} }
func pluginOf(t *testing.T, w map[string]string) map[string]PluginFile {
t.Helper()
var files map[string]PluginFile
if err := json.Unmarshal([]byte(w[MarketplaceDir+"/"]), &files); err != nil {
t.Fatalf("no marketplace written: %v", err)
}
return files
}
func TestEachKindLandsInItsOnePlace(t *testing.T) {
p, w := node(t, "laptop")
state, view := memConfig{}, NewConfigView(p)
register := func(it Item) {
t.Helper()
answer, err := Register(p, it, nil, false, state, view, writer(w))
if err != nil || answer["registered"] == false {
t.Fatalf("%s %s: %v %v", it.Kind, it.Name, answer, err)
}
}
register(Item{Kind: KindSkill, Name: "review", Scope: ScopeMesh,
Files: map[string]string{"SKILL.md": "---\nname: review\ndescription: d\n---\nbody", "scripts/run.sh": "#!/bin/sh\necho hi\n"}})
register(Item{Kind: KindAgent, Name: "reviewer", Scope: ScopeMesh, Files: one("reviewer", "---\nname: reviewer\n---\nx")})
register(Item{Kind: KindCommand, Name: "ship", Scope: ScopeMesh, Files: one("ship", "ship it")})
register(Item{Kind: KindOutputStyle, Name: "terse", Scope: ScopeMesh, Files: one("terse", "---\nname: terse\n---\nshort")})
register(Item{Kind: KindHook, Name: "guard", Scope: ScopeMesh, Event: "PreToolUse", Matcher: "Bash",
Command: "${HOOK_DIR}/guard.sh", Files: map[string]string{"guard.sh": "#!/bin/sh\nexit 0\n"}})
register(Item{Kind: KindInstructions, Name: "conventions", Scope: ScopeMesh, Files: one("conventions", "Commit in the imperative.")})
register(Item{Kind: KindSettings, Name: SettingsName, Scope: ScopeMesh,
Settings: map[string]any{"permissions": map[string]any{"deny": []any{"Bash(rm -rf:*)"}}}})
plugin := pluginOf(t, w)
root := "plugins/" + Plugin + "/"
for _, want := range []string{".claude-plugin/marketplace.json", root + ".claude-plugin/plugin.json",
root + "skills/review/SKILL.md", root + "skills/review/scripts/run.sh", root + "agents/reviewer.md",
root + "commands/ship.md", root + "output-styles/terse.md", root + "hooks/hooks.json", root + "hooks/guard/guard.sh"} {
if _, ok := plugin[want]; !ok {
t.Errorf("the plugin lacks %s", want)
}
}
if !plugin[root+"skills/review/scripts/run.sh"].Executable || !plugin[root+"hooks/guard/guard.sh"].Executable {
t.Error("a script is not executable")
}
var hooks struct {
Hooks map[string][]struct {
Matcher string `json:"matcher"`
Hooks []struct {
Command string `json:"command"`
} `json:"hooks"`
} `json:"hooks"`
}
_ = json.Unmarshal([]byte(plugin[root+"hooks/hooks.json"].Content), &hooks)
if pre := hooks.Hooks["PreToolUse"]; len(pre) != 1 || pre[0].Matcher != "Bash" ||
pre[0].Hooks[0].Command != `"${CLAUDE_PLUGIN_ROOT}/hooks/guard"/guard.sh` {
t.Errorf("the hook's command does not name its directory, quoted: %s", plugin[root+"hooks/hooks.json"].Content)
}
if !strings.Contains(w["CLAUDE.md"], "### conventions\n\nCommit in the imperative.") {
t.Errorf("the instruction section is not in the managed instruction file:\n%s", w["CLAUDE.md"])
}
var managed map[string]any
_ = json.Unmarshal([]byte(w["managed-settings.json"]), &managed)
if managed["permissions"] == nil || managed["enabledPlugins"].(map[string]any)[Plugin+"@"+Plugin] != true {
t.Errorf("managed settings: %v", managed)
}
if _, inPlugin := plugin[root+"settings.json"]; inPlugin {
t.Error("settings were put in the plugin, which drops them")
}
}
func TestAMeshItemReachesEveryNodeANodeItemOneAndAHomeItemOneHome(t *testing.T) {
a, wa := node(t, "laptop")
b, wb := node(t, "server")
state := memConfig{}
va, vb := NewConfigView(a), NewConfigView(b)
for _, r := range []struct {
it Item
nodes []string
}{
{Item{Kind: KindCommand, Name: "everywhere", Scope: ScopeMesh, Files: one("everywhere", "x")}, nil},
{Item{Kind: KindCommand, Name: "server-only", Scope: ScopeNode, Files: one("server-only", "x")}, []string{"server"}},
{Item{Kind: KindCommand, Name: "at-home", Scope: ScopeHome, Files: one("at-home", "x")}, []string{"laptop"}},
} {
if _, err := Register(a, r.it, r.nodes, false, state, va, writer(wa)); err != nil {
t.Fatal(err)
}
}
deliver(state, vb)
if _, err := RenderNow(b, writer(wb)); err != nil {
t.Fatal(err)
}
root := "plugins/" + Plugin + "/commands/"
pa, pb := pluginOf(t, wa), pluginOf(t, wb)
if _, ok := pa[root+"everywhere.md"]; !ok {
t.Error("the mesh item is missing on the laptop")
}
if _, ok := pb[root+"everywhere.md"]; !ok {
t.Error("the mesh item is missing on the server")
}
if _, ok := pa[root+"server-only.md"]; ok {
t.Error("the server's item reached the laptop")
}
if _, ok := pb[root+"server-only.md"]; !ok {
t.Error("the server's item is missing on the server")
}
if _, err := os.Stat(filepath.Join(a.Home, ".claude", "commands", "at-home.md")); err != nil {
t.Error("the home item was not placed in the laptop's home")
}
if _, err := os.Stat(filepath.Join(b.Home, ".claude", "commands", "at-home.md")); err == nil {
t.Error("the laptop's home item reached the server's home")
}
if _, ok := pa[root+"at-home.md"]; ok {
t.Error("a home item went into the plugin")
}
}
func TestTheMeshsOwnKeysCannotBeSetOrReplaced(t *testing.T) {
for _, key := range []string{"extraKnownMarketplaces", "enabledPlugins", "attribution", "apiKeyHelper"} {
it := Item{Kind: KindSettings, Name: SettingsName, Scope: ScopeMesh, Settings: map[string]any{key: true}}
if it.Problem() == "" {
t.Errorf("a registration could set %s", key)
}
}
out := Render(Facts{Console: "x"}, Settings{ManagedSettings: map[string]any{"enabledPlugins": map[string]any{Plugin + "@" + Plugin: false}}},
nil, "/h", nil, Config{})
var managed map[string]any
_ = json.Unmarshal([]byte(out["managed-settings.json"]), &managed)
if managed["enabledPlugins"].(map[string]any)[Plugin+"@"+Plugin] != true {
t.Error("the operator's setting turned the mesh's plugin off")
}
}
func TestSettingsLayMeshThenNodeAndJoinTheirLists(t *testing.T) {
c := Config{
Mesh: []Item{{Kind: KindSettings, Scope: ScopeMesh, Settings: map[string]any{
"permissions": map[string]any{"deny": []any{"A"}}, "model": "mesh-model"}}},
Node: []Item{{Kind: KindSettings, Scope: ScopeNode, Settings: map[string]any{
"permissions": map[string]any{"deny": []any{"A", "B"}}, "model": "node-model"}}},
}
out := Render(Facts{Console: "x"}, Settings{ManagedSettings: map[string]any{"permissions": map[string]any{"allow": []any{"C"}}}},
nil, "/h", nil, c)
var managed struct {
Permissions map[string][]string `json:"permissions"`
Model string `json:"model"`
}
_ = json.Unmarshal([]byte(out["managed-settings.json"]), &managed)
if strings.Join(managed.Permissions["deny"], ",") != "A,B" || strings.Join(managed.Permissions["allow"], ",") != "C" || managed.Model != "node-model" {
t.Fatalf("%+v", managed)
}
}
func TestTheHomeScopeOwnsOnlyWhatItPlaced(t *testing.T) {
p, w := node(t, "laptop")
state, view := memConfig{}, NewConfigView(p)
mine := filepath.Join(p.Home, ".claude", "agents", "mine.md")
_ = os.MkdirAll(filepath.Dir(mine), 0o755)
writeFile(t, mine, "the person's own")
answer, _ := Register(p, Item{Kind: KindAgent, Name: "mine", Scope: ScopeHome, Files: one("mine", "the mesh's")}, nil, false, state, view, writer(w))
if answer["registered"] != false {
t.Fatalf("a name the person uses was taken: %v", answer)
}
if raw, _ := os.ReadFile(mine); string(raw) != "the person's own" {
t.Fatal("the person's file was overwritten")
}
placed := filepath.Join(p.Home, ".claude", "agents", "placed.md")
if _, err := Register(p, Item{Kind: KindAgent, Name: "placed", Scope: ScopeHome, Files: one("placed", "v1")}, nil, false, state, view, writer(w)); err != nil {
t.Fatal(err)
}
if raw, _ := os.ReadFile(placed); string(raw) != "v1" {
t.Fatal("the home item was not placed")
}
if _, err := Register(p, Item{Kind: KindAgent, Name: "placed", Scope: ScopeHome}, nil, true, state, view, writer(w)); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(placed); err == nil {
t.Fatal("unregistering did not remove what the mesh placed")
}
if _, err := os.Stat(mine); err != nil {
t.Fatal("unregistering removed the person's file")
}
// Changed by hand after it was placed: left alone when unregistered.
if _, err := Register(p, Item{Kind: KindAgent, Name: "edited", Scope: ScopeHome, Files: one("edited", "v1")}, nil, false, state, view, writer(w)); err != nil {
t.Fatal(err)
}
edited := filepath.Join(p.Home, ".claude", "agents", "edited.md")
writeFile(t, edited, "changed by hand")
if _, err := Register(p, Item{Kind: KindAgent, Name: "edited", Scope: ScopeHome}, nil, true, state, view, writer(w)); err != nil {
t.Fatal(err)
}
if raw, _ := os.ReadFile(edited); string(raw) != "changed by hand" {
t.Fatal("a placed file changed by hand was removed")
}
}
func TestAnItemAboveTheLimitOrMisshapenIsRefused(t *testing.T) {
big := Item{Kind: KindSkill, Name: "big", Scope: ScopeMesh, Files: map[string]string{"SKILL.md": strings.Repeat("x", MaxItemBytes+1)}}
cases := map[string]Item{
"too big": big,
"a bad name": {Kind: KindAgent, Name: "Bad Name", Scope: ScopeMesh, Files: one("Bad Name", "x")},
"a skill without one": {Kind: KindSkill, Name: "s", Scope: ScopeMesh, Files: map[string]string{"other.md": "x"}},
"a path outside": {Kind: KindSkill, Name: "s", Scope: ScopeMesh, Files: map[string]string{"SKILL.md": "x", "../escape": "x"}},
"a hook at home": {Kind: KindHook, Name: "h", Scope: ScopeHome, Event: "Stop", Command: "true"},
"settings at home": {Kind: KindSettings, Name: SettingsName, Scope: ScopeHome, Settings: map[string]any{"model": "x"}},
"an unknown event": {Kind: KindHook, Name: "h", Scope: ScopeMesh, Event: "Whenever", Command: "true"},
}
for label, it := range cases {
if it.Problem() == "" {
t.Errorf("%s was accepted", label)
}
}
}
func TestAHomeItemIsImportedAndTheStaleOnesAreNamed(t *testing.T) {
p, _ := node(t, "laptop")
dir := filepath.Join(p.Home, ".claude")
_ = os.MkdirAll(filepath.Join(dir, "skills", "old", "scripts"), 0o755)
writeFile(t, filepath.Join(dir, "skills", "old", "SKILL.md"), "---\nname: old\n---\nuse mcp__gone__do_it")
writeFile(t, filepath.Join(dir, "skills", "old", "scripts", "a.sh"), "#!/bin/sh\n")
it, err := ImportFromHome(p, KindSkill, "old")
if err != nil || len(it.Files) != 2 || it.Files["scripts/a.sh"] == "" {
t.Fatalf("%+v %v", it, err)
}
items := HomeItems(p, Config{Mesh: []Item{{Kind: KindSkill, Name: "old"}}}, Servers{})
if len(items) != 1 || len(items[0].Notes) != 2 {
t.Fatalf("%+v", items)
}
}
// The manifest lists exactly the tools the bundle serves: a tool missing from it is never announced.
func TestTheManifestListsEveryToolServed(t *testing.T) {
raw, err := os.ReadFile("../../module.json")
if err != nil {
t.Fatal(err)
}
var m struct {
Tools []string `json:"tools"`
}
_ = json.Unmarshal(raw, &m)
listed := map[string]bool{}
for _, n := range m.Tools {
listed[n] = true
}
p, _ := node(t, "laptop")
served := tools(p, nil, NewServerView(p), memConfig{}, NewConfigView(p))
for _, tool := range served {
if !listed[tool.Name] {
t.Errorf("%s is served and not in the manifest", tool.Name)
}
delete(listed, tool.Name)
}
for n := range listed {
t.Errorf("%s is in the manifest and not served", n)
}
}
// The tree writer's comparison: a directory holding exactly the files given, executable bits included.
func TestATreeIsTheSameOnlyWhenEveryFileAndModeIs(t *testing.T) {
dir := t.TempDir()
files := map[string]PluginFile{"a.md": {Content: "a"}, "s/run.sh": {Content: "#!/bin/sh\n", Executable: true}}
_ = os.MkdirAll(filepath.Join(dir, "s"), 0o755)
writeFile(t, filepath.Join(dir, "a.md"), "a")
writeFile(t, filepath.Join(dir, "s", "run.sh"), "#!/bin/sh\n")
if sameTree(dir, files) {
t.Fatal("a script without its executable bit counted as the same")
}
_ = os.Chmod(filepath.Join(dir, "s", "run.sh"), 0o755)
if !sameTree(dir, files) {
t.Fatal("the same tree counted as different")
}
writeFile(t, filepath.Join(dir, "extra.md"), "x")
if sameTree(dir, files) {
t.Fatal("an extra file counted as the same")
}
}
// A registration removed while a node was away is dropped when it is back: the watch hands over only what is
// there, so the view is pruned to the keys the state still holds.
func TestWhatWasRemovedWhileANodeWasAwayIsDropped(t *testing.T) {
p, w := node(t, "laptop")
state, view := memConfig{}, NewConfigView(p)
for _, name := range []string{"kept", "gone"} {
if _, err := Register(p, Item{Kind: KindCommand, Name: name, Scope: ScopeMesh, Files: one(name, "x")}, nil, false, state, view, writer(w)); err != nil {
t.Fatal(err)
}
}
delete(state, "mesh.command.gone") // removed from another node while this one was off
back := NewConfigView(p) // the node starts again from what it last wrote
if len(back.Items()) != 2 {
t.Fatalf("the view did not start from what was last written: %v", back.Items())
}
keys, _ := state.Keys()
if !back.Prune(keys) || len(back.Items()) != 1 {
t.Fatalf("after pruning: %v", back.Items())
}
}
// The review's findings, each held by a test.
func TestAPathOrAKeyThatIsNotWhatItSaysIsRefused(t *testing.T) {
for label, files := range map[string]map[string]string{
"a dot": {"SKILL.md": "x", ".": "x"},
"an empty segment": {"SKILL.md": "x", "a//b": "x"},
"a parent segment": {"SKILL.md": "x", "a/../b": "x"},
"a file and directory": {"SKILL.md": "x", "a": "x", "a/b": "x"},
} {
if (Item{Kind: KindSkill, Name: "s", Scope: ScopeMesh, Files: files}).Problem() == "" {
t.Errorf("%s was accepted", label)
}
}
p, _ := node(t, "laptop")
v := NewConfigView(p)
home := Item{Kind: KindCommand, Name: "x", Scope: ScopeHome, Files: one("x", "y")}
if v.Take("mesh.command.x", "put", &home); len(v.Items()) != 0 {
t.Fatal("a mesh key holding a home item was taken")
}
for _, key := range []string{"mesh.command.x", "mesh.agent.x"} {
mesh := Item{Kind: KindCommand, Name: "x", Scope: ScopeMesh, Files: one("x", "y")}
v.Take(key, "put", &mesh)
}
if len(v.Items()) != 1 {
t.Fatalf("an item under a key of another kind was taken: %v", v.Items())
}
}
func TestAllIsEveryNodeAndANameThatCannotBeAKeyIsRefused(t *testing.T) {
nodes, err := ExpandNodes([]string{"all"}, func() ([]string, error) { return []string{"ace", "g14"}, nil })
if err != nil || strings.Join(nodes, ",") != "ace,g14" {
t.Fatalf("%v %v", nodes, err)
}
p, w := node(t, "laptop")
answer, _ := Register(p, Item{Kind: KindCommand, Name: "c", Scope: ScopeNode, Files: one("c", "x")}, []string{"a.b"}, false, memConfig{}, NewConfigView(p), writer(w))
if answer["registered"] != false {
t.Fatalf("a node name with a dot was used in a key: %v", answer)
}
if (Item{Kind: KindSettings, Name: SettingsName, Scope: ScopeMesh, Settings: map[string]any{"deniedMcpServers": []any{}}}).Problem() == "" {
t.Fatal("a registration could deny the mesh's console")
}
}
func TestTheOperatorsOwnPluginsAndMarketplacesAreKept(t *testing.T) {
out := Render(Facts{Console: "x"}, Settings{ManagedSettings: map[string]any{
"enabledPlugins": map[string]any{"theirs@market": true},
"extraKnownMarketplaces": map[string]any{"market": map[string]any{"source": map[string]any{"source": "github", "repo": "o/r"}}},
}}, nil, "/h", nil, Config{})
var managed struct {
Enabled map[string]any `json:"enabledPlugins"`
Known map[string]any `json:"extraKnownMarketplaces"`
}
_ = json.Unmarshal([]byte(out["managed-settings.json"]), &managed)
if managed.Enabled["theirs@market"] != true || managed.Enabled[Plugin+"@"+Plugin] != true || managed.Known["market"] == nil || managed.Known[Plugin] == nil {
t.Fatalf("%+v", managed)
}
}
func TestTheHomeLeavesThePersonsChoicesAlone(t *testing.T) {
p, _ := node(t, "laptop")
dir := filepath.Join(p.Home, ".claude")
// An identical file the person made is not taken over, so unregistering never removes it.
_ = os.MkdirAll(filepath.Join(dir, "agents"), 0o755)
writeFile(t, filepath.Join(dir, "agents", "same.md"), "x")
if _, left := PlaceHome(p, map[string]string{"agents/same.md": "x"}); len(left) != 1 {
t.Fatalf("an identical file of the person's was taken over: %v", left)
}
PlaceHome(p, map[string]string{})
if _, err := os.Stat(filepath.Join(dir, "agents", "same.md")); err != nil {
t.Fatal("the person's file was removed")
}
// A placed file the person deleted stays deleted while it is registered.
PlaceHome(p, map[string]string{"commands/c.md": "x"})
_ = os.Remove(filepath.Join(dir, "commands", "c.md"))
PlaceHome(p, map[string]string{"commands/c.md": "x"})
if _, err := os.Stat(filepath.Join(dir, "commands", "c.md")); err == nil {
t.Fatal("a file the person deleted was placed again")
}
// The kind's own directory stays when the mesh's last item in it goes.
PlaceHome(p, map[string]string{"skills/s/SKILL.md": "x"})
PlaceHome(p, map[string]string{})
if _, err := os.Stat(filepath.Join(dir, "skills", "s")); err == nil {
t.Fatal("the item's own directory was left")
}
if _, err := os.Stat(filepath.Join(dir, "skills")); err != nil {
t.Fatal("the kind's directory was removed")
}
// Never through a symbolic link.
elsewhere := t.TempDir()
if err := os.Symlink(elsewhere, filepath.Join(dir, "output-styles")); err != nil {
t.Skip("no symbolic links here")
}
PlaceHome(p, map[string]string{"output-styles/o.md": "x"})
if _, err := os.Stat(filepath.Join(elsewhere, "o.md")); err == nil {
t.Fatal("a file was written through a symbolic link")
}
}
func TestOneFileThatCannotBeWrittenDoesNotStopTheOthers(t *testing.T) {
p, _ := node(t, "laptop")
w := map[string]string{}
failing := func(name, content string) (string, error) {
if name == MarketplaceDir+"/" {
return "", os.ErrPermission
}
w[name] = content
return name + ": written", nil
}
if _, err := RenderNow(p, failing); err == nil {
t.Fatal("the failure was not reported")
}
if w["managed-settings.json"] == "" || w["managed-mcp.json"] == "" || w["CLAUDE.md"] == "" {
t.Fatalf("the other files were not written: %v", w)
}
}
// The answer says what this node wrote even when its own watch took the change first.
func TestTheAnswerSaysWhatWasWrittenWhenTheWatchWasFirst(t *testing.T) {
p, w := node(t, "laptop")
state, view := memConfig{}, NewConfigView(p)
it := Item{Kind: KindCommand, Name: "c", Scope: ScopeHome, Files: one("c", "x")}
first := it
view.Take(it.Key("laptop"), "put", &first) // the watch, first
answer, err := Register(p, it, nil, false, state, view, writer(w))
if err != nil || answer["rendered here"] == nil || answer["here"] != nil {
t.Fatalf("%v %v", answer, err)
}
}
@@ -0,0 +1,388 @@
package main
// The tools that register the agent's configuration (novox/hq ADR 0216): for each kind a list, a register and an
// unregister; for settings, a read, a set, a clear and the permission rules; and the status and import tools
// for what the module did not place.
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
)
// kindTool is how one kind is named in its tools and described to whoever calls them.
type kindTool struct {
kind, tool, what, lands string
}
var kindTools = []kindTool{
{KindSkill, "skill", "a skill: a folder with a SKILL.md (front matter `name` and `description`) and any files beside it",
"the plugin's skills/<name>/ (home: ~/.claude/skills/<name>/)"},
{KindAgent, "agent", "a subagent: one markdown file with front matter (`name`, `description`, optionally `tools`, `model`)",
"the plugin's agents/<name>.md (home: ~/.claude/agents/<name>.md)"},
{KindCommand, "command", "a slash command: one markdown file, its front matter optional (`description`, `argument-hint`, `allowed-tools`)",
"the plugin's commands/<name>.md, run as /" + Plugin + ":<name> (home: ~/.claude/commands/<name>.md, run as /<name>)"},
{KindHook, "hook", "a hook: an event, an optional matcher, a command, and optionally the scripts it runs — ${HOOK_DIR} in the command is their directory",
"the plugin's hooks/hooks.json, its scripts in hooks/<name>/ (mesh and node scopes only)"},
{KindOutputStyle, "output_style", "an output style: one markdown file with front matter (`name`, `description`)",
"the plugin's output-styles/<name>.md (home: ~/.claude/output-styles/<name>.md)"},
{KindInstructions, "instructions", "a section of instructions every session reads",
"a section of the managed instruction file, after the mesh's own text (home: a rule file, ~/.claude/rules/<name>.md)"},
}
func boolArg(a map[string]any, k string) bool { b, _ := a[k].(bool); return b }
func strArg(a map[string]any, k string) string {
s, _ := a[k].(string)
return strings.TrimSpace(s)
}
// targetNodes reads the `nodes` argument: absent is this node, "all" every node running claude-code, else a list.
func targetNodes(a map[string]any) ([]string, error) {
return ExpandNodes(nodesOf(a["nodes"]), nodesRunningMe)
}
// scopeOf reads the `scope` argument; absent is the mesh, which is what a registration is most often for.
func scopeOf(a map[string]any) string {
if s := strArg(a, "scope"); s != "" {
return s
}
return ScopeMesh
}
// filesOf reads an item's files: `content` for a one-file kind, or `files`, a map of path to content.
func filesOf(kind, name string, a map[string]any) (map[string]string, error) {
out := map[string]string{}
if raw, ok := a["files"].(map[string]any); ok {
for rel, v := range raw {
s, ok := v.(string)
if !ok {
return nil, fmt.Errorf("files.%s is not text", rel)
}
out[rel] = s
}
}
if content, ok := a["content"].(string); ok && content != "" {
switch kind {
case KindSkill:
out["SKILL.md"] = content
case KindHook:
return nil, errors.New("a hook's scripts are given as files")
default:
out[name+".md"] = content
}
}
return out, nil
}
func configTools(p Paths, state ConfigState, view *ConfigView) []stdio.Tool {
scopeArg := str(`mesh (every node; the default), node (the nodes given, or this one) or home (the operator account's own ~/.claude on the nodes given, or this one)`)
nodesArg := str(`for the node and home scopes: "all" for every node running claude-code, or a comma-separated list; absent is this node`)
var out []stdio.Tool
for _, k := range kindTools {
k := k
input := map[string]any{
"name": str("the name: lower-case letters, digits and -"),
"scope": scopeArg,
"nodes": nodesArg,
}
switch k.kind {
case KindSkill:
input["content"] = str("the SKILL.md, when the skill is that one file")
input["files"] = map[string]any{"type": "object", "description": "the skill's files by path inside it, SKILL.md among them, e.g. {\"SKILL.md\": \"...\", \"scripts/run.sh\": \"#!/bin/sh ...\"}"}
case KindHook:
input["event"] = str("PreToolUse, PostToolUse, UserPromptSubmit, Notification, Stop, SubagentStop, SessionStart, SessionEnd or PreCompact")
input["matcher"] = str("for tool events, which tools, e.g. Bash or Edit|Write; absent is every one")
input["command"] = str("the shell command; ${HOOK_DIR} is the directory of the files given, e.g. ${HOOK_DIR}/check.sh")
input["files"] = map[string]any{"type": "object", "description": "the scripts the command runs, by path, e.g. {\"check.sh\": \"#!/bin/sh ...\"}"}
default:
input["content"] = str("the file's content, front matter included")
}
out = append(out,
stdio.Tool{Name: "claude_code_" + k.tool + "_list",
Description: "Every " + k.kind + " registered for Claude Code on the mesh, by key (`mesh.…` every node, `node.<node>.…` one node, `home.<node>.…` an account's own directory), with who registered it and its files. Lands in " + k.lands + ".",
Run: func(map[string]any) (any, error) { return List(state, k.kind) }},
stdio.Tool{Name: "claude_code_" + k.tool + "_register",
Description: "Register " + k.what + ", for every node (scope mesh, the default), some nodes (node) or an account's own directory (home). Kept on the bus: a node that joins later takes it too. Lands in " + k.lands + "; a new session takes it, a running one at /reload-plugins. Refused above 256 KiB, or at home where the person already has one of that name.",
Input: input,
Run: func(a map[string]any) (any, error) {
name := strArg(a, "name")
files, err := filesOf(k.kind, name, a)
if err != nil {
return nil, err
}
it := Item{Kind: k.kind, Name: name, Scope: scopeOf(a), Files: files,
Event: strArg(a, "event"), Matcher: strArg(a, "matcher"), Command: strArg(a, "command")}
nodes, err := targetNodes(a)
if err != nil {
return nil, err
}
return Register(p, it, nodes, false, state, view, writeManaged)
}},
stdio.Tool{Name: "claude_code_" + k.tool + "_unregister",
Description: "Remove a " + k.kind + " registered through this module, at its scope. At home, only what the mesh placed is removed, and not if it was changed by hand since.",
Input: map[string]any{"name": str("the name"), "scope": scopeArg, "nodes": nodesArg},
Run: func(a map[string]any) (any, error) {
it := Item{Kind: k.kind, Name: strArg(a, "name"), Scope: scopeOf(a)}
nodes, err := targetNodes(a)
if err != nil {
return nil, err
}
return Register(p, it, nodes, true, state, view, writeManaged)
}},
)
}
settingsScope := str(`mesh (every node; the default) or node (the nodes given, or this one)`)
out = append(out,
stdio.Tool{Name: "claude_code_settings_get",
Description: "Claude Code's managed settings on this node as written, and where each part came from: the operator's `managed_settings` setting (ADR 0213), the settings registered for the mesh and for this node, and the mesh's own keys, which always win.",
Run: func(map[string]any) (any, error) {
written := map[string]any{}
_ = readJSON(filepath.Join(ManagedDir, "managed-settings.json"), &written)
var settings Settings
_ = readJSON(p.Settings, &settings)
c := ConfigOf(view.Items())
layers := map[string]any{"managed_settings setting": settings.ManagedSettings}
for _, it := range c.Mesh {
if it.Kind == KindSettings {
layers["registered for the mesh"] = it.Settings
}
}
for _, it := range c.Node {
if it.Kind == KindSettings {
layers["registered for this node"] = it.Settings
}
}
return map[string]any{"written": written, "layers": layers,
"order": "managed_settings setting, then mesh, then node — objects merged, lists joined — then the mesh's own keys"}, nil
}},
stdio.Tool{Name: "claude_code_settings_set",
Description: "Set Claude Code settings (the vendor's settings keys: permissions, autoMode, env, model, hooks, statusLine, …) for every node or some. Merged into what that scope holds — objects key by key, lists joined — unless replace is set. The mesh's own keys (attribution, the connectors key, the key-helper, the plugin's marketplace) cannot be set. The agent refuses to loosen its own settings: this is the operator's act.",
Input: map[string]any{
"settings": map[string]any{"type": "object", "description": "settings keys, e.g. {\"permissions\": {\"deny\": [\"Bash(rm -rf:*)\"]}}"},
"replace": map[string]any{"type": "boolean", "description": "replace what the scope holds instead of merging into it"},
"scope": settingsScope, "nodes": nodesArg,
},
Run: func(a map[string]any) (any, error) {
given, _ := a["settings"].(map[string]any)
if len(given) == 0 {
return nil, errors.New("no settings given; to remove a scope's settings, claude_code_settings_clear")
}
nodes, err := targetNodes(a)
if err != nil {
return nil, err
}
return SetSettings(p, scopeOf(a), nodes, func(held map[string]any) map[string]any {
if boolArg(a, "replace") {
return given
}
return mergeSettings(held, given)
}, state, view)
}},
stdio.Tool{Name: "claude_code_settings_clear",
Description: "Remove the Claude Code settings registered at a scope.",
Input: map[string]any{"scope": settingsScope, "nodes": nodesArg},
Run: func(a map[string]any) (any, error) {
it := Item{Kind: KindSettings, Name: SettingsName, Scope: scopeOf(a)}
nodes, err := targetNodes(a)
if err != nil {
return nil, err
}
return Register(p, it, nodes, true, state, view, writeManaged)
}},
stdio.Tool{Name: "claude_code_permission_add",
Description: "Add a permission rule to Claude Code's managed settings, for every node or some: allow (runs without asking), ask (always asks) or deny (never runs). A rule is a tool and an optional specifier, e.g. Bash(git status:*), Read(./secrets/**), mcp__mesh__mesh_call.",
Input: map[string]any{"list": str("allow, ask or deny"), "rule": str("the rule"), "scope": settingsScope, "nodes": nodesArg},
Run: func(a map[string]any) (any, error) {
list, rule := strArg(a, "list"), strArg(a, "rule")
if (list != "allow" && list != "ask" && list != "deny") || rule == "" {
return nil, errors.New("list is allow, ask or deny, and a rule is needed")
}
nodes, err := targetNodes(a)
if err != nil {
return nil, err
}
return SetSettings(p, scopeOf(a), nodes, func(held map[string]any) map[string]any {
return mergeSettings(held, map[string]any{"permissions": map[string]any{list: []any{rule}}})
}, state, view)
}},
stdio.Tool{Name: "claude_code_permission_remove",
Description: "Remove a permission rule added through this module, at its scope.",
Input: map[string]any{"list": str("allow, ask or deny"), "rule": str("the rule"), "scope": settingsScope, "nodes": nodesArg},
Run: func(a map[string]any) (any, error) {
list, rule := strArg(a, "list"), strArg(a, "rule")
nodes, err := targetNodes(a)
if err != nil {
return nil, err
}
return SetSettings(p, scopeOf(a), nodes, func(held map[string]any) map[string]any {
perms, _ := held["permissions"].(map[string]any)
rules, _ := perms[list].([]any)
if len(rules) == 0 {
return held // nothing to remove: what the scope holds stays as it is
}
kept := []any{}
for _, r := range rules {
if r != rule {
kept = append(kept, r)
}
}
next := mergeSettings(map[string]any{}, held)
np := mergeSettings(map[string]any{}, perms)
np[list] = kept
next["permissions"] = np
return next
}, state, view)
}},
stdio.Tool{Name: "claude_code_config_list",
Description: "Everything registered for Claude Code on the mesh through this module — skills, subagents, commands, hooks, output styles, instruction sections, settings — by key, with who registered each and its files.",
Run: func(map[string]any) (any, error) { return List(state, "") }},
stdio.Tool{Name: "claude_code_config_show",
Description: "One registration in full, its files' content included, by its key as a list shows it (e.g. mesh.skill.review).",
Input: map[string]any{"key": str("the key")},
Run: func(a map[string]any) (any, error) { return Show(state, strArg(a, "key")) }},
stdio.Tool{Name: "claude_code_config_status",
Description: "Claude Code's configuration on this node: what was registered and applies here (mesh, node, home), the plugin as written, and the home's own skills, subagents, commands, output styles, rule files and memory (CLAUDE.md) — which the mesh placed, which share a name with a mesh item, and which call tools of a tool server not loaded here (stale).",
Run: func(map[string]any) (any, error) {
c := ConfigOf(view.Items())
names := func(items []Item) []string {
out := []string{}
for _, it := range items {
out = append(out, it.Kind+" "+it.Name)
}
return out
}
var mcp struct {
MCPServers Servers `json:"mcpServers"`
}
_ = readJSON(filepath.Join(ManagedDir, "managed-mcp.json"), &mcp)
var placed Placed
_ = readJSON(p.placed(), &placed)
plugin := []string{}
root := filepath.Join(ManagedDir, MarketplaceDir, "plugins", Plugin)
_ = filepath.WalkDir(root, func(full string, d os.DirEntry, err error) error {
if err == nil && !d.IsDir() {
rel, _ := filepath.Rel(root, full)
plugin = append(plugin, rel)
}
return nil
})
return map[string]any{
"applies here": map[string]any{"mesh": names(c.Mesh), "node": names(c.Node), "home": names(c.Home)},
"plugin": map[string]any{"name": Plugin, "files": plugin},
"home": HomeItems(p, c, mcp.MCPServers),
"placed": placed,
}, nil
}},
stdio.Tool{Name: "claude_code_config_import",
Description: "Register an item found in this node's own ~/.claude — a skill, subagent, command, output style, or a rule file as instructions — at the scope given, so something written by hand on one machine reaches every node, some, or stays at home under the mesh's care. The original is left where it is: removing it is the person's act.",
Input: map[string]any{
"kind": str("skill, agent, command, output-style or instructions (a rule file)"),
"name": str("its name in the home: the skill's folder, or the file without .md"),
"scope": scopeArg, "nodes": nodesArg,
},
Run: func(a map[string]any) (any, error) {
it, err := ImportFromHome(p, strArg(a, "kind"), strArg(a, "name"))
if err != nil {
return nil, err
}
it.Scope = scopeOf(a)
if it.Scope == ScopeHome {
return nil, errors.New("it is already at home; import it to the mesh or node scope")
}
nodes, err := targetNodes(a)
if err != nil {
return nil, err
}
return Register(p, it, nodes, false, state, view, writeManaged)
}},
stdio.Tool{Name: "claude_code_home_show",
Description: "One item of this node's operator account's own ~/.claude in full, as the status tool lists it — a skill (every file in its folder), subagent, command, output style or rule file — or the account's own memory, ~/.claude/CLAUDE.md: where it is, whether the mesh placed it, and its content.",
Input: map[string]any{
"kind": str("skill, agent, command, output-style, instructions (a rule file) or memory (~/.claude/CLAUDE.md)"),
"name": str("its name in the home: the skill's folder, or the file without .md; absent for memory"),
},
Run: func(a map[string]any) (any, error) { return ShowHome(p, strArg(a, "kind"), strArg(a, "name")) }},
stdio.Tool{Name: "claude_code_home_remove",
Description: "Remove one item the person made in this node's operator account's own ~/.claude — a skill, subagent, command, output style, rule file, or the memory ~/.claude/CLAUDE.md — on the person's word: removing it is the person's act, and this tool is that act made explicit. Called only when the person asked for that item to go, never on the agent's own judgement. `why` is required. An item the mesh placed is refused (its unregister owns it). A copy is kept first in the module's state, under removed-from-home/<date>/, and the removal and its reason are logged there; the answer says where the copy is, so it can be put back.",
Input: map[string]any{
"kind": str("skill, agent, command, output-style, instructions (a rule file) or memory (~/.claude/CLAUDE.md)"),
"name": str("its name in the home: the skill's folder, or the file without .md; absent for memory"),
"why": str("the person's reason for removing it, kept in the log"),
},
Run: func(a map[string]any) (any, error) {
answer, err := RemoveHome(p, strArg(a, "kind"), strArg(a, "name"), strArg(a, "why"), time.Now())
if err != nil {
return nil, err
}
// A home registration of the same name was held back by the person's file; it is placed now.
for _, it := range ConfigOf(view.Items()).Home {
if it.Kind == answer["kind"] && it.Name == answer["name"] {
answer["then"] = "the mesh registers a " + it.Kind + " of this name for this home; it is placed at the next render"
}
}
return answer, nil
}},
stdio.Tool{Name: "claude_code_home_removed",
Description: "Every item removed from this node's home through claude_code_home_remove whose copy the module keeps, newest first: what it was, where it was, why it went, its keptAt, and whether it was put back.",
Run: func(map[string]any) (any, error) { return KeptRemovals(p) }},
stdio.Tool{Name: "claude_code_home_restore",
Description: "Undo a removal made with claude_code_home_remove: put the kept copy back at the path it was removed from. Refused when something is at that path now, or when the copy is not exactly what was removed (checked file by file against the digests its removal.json recorded). Logged as the removal was; the copy stays.",
Input: map[string]any{"kept": str("the keptAt the removal answered, as claude_code_home_removed lists it")},
Run: func(a map[string]any) (any, error) { return RestoreHome(p, strArg(a, "kept"), time.Now()) }},
)
return out
}
// SetSettings changes the settings registered at a scope, one key per node, by what change makes of what
// the key holds. An empty result removes the key.
func SetSettings(p Paths, scope string, nodes []string, change func(held map[string]any) map[string]any,
state ConfigState, view *ConfigView) (map[string]any, error) {
if scope != ScopeMesh && scope != ScopeNode {
return map[string]any{"set": false, "reason": "settings take the mesh and node scopes only"}, nil
}
if scope == ScopeMesh {
nodes = []string{""}
} else if len(nodes) == 0 {
nodes = []string{p.Node}
} else if problem := nodesProblem(nodes); problem != "" {
return map[string]any{"set": false, "reason": problem}, nil
}
answers := map[string]any{}
for _, n := range nodes {
it := Item{Kind: KindSettings, Name: SettingsName, Scope: scope}
held := map[string]any{}
if raw, found, err := state.Get(it.Key(n)); err != nil {
return nil, err
} else if found {
var was Item
if json.Unmarshal(raw, &was) == nil && was.Settings != nil {
held = was.Settings
}
}
it.Settings = change(held)
target := []string(nil)
if n != "" {
target = []string{n}
}
var answer map[string]any
var err error
if len(it.Settings) == 0 {
answer, err = Register(p, it, target, true, state, view, writeManaged)
} else {
answer, err = Register(p, it, target, false, state, view, writeManaged)
}
if err != nil {
return nil, err
}
answer["settings"] = it.Settings
answers[it.Key(n)] = answer
}
return answers, nil
}
+448
View File
@@ -0,0 +1,448 @@
package main
// The person's own items in the operator account's agent directory — the ones the mesh did not place (novox/hq
// ADR 0216 rule 6) — read and removed through the mesh rather than over a shell on the machine.
//
// Removing one stays the person's act: the remove tool is the act made explicit. It is called on the person's
// word, takes their reason, refuses anything the mesh placed (unregister owns those), keeps a copy outside the
// agent directory before it deletes, and logs what it removed and why. Nothing here removes on its own.
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
// KindMemory is the account's own instruction file, ~/.claude/CLAUDE.md: never placed by the mesh, read by
// every session of the account.
const KindMemory = "memory"
// memoryName is the one name the memory kind has.
const memoryName = "CLAUDE"
// RemovedDir is where, in the module's state, a removed item is kept: one dated folder per day, one folder
// per removal inside it.
const RemovedDir = "removed-from-home"
// homeKinds are the kinds the home tools take: those the status tool lists, and the memory.
var homeKinds = map[string]string{KindSkill: "skills", KindAgent: "agents", KindCommand: "commands",
KindOutputStyle: "output-styles", KindInstructions: "rules", KindMemory: ""}
func (p Paths) removedLog() string { return filepath.Join(p.State, RemovedDir, "removed.log") }
// homeKindOf reads a kind as a person may write it: output_style for output-style, rule for instructions.
func homeKindOf(kind string) string {
switch k := strings.ToLower(strings.TrimSpace(kind)); k {
case "output_style":
return KindOutputStyle
case "rule", "rules":
return KindInstructions
case "claude.md":
return KindMemory
default:
return k
}
}
// HomeItemPath is where one item of the home is, relative to the agent directory: a skill is its folder,
// every other kind one file.
func HomeItemPath(kind, name string) (rel string, folder bool, err error) {
kind = homeKindOf(kind)
sub, ok := homeKinds[kind]
if !ok {
return "", false, fmt.Errorf("kind is skill, agent, command, output-style, instructions (a rule file) or memory (~/.claude/CLAUDE.md), not %q", kind)
}
if kind == KindMemory {
if name != "" && name != memoryName && name != memoryName+".md" {
return "", false, fmt.Errorf("the memory is one file, CLAUDE.md; its name is %s or absent", memoryName)
}
return "CLAUDE.md", false, nil
}
name = strings.TrimSuffix(name, ".md")
if name == "" || name == "." || name == ".." || strings.HasPrefix(name, ".") || strings.ContainsAny(name, `/\`) || strings.ContainsRune(name, 0) {
return "", false, fmt.Errorf("%q is not an item's name: its folder, or its file without .md, as the status tool lists it", name)
}
if kind == KindSkill {
return sub + "/" + name, true, nil
}
return sub + "/" + name + ".md", false, nil
}
// placedUnder says whether the mesh placed the path, or anything inside it, and still holds it as its own: a
// path it placed that the person deleted and then made again is theirs (PlaceHome leaves it alone).
func placedUnder(p Paths, rel string) string {
var placed Placed
_ = readJSON(p.placed(), &placed)
for at, ours := range placed {
if ours == deletedByHand {
continue
}
if at == rel || strings.HasPrefix(at, rel+"/") {
return at
}
}
return ""
}
// readItem reads one item's files by path inside it, refusing a symbolic link anywhere on the way or in it:
// what it points at is not the home's.
func readItem(dir, rel string, folder bool) (map[string]string, map[string]fs.FileMode, error) {
if why := linkedParent(dir, rel+"/x"); why != "" {
return nil, nil, errors.New(why)
}
full := filepath.Join(dir, filepath.FromSlash(rel))
info, err := os.Lstat(full)
if err != nil {
if os.IsNotExist(err) {
return nil, nil, fmt.Errorf("%s is not in this home", full)
}
return nil, nil, err
}
if info.Mode()&os.ModeSymlink != 0 {
return nil, nil, fmt.Errorf("%s is a symbolic link", full)
}
files, modes := map[string]string{}, map[string]fs.FileMode{}
if !folder {
if !info.Mode().IsRegular() {
return nil, nil, fmt.Errorf("%s is not a file", full)
}
raw, err := os.ReadFile(full)
if err != nil {
return nil, nil, err
}
files[filepath.Base(full)], modes[filepath.Base(full)] = string(raw), info.Mode().Perm()
return files, modes, nil
}
if !info.IsDir() {
return nil, nil, fmt.Errorf("%s is not a folder", full)
}
err = filepath.WalkDir(full, func(at string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.Type()&fs.ModeSymlink != 0 {
return fmt.Errorf("%s is a symbolic link", at)
}
if d.IsDir() {
return nil
}
if !d.Type().IsRegular() {
return fmt.Errorf("%s is not a file", at)
}
in, _ := filepath.Rel(full, at)
raw, err := os.ReadFile(at)
if err != nil {
return err
}
fi, err := d.Info()
if err != nil {
return err
}
files[filepath.ToSlash(in)], modes[filepath.ToSlash(in)] = string(raw), fi.Mode().Perm()
return nil
})
if err != nil {
return nil, nil, err
}
return files, modes, nil
}
// ShowHome answers one item of the home in full: where it is, whether the mesh placed it, and its files.
func ShowHome(p Paths, kind, name string) (map[string]any, error) {
rel, folder, err := HomeItemPath(kind, name)
if err != nil {
return nil, err
}
dir := filepath.Join(p.Home, ".claude")
files, _, err := readItem(dir, rel, folder)
if err != nil {
return nil, err
}
answer := map[string]any{"kind": homeKindOf(kind), "path": filepath.Join(dir, filepath.FromSlash(rel)),
"placedByTheMesh": placedUnder(p, rel) != "", "files": files}
if !folder {
for _, content := range files {
answer["content"] = content
}
}
return answer, nil
}
// Removal is what the removed-items log keeps of one removal, and the copy's own note.
type Removal struct {
Action string `json:"action"`
At string `json:"at"`
Node string `json:"node"`
Kind string `json:"kind"`
Name string `json:"name"`
Path string `json:"path"`
Why string `json:"why,omitempty"`
Kept string `json:"keptAt"`
Files map[string]KeptFile `json:"files"`
}
// KeptFile is one file of a kept copy as it was in the home: its digest, checked before it is put back, and
// its mode.
type KeptFile struct {
Digest string `json:"sha256"`
Mode string `json:"mode"`
}
// RemoveHome removes one item the person made in the home, on their word and for the reason given: it keeps
// a copy in the module's state first, under a dated folder, checks the copy, then deletes and logs. An item
// the mesh placed is refused — unregistering owns it. Answers where the copy is, so it can be put back.
func RemoveHome(p Paths, kind, name, why string, now time.Time) (map[string]any, error) {
why = strings.TrimSpace(why)
if why == "" {
return nil, errors.New("why is required: the person's reason for removing it, kept in the log")
}
rel, folder, err := HomeItemPath(kind, name)
if err != nil {
return nil, err
}
kind = homeKindOf(kind)
if kind == KindMemory {
name = memoryName
} else {
name = strings.TrimSuffix(name, ".md")
}
if at := placedUnder(p, rel); at != "" {
return nil, fmt.Errorf("the mesh placed %s: remove it with claude_code_%s_unregister at the home scope", at,
strings.ReplaceAll(kind, "-", "_"))
}
dir := filepath.Join(p.Home, ".claude")
files, modes, err := readItem(dir, rel, folder)
if err != nil {
return nil, err
}
full := filepath.Join(dir, filepath.FromSlash(rel))
// The copy: <state>/removed-from-home/<date>/<time>-<kind>-<name>/<path as it was in the home>.
now = now.UTC()
day := filepath.Join(p.State, RemovedDir, now.Format("2006-01-02"))
kept := filepath.Join(day, now.Format("150405")+"-"+kind+"-"+name)
for n := 2; ; n++ {
if _, err := os.Lstat(kept); os.IsNotExist(err) {
break
}
kept = filepath.Join(day, fmt.Sprintf("%s-%s-%s-%d", now.Format("150405"), kind, name, n))
}
copyRoot := filepath.Join(kept, filepath.FromSlash(rel))
if !folder {
copyRoot = filepath.Dir(copyRoot)
}
names := map[string]KeptFile{}
for in, content := range files {
to := filepath.Join(copyRoot, filepath.FromSlash(in))
if err := os.MkdirAll(filepath.Dir(to), 0o700); err != nil {
return nil, fmt.Errorf("the copy could not be made, nothing removed: %w", err)
}
if err := os.WriteFile(to, []byte(content), modes[in]|0o600); err != nil {
return nil, fmt.Errorf("the copy could not be made, nothing removed: %w", err)
}
if back, err := os.ReadFile(to); err != nil || !bytes.Equal(back, []byte(content)) {
return nil, fmt.Errorf("the copy of %s does not read back the same, nothing removed", in)
}
names[in] = KeptFile{Digest: digest(content), Mode: fmt.Sprintf("%04o", modes[in])}
}
r := Removal{Action: "removed", At: now.Format(time.RFC3339), Node: p.Node, Kind: kind, Name: name, Path: full, Why: why,
Kept: filepath.Join(kept, filepath.FromSlash(rel)), Files: names}
note, _ := indented(r)
if err := os.WriteFile(filepath.Join(kept, "removal.json"), note, 0o600); err != nil {
return nil, fmt.Errorf("the copy's note could not be written, nothing removed: %w", err)
}
// The item may have changed while it was copied: only what was copied is removed.
again, _, err := readItem(dir, rel, folder)
if err != nil || len(again) != len(files) {
return nil, fmt.Errorf("%s changed while it was copied, nothing removed; the copy is at %s", full, kept)
}
for in, content := range again {
if files[in] != content {
return nil, fmt.Errorf("%s changed while it was copied, nothing removed; the copy is at %s", full, kept)
}
}
if folder {
err = os.RemoveAll(full)
} else {
err = os.Remove(full)
}
if err != nil {
return nil, fmt.Errorf("%s could not be removed (the copy is at %s): %w", full, kept, err)
}
logged := logRemoval(p, r)
say("removed %s from the home on the person's word, kept at %s: %s", full, r.Kept, why)
answer := map[string]any{"removed": full, "kind": kind, "name": name, "why": why, "keptAt": r.Kept,
"undo": "copy " + r.Kept + " back to " + full, "log": p.removedLog()}
if !logged {
answer["log"] = "the log could not be written; the removal is noted in " + filepath.Join(kept, "removal.json")
}
return answer, nil
}
// logRemoval appends one line to the removed-items log, and answers whether it could.
func logRemoval(p Paths, r Removal) bool {
line, _ := json.Marshal(r)
f, err := os.OpenFile(p.removedLog(), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
if err != nil {
return false
}
_, werr := f.Write(append(line, '\n'))
return f.Close() == nil && werr == nil
}
// ---- putting a removal back ---------------------------------------------------------------------------
// Kept is one removal whose copy the module keeps, as the list of removals shows it.
type Kept struct {
Removal
Restored string `json:"restored,omitempty"`
}
// KeptRemovals lists every removal the module keeps a copy of, newest first, and whether it was put back.
func KeptRemovals(p Paths) ([]Kept, error) {
root := filepath.Join(p.State, RemovedDir)
notes, _ := filepath.Glob(filepath.Join(root, "*", "*", "removal.json"))
out := []Kept{}
for _, note := range notes {
var k Kept
if !readJSON(note, &k.Removal) {
continue
}
var back Removal
if readJSON(filepath.Join(filepath.Dir(note), "restored.json"), &back) {
k.Restored = back.At
}
out = append(out, k)
}
sort.Slice(out, func(i, j int) bool {
if out[i].At != out[j].At {
return out[i].At > out[j].At
}
return out[i].Kept > out[j].Kept
})
return out, nil
}
// removalFolder finds the folder of one removal from what a removal answered as keptAt (or the folder
// itself): <state>/removed-from-home/<date>/<removal>, and nothing outside it.
func removalFolder(p Paths, kept string) (string, error) {
root := filepath.Join(p.State, RemovedDir)
rel, err := filepath.Rel(root, filepath.Clean(kept))
parts := strings.Split(filepath.ToSlash(rel), "/")
if err != nil || kept == "" || len(parts) < 2 || parts[0] == ".." || parts[0] == "." {
return "", fmt.Errorf("%q is not a copy this module kept: give the keptAt a removal answered, as claude_code_home_removed lists it", kept)
}
return filepath.Join(root, parts[0], parts[1]), nil
}
// RestoreHome puts a removed item back where it was: only when nothing is at that path now, and only when the
// kept copy is exactly what was removed, file by file against the digests its note recorded. Logged as the
// removal was; the copy stays, marked as put back.
func RestoreHome(p Paths, kept string, now time.Time) (map[string]any, error) {
folder, err := removalFolder(p, kept)
if err != nil {
return nil, err
}
var r Removal
if !readJSON(filepath.Join(folder, "removal.json"), &r) {
return nil, fmt.Errorf("%s holds no readable removal.json: nothing to check the copy against, nothing restored", folder)
}
// Where it goes is worked out again from its kind and name, never taken from the note alone.
rel, isFolder, err := HomeItemPath(r.Kind, r.Name)
if err != nil {
return nil, err
}
dir := filepath.Join(p.Home, ".claude")
full := filepath.Join(dir, filepath.FromSlash(rel))
if r.Path != full || r.Kept != filepath.Join(folder, filepath.FromSlash(rel)) {
return nil, fmt.Errorf("%s/removal.json does not describe the copy beside it, nothing restored", folder)
}
// The copy's own integrity: the same files, each with the digest recorded when it was removed.
files, _, err := readItem(folder, rel, isFolder)
if err != nil {
return nil, fmt.Errorf("the kept copy cannot be read, nothing restored: %w", err)
}
if len(files) != len(r.Files) {
return nil, fmt.Errorf("the kept copy holds %d file(s), its note %d: nothing restored", len(files), len(r.Files))
}
for in, content := range files {
want, ok := r.Files[in]
if !ok || digest(content) != want.Digest {
return nil, fmt.Errorf("the kept copy's %s is not what was removed: nothing restored", in)
}
}
// Nothing may be in the way: whatever is there now is the person's, or the mesh's.
if why := linkedParent(dir, rel+"/x"); why != "" {
return nil, errors.New(why + ", nothing restored")
}
if _, err := os.Lstat(full); err == nil {
return nil, fmt.Errorf("%s exists now, nothing restored: look at it with claude_code_home_show first", full)
} else if !os.IsNotExist(err) {
return nil, err
}
base := full
if !isFolder {
base = filepath.Dir(full)
}
written := []string{}
undo := func() {
for _, w := range written {
_ = os.Remove(w)
}
if isFolder {
_ = os.RemoveAll(full)
}
}
for in, content := range files {
to := filepath.Join(base, filepath.FromSlash(in))
if !isFolder {
to = full
}
mode := os.FileMode(0o644)
var m uint32
if _, err := fmt.Sscanf(r.Files[in].Mode, "%o", &m); err == nil && m != 0 {
mode = os.FileMode(m).Perm()
}
if err := os.MkdirAll(filepath.Dir(to), 0o755); err != nil {
undo()
return nil, fmt.Errorf("%s could not be restored: %w", full, err)
}
f, err := os.OpenFile(to, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode)
if err != nil {
undo()
return nil, fmt.Errorf("%s could not be restored: %w", full, err)
}
_, werr := f.WriteString(content)
if cerr := f.Close(); werr != nil || cerr != nil {
undo()
return nil, fmt.Errorf("%s could not be restored", to)
}
_ = os.Chmod(to, mode) // the umask may have taken bits the file had
written = append(written, to)
}
back := Removal{Action: "restored", At: now.UTC().Format(time.RFC3339), Node: p.Node, Kind: r.Kind, Name: r.Name,
Path: full, Why: "undoes the removal of " + r.At + ": " + r.Why, Kept: r.Kept, Files: r.Files}
note, _ := indented(back)
_ = os.WriteFile(filepath.Join(folder, "restored.json"), note, 0o600)
logged := logRemoval(p, back)
say("restored %s from %s, undoing its removal of %s", full, r.Kept, r.At)
answer := map[string]any{"restored": full, "kind": r.Kind, "name": r.Name, "from": r.Kept, "removedAt": r.At,
"log": p.removedLog()}
if !logged {
answer["log"] = "the log could not be written; the restore is noted in " + filepath.Join(folder, "restored.json")
}
return answer, nil
}
@@ -0,0 +1,319 @@
package main
// The person's own items in the home, read and removed on their word (novox/hq ADR 0216 rule 6: removing the
// original is the person's act — the remove tool is that act made explicit).
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
var at = time.Date(2026, 10, 7, 9, 30, 0, 0, time.UTC)
func TestEveryKindIsShownInFull(t *testing.T) {
p, _ := node(t, "laptop")
dir := filepath.Join(p.Home, ".claude")
_ = os.MkdirAll(filepath.Join(dir, "rules"), 0o755)
_ = os.MkdirAll(filepath.Join(dir, "skills", "old", "scripts"), 0o755)
writeFile(t, filepath.Join(dir, "rules", "hal-era.md"), "# old rule\n")
writeFile(t, filepath.Join(dir, "skills", "old", "SKILL.md"), "---\nname: old\n---\n")
writeFile(t, filepath.Join(dir, "skills", "old", "scripts", "a.sh"), "#!/bin/sh\n")
writeFile(t, filepath.Join(dir, "CLAUDE.md"), "# my memory\n")
rule, err := ShowHome(p, "instructions", "hal-era")
if err != nil || rule["content"] != "# old rule\n" || rule["placedByTheMesh"] != false {
t.Fatalf("%v %v", rule, err)
}
if again, err := ShowHome(p, "rule", "hal-era.md"); err != nil || again["content"] != "# old rule\n" {
t.Fatalf("a rule named as a person writes it: %v %v", again, err)
}
skill, err := ShowHome(p, KindSkill, "old")
if files, _ := skill["files"].(map[string]string); err != nil || len(files) != 2 || files["scripts/a.sh"] != "#!/bin/sh\n" {
t.Fatalf("%v %v", skill, err)
}
memory, err := ShowHome(p, KindMemory, "")
if err != nil || memory["content"] != "# my memory\n" {
t.Fatalf("%v %v", memory, err)
}
if _, err := ShowHome(p, KindAgent, "absent"); err == nil {
t.Fatal("an absent item was shown")
}
for _, name := range []string{"../../etc/passwd", "..", ".credentials", "a/b"} {
if _, err := ShowHome(p, KindInstructions, name); err == nil {
t.Errorf("%q reached outside its kind's folder", name)
}
}
if _, err := ShowHome(p, KindHook, "x"); err == nil {
t.Fatal("a kind the home does not hold was taken")
}
// The status tool lists the memory beside the rest.
found := false
for _, it := range HomeItems(p, Config{}, Servers{}) {
found = found || (it.Kind == KindMemory && it.Name == memoryName && !it.Placed)
}
if !found {
t.Fatal("the status does not list the home's memory")
}
}
func TestARemovalNeedsItsReason(t *testing.T) {
p, _ := node(t, "laptop")
dir := filepath.Join(p.Home, ".claude")
_ = os.MkdirAll(filepath.Join(dir, "rules"), 0o755)
writeFile(t, filepath.Join(dir, "rules", "keep.md"), "x")
if _, err := RemoveHome(p, KindInstructions, "keep", " ", at); err == nil {
t.Fatal("removed without a reason")
}
if _, err := os.Stat(filepath.Join(dir, "rules", "keep.md")); err != nil {
t.Fatal("the file went although the removal was refused")
}
}
func TestARemovalKeepsACopyAndLogsWhy(t *testing.T) {
p, _ := node(t, "laptop")
dir := filepath.Join(p.Home, ".claude")
_ = os.MkdirAll(filepath.Join(dir, "rules"), 0o755)
_ = os.MkdirAll(filepath.Join(dir, "skills", "old", "scripts"), 0o755)
writeFile(t, filepath.Join(dir, "rules", "hal-era.md"), "# old rule\n")
writeFile(t, filepath.Join(dir, "skills", "old", "SKILL.md"), "---\nname: old\n---\n")
writeFile(t, filepath.Join(dir, "skills", "old", "scripts", "a.sh"), "#!/bin/sh\n")
_ = os.Chmod(filepath.Join(dir, "skills", "old", "scripts", "a.sh"), 0o755)
answer, err := RemoveHome(p, KindInstructions, "hal-era", "HAL is retired", at)
if err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(dir, "rules", "hal-era.md")); !os.IsNotExist(err) {
t.Fatal("the rule file is still there")
}
if _, err := os.Stat(filepath.Join(dir, "rules")); err != nil {
t.Fatal("the kind's own folder went with it")
}
kept, _ := answer["keptAt"].(string)
want := filepath.Join(p.State, RemovedDir, "2026-10-07", "093000-instructions-hal-era", "rules", "hal-era.md")
if kept != want {
t.Fatalf("kept at %s, not %s", kept, want)
}
if !strings.HasPrefix(kept, p.State) || strings.HasPrefix(kept, dir) {
t.Fatal("the copy is inside the agent directory")
}
if raw, _ := os.ReadFile(kept); string(raw) != "# old rule\n" {
t.Fatalf("the copy holds %q", raw)
}
// A second removal in the same second does not overwrite the first's copy.
writeFile(t, filepath.Join(dir, "rules", "hal-era.md"), "# made again\n")
second, err := RemoveHome(p, KindInstructions, "hal-era", "again", at)
if err != nil || second["keptAt"] == kept {
t.Fatalf("%v %v", second, err)
}
if raw, _ := os.ReadFile(kept); string(raw) != "# old rule\n" {
t.Fatal("the first copy was overwritten")
}
// A skill goes as its whole folder, kept with its modes.
skill, err := RemoveHome(p, KindSkill, "old", "superseded by the plugin's", at)
if err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(dir, "skills", "old")); !os.IsNotExist(err) {
t.Fatal("the skill's folder is still there")
}
script := filepath.Join(skill["keptAt"].(string), "scripts", "a.sh")
if info, err := os.Stat(script); err != nil || info.Mode().Perm()&0o100 == 0 {
t.Fatalf("the script's copy lost its executable bit: %v", err)
}
// The log holds every removal, each with its reason.
raw, err := os.ReadFile(p.removedLog())
if err != nil {
t.Fatal(err)
}
lines := strings.Split(strings.TrimSpace(string(raw)), "\n")
if len(lines) != 3 {
t.Fatalf("%d lines logged", len(lines))
}
var r Removal
if err := json.Unmarshal([]byte(lines[0]), &r); err != nil || r.Why != "HAL is retired" || r.Node != "laptop" || r.Kept != kept {
t.Fatalf("%+v %v", r, err)
}
}
func TestTheMemoryIsRemovedAndKept(t *testing.T) {
p, _ := node(t, "laptop")
dir := filepath.Join(p.Home, ".claude")
writeFile(t, filepath.Join(dir, "CLAUDE.md"), "# HAL era\n")
answer, err := RemoveHome(p, KindMemory, "", "the managed file says it now", at)
if err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(dir, "CLAUDE.md")); !os.IsNotExist(err) {
t.Fatal("the memory is still there")
}
if raw, _ := os.ReadFile(answer["keptAt"].(string)); string(raw) != "# HAL era\n" {
t.Fatal("the memory was not kept")
}
}
func TestWhatTheMeshPlacedIsRefused(t *testing.T) {
p, w := node(t, "laptop")
state, view := memConfig{}, NewConfigView(p)
if _, err := Register(p, Item{Kind: KindAgent, Name: "placed", Scope: ScopeHome, Files: one("placed", "v1")}, nil, false, state, view, writer(w)); err != nil {
t.Fatal(err)
}
full := filepath.Join(p.Home, ".claude", "agents", "placed.md")
if _, err := os.Stat(full); err != nil {
t.Fatal("the home item was not placed")
}
shown, err := ShowHome(p, KindAgent, "placed")
if err != nil || shown["placedByTheMesh"] != true {
t.Fatalf("%v %v", shown, err)
}
if _, err := RemoveHome(p, KindAgent, "placed", "tidy", at); err == nil || !strings.Contains(err.Error(), "claude_code_agent_unregister") {
t.Fatalf("what the mesh placed was not refused: %v", err)
}
if _, err := os.Stat(full); err != nil {
t.Fatal("the mesh's item was removed")
}
if _, err := os.Stat(filepath.Join(p.State, RemovedDir)); !os.IsNotExist(err) {
t.Fatal("a refused removal left a copy")
}
}
func TestASymbolicLinkIsLeftAlone(t *testing.T) {
p, _ := node(t, "laptop")
dir := filepath.Join(p.Home, ".claude")
elsewhere := t.TempDir()
writeFile(t, filepath.Join(elsewhere, "target.md"), "not the home's")
_ = os.MkdirAll(filepath.Join(dir, "rules"), 0o755)
if err := os.Symlink(filepath.Join(elsewhere, "target.md"), filepath.Join(dir, "rules", "linked.md")); err != nil {
t.Skip(err)
}
if _, err := RemoveHome(p, KindInstructions, "linked", "x", at); err == nil {
t.Fatal("a symbolic link was removed")
}
if err := os.Symlink(elsewhere, filepath.Join(dir, "skills")); err != nil {
t.Skip(err)
}
_ = os.MkdirAll(filepath.Join(elsewhere, "s"), 0o755)
writeFile(t, filepath.Join(elsewhere, "s", "SKILL.md"), "x")
if _, err := RemoveHome(p, KindSkill, "s", "x", at); err == nil {
t.Fatal("a skill was removed through a linked folder")
}
if _, err := os.Stat(filepath.Join(elsewhere, "s", "SKILL.md")); err != nil {
t.Fatal("what the link points at was removed")
}
}
func TestARemovalIsListedAndPutBack(t *testing.T) {
p, _ := node(t, "laptop")
dir := filepath.Join(p.Home, ".claude")
_ = os.MkdirAll(filepath.Join(dir, "rules"), 0o755)
_ = os.MkdirAll(filepath.Join(dir, "skills", "old", "scripts"), 0o755)
writeFile(t, filepath.Join(dir, "rules", "hal-era.md"), "# old rule\n")
_ = os.Chmod(filepath.Join(dir, "rules", "hal-era.md"), 0o644)
writeFile(t, filepath.Join(dir, "skills", "old", "SKILL.md"), "---\nname: old\n---\n")
writeFile(t, filepath.Join(dir, "skills", "old", "scripts", "a.sh"), "#!/bin/sh\n")
_ = os.Chmod(filepath.Join(dir, "skills", "old", "scripts", "a.sh"), 0o755)
rule, err := RemoveHome(p, KindInstructions, "hal-era", "HAL is retired", at)
if err != nil {
t.Fatal(err)
}
skill, err := RemoveHome(p, KindSkill, "old", "superseded", at.Add(time.Minute))
if err != nil {
t.Fatal(err)
}
kept, err := KeptRemovals(p)
if err != nil || len(kept) != 2 || kept[0].Name != "old" || kept[1].Kept != rule["keptAt"] || kept[1].Restored != "" {
t.Fatalf("%+v %v", kept, err)
}
back, err := RestoreHome(p, rule["keptAt"].(string), at.Add(time.Hour))
if err != nil {
t.Fatal(err)
}
full := filepath.Join(dir, "rules", "hal-era.md")
if raw, _ := os.ReadFile(full); string(raw) != "# old rule\n" || back["restored"] != full {
t.Fatalf("%v: %q", back, raw)
}
if info, _ := os.Stat(full); info.Mode().Perm() != 0o644 {
t.Fatalf("restored as %v, removed as 0644", info.Mode().Perm())
}
if _, err := RestoreHome(p, skill["keptAt"].(string), at.Add(time.Hour)); err != nil {
t.Fatal(err)
}
if info, err := os.Stat(filepath.Join(dir, "skills", "old", "scripts", "a.sh")); err != nil || info.Mode().Perm()&0o100 == 0 {
t.Fatalf("the skill's script came back without its executable bit: %v", err)
}
// Listed as put back; logged with the removals.
kept, _ = KeptRemovals(p)
for _, k := range kept {
if k.Restored == "" {
t.Errorf("%s is not listed as put back", k.Kept)
}
}
raw, _ := os.ReadFile(p.removedLog())
lines := strings.Split(strings.TrimSpace(string(raw)), "\n")
var last Removal
if len(lines) != 4 || json.Unmarshal([]byte(lines[2]), &last) != nil || last.Action != "restored" || last.Path != full {
t.Fatalf("%d lines, %+v", len(lines), last)
}
// Something at the path now: refused, and left as it is.
if _, err := RestoreHome(p, rule["keptAt"].(string), at); err == nil {
t.Fatal("restored over what is there now")
}
if raw, _ := os.ReadFile(full); string(raw) != "# old rule\n" {
t.Fatal("what was there was changed")
}
}
func TestACopyThatChangedIsNotPutBack(t *testing.T) {
p, _ := node(t, "laptop")
dir := filepath.Join(p.Home, ".claude")
_ = os.MkdirAll(filepath.Join(dir, "rules"), 0o755)
_ = os.MkdirAll(filepath.Join(dir, "skills", "s"), 0o755)
writeFile(t, filepath.Join(dir, "rules", "r.md"), "original")
writeFile(t, filepath.Join(dir, "skills", "s", "SKILL.md"), "x")
rule, _ := RemoveHome(p, KindInstructions, "r", "why", at)
skill, _ := RemoveHome(p, KindSkill, "s", "why", at)
writeFile(t, rule["keptAt"].(string), "tampered")
if _, err := RestoreHome(p, rule["keptAt"].(string), at); err == nil {
t.Fatal("a changed copy was put back")
}
if _, err := os.Stat(filepath.Join(dir, "rules", "r.md")); !os.IsNotExist(err) {
t.Fatal("something was written from a changed copy")
}
// A file added to a kept skill: not what was removed.
writeFile(t, filepath.Join(skill["keptAt"].(string), "extra.md"), "x")
if _, err := RestoreHome(p, skill["keptAt"].(string), at); err == nil {
t.Fatal("a copy with an extra file was put back")
}
if _, err := os.Stat(filepath.Join(dir, "skills", "s")); !os.IsNotExist(err) {
t.Fatal("the skill's folder was made from a changed copy")
}
// A note pointing elsewhere than its own kind and name: refused.
note := filepath.Join(filepath.Dir(filepath.Dir(rule["keptAt"].(string))), "removal.json")
var r Removal
_ = readJSON(note, &r)
writeFile(t, rule["keptAt"].(string), "original")
r.Path = "/etc/passwd"
raw, _ := json.Marshal(r)
writeFile(t, note, string(raw))
if _, err := RestoreHome(p, rule["keptAt"].(string), at); err == nil {
t.Fatal("a note naming another path was followed")
}
// Nothing outside the module's kept copies.
for _, kept := range []string{"", "/etc/passwd", filepath.Join(p.State, RemovedDir), filepath.Join(p.State, RemovedDir, "..", "config.json")} {
if _, err := RestoreHome(p, kept, at); err == nil {
t.Errorf("%q was taken as a kept copy", kept)
}
}
}
+122 -5
View File
@@ -30,6 +30,9 @@ func say(format string, args ...any) {
// writeManaged writes one managed file as root, only when its content changed. From a staged file, never // 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). // /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) { func writeManaged(name, content string) (string, error) {
if strings.HasSuffix(name, "/") {
return writeManagedTree(strings.TrimSuffix(name, "/"), content)
}
path := filepath.Join(ManagedDir, name) path := filepath.Join(ManagedDir, name)
if was, err := os.ReadFile(path); err == nil && string(was) == content { if was, err := os.ReadFile(path); err == nil && string(was) == content {
return name + ": unchanged", nil return name + ": unchanged", nil
@@ -54,6 +57,74 @@ func writeManaged(name, content string) (string, error) {
return name + ": written", nil return name + ": written", nil
} }
// writeManagedTree replaces one directory of the managed directory whole — the plugin's marketplace (ADR
// 0216) — when what it holds differs from the files given (a JSON map of path to PluginFile). Staged
// beside, then swapped in as root, so a session never reads half of it.
func writeManagedTree(name, content string) (string, error) {
var files map[string]PluginFile
if err := json.Unmarshal([]byte(content), &files); err != nil {
return "", err
}
target := filepath.Join(ManagedDir, name)
if sameTree(target, files) {
return name + "/: unchanged", nil
}
staged, err := os.MkdirTemp("", "claude-code-tree-")
if err != nil {
return "", err
}
defer os.RemoveAll(staged)
for rel, f := range files {
full := filepath.Join(staged, filepath.FromSlash(rel))
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
return "", err
}
mode := os.FileMode(0o644)
if f.Executable {
mode = 0o755
}
if err := os.WriteFile(full, []byte(f.Content), mode); err != nil {
return "", err
}
_ = os.Chmod(full, mode)
}
_ = os.Chmod(staged, 0o755)
script := `set -e; rm -rf "$2.next" "$2.old"; cp -r "$1" "$2.next"; chown -R root:root "$2.next"; chmod -R go-w,a+rX "$2.next";
if [ -d "$2" ]; then mv "$2" "$2.old"; fi; mv "$2.next" "$2"; rm -rf "$2.old"`
args := []string{"sh", "-c", script, "sh", staged, target}
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 fmt.Sprintf("%s/: written, %d file(s)", name, len(files)), nil
}
// sameTree says whether a directory holds exactly these files, with these contents and executable bits.
func sameTree(dir string, files map[string]PluginFile) bool {
found := 0
err := filepath.WalkDir(dir, func(full string, d os.DirEntry, err error) error {
if err != nil || d.IsDir() {
return err
}
rel, _ := filepath.Rel(dir, full)
f, ok := files[filepath.ToSlash(rel)]
if !ok {
return errors.New("not wanted")
}
raw, err := os.ReadFile(full)
info, ierr := d.Info()
if err != nil || ierr != nil || string(raw) != f.Content || (info.Mode()&0o111 != 0) != f.Executable {
return errors.New("differs")
}
found++
return nil
})
return err == nil && found == len(files)
}
// ask is a tool on the bus, through the runtime: its answer is the tool's value. // 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) } func ask(address string, args any) (json.RawMessage, error) { return stdio.Ask(address, args) }
@@ -63,6 +134,13 @@ 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) 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) Delete(key string) error { return s.s.Delete(key) }
func (s stateOf) Keys() ([]string, error) { return s.s.Keys() } func (s stateOf) Keys() ([]string, error) { return s.s.Keys() }
func (s stateOf) Get(key string) (json.RawMessage, bool, error) {
e, err := s.s.Get(key)
if err != nil || e == nil {
return nil, false, err
}
return e.Value, true, nil
}
// nodesRunningMe is the nodes claude-code runs on, from the controller's list of modules — for the register // nodesRunningMe is the nodes claude-code runs on, from the controller's list of modules — for the register
// tool's question. // tool's question.
@@ -152,9 +230,9 @@ func nodesOf(v any) []string {
return out return out
} }
func tools(p Paths, servers ServerState, view *ServerView) []stdio.Tool { func tools(p Paths, servers ServerState, view *ServerView, config ConfigState, configView *ConfigView) []stdio.Tool {
nodesArg := str(`more nodes: "all" for every node running claude-code, or a comma-separated list; absent is this node only`) nodesArg := str(`more nodes: "all" for every node running claude-code, or a comma-separated list; absent is this node only`)
return []stdio.Tool{ out := []stdio.Tool{
{Name: "claude_code_status", {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.", 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 }}, Run: func(map[string]any) (any, error) { return status(p), nil }},
@@ -239,6 +317,7 @@ func tools(p Paths, servers ServerState, view *ServerView) []stdio.Tool {
return RegisterServer(p, Registration{Name: name, Nodes: nodesOf(a["nodes"])}, servers, view, writeManaged, nodesRunningMe) return RegisterServer(p, Registration{Name: name, Nodes: nodesOf(a["nodes"])}, servers, view, writeManaged, nodesRunningMe)
}}, }},
} }
return append(out, configTools(p, config, configView)...)
} }
// persist asks the state again until it answers: its bucket or the bus's grant may arrive after the module. // persist asks the state again until it answers: its bucket or the bus's grant may arrive after the module.
@@ -283,15 +362,53 @@ func main() {
} }
servers := stateOf{stdio.State("servers")} servers := stateOf{stdio.State("servers")}
view := NewServerView(p) view := NewServerView(p)
go run(p, view) config := stateOf{stdio.State("config")}
if err := stdio.Serve("", tools(p, servers, view)); err != nil { configView := NewConfigView(p)
go run(p, view, configView)
if err := stdio.Serve("", tools(p, servers, view, config, configView)); err != nil {
say("%v", err) say("%v", err)
os.Exit(1) os.Exit(1)
} }
} }
// run is the module's long-running half, beside the tools (ADR 0198). // run is the module's long-running half, beside the tools (ADR 0198).
func run(p Paths, view *ServerView) { func run(p Paths, view *ServerView, configView *ConfigView) {
// The agent's configuration, at every scope (ADR 0216): the whole current set first, then each change.
go persist("watching the agent's configuration", func() error {
keys, err := stdio.State("config").Keys()
if err != nil {
return err
}
if configView.Prune(keys) {
if _, err := RenderNow(p, writeManaged); err != nil {
say("rendering after what was removed while away: %v", err)
}
}
return stdio.State("config").Watch("", func(c stdio.StateChange) error {
var item *Item
if c.Op == "put" {
item = &Item{}
if json.Unmarshal(c.Value, item) != nil {
item = nil
}
}
if !configView.Take(c.Key, c.Op, item) {
return nil
}
if out, err := RenderNow(p, writeManaged); err != nil {
say("taking %s %s: %v", c.Op, c.Key, err)
} else {
say("took %s %s", c.Op, c.Key)
for _, line := range out {
if !strings.HasSuffix(line, "unchanged") {
say("%s", line)
}
}
}
return nil
})
}, func(n int) { say("watching the agent's configuration%s", refusals(n)) })
// Every node's MCP servers: the whole current set first, then each change (ADR 0201). // Every node's MCP servers: the whole current set first, then each change (ADR 0201).
go persist("watching the MCP servers", func() error { go persist("watching the MCP servers", func() error {
return stdio.State("servers").Watch("", func(c stdio.StateChange) error { return stdio.State("servers").Watch("", func(c stdio.StateChange) error {
+35 -4
View File
@@ -62,6 +62,8 @@ func (p Paths) binding() string { return filepath.Join(p.State, "licence.jso
func (p Paths) apiKey() string { return filepath.Join(p.State, "api-key") } 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) helper() string { return filepath.Join(p.State, "api-key-helper") }
func (p Paths) registry() string { return filepath.Join(p.State, "mcp-servers.json") } func (p Paths) registry() string { return filepath.Join(p.State, "mcp-servers.json") }
func (p Paths) config() string { return filepath.Join(p.State, "config.json") }
func (p Paths) placed() string { return filepath.Join(p.State, "home-placed.json") }
// Ask is a tool on the bus: its address and arguments in, its JSON answer out. // 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) type Ask func(address string, args any) (json.RawMessage, error)
@@ -102,9 +104,15 @@ func Registered(p Paths) Servers {
return s return s
} }
var renderMu sync.Mutex
// RenderNow writes the managed directory from the facts, the settings, the licence held and the servers // RenderNow writes the managed directory from the facts, the settings, the licence held and the servers
// registered here. // registered here.
func RenderNow(p Paths, write WriteManaged) ([]string, error) { func RenderNow(p Paths, write WriteManaged) ([]string, error) {
// One at a time: the watches and the tools all render, and the home's record of what was placed is
// read and written whole.
renderMu.Lock()
defer renderMu.Unlock()
var facts Facts var facts Facts
if !readJSON(p.Facts, &facts) || facts.Console == "" { if !readJSON(p.Facts, &facts) || facts.Console == "" {
return nil, fmt.Errorf("the mesh has not rendered %s yet; nothing to write", p.Facts) return nil, fmt.Errorf("the mesh has not rendered %s yet; nothing to write", p.Facts)
@@ -116,21 +124,44 @@ func RenderNow(p Paths, write WriteManaged) ([]string, error) {
if readJSON(p.binding(), &b) { if readJSON(p.binding(), &b) {
binding = &b binding = &b
} }
files := Render(facts, settings, binding, p.helper(), Registered(p)) var items map[string]Item
_ = readJSON(p.config(), &items)
config := ConfigOf(items)
files := Render(facts, settings, binding, p.helper(), Registered(p), config)
tree, _ := json.Marshal(Marketplace(config))
files[MarketplaceDir+"/"] = string(tree)
names := make([]string, 0, len(files)) names := make([]string, 0, len(files))
for n := range files { for n := range files {
names = append(names, n) names = append(names, n)
} }
sort.Strings(names) // The marketplace first: the settings that enable its plugin must never name one that is not there yet.
sort.Slice(names, func(i, j int) bool {
if (names[i] == MarketplaceDir+"/") != (names[j] == MarketplaceDir+"/") {
return names[i] == MarketplaceDir+"/"
}
return names[i] < names[j]
})
// Every file is attempted: one that cannot be written — a registration the vendor's layout refuses, a
// failed escalation — must not keep the licence, the tool servers or the instructions from landing.
var out []string var out []string
var failed []error
for _, n := range names { for _, n := range names {
line, err := write(n, files[n]) line, err := write(n, files[n])
if err != nil { if err != nil {
return out, err failed = append(failed, err)
continue
} }
out = append(out, line) out = append(out, line)
} }
return out, nil // The home scope: what this module places in the account's own agent directory (ADR 0182).
done, left := PlaceHome(p, HomeFiles(config))
for _, line := range done {
out = append(out, "home "+line)
}
for _, line := range left {
out = append(out, "home "+line)
}
return out, errors.Join(failed...)
} }
// ---- the licence ---------------------------------------------------------------------------------- // ---- the licence ----------------------------------------------------------------------------------
+22 -7
View File
@@ -96,8 +96,10 @@ func jsonFile(v any) string {
} }
// Render composes the three files. registered — what was registered through this module and applies // 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. // here — is laid over the servers the operator set in its settings; config is the rest of what was
func Render(facts Facts, settings Settings, binding *Binding, helperPath string, registered Servers) map[string]string { // registered (ADR 0216): its settings laid over the operator's `managed_settings`, its instruction sections
// after the mesh's own text. The plugin itself is Marketplace's, and the home's PlaceHome's.
func Render(facts Facts, settings Settings, binding *Binding, helperPath string, registered Servers, config Config) map[string]string {
servers := map[string]any{} servers := map[string]any{}
for _, layer := range []map[string]map[string]any{settings.MCPServers, registered} { for _, layer := range []map[string]map[string]any{settings.MCPServers, registered} {
for name, entry := range layer { for name, entry := range layer {
@@ -109,14 +111,27 @@ func Render(facts Facts, settings Settings, binding *Binding, helperPath string,
} }
servers[meshEntry] = map[string]any{"type": "http", "url": facts.Console} servers[meshEntry] = map[string]any{"type": "http", "url": facts.Console}
managed := map[string]any{} // The operator's `managed_settings` (ADR 0213), then what was registered for the mesh, then for this node.
for key, value := range settings.ManagedSettings { managed := mergeSettings(map[string]any{}, settings.ManagedSettings)
managed[key] = value managed = mergeSettings(managed, RegisteredSettings(config))
}
// The mesh's own keys are laid last: a setting never replaces them. // The mesh's own keys are laid last: a setting never replaces them.
managed["attribution"] = map[string]any{"commit": "", "pr": ""} managed["attribution"] = map[string]any{"commit": "", "pr": ""}
managed["allowAllClaudeAiMcps"] = true managed["allowAllClaudeAiMcps"] = true
delete(managed, "apiKeyHelper") delete(managed, "apiKeyHelper")
// The plugin's marketplace and the plugin itself, as entries in the operator's own maps, the mesh's
// entry winning: the operator may know more marketplaces and enable more plugins.
for key, value := range MarketplaceKeys() {
entries := map[string]any{}
if held, ok := managed[key].(map[string]any); ok {
for k, v := range held {
entries[k] = v
}
}
for k, v := range value.(map[string]any) {
entries[k] = v
}
managed[key] = entries
}
if binding != nil && binding.Kind == "api-key" { if binding != nil && binding.Kind == "api-key" {
managed["apiKeyHelper"] = helperPath managed["apiKeyHelper"] = helperPath
} }
@@ -127,6 +142,6 @@ func Render(facts Facts, settings Settings, binding *Binding, helperPath string,
return map[string]string{ return map[string]string{
"managed-mcp.json": jsonFile(map[string]any{"mcpServers": servers}), "managed-mcp.json": jsonFile(map[string]any{"mcpServers": servers}),
"managed-settings.json": jsonFile(managed), "managed-settings.json": jsonFile(managed),
"CLAUDE.md": instructionsText(facts.Node, role), "CLAUDE.md": instructionsText(facts.Node, role) + InstructionSections(config),
} }
} }
+44 -2
View File
@@ -13,7 +13,8 @@
}, },
"state": [ "state": [
"servers", "servers",
"holdings" "holdings",
"config"
], ],
"reads": [ "reads": [
"claude-licence-manager.bindings" "claude-licence-manager.bindings"
@@ -26,8 +27,49 @@
"claude_code_add_api_key", "claude_code_add_api_key",
"claude_code_mcp_list", "claude_code_mcp_list",
"claude_code_mcp_register", "claude_code_mcp_register",
"claude_code_mcp_unregister" "claude_code_mcp_unregister",
"claude_code_skill_list",
"claude_code_skill_register",
"claude_code_skill_unregister",
"claude_code_agent_list",
"claude_code_agent_register",
"claude_code_agent_unregister",
"claude_code_command_list",
"claude_code_command_register",
"claude_code_command_unregister",
"claude_code_hook_list",
"claude_code_hook_register",
"claude_code_hook_unregister",
"claude_code_output_style_list",
"claude_code_output_style_register",
"claude_code_output_style_unregister",
"claude_code_instructions_list",
"claude_code_instructions_register",
"claude_code_instructions_unregister",
"claude_code_settings_get",
"claude_code_settings_set",
"claude_code_settings_clear",
"claude_code_permission_add",
"claude_code_permission_remove",
"claude_code_config_list",
"claude_code_config_show",
"claude_code_config_status",
"claude_code_config_import",
"claude_code_home_show",
"claude_code_home_remove",
"claude_code_home_removed",
"claude_code_home_restore"
], ],
"data": {
"own": [
{
"id": "agent-home",
"path": "${dir:agent-home}",
"class": "valuable",
"why": "the operator's agent's own files: its memory, history and projects"
}
]
},
"resources": [ "resources": [
{ {
"id": "package", "id": "package",
-127
View File
@@ -1,127 +0,0 @@
// cloudflare-dns's own code (novox/hq ADR 0039). It provides the mesh `public-dns` interface
// (ADR 0044): a public name that resolves to the mesh's public ingress. Cloudflare is one registrar
// behind the neutral interface — a consumer names `public-dns`, never Cloudflare — so this file is
// the only place Cloudflare's API appears, and swapping registrars swaps only this module.
import { readFileSync } from "node:fs";
export interface PublicRecord {
id: string;
name: string;
type: string;
content: string;
}
export class CloudflareClient {
constructor(
private readonly token: string,
private readonly zoneId: string,
/** The zone this registers under, e.g. "example.com". */
readonly domain: string,
/** What every public name points at — the mesh's public ingress (the reverse proxy). */
readonly ingress: string,
) {}
static fromEnv(env: NodeJS.ProcessEnv = process.env): CloudflareClient {
// Which zone, domain and ingress are a mesh's own facts, not this module's — so they are
// settings, merged into a config file the mesh manages (novox/hq ADR 0046), read here. The
// token is the one secret and stays an own-secret. Env is honoured as a fallback for a
// hand-run instance, but the deployed path is the config file settings fill.
const config = readConfig(env.MESH_CLOUDFLARE_CONFIG_FILE);
const token = env.MESH_CLOUDFLARE_TOKEN ?? readSecret(env.MESH_CLOUDFLARE_TOKEN_FILE);
const zoneId = config.zone ?? env.MESH_CLOUDFLARE_ZONE_ID;
const domain = config.domain ?? env.MESH_PUBLIC_DOMAIN;
const ingress = config.ingress ?? env.MESH_PUBLIC_INGRESS;
if (!token || !zoneId || !domain || !ingress) {
throw new Error(
"cloudflare-dns is not configured — set its zone, domain and ingress in settings (and the " +
"token as its own-secret); until then it registers nothing",
);
}
return new CloudflareClient(token, zoneId, domain, ingress);
}
/**
* The public name a consumer gets: derived from its identity under the mesh's domain. Derived, not
* contributed, for the same reason minio derives a bucket name — the harness hands `remove` only
* the identity, so teardown must recompute exactly what creation made.
*/
nameFor(consumer: string): string {
return `${consumer.replace(/[^A-Za-z0-9-]/g, "-").toLowerCase()}.${this.domain}`;
}
/** An IP points at itself (A/AAAA); a hostname points through a CNAME. */
private recordType(): "A" | "AAAA" | "CNAME" {
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(this.ingress)) return "A";
if (this.ingress.includes(":")) return "AAAA";
return "CNAME";
}
private async api<T>(method: string, path: string, body?: unknown): Promise<T> {
const res = await fetch(`https://api.cloudflare.com/client/v4${path}`, {
method,
headers: { authorization: `Bearer ${this.token}`, "content-type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
});
const json = (await res.json()) as { success?: boolean; result?: unknown; errors?: unknown };
if (!res.ok || json.success === false) {
throw new Error(`cloudflare ${method} ${path}: ${res.status} ${JSON.stringify(json.errors ?? json)}`);
}
return json.result as T;
}
async findRecord(name: string): Promise<PublicRecord | undefined> {
const records = await this.api<PublicRecord[]>(
"GET",
`/zones/${this.zoneId}/dns_records?name=${encodeURIComponent(name)}`,
);
return records[0];
}
/** Point a public name at the mesh's ingress, idempotently — create it, or update one already there. */
async upsert(name: string): Promise<PublicRecord> {
const body = { type: this.recordType(), name, content: this.ingress, ttl: 300, proxied: false };
const existing = await this.findRecord(name);
if (existing) {
return this.api<PublicRecord>("PUT", `/zones/${this.zoneId}/dns_records/${existing.id}`, body);
}
return this.api<PublicRecord>("POST", `/zones/${this.zoneId}/dns_records`, body);
}
/** Remove a public name, idempotently — a record already gone is not an error on reconcile. */
async remove(name: string): Promise<void> {
const existing = await this.findRecord(name);
if (existing) await this.api("DELETE", `/zones/${this.zoneId}/dns_records/${existing.id}`);
}
/** Every record in the zone, for the diagnostic tool. */
async records(): Promise<PublicRecord[]> {
return this.api<PublicRecord[]>("GET", `/zones/${this.zoneId}/dns_records`);
}
}
function readSecret(path: string | undefined): string | undefined {
if (!path) return undefined;
try {
return readFileSync(path, "utf8").trim();
} catch {
return undefined;
}
}
interface Config {
zone?: string;
domain?: string;
ingress?: string;
}
/** The settings-managed config file (a JSON document the mesh merges settings into). Absent or
* unparseable yields an empty config, which fromEnv then reports as unconfigured. */
function readConfig(path: string | undefined): Config {
if (!path) return {};
try {
return JSON.parse(readFileSync(path, "utf8")) as Config;
} catch {
return {};
}
}
-73
View File
@@ -1,73 +0,0 @@
{
"module": "cloudflare-dns",
"version": "1",
"slug": "cfdns",
"provides": [
{
"name": "public-dns",
"scope": "mesh"
}
],
"serves": {
"public-dns": {}
},
"grants": {
"public-dns": "${dir:grants}"
},
"receives": {
"public-dns": "${dir:grants}/mesh.json"
},
"own-secrets": {
"token": "${dir:state}/token"
},
"emits": [
"record.created",
"record.removed"
],
"resources": [
{
"id": "state",
"type": "directory",
"mode": "0700",
"place": "."
},
{
"id": "grants",
"type": "directory",
"mode": "0700"
},
{
"id": "config",
"type": "file",
"path": "${dir:state}/config.json",
"merge": "json",
"content": "{}",
"mode": "0600"
}
],
"capabilities": [
"container-runtime"
],
"build": {
"artifacts": [
{
"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"
}
}
]
}
}
-14
View File
@@ -1,14 +0,0 @@
{
"name": "@novox/module-cloudflare-dns",
"version": "0.1.0",
"description": "cloudflare-dns — a public-dns provider (ADR 0044): registers public names at Cloudflare.",
"type": "module",
"private": true,
"dependencies": {
"@novox/mesh-sdk": "^0.1.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.6.0"
}
}
@@ -1,44 +0,0 @@
// cloudflare-dns's provisioner — the adapter making it a provider of the mesh `public-dns` interface
// (novox/hq ADR 0044). The reconcile loop and the contributions file are the sdk harness's; this
// writes only the per-registrar half: register a consumer's public name at Cloudflare, pointing it
// at the mesh's ingress, and remove it when the consumer is withdrawn (ADR 0048).
//
// The `public-dns` interface hands a consumer { fqdn, target, ttl } — a name that resolves publicly
// and what it resolves to. Like umami's analytics it is a *data* provision, not a credential one:
// nothing the mesh mints is set here (a DNS record is public, and the only secret is this module's
// own Cloudflare token, which never leaves). So the password the harness carries is unused; the name
// is derived from the login the mesh gave the consumer, which the consumer can derive too. Delivering
// the record back to the consumer is the data-provision return path ADR 0048 leaves out of scope.
import { runProvisioner, type Provision } from "@novox/mesh-sdk/provisioner";
import { emit } from "@novox/mesh-sdk/events";
import { CloudflareClient } from "../client.js";
const cloudflare = CloudflareClient.fromEnv();
runProvisioner("public-dns", {
async create(p: Provision): Promise<void> {
const fqdn = cloudflare.nameFor(p.as);
await cloudflare.upsert(fqdn);
await announce("record.created", {
name: fqdn,
target: cloudflare.ingress,
consumer: p.consumer ?? "",
});
},
async remove(p: { as: string }): Promise<void> {
const fqdn = cloudflare.nameFor(p.as);
await cloudflare.remove(fqdn);
await announce("record.removed", { name: fqdn, consumer: p.as });
},
});
/** Emit best-effort: a broker hiccup must never fail or reverse a DNS change that already happened. */
async function announce(type: string, body: unknown): Promise<void> {
try {
await emit(type, body);
} catch (err) {
console.error(`[cloudflare-dns] could not emit ${type}: ${err}`);
}
}
-23
View File
@@ -1,23 +0,0 @@
// cloudflare-dns's tool — the diagnostic: what public names the mesh currently publishes here.
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
import { CloudflareClient } from "../client.js";
export function getCloudflareDnsTools(cloudflare: CloudflareClient): ToolDefinition[] {
return [
{
name: "cloudflare_dns_records",
description: "The public DNS records in the mesh's zone — the names it currently publishes.",
input: {},
run: async () => ({ domain: cloudflare.domain, ingress: cloudflare.ingress, records: await cloudflare.records() }),
},
];
}
registerModuleTools("cloudflare-dns", (env) => {
try {
return getCloudflareDnsTools(CloudflareClient.fromEnv(env));
} catch {
return [];
}
});
-16
View File
@@ -1,16 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true
},
"include": [
"client.ts",
"tools/index.ts",
"provisioner/index.ts"
]
}
+19
View File
@@ -78,6 +78,25 @@ today), remove the unused vendor drivers, once:
- **desktop:** `brother-mfc-l8390cdw` and `brother-mfc-l8390cdw-debug`. Keep `cnijfilter-mg4200` while - **desktop:** `brother-mfc-l8390cdw` and `brother-mfc-l8390cdw-debug`. Keep `cnijfilter-mg4200` while
the Canon queue is used. the Canon queue is used.
## The print applet: not this module's
The desktop has `system-config-printer` (official, explicit, installed 2026-10-04). Its package ships
`/etc/xdg/autostart/print-applet.desktop`, so from the desktop's next login `dex` starts
`system-config-printer-applet`, a tray icon for print jobs and printer problems. The laptop does not
have the package.
This module does not take it:
- `cups` needs no display and declares nothing graphical. The applet requires `x11-display`, and a
GTK package in this module would put it on any machine that prints, the laptop included, where the
operator never installed it.
- The queues and the default printer are already this module's tools (`cups_printers`, `cups_queue`,
`cups_default`), which is most of what the applet's window offers.
So the applet is left as found on the desktop, started by its package's entry. If it is wanted on both
workstations, it becomes a `system-config-printer` desktop module of its own, beside `blueman` and
`nm-applet`, requiring `x11-display`. If not, removing the package on the desktop ends it.
## Leaves as found ## Leaves as found
The queues and their PPDs, the default printer, `cups.path` (enabled by the package's preset), The queues and their PPDs, the default printer, `cups.path` (enabled by the package's preset),
-43
View File
@@ -1,43 +0,0 @@
# dhcpcd
The uplink seat's module for a machine whose own network is dhcpcd's (novox/hq ADR 0117). It
asks two things of dhcpcd, and nothing else: leave the resolver file to the mesh, and leave the
private network's interface alone. It never declares an interface, an address, a route, a
wireless network or its credentials — the link dhcpcd keeps is the only channel the mesh reaches
the machine over.
## What it writes
Two lines into `/etc/dhcpcd.conf`, as the mesh's marked region (`into: block`) — dhcpcd reads no
drop-in directory, so the mesh writes into its one file rather than over it (ADR 0102):
- `nohook resolv.conf` — dhcpcd's resolv.conf hook rewrites `/etc/resolv.conf` on every lease it
takes or renews, which would silently replace the resolver `resolv-conf` names.
- `denyinterfaces mesh0` — dhcpcd never asks for a lease on the private network's interface, and
never takes it down. dhcpcd leaves a point-to-point interface alone by default; this says so
rather than relying on it.
**At the start of the file** (`at: start`). Both are global options, and dhcpcd reads every line
after an `interface` or `ssid` line as that interface's own. A configured machine's file ends in
exactly such a block (the interface, its static address), so appended at the end these two would
quietly apply to one interface only.
## Why it declares no service
dhcpcd is the machine's, not the mesh's. The mesh never starts, stops or enables it: stopping it
drops the address the machine is reached at, and a module unassigned by mistake must not be able
to do that. And there is nothing to reload it with — `dhcpcd.service` reports `CanReload=no`, and
a restart drops the lease. So the two lines take effect at **dhcpcd's next start**.
On an adopted machine that is normally no gap: the predecessor wrote the same `nohook` line, and
it is already in force. **On a machine that was not adopted, it is one:** until dhcpcd next
starts (a reboot, or the operator restarting it in a window of their choosing), a lease renewal
still rewrites `/etc/resolv.conf`, and `resolv-conf` puts it back at the next push. Assign this
module before `resolv-conf` on such a machine, and restart dhcpcd once, by hand, when losing the
link for a moment is acceptable.
## One manager per machine
It claims `the-uplink`: a machine runs one network manager, and assigning a second module that
claims the seat is refused. Assigning this one to a machine whose network is NetworkManager's
installs the package and writes the two lines, and starts nothing.
-31
View File
@@ -1,31 +0,0 @@
{
"module": "dhcpcd",
"version": "1",
"capabilities": [
"package-manager",
"service-manager",
"uplink-dhcpcd"
],
"claims": [
{
"name": "node-uplink",
"scope": "node"
}
],
"resources": [
{
"id": "package",
"type": "package",
"package": "dhcpcd"
},
{
"id": "config",
"type": "file",
"path": "/etc/dhcpcd.conf",
"mode": "0644",
"into": "block",
"at": "start",
"content": "# The mesh's two lines (module dhcpcd, novox/hq ADR 0117). Global options, so\n# kept above any interface line; read at dhcpcd's next start.\nnohook resolv.conf\ndenyinterfaces mesh0\n"
}
]
}
+29
View File
@@ -36,6 +36,18 @@
"why": "every machine pulls images and artifacts from here" "why": "every machine pulls images and artifacts from here"
} }
], ],
"data": {
"own": [
{
"id": "registry",
"path": "${dir:registry-data}",
"class": "rebuildable",
"backup": "none",
"measure": "shallow",
"why": "the artifact store: every image is built again from its source, and too large to copy every night (ADR 0214 left it out)"
}
]
},
"resources": [ "resources": [
{ {
"id": "state", "id": "state",
@@ -63,6 +75,23 @@
"env": { "env": {
"REGISTRY_STORAGE_DELETE_ENABLED": "true" "REGISTRY_STORAGE_DELETE_ENABLED": "true"
} }
},
{
"id": "collect",
"type": "container",
"name": "mesh-registry-collect",
"image": "registry@sha256:a3d8aaa63ed8681a604f1dea0aa03f100d5895b6a58ace528858a7b332415373",
"volumes": [
"/var/lib/mesh-registry:/var/lib/registry"
],
"args": [
"garbage-collect",
"/etc/docker/registry/config.yml"
],
"schedule": "30 3 * * *",
"while-stopped": [
"store"
]
} }
] ]
} }
File diff suppressed because one or more lines are too long
+110 -54
View File
@@ -14,6 +14,8 @@ tools below are the module's own.
| `socket` | `docker.socket` running, enabled at boot | given back as found when the module goes (ADR 0118) | | `socket` | `docker.socket` running, enabled at boot | given back as found when the module goes (ADR 0118) |
| `prune-service`, `prune-timer` | `/etc/systemd/system/docker-prune.{service,timer}`, written whole | removed with the module | | `prune-service`, `prune-timer` | `/etc/systemd/system/docker-prune.{service,timer}`, written whole | removed with the module |
| `prune` | `docker-prune.timer` running, enabled at boot; restarted when either file changes | stopped and disabled with the module (the mesh made the unit) | | `prune` | `docker-prune.timer` running, enabled at boot; restarted when either file changes | stopped and disabled with the module (the mesh made the unit) |
| `daemon` | `live-restore` and the mesh's registry in `insecure-registries` written into `/etc/docker/daemon.json`, beside other keys | each key given back as found when the module goes; only the member this module added leaves the list |
| `runtime` | `docker.service` running, enabled at boot; reloaded, never restarted, when `daemon` changes | given back as found (ADR 0118) |
The weekly prune takes **dangling images and build cache unused for a week, and nothing else**. It The weekly prune takes **dangling images and build cache unused for a week, and nothing else**. It
takes no volume, no container and no image a container uses, so it never touches a container the mesh takes no volume, no container and no image a container uses, so it never touches a container the mesh
@@ -24,62 +26,60 @@ while the machine was off happens at the next boot.
`container-runtime`: under ADR 0165, which is still proposed, that word means a running daemon, and `container-runtime`: under ADR 0165, which is still proposed, that word means a running daemon, and
the module that installs the daemon cannot require it. the module that installs the daemon cannot require it.
## The runtime's own file and service (issue 190, hq ADR 0196, ADR 0222)
This module writes two keys into `/etc/docker/daemon.json` (`into: json`, ADR 0102): `live-restore`
and `insecure-registries`. `dnsmasq` used to write `live-restore`, beside `dns`; it no longer writes
either. Under ADR 0196 a container copies its machine's resolvers, so no module writes `dns`. The
controller's private network wrote `insecure-registries`; under ADR 0222 the controller writes
nothing into this file, and this module states the registry itself (the order below).
- `daemon`: `{"live-restore": true, "insecure-registries": ["${seat:mesh-artifact-store:reach}"]}`,
merged into the file beside the keys others write.
- `${seat:mesh-artifact-store:reach}` is where this machine reaches the mesh's artifact store
(host:port), filled in by the controller: no binding, no credential, the same address the mesh
composes into every image it built. Trusting it in the clear is ADR 0082's decision: every path to
it is inside the private network's encryption. While no machine on the network holds the store the
answer is empty, and the controller drops the empty member, so the list gets nothing.
- `insecure-registries` is a list, and the host adds to it rather than replacing it: a machine's own
trusted registries stay, and undeclaring takes out only the member this module added.
- `runtime`: `docker.service` running, enabled at boot, and **reloaded, never restarted**, when
`daemon` changes. A restart stops every container. A reload turns `live-restore` on and takes the
trusted registries, and with `live-restore` on a later restart keeps every container running.
In the apply that moves the key, the host first gives back `dnsmasq`'s resources, then applies this
module's: `live-restore` is set again in the same apply, and the daemon is reloaded once.
`dns` is removed from the file then, but the daemon reads it only at its next start, and a running
container keeps the resolvers it was created with. Each container pinned to a machine's own resolver
is restarted before that machine's `dnsmasq` goes (ADR 0194, step 4).
**The order it lands in.** The controller that fills `${seat:…:reach}` is deployed first: one that
does not know the placeholder would send it through unfilled. Then this module. Then the controller
stops generating the private network's `registry-trust` and `registry-trust-reload` and refuses a
generated resource that collides with a module's (issue 190, steps 2 and 5). In the apply that moves
the member, the host removes the private network's record first (the member leaves the list) and
then applies this module's (it is added back, recorded as this module's); the daemon is reloaded
once, for `daemon`. The address is the same one, so the runtime's trust does not change.
Still elsewhere:
- **Nobody** writes log rotation. One machine has `log-driver` and `log-opts` by hand; they are left
as they are until a size is chosen for every machine.
## What it does not declare yet, and why ## What it does not declare yet, and why
Three things this module should own are already declared by other modules on every machine. The One thing this module should own is still declared elsewhere. The controller refuses two modules
controller refuses two modules on one node that declare the same `path`, `unit`, `name` or `package` on one node that declare the same `path`, `unit`, `name` or `package` (`checkResources`,
(`checkResources`, mesh-controller `internal/catalogue/resolve.go`). Declaring any of them here would mesh-controller `internal/catalogue/resolve.go`), so declaring it here would make the module
make the module unassignable everywhere. The refusals were checked against the controller's own unassignable everywhere. The refusals were checked against the controller's own
check: check:
``` ```
zsh and docker both declare the name "${machine:account}" zsh and docker both declare the name "${machine:account}"
dnsmasq and docker both declare the path "/etc/docker/daemon.json"
dnsmasq and docker both declare the unit "docker.service"
``` ```
### 1. `/etc/docker/daemon.json` and `docker.service` (issue 190) ### The operator account's membership of the `docker` group
Today the file has three writers. Each writes into it (`into: json`, ADR 0102) and reloads the
service:
- **`dnsmasq`** writes `dns` and `live-restore`, through `dnsmasq.runtime-dns` and `dnsmasq.runtime`.
- **The private network**, generated by the controller (`internal/overlay/generator.go`), writes
`insecure-registries`. The collision check does not see generated resources.
- **Nobody** writes log rotation. One machine has `log-driver` and `log-opts` by hand.
**The change proposed, in one merge:**
1. `dnsmasq` drops its `runtime-dns` and `runtime` resources.
2. `docker` adds the two resources below:
```json
{"id": "daemon", "type": "file", "path": "/etc/docker/daemon.json", "mode": "0644", "into": "json",
"content": "{\"dns\": [\"${machine:address}\"], \"live-restore\": true, \"log-driver\": \"json-file\", \"log-opts\": {\"max-size\": \"100m\", \"max-file\": \"5\"}}\n"},
{"id": "runtime", "type": "service", "unit": "docker.service", "state": "running", "boot": "enabled", "reload-on": ["daemon"]}
```
The service is **reloaded, never restarted**: a restart stops every container. The daemon reads
`live-restore` on a reload. It reads `dns`, `log-driver` and `log-opts` only at its next start, so
they apply then (to containers created afterwards, for the log keys). With `live-restore` on, that
start keeps every container running.
**Why one merge, and only after this module is on every machine:**
- In one apply, the host first gives back the resources that are no longer declared, then applies
the new ones (mesh-host `apply.go`).
- `dnsmasq` gives back `dns` and `live-restore` to what they held before it, and `docker` sets them
again in the same apply. The daemon is reloaded once, after both steps.
- A machine pushed the new `dnsmasq` *without* this module would keep its pre-mesh values for both
keys. On one machine that is `live-restore: false`, and the next daemon restart there would stop
every container.
**Later:** the controller hands the registry to this module as a value, and the overlay stops
generating its two resources (issue 190, steps 2 and 5). Until then the overlay keeps writing its one
key beside this module's. The host merges disjoint keys correctly; the mesh-host `into.go` record is
per resource.
### 2. The operator account's membership of the `docker` group
The right shape is the host's `user` shape. Its `groups` are additive: the host runs The right shape is the host's `user` shape. Its `groups` are additive: the host runs
`usermod --append` and never takes a group away. `usermod --append` and never takes a group away.
@@ -111,9 +111,8 @@ On the machine the mesh was first installed on, the foundation bundle declared `
never sees them (mesh-host `store.go`). never sees them (mesh-host `store.go`).
- So `docker.package` here is a **second record of the same package**. The apply says "already - So `docker.package` here is a **second record of the same package**. The apply says "already
installed", and neither record ever uninstalls it. installed", and neither record ever uninstalls it.
- This module does not declare `docker.service` today, so nothing overlaps there. The proposed step - `docker.runtime` is likewise a second record of `docker.service`. Its found state is *running*,
1 would add a second record of that unit. Its found state is *running*, because genesis started because genesis started it, so undeclaring this module leaves the daemon running.
it, so undeclaring this module would leave the daemon running.
## Tools ## Tools
@@ -127,8 +126,10 @@ A failure is an error naming how it failed, never an empty answer.
| tool | | what | | tool | | what |
|---|---|---| |---|---|---|
| `docker_list` | r | every container: image, state, health, restarts, ports, mounts, compose project, `mesh_held`; filter by owner, state or name | | `docker_list` | r | every container: image, state, health, restarts, ports, mounts, compose project, `mesh_held`; filter by owner, state or name |
| `docker_inspect` | r | one container whole, **environment values left out** (names kept) | | `docker_inspect` | r | one container whole, **environment values left out** (names kept), and its command line redacted as an exec's is |
| `docker_logs` | r | the last lines of both streams, merged in order, with timestamps (default 200, at most 2000) | | `docker_logs` | r | the last lines of both streams, merged in order, with timestamps (default 200, at most 2000); **a secret the container printed is shown as `[redacted: <name>]`** |
| `docker_secrets_in_logs` | r | which containers printed a secret they were given, **by name, never by value** (below) |
| `docker_secrets_in_events` | r | which secrets exec command lines carried, as the runtime recorded them in its events, **by name, never by value** (below) |
| `docker_stats` | r | CPU, memory, I/O and process count per running container, heaviest first | | `docker_stats` | r | CPU, memory, I/O and process count per running container, heaviest first |
| `docker_start` / `docker_stop` / `docker_restart` | a | one container. On a mesh-held one, the answer says the host restores its declared state at its next apply | | `docker_start` / `docker_stop` / `docker_restart` | a | one container. On a mesh-held one, the answer says the host restores its declared state at its next apply |
| `docker_top` | r | the processes inside one container | | `docker_top` | r | the processes inside one container |
@@ -137,12 +138,66 @@ A failure is an error naming how it failed, never an empty answer.
| `docker_disk_usage` | r | `docker system df -v`: total, active and reclaimable per kind, with the largest of each | | `docker_disk_usage` | r | `docker system df -v`: total, active and reclaimable per kind, with the largest of each |
| `docker_networks` | r | networks, subnets, and the containers on each | | `docker_networks` | r | networks, subnets, and the containers on each |
| `docker_volumes` | r | volumes, who mounts each, whether the mesh holds one of them, anonymous or not, and sizes if asked | | `docker_volumes` | r | volumes, who mounts each, whether the mesh holds one of them, anonymous or not, and sizes if asked |
| `docker_events` | r | the runtime's events over a window ending now (default 60 min, at most 24 h), without exec noise | | `docker_events` | r | the runtime's events over a window ending now (default 60 min, at most 24 h), without exec noise unless asked; **an exec's command line is shown with any secret it carried as `[redacted: <what it was>]`** |
| `docker_daemon_config` | r | `daemon.json` as on disk, `docker info`'s essentials, and keys the daemon has not taken yet | | `docker_daemon_config` | r | `daemon.json` as on disk, `docker info`'s essentials, and keys the daemon has not taken yet |
| `docker_unlabelled` | r | the containers the mesh does not hold: the cleanup list | | `docker_unlabelled` | r | the containers the mesh does not hold: the cleanup list |
| `docker_problems` | r | unhealthy, restarting, dead, killed for memory, failed, or restarted five times or more | | `docker_problems` | r | unhealthy, restarting, dead, killed for memory, failed, or restarted five times or more |
| `docker_ports` | r | every published port, and the containers on the host's network | | `docker_ports` | r | every published port, and the containers on the host's network |
## Secrets in a container's own log (hq issue 268)
Software prints what it is given: a server announcing its password as it starts, a startup script
echoing the database URI it connects with. The container's log is then a copy of the secret, held by
whoever reads it — this bundle's `docker_logs` among them. `docker_secrets_in_logs` reads the last
lines of each container's log (the mesh's by default, 5000 lines each, at most 50000) and compares
them with:
- the values of the container's environment whose names say they are secrets (`PASSWORD`, `SECRET`,
`TOKEN`, `API_KEY`, …; not a path, a URL, a number or a switch), as given and URL-encoded;
- the password inside any URI its environment holds;
- the shape `scheme://user:password@`, anywhere in a line, whatever the source — a password a program
already masked (`***`) is not one.
A finding names the container, the module, the assignment and the secret's variable, with how many
lines carry it and the first and last time. **It never carries the value or the line.** A secret
delivered only as a mounted file, never in the environment, is not known here — the bundle runs as the
operator account, which cannot read the host's 0600 files — and is caught only inside a URI.
`docker_logs` redacts the same values before it answers, because what it answers is read by agents
and kept in their transcripts. Its answer says how many it redacted, and points here.
A finding is a secret to rotate once the program stops printing it; recreating the container drops
its old log (the runtime's file goes with the container).
## Secrets on an exec's command line (hq issue 282)
The runtime records the command line of every exec — a `docker exec`, and a health check, which is one
— in its event stream (`exec_create: <argv joined by spaces>`). A program that passes a password to a
tool as an argument has given it to everyone who may ask the runtime what happened, for as long as the
runtime keeps its events, and to every transcript of a `docker_events` call. The mosquitto module did
that with the broker's admin password on every administrative call, until it handed it over on stdin.
`docker_events` redacts, in every exec's command line, before it answers:
- the values of that container's environment named like a secret, and the passwords in its URIs —
the same values `docker_logs` redacts;
- any URI carrying a password;
- by shape, whatever the source: the word after a flag that takes a password (`-P`, `--password`,
`--secret-key`, `--token`, …; `-a` for `redis-cli`; `-p` for `mosquitto_ctrl`, whose connect `-p`
is a port and is left alone as a number), a `NAME=value` whose name says secret, and the password a
`mosquitto_ctrl dynsec` command sets as an argument (`setClientPassword`, `init`).
`docker_secrets_in_events` reads the exec events of a window ending now (60 minutes by default, at most
24 hours) and names each secret found by container, module, what it was and the program, with how many
execs carried it and the first and last time — **never the value or the command line**. A finding is
code to change first (the secret handed over as a file or on stdin), then a secret to rotate. The
runtime keeps a bounded number of events, so on a busy machine a long window reads only what it still
holds — which is also why a leaked value ages out of the event stream quickly, and not out of a
transcript that already copied it.
`docker_inspect` shows a container's own command line (`Path`/`Args`, `Cmd`, `Entrypoint`) redacted
the same way.
## Tests ## Tests
``` ```
@@ -159,6 +214,7 @@ The tests run against a fake runner and cover:
- the restore note on a mesh-held act; - the restore note on a mesh-held act;
- prune being a dry run by default and never reaching a volume, a mesh container or `--volumes`; - prune being a dry run by default and never reaching a volume, a mesh container or `--volumes`;
- the log merge; - the log merge;
- a printed secret found by name and never answered by value, in the scan and in `docker_logs`;
- size parsing; - size parsing;
- what the daemon has not yet taken; - what the daemon has not yet taken;
- event filtering; - event filtering;
+256
View File
@@ -0,0 +1,256 @@
package main
// A secret on a command line (novox/hq issue 282).
//
// **The leak this catches.** The runtime records the command line of every exec — `docker exec`, and
// a health check, which is one — in its event stream, as the event's action (`exec_create: <argv
// joined by spaces>`). A program that hands a password to a tool as an argument (`-P <password>`,
// `--password <password>`, `PGPASSWORD=<password> psql`) has therefore given it to everyone who may
// ask the runtime what happened, for as long as the runtime keeps its events — and, through
// docker_events, to every transcript of an agent that asked. The mosquitto module did exactly that
// with the broker's admin password, on every administrative call.
//
// **What is known here.** As for a log: the values of the container's environment named like a
// secret and the passwords inside its URIs, by name; and, whatever their source, the values a
// command line carries by its shape — the word after a flag that takes a password, a NAME=value
// whose name says secret, the password a dynsec command sets. A secret given as a file and passed by
// a flag the shapes do not know is not caught.
//
// **Never the value.** What is shown carries `[redacted: <what it was>]` in its place, and a finding
// names the container, the module and what it was, by name.
import (
"context"
"fmt"
"path"
"sort"
"strings"
"time"
)
// passwordFlags take a secret as their next word, whatever the program.
var passwordFlags = map[string]bool{
"-P": true, "--password": true, "--pass": true, "--passwd": true, "--secret": true, "--secret-key": true,
"--token": true, "--api-key": true, "--apikey": true, "--auth": true,
}
// programFlags take a secret as their next word for one program only: elsewhere the same flag means
// something else (redis-cli's -a is its password; nft's -a is not).
var programFlags = map[string]map[string]bool{
"redis-cli": {"-a": true},
"keydb-cli": {"-a": true},
"valkey-cli": {"-a": true},
"mosquitto_ctrl": {"-p": true}, // createClient -p <password>; the connect -p is a port, and a port is ordinary
}
// positionalSecret is where a dynsec command carries a password as an argument: the word that many
// places after the command's name.
var positionalSecret = map[string]int{"setClientPassword": 2, "init": 3}
// commandSecret is one secret a command line carried, by what it was.
type commandSecret struct {
Name string
Value string
}
// secretsOnCommandLine are the values a command line carries by their shape, by what each one is.
func secretsOnCommandLine(words []string) []commandSecret {
var out []commandSecret
add := func(name, value string) {
if len(value) < leastSecret || masked.MatchString(value) || ordinary.MatchString(value) {
return
}
out = append(out, commandSecret{name, value})
}
program := ""
for i, w := range words {
base := path.Base(w)
if _, known := programFlags[base]; known || base == "mosquitto_ctrl" {
program = base
}
if flag, value, ok := strings.Cut(w, "="); ok && strings.HasPrefix(flag, "-") {
if passwordFlags[flag] || programFlags[program][flag] {
add("the value of "+flag, value)
}
continue
}
if name, value, ok := strings.Cut(w, "="); ok && name != "" && !strings.HasPrefix(name, "-") &&
secretName.MatchString(name) && !notAValue.MatchString(name) && !strings.ContainsAny(name, "/:") {
add("the value of "+name, value)
continue
}
if i+1 < len(words) && (passwordFlags[w] || programFlags[program][w]) {
add("the word after "+w+" in a "+orProgram(program, words)+" command line", words[i+1])
}
if program == "mosquitto_ctrl" {
if at, ok := positionalSecret[w]; ok && i+at < len(words) && dynsecVerb(words, i) {
add("the password given to dynsec "+w, words[i+at])
}
}
}
return out
}
// dynsecVerb says the word at i is a dynsec command's name: it follows "dynsec".
func dynsecVerb(words []string, i int) bool {
for j := i - 1; j >= 0; j-- {
if words[j] == "dynsec" {
return true
}
}
return false
}
func orProgram(program string, words []string) string {
if program != "" {
return program
}
if len(words) > 0 {
return path.Base(words[0])
}
return "program's"
}
// redactCommand is a command line with every known secret, every password inside a URI and every
// value its shape says is a secret replaced by a mark naming what was there; and what was replaced,
// by name.
func redactCommand(command string, known []knownSecret) (string, []string) {
var names []string
for _, s := range known {
for _, f := range forms(s.Value) {
if strings.Contains(command, f) {
command = strings.ReplaceAll(command, f, "[redacted: "+s.Name+"]")
names = append(names, s.Name)
break
}
}
}
for _, s := range secretsOnCommandLine(strings.Fields(command)) {
if strings.Contains(command, s.Value) {
command = strings.ReplaceAll(command, s.Value, "[redacted: "+s.Name+"]")
names = append(names, s.Name)
}
}
if line, n := redact(command, nil); n > 0 {
command = line
names = append(names, "a password in a URI")
}
return command, names
}
// execCommand is the command line an exec event carries, and whether it carries one.
func execCommand(action string) (verb, command string, ok bool) {
verb, command, ok = strings.Cut(action, ": ")
if !ok || !strings.HasPrefix(verb, "exec_") {
return "", "", false
}
return verb, command, true
}
// envCache reads each container's environment once per call.
type envCache struct {
c *Client
ctx context.Context
seen map[string][]knownSecret
}
func (e *envCache) of(id string) []knownSecret {
if e.seen == nil {
e.seen = map[string][]knownSecret{}
}
if k, ok := e.seen[id]; ok {
return k
}
env, _ := e.c.envOf(e.ctx, id) // a container gone since: its shapes are still caught
e.seen[id] = secretsIn(env)
return e.seen[id]
}
// CommandLeak is one secret the runtime recorded on exec command lines: by name, never by value.
type CommandLeak struct {
Container string `json:"container"`
HeldBy string `json:"held_by,omitempty"`
Module string `json:"module,omitempty"`
Secret string `json:"secret"`
Execs int `json:"execs"`
Program string `json:"program"`
First string `json:"first"`
Last string `json:"last"`
}
// SecretsInEvents reads the runtime's exec events in a window ending now and says which secrets
// their command lines carried, by container and name.
func (c *Client) SecretsInEvents(ctx context.Context, minutes int) (map[string]any, error) {
out, err := c.docker(ctx, "events", "--since", fmt.Sprintf("%dm", minutes), "--until", "0s",
"--filter", "type=container", "--filter", "event=exec_create", "--format", "{{json .}}")
if err != nil {
return nil, err
}
raw, err := jsonLines[runtimeEvent](out)
if err != nil {
return nil, err
}
envs := &envCache{c: c, ctx: ctx}
type key struct{ container, secret string }
found := map[key]*CommandLeak{}
execs := 0
for _, e := range raw {
verb, command, ok := execCommand(e.Action)
if !ok || verb != "exec_create" {
continue // an exec_start repeats its exec_create's command line
}
execs++
_, names := redactCommand(command, envs.of(e.Actor.ID))
if len(names) == 0 {
continue
}
at := time.Unix(0, e.TimeNano).UTC().Format(time.RFC3339)
held := e.Actor.Attributes[MeshLabel]
module, _, _ := strings.Cut(held, ".")
program := ""
if f := strings.Fields(command); len(f) > 0 {
program = path.Base(f[0])
}
for _, n := range names {
k := key{e.Actor.Attributes["name"], n}
l, ok := found[k]
if !ok {
l = &CommandLeak{Container: k.container, HeldBy: held, Module: module, Secret: n, Program: program, First: at}
found[k] = l
}
l.Execs++
l.Last = at
}
}
leaks := []CommandLeak{}
for _, l := range found {
leaks = append(leaks, *l)
}
sort.Slice(leaks, func(i, j int) bool {
if leaks[i].Container != leaks[j].Container {
return leaks[i].Container < leaks[j].Container
}
return leaks[i].Secret < leaks[j].Secret
})
verdict := fmt.Sprintf("no exec in the last %d minutes carried a secret on its command line", minutes)
if len(leaks) > 0 {
verdict = fmt.Sprintf("%d secret(s) on exec command lines the runtime recorded: the code that runs the exec must hand "+
"them over another way (a file, stdin), and each is rotated once it does (novox/hq issue 282)", len(leaks))
}
return map[string]any{
"verdict": verdict, "leaks": leaks, "count": len(leaks), "execs_read": execs, "minutes": minutes,
"knows": "values of each container's environment named like a secret, passwords in URIs, and by shape: the word after " +
"a password flag, a NAME=value named like a secret, and the password a dynsec command sets",
"history": "the runtime keeps a bounded number of events, so a window longer than what it holds reads only what it still has",
}, nil
}
// runtimeEvent is one line of `docker events --format '{{json .}}'`.
type runtimeEvent struct {
Type, Action string
Actor struct {
ID string
Attributes map[string]string
}
TimeNano int64 `json:"timeNano"`
}
+109 -20
View File
@@ -345,6 +345,27 @@ func (c *Client) Inspect(ctx context.Context, ref string) (map[string]any, error
return nil, fmt.Errorf("docker inspect answered something that is not one container") return nil, fmt.Errorf("docker inspect answered something that is not one container")
} }
obj := got[0] obj := got[0]
// A container's command line is shown without the secrets it carries, as an exec's is (novox/hq
// issue 282): known from its environment, and by shape.
var known []knownSecret
if cfg, ok := obj["Config"].(map[string]any); ok {
if env, ok := cfg["Env"].([]any); ok {
list := make([]string, 0, len(env))
for _, e := range env {
list = append(list, fmt.Sprint(e))
}
known = secretsIn(list)
}
for _, k := range []string{"Cmd", "Entrypoint"} {
if words, ok := cfg[k].([]any); ok {
cfg[k] = redactWords(words, known)
}
}
}
if words, ok := obj["Args"].([]any); ok {
path, _ := obj["Path"].(string)
obj["Args"] = redactWords(append([]any{path}, words...), known)[1:]
}
if cfg, ok := obj["Config"].(map[string]any); ok { if cfg, ok := obj["Config"].(map[string]any); ok {
if env, ok := cfg["Env"].([]any); ok { if env, ok := cfg["Env"].([]any); ok {
names := []string{} names := []string{}
@@ -380,6 +401,44 @@ func (c *Client) Logs(ctx context.Context, ref string, tail int, since string) (
args = append(args, "--since", since) args = append(args, "--since", since)
} }
args = append(args, ref) args = append(args, ref)
all, err := c.logLines(ctx, args)
if err != nil {
return nil, err
}
if len(all) > tail {
all = all[len(all)-tail:]
}
// What the container printed of the secrets it was given is not shown (novox/hq issue 268): the
// answer of this tool is read by agents and kept in their transcripts, which would make it a
// second copy of the leak. Redacted before the lines are cut, so a cut never splits a value.
answer := map[string]any{"container": ref}
env, envErr := c.envOf(ctx, ref)
known := secretsIn(env)
redacted := 0
for i, l := range all {
var n int
all[i], n = redact(l, known)
redacted += n
}
if envErr != nil {
answer["redaction"] = "only passwords inside URIs: the environment could not be read (" + envErr.Error() + ")"
}
if redacted > 0 {
answer["redacted"] = redacted
answer["leak"] = "this container printed secrets it was given; docker_secrets_in_logs names them (novox/hq issue 268)"
}
const most = 4096
for i, l := range all {
if len(l) > most {
all[i] = l[:most] + "…"
}
}
answer["lines"], answer["count"] = all, len(all)
return answer, nil
}
// logLines runs `docker logs …` and answers both streams' lines merged in the order written.
func (c *Client) logLines(ctx context.Context, args []string) ([]string, error) {
r := c.Run(ctx, "docker", args...) r := c.Run(ctx, "docker", args...)
program := "docker" program := "docker"
if r.Status != 0 && r.Err == "" && c.UID != 0 && socketRefused.MatchString(r.Stderr) { if r.Status != 0 && r.Err == "" && c.UID != 0 && socketRefused.MatchString(r.Stderr) {
@@ -392,16 +451,7 @@ func (c *Client) Logs(ctx context.Context, ref string, tail int, since string) (
// Both streams carry the container's lines, each led by its timestamp, so they merge in order. // Both streams carry the container's lines, each led by its timestamp, so they merge in order.
all := append(lines(r.Stdout), lines(r.Stderr)...) all := append(lines(r.Stdout), lines(r.Stderr)...)
sort.SliceStable(all, func(a, b int) bool { return all[a] < all[b] }) sort.SliceStable(all, func(a, b int) bool { return all[a] < all[b] })
if len(all) > tail { return all, nil
all = all[len(all)-tail:]
}
const most = 4096
for i, l := range all {
if len(l) > most {
all[i] = l[:most] + "…"
}
}
return map[string]any{"container": ref, "lines": all, "count": len(all)}, nil
} }
// Stat is one container's use of the machine now. // Stat is one container's use of the machine now.
@@ -950,24 +1000,29 @@ func (c *Client) Events(ctx context.Context, minutes int, kind string, limit int
if err != nil { if err != nil {
return nil, err return nil, err
} }
raw, err := jsonLines[struct { raw, err := jsonLines[runtimeEvent](out)
Type, Action string
Actor struct {
ID string
Attributes map[string]string
}
TimeNano int64 `json:"timeNano"`
}](out)
if err != nil { if err != nil {
return nil, err return nil, err
} }
// An exec's command line is shown without the secrets it carried (novox/hq issue 282): this answer
// is read by agents and kept in their transcripts, which would make it a second copy of the leak.
envs := &envCache{c: c, ctx: ctx}
redacted := map[string]bool{}
events := []map[string]any{} events := []map[string]any{}
for _, e := range raw { for _, e := range raw {
if !execs && strings.HasPrefix(e.Action, "exec_") { if !execs && strings.HasPrefix(e.Action, "exec_") {
continue continue
} }
action := e.Action
if verb, command, ok := execCommand(action); ok {
shown, names := redactCommand(command, envs.of(e.Actor.ID))
action = verb + ": " + shown
for _, n := range names {
redacted[n] = true
}
}
_, held := e.Actor.Attributes[MeshLabel] _, held := e.Actor.Attributes[MeshLabel]
ev := map[string]any{"time": time.Unix(0, e.TimeNano).UTC().Format(time.RFC3339), "type": e.Type, "action": e.Action, ev := map[string]any{"time": time.Unix(0, e.TimeNano).UTC().Format(time.RFC3339), "type": e.Type, "action": action,
"id": shortID(e.Actor.ID), "name": e.Actor.Attributes["name"]} "id": shortID(e.Actor.ID), "name": e.Actor.Attributes["name"]}
if e.Type == "container" { if e.Type == "container" {
ev["mesh_held"] = held ev["mesh_held"] = held
@@ -982,7 +1037,41 @@ func (c *Client) Events(ctx context.Context, minutes int, kind string, limit int
if len(events) > limit { if len(events) > limit {
events = events[len(events)-limit:] events = events[len(events)-limit:]
} }
return map[string]any{"minutes": minutes, "count": total, "shown": len(events), "events": events}, nil answer := map[string]any{"minutes": minutes, "count": total, "shown": len(events), "events": events}
if len(redacted) > 0 {
answer["redacted"] = sortedSet(redacted)
answer["leak"] = "exec command lines carried secrets, which the runtime keeps in its events; docker_secrets_in_events names them (novox/hq issue 282)"
}
return answer, nil
}
func sortedSet(set map[string]bool) []string {
out := make([]string, 0, len(set))
for k := range set {
out = append(out, k)
}
sort.Strings(out)
return out
}
// redactWords is a command's words with what redactCommand hides hidden, word by word.
func redactWords(words []any, known []knownSecret) []any {
list := make([]string, len(words))
for i, w := range words {
list[i] = fmt.Sprint(w)
}
shapes := secretsOnCommandLine(list)
out := make([]any, len(list))
for i, w := range list {
for _, s := range shapes {
if strings.Contains(w, s.Value) {
w = strings.ReplaceAll(w, s.Value, "[redacted: "+s.Name+"]")
}
}
w, _ = redact(w, known)
out[i] = w
}
return out
} }
// restartOnly are the daemon keys the runtime reads only when it starts: a reload leaves them as // restartOnly are the daemon keys the runtime reads only when it starts: a reload leaves them as
@@ -8,6 +8,7 @@ import (
"os" "os"
"reflect" "reflect"
"strings" "strings"
"sync"
"testing" "testing"
"time" "time"
) )
@@ -19,6 +20,7 @@ type call struct {
// fake answers each command by the first rule whose prefix matches "name arg arg…". // fake answers each command by the first rule whose prefix matches "name arg arg…".
type fake struct { type fake struct {
mu sync.Mutex
rules []rule rules []rule
calls []call calls []call
} }
@@ -31,6 +33,8 @@ type rule struct {
func (f *fake) on(prefix string, r Ran) *fake { f.rules = append(f.rules, rule{prefix, r}); return f } func (f *fake) on(prefix string, r Ran) *fake { f.rules = append(f.rules, rule{prefix, r}); return f }
func (f *fake) run(_ context.Context, name string, args ...string) Ran { func (f *fake) run(_ context.Context, name string, args ...string) Ran {
f.mu.Lock()
defer f.mu.Unlock()
f.calls = append(f.calls, call{name, args}) f.calls = append(f.calls, call{name, args})
line := strings.Join(append([]string{name}, args...), " ") line := strings.Join(append([]string{name}, args...), " ")
for _, r := range f.rules { for _, r := range f.rules {
+43 -4
View File
@@ -71,8 +71,9 @@ func tools(c *Client) []stdio.Tool {
}, },
}, },
{ {
Name: "docker_logs", Name: "docker_logs",
Description: "The last lines one container wrote, both streams merged in order, each with its timestamp (default 200, at most 2000 lines; a line is cut at 4 KiB).", Description: "The last lines one container wrote, both streams merged in order, each with its timestamp (default 200, at most 2000 lines; a line is cut at 4 KiB). " +
"A secret the container was given that it printed is shown as [redacted: <name>], and so is a password inside a URI.",
Input: map[string]any{ Input: map[string]any{
"container": containerArg, "container": containerArg,
"lines": map[string]any{"type": "integer", "description": "how many lines from the end (default 200, at most 2000)"}, "lines": map[string]any{"type": "integer", "description": "how many lines from the end (default 200, at most 2000)"},
@@ -90,6 +91,43 @@ func tools(c *Client) []stdio.Tool {
return c.Logs(ctx, ref, n, optional(args, "since")) return c.Logs(ctx, ref, n, optional(args, "since"))
}, },
}, },
{
Name: "docker_secrets_in_logs",
Description: "Which containers printed a secret they were given into their own log — by container, module and the secret's name, never its value: " +
"each one's recent lines compared with the values of its environment named like a secret and the passwords in its URIs, and any URI carrying a password. " +
"A finding is a secret to rotate once the program stops printing it (novox/hq issue 268).",
Input: map[string]any{
"held": map[string]any{"type": "string", "enum": []string{"all", "mesh", "other"}, "description": "whose: the mesh's (default), every container, or the others"},
"lines": map[string]any{"type": "integer", "description": "how many lines from the end of each log (default 5000, at most 50000)"},
},
Run: func(args map[string]any) (any, error) {
n, err := bounded(args, "lines", 5000, 50000)
if err != nil {
return nil, err
}
held := optional(args, "held")
if held == "" {
held = "mesh"
}
return c.SecretsInLogs(ctx, held, n)
},
},
{
Name: "docker_secrets_in_events",
Description: "Which secrets exec command lines carried in a window ending now (default the last 60 minutes, at most 24 hours) — the runtime records every exec's command line in its events, " +
"so a password passed as an argument is kept there for anyone who may ask it. By container, module and the secret's name, never its value. " +
"A finding is code to change (hand the secret over as a file or on stdin) and then a secret to rotate (novox/hq issue 282).",
Input: map[string]any{
"minutes": map[string]any{"type": "integer", "description": "how far back (default 60, at most 1440)"},
},
Run: func(args map[string]any) (any, error) {
minutes, err := bounded(args, "minutes", 60, 1440)
if err != nil {
return nil, err
}
return c.SecretsInEvents(ctx, minutes)
},
},
{ {
Name: "docker_stats", Name: "docker_stats",
Description: "What the running containers use now — CPU, memory, network and disk I/O, processes — the heaviest by memory first; or one container's.", Description: "What the running containers use now — CPU, memory, network and disk I/O, processes — the heaviest by memory first; or one container's.",
@@ -189,8 +227,9 @@ func tools(c *Client) []stdio.Tool {
}, },
}, },
{ {
Name: "docker_events", Name: "docker_events",
Description: "What the runtime did in a window ending now (default the last 60 minutes, at most 24 hours): containers created, started, died, health changes, images pulled — with mesh_held. Exec events are left out unless asked.", Description: "What the runtime did in a window ending now (default the last 60 minutes, at most 24 hours): containers created, started, died, health changes, images pulled — with mesh_held. Exec events are left out unless asked; " +
"an exec's command line is shown with any secret it carried as [redacted: <what it was>] — a value of the container's environment named like a secret, a password in a URI, the word after a password flag.",
Input: map[string]any{ Input: map[string]any{
"minutes": map[string]any{"type": "integer", "description": "how far back (default 60, at most 1440)"}, "minutes": map[string]any{"type": "integer", "description": "how far back (default 60, at most 1440)"},
"type": map[string]any{"type": "string", "description": "only one kind: container, image, network, volume, daemon, plugin or builder"}, "type": map[string]any{"type": "string", "description": "only one kind: container, image, network, volume, daemon, plugin or builder"},
+289
View File
@@ -0,0 +1,289 @@
package main
// A container's secrets in its own output (novox/hq issue 268).
//
// **The leak this catches.** Software prints what it was given: a server that announces its
// password when it starts in secure mode, a startup script that echoes the database URI it
// connects with, password and all. The container's log is then a copy of the secret that every
// reader of the log holds — this bundle's docker_logs, the journal where a container logs there,
// and whatever kept a transcript of either. Nothing in the mesh noticed, because nothing looked.
//
// **What is known here, and what is not.** A container's environment is readable through the
// runtime (docker inspect), so its values can be compared with what it printed: a variable named
// like a secret (PASSWORD, SECRET, TOKEN, KEY, …) and the password inside any URI a variable holds.
// Beside that, a credential-bearing URI anywhere in a line (`scheme://user:password@`) is caught
// by its shape, whatever the source. A secret handed only as a mounted file and never in the
// environment is not known to this bundle — it runs as the operator account, which cannot read
// the files the host writes at 0600 — and is caught only if the program prints it inside a URI.
//
// **Never the value.** A finding names the container, the module and the variable; it carries
// no value and no line. A tool that quoted the leak to report it would be a second leak.
import (
"context"
"encoding/json"
"fmt"
"net/url"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"time"
)
// secretName is a variable name that says its value is a secret.
var secretName = regexp.MustCompile(`(?i)(pass(word|wd|phrase)?|secret|token|api_?key|private_?key|access_?key|credential|auth)`)
// notAValue is a name that says its value is where a secret is, not the secret: a file or a path.
var notAValue = regexp.MustCompile(`(?i)(_FILE|FILE|_PATH|_DIR)$`)
// uriPassword is a URI carrying a password in its userinfo: scheme://user:password@.
var uriPassword = regexp.MustCompile(`[A-Za-z][A-Za-z0-9+.-]*://[^\s/:@'"]*:([^\s/@'"]+)@`)
// masked is a password a program already hid: ***, xxx, <redacted>, [REDACTED].
var masked = regexp.MustCompile(`^(\*+|x+|X+|<[^>]*>|\[[^\]]*\]|%2A+)$`)
// ordinary is a value under a secret's name that is not one: a path, an address, a number, a switch.
var ordinary = regexp.MustCompile(`^(/.*|[A-Za-z][A-Za-z0-9+.-]*://.*|[0-9.]+[a-z]?|(?i:true|false|yes|no|on|off|none|null))$`)
// leastSecret is the shortest value compared as a secret: a shorter one matches ordinary words.
const leastSecret = 6
// knownSecret is one value a container was given, by the name it came under.
type knownSecret struct {
Name string
Value string
}
// secretsIn are the values in a container's environment that must never appear in its output.
func secretsIn(env []string) []knownSecret {
var out []knownSecret
seen := map[string]bool{}
add := func(name, value string) {
if len(value) < leastSecret || masked.MatchString(value) || seen[name+"\x00"+value] {
return
}
seen[name+"\x00"+value] = true
out = append(out, knownSecret{name, value})
}
for _, e := range env {
name, value, ok := strings.Cut(e, "=")
if !ok || value == "" {
continue
}
for _, m := range uriPassword.FindAllStringSubmatch(value, -1) {
add(name+" (the password in its URI)", m[1])
if dec, err := url.PathUnescape(m[1]); err == nil && dec != m[1] {
add(name+" (the password in its URI)", dec)
}
}
if secretName.MatchString(name) && !notAValue.MatchString(name) && !ordinary.MatchString(value) {
add(name, value)
}
}
return out
}
// forms are the ways a value may appear printed: as given, and URL-encoded.
func forms(value string) []string {
out := []string{value}
for _, f := range []string{url.QueryEscape(value), url.PathEscape(value)} {
if f != value && !contains(out, f) {
out = append(out, f)
}
}
return out
}
func contains(list []string, s string) bool {
for _, x := range list {
if x == s {
return true
}
}
return false
}
// leaksIn is, per secret name, how many lines carry that secret; and how many carry a URI with a
// password that is none of the known ones. The lines are read and forgotten.
func leaksIn(lines []string, known []knownSecret) (byName map[string][]string, uris []string) {
byName = map[string][]string{}
for _, l := range lines {
hit := false
for _, s := range known {
for _, f := range forms(s.Value) {
if strings.Contains(l, f) {
byName[s.Name] = append(byName[s.Name], stamp(l))
hit = true
break
}
}
}
if hit {
continue
}
for _, m := range uriPassword.FindAllStringSubmatch(l, -1) {
if !masked.MatchString(m[1]) {
uris = append(uris, stamp(l))
break
}
}
}
return byName, uris
}
// redact is a line with every known secret, and every password inside a URI, replaced by a mark
// naming what was there.
func redact(line string, known []knownSecret) (string, int) {
n := 0
for _, s := range known {
for _, f := range forms(s.Value) {
if c := strings.Count(line, f); c > 0 {
line = strings.ReplaceAll(line, f, "[redacted: "+s.Name+"]")
n += c
}
}
}
line = uriPassword.ReplaceAllStringFunc(line, func(m string) string {
sub := uriPassword.FindStringSubmatch(m)
if masked.MatchString(sub[1]) {
return m
}
n++
return strings.TrimSuffix(m, sub[1]+"@") + "[redacted: a password in a URI]@"
})
return line, n
}
// stamp is the timestamp leading a line `docker logs --timestamps` printed.
func stamp(line string) string {
t, _, _ := strings.Cut(line, " ")
return t
}
// envOf is one container's environment, values included — kept inside this process.
func (c *Client) envOf(ctx context.Context, ref string) ([]string, error) {
out, err := c.docker(ctx, "container", "inspect", "--format", "{{json .Config.Env}}", ref)
if err != nil {
return nil, err
}
var env []string
if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &env); err != nil {
return nil, fmt.Errorf("docker inspect answered an environment that is not JSON")
}
return env, nil
}
// Leak is one secret a container printed: by name, never by value.
type Leak struct {
Container string `json:"container"`
HeldBy string `json:"held_by,omitempty"`
Module string `json:"module,omitempty"`
Secret string `json:"secret"`
Lines int `json:"lines"`
First string `json:"first"`
Last string `json:"last"`
}
// ScanBudget is how long a scan may take: below the runtime's thirty-second call limit, so a scan of
// a machine with many containers answers what it read rather than nothing. ScanWidth is how many
// containers are read at once.
const (
ScanBudget = 25 * time.Second
ScanWidth = 6
)
// scanned is what one container's log held.
type scanned struct {
leaks []Leak
lines int
why string
}
// scanOne reads one container's environment and log, and keeps only what was printed, by name.
func (c *Client) scanOne(ctx context.Context, ct Container, tail int) scanned {
env, err := c.envOf(ctx, ct.ID)
if err != nil {
return scanned{why: err.Error()}
}
lines, err := c.logLines(ctx, []string{"logs", "--timestamps", "--tail", strconv.Itoa(tail), ct.ID})
if err != nil {
if ctx.Err() != nil {
return scanned{why: "not read within the scan's " + ScanBudget.String()}
}
return scanned{why: err.Error()}
}
byName, uris := leaksIn(lines, secretsIn(env))
if len(uris) > 0 {
byName["a password inside a URI (not from its environment)"] = uris
}
names := make([]string, 0, len(byName))
for n := range byName {
names = append(names, n)
}
sort.Strings(names)
out := scanned{lines: len(lines)}
for _, n := range names {
at := byName[n]
sort.Strings(at)
out.leaks = append(out.leaks, Leak{Container: ct.Name, HeldBy: ct.HeldBy, Module: ct.Module, Secret: n,
Lines: len(at), First: at[0], Last: at[len(at)-1]})
}
return out
}
// SecretsInLogs scans the last `tail` lines of each container — the mesh's, or every one — for the
// secrets it was given. A container whose log could not be read is listed as unread, never as clean.
func (c *Client) SecretsInLogs(ctx context.Context, held string, tail int) (map[string]any, error) {
all, err := c.Containers(ctx, held, "", "")
if err != nil {
return nil, err
}
ctx, cancel := context.WithTimeout(ctx, ScanBudget)
defer cancel()
results := make([]scanned, len(all))
var wg sync.WaitGroup
slots := make(chan struct{}, ScanWidth)
for i, ct := range all {
wg.Add(1)
go func(i int, ct Container) {
defer wg.Done()
select {
case slots <- struct{}{}:
defer func() { <-slots }()
results[i] = c.scanOne(ctx, ct, tail)
case <-ctx.Done():
results[i] = scanned{why: "not read within the scan's " + ScanBudget.String()}
}
}(i, ct)
}
wg.Wait()
leaks := []Leak{}
unread := []map[string]string{}
count, read := 0, 0
for i, r := range results {
if r.why != "" {
unread = append(unread, map[string]string{"container": all[i].Name, "why": r.why})
continue
}
count++
read += r.lines
leaks = append(leaks, r.leaks...)
}
verdict := "no container printed a secret it was given in the lines read"
if len(leaks) > 0 {
verdict = fmt.Sprintf("%d secret(s) printed into container logs: rotate each one after the program stops printing it, "+
"and recreate the container to drop its old log", len(leaks))
}
if len(unread) > 0 {
verdict += fmt.Sprintf("; %d container(s) not read, so not known to be clean", len(unread))
}
return map[string]any{
"verdict": verdict, "leaks": leaks, "count": len(leaks), "containers_scanned": count, "lines_read": read,
"unread": unread,
"knows": "values in each container's environment named like a secret, the password in any URI it holds, and any " +
"URI carrying a password; a secret delivered only as a mounted file is caught only inside a URI",
}, nil
}
@@ -0,0 +1,262 @@
package main
import (
"context"
"encoding/json"
"strings"
"testing"
)
// The shape of the leak in hq issue 268: a server password announced at start and a database URI
// echoed whole. The values are made up for the test.
const (
serverPassword = "Zq8-server-pass_word"
dbPassword = "Db_pa55-word-xyz"
)
var lettaEnv = []string{
"LETTA_PG_URI=postgresql://letta@db:5432/letta",
"LETTA_SERVER_PASSWORD=" + serverPassword,
"OTHER_URI=postgresql://other:" + dbPassword + "@db:5432/other",
"PGPASSFILE=/run/secrets/pgpass",
"SECURE=true",
"AUTH_URL=https://id.example/auth",
"TOKEN_TTL=3600",
"POSTGRES_PASSWORD=letta",
"TZ=Europe/Brussels",
}
func TestOnlyValuesNamedAsSecretsAndPasswordsInURIsAreKnown(t *testing.T) {
got := map[string]string{}
for _, s := range secretsIn(lettaEnv) {
got[s.Name] = s.Value
}
if got["LETTA_SERVER_PASSWORD"] != serverPassword || got["OTHER_URI (the password in its URI)"] != dbPassword {
t.Fatalf("missed a secret: %v", keys(got))
}
for _, not := range []string{"LETTA_PG_URI (the password in its URI)", "PGPASSFILE", "SECURE", "AUTH_URL", "TOKEN_TTL", "POSTGRES_PASSWORD", "TZ"} {
if _, ok := got[not]; ok {
t.Errorf("%s taken for a secret", not)
}
}
}
func scanMachine(logs string) *fake {
env, _ := json.Marshal(lettaEnv)
return (&fake{}).
on("docker ps --all --quiet --no-trunc", Ran{Stdout: "aaaaaaaaaaaaaaaa\nbbbbbbbbbbbbbbbb\n"}).
on("docker container inspect aaaaaaaaaaaaaaaa bbbbbbbbbbbbbbbb", Ran{Stdout: "[" + held + "," + stray + "]"}).
on("docker container inspect --format {{json .Config.Env}}", Ran{Stdout: string(env) + "\n"}).
on("docker logs --timestamps --tail 5000 aaaaaaaaaaaa", Ran{Stdout: logs})
}
const leakyLog = "2026-10-05T19:16:40Z External Postgres configuration detected, using postgresql://letta@db:5432/letta\n" +
"2026-10-05T19:16:41Z Creating engine postgresql://other:" + dbPassword + "@db:5432/other\n" +
"2026-10-05T19:16:42Z ▶ Using secure mode with password: " + serverPassword + "\n" +
"2026-10-05T19:16:43Z connecting to mongodb://app:s3cr3t-elsewhere@mongo:27017\n" +
"2026-10-05T19:16:44Z Using database: postgresql://letta:***@db:5432/letta\n" +
"2026-10-05T19:20:42Z ▶ Using secure mode with password: " + serverPassword + "\n"
func TestAScanNamesEachPrintedSecretAndNeverItsValue(t *testing.T) {
got, err := client(scanMachine(leakyLog), 1000).SecretsInLogs(context.Background(), "mesh", 5000)
if err != nil {
t.Fatal(err)
}
raw, _ := json.Marshal(got)
for _, v := range []string{serverPassword, dbPassword, "s3cr3t-elsewhere"} {
if strings.Contains(string(raw), v) {
t.Fatalf("the answer carries a secret's value: %s", raw)
}
}
leaks := got["leaks"].([]Leak)
byName := map[string]Leak{}
for _, l := range leaks {
byName[l.Secret] = l
if l.Container != "mesh-web" || l.Module != "hello-web" || l.HeldBy != "hello-web.server" {
t.Errorf("finding not named by its container and module: %+v", l)
}
}
if l := byName["LETTA_SERVER_PASSWORD"]; l.Lines != 2 || l.First != "2026-10-05T19:16:42Z" || l.Last != "2026-10-05T19:20:42Z" {
t.Errorf("server password: %+v", l)
}
if l := byName["OTHER_URI (the password in its URI)"]; l.Lines != 1 {
t.Errorf("password in a URI from the environment: %+v", l)
}
if l := byName["a password inside a URI (not from its environment)"]; l.Lines != 1 {
t.Errorf("a URI's password by its shape (and not a masked one): %+v", l)
}
if len(leaks) != 3 || got["containers_scanned"] != 1 {
t.Errorf("leaks %d, scanned %v (only the mesh's)", len(leaks), got["containers_scanned"])
}
}
func TestACleanLogIsSaidToBeClean(t *testing.T) {
got, err := client(scanMachine("2026-10-05T19:16:40Z started\n"), 1000).SecretsInLogs(context.Background(), "mesh", 5000)
if err != nil {
t.Fatal(err)
}
if got["count"] != 0 || !strings.HasPrefix(got["verdict"].(string), "no container") {
t.Errorf("%v", got)
}
}
func TestAContainerWhoseLogCannotBeReadIsSaidSoRatherThanCalledClean(t *testing.T) {
f := scanMachine("")
f.rules = append([]rule{{"docker logs", Ran{Status: 1, Stderr: "Error response from daemon: configured logging driver does not support reading\n"}}}, f.rules...)
got, err := client(f, 1000).SecretsInLogs(context.Background(), "mesh", 5000)
if err != nil {
t.Fatal(err)
}
if len(got["unread"].([]map[string]string)) != 1 || got["containers_scanned"] != 0 {
t.Errorf("%v", got)
}
}
func TestLogsRedactWhatTheContainerPrintedOfItsSecrets(t *testing.T) {
env, _ := json.Marshal(lettaEnv)
f := (&fake{}).
on("docker logs", Ran{Stdout: leakyLog}).
on("docker container inspect --format {{json .Config.Env}} letta", Ran{Stdout: string(env)})
got, err := client(f, 1000).Logs(context.Background(), "letta", 200, "")
if err != nil {
t.Fatal(err)
}
raw, _ := json.Marshal(got)
for _, v := range []string{serverPassword, dbPassword, "s3cr3t-elsewhere"} {
if strings.Contains(string(raw), v) {
t.Fatalf("docker_logs answered a secret: %s", raw)
}
}
lines := got["lines"].([]string)
if !strings.Contains(lines[2], "[redacted: LETTA_SERVER_PASSWORD]") ||
!strings.Contains(lines[3], "mongodb://app:[redacted: a password in a URI]@mongo") ||
!strings.Contains(lines[4], "letta:***@db") || got["redacted"] != 4 {
t.Errorf("%v %v", lines, got["redacted"])
}
}
func TestLogsWithoutTheEnvironmentStillHideAURIsPasswordAndSaySo(t *testing.T) {
f := (&fake{}).on("docker logs", Ran{Stdout: leakyLog})
got, err := client(f, 1000).Logs(context.Background(), "letta", 200, "")
if err != nil {
t.Fatal(err)
}
raw, _ := json.Marshal(got)
if strings.Contains(string(raw), dbPassword) || got["redaction"] == nil {
t.Errorf("%s", raw)
}
}
func keys(m map[string]string) []string {
out := []string{}
for k := range m {
out = append(out, k)
}
return out
}
// The shape of the leak in hq issue 282: a broker's admin password on an exec's command line. The
// values are made up for the test.
const (
adminPassword = "Adm1n-pass_word-xyz"
clientPassword = "Cl1ent-pass_word-abc"
)
func TestACommandLineIsShownWithoutTheSecretsItCarried(t *testing.T) {
for _, tc := range []struct{ command, mark string }{
{"mosquitto_ctrl -h 127.0.0.1 -p 1883 -u mesh-admin -P " + adminPassword + " dynsec listClients", "the word after -P"},
{"mosquitto_ctrl -h 127.0.0.1 -p 1883 dynsec createClient alice -p " + clientPassword, "the word after -p in a mosquitto_ctrl"},
{"mosquitto_ctrl -o /tmp/x dynsec setClientPassword alice " + clientPassword, "the password given to dynsec setClientPassword"},
{"redis-cli -a " + adminPassword + " ping", "the word after -a"},
{"env PGPASSWORD=" + adminPassword + " psql -U app", "the value of PGPASSWORD"},
{"tool --password=" + adminPassword, "the value of --password"},
{"psql postgresql://app:" + adminPassword + "@db/app", "a password in a URI"},
} {
shown, names := redactCommand(tc.command, nil)
if strings.Contains(shown, adminPassword) || strings.Contains(shown, clientPassword) {
t.Errorf("%q: still carries the value: %q", tc.command, shown)
}
if len(names) == 0 || !strings.Contains(strings.Join(names, "|"), tc.mark) {
t.Errorf("%q: named %v, want %q", tc.command, names, tc.mark)
}
}
// A port, a path and a plain command are not secrets.
for _, plain := range []string{
"mosquitto_ctrl -h 127.0.0.1 -p 1883 dynsec listClients",
"/usr/bin/lavinmqctl status",
"sh -c umask 077\nf=$(mktemp) || exit 1 mosquitto_ctrl -h 127.0.0.1 -p 1883 dynsec getClient alice",
"pg_dump -Fc -f /dumps/app.dump app",
} {
if shown, names := redactCommand(plain, nil); shown != plain || len(names) > 0 {
t.Errorf("%q: redacted %v as %q", plain, names, shown)
}
}
// What the container's environment holds is known by its name, wherever it appears.
known := []knownSecret{{"SERVER_PASSWORD", adminPassword}}
if shown, names := redactCommand("app login "+adminPassword, known); strings.Contains(shown, adminPassword) ||
len(names) != 1 || names[0] != "SERVER_PASSWORD" {
t.Errorf("an environment secret on a command line: %q %v", shown, names)
}
}
const execEvents = `{"Type":"container","Action":"exec_create: mosquitto_ctrl -h 127.0.0.1 -p 1883 -u mesh-admin -P ` + adminPassword + ` dynsec listClients","Actor":{"ID":"aaaaaaaaaaaaaaaa","Attributes":{"name":"mosquitto","mesh-host.id":"mosquitto.server","execID":"e1"}},"timeNano":1791320000000000000}
{"Type":"container","Action":"exec_start: mosquitto_ctrl -h 127.0.0.1 -p 1883 -u mesh-admin -P ` + adminPassword + ` dynsec listClients","Actor":{"ID":"aaaaaaaaaaaaaaaa","Attributes":{"name":"mosquitto","mesh-host.id":"mosquitto.server","execID":"e1"}},"timeNano":1791320000000100000}
{"Type":"container","Action":"exec_die","Actor":{"ID":"aaaaaaaaaaaaaaaa","Attributes":{"name":"mosquitto","exitCode":"0"}},"timeNano":1791320000000200000}
{"Type":"container","Action":"exec_create: /usr/bin/healthcheck","Actor":{"ID":"bbbbbbbbbbbbbbbb","Attributes":{"name":"other"}},"timeNano":1791320060000000000}
{"Type":"container","Action":"exec_create: mosquitto_ctrl -h 127.0.0.1 -p 1883 -u mesh-admin -P ` + adminPassword + ` dynsec getClient a","Actor":{"ID":"aaaaaaaaaaaaaaaa","Attributes":{"name":"mosquitto","mesh-host.id":"mosquitto.server","execID":"e2"}},"timeNano":1791320120000000000}
`
func TestEventsShowAnExecsCommandLineWithoutItsSecrets(t *testing.T) {
f := (&fake{}).
on("docker events", Ran{Stdout: execEvents}).
on("docker container inspect --format {{json .Config.Env}}", Ran{Stdout: "[]\n"})
got, err := client(f, 1000).Events(context.Background(), 30, "", 100, true)
if err != nil {
t.Fatal(err)
}
b, _ := json.Marshal(got)
if strings.Contains(string(b), adminPassword) {
t.Fatalf("the answer carries the value: %s", b)
}
if !strings.Contains(string(b), "[redacted: the word after -P") || got["leak"] == nil {
t.Fatalf("not marked as redacted: %s", b)
}
}
func TestAScanOfExecEventsNamesEachSecretAndNeverItsValue(t *testing.T) {
f := (&fake{}).
on("docker events", Ran{Stdout: execEvents}).
on("docker container inspect --format {{json .Config.Env}}", Ran{Stdout: "[]\n"})
got, err := client(f, 1000).SecretsInEvents(context.Background(), 60)
if err != nil {
t.Fatal(err)
}
b, _ := json.Marshal(got)
if strings.Contains(string(b), adminPassword) {
t.Fatalf("the finding carries the value: %s", b)
}
leaks := got["leaks"].([]CommandLeak)
if len(leaks) != 1 || leaks[0].Container != "mosquitto" || leaks[0].Module != "mosquitto" || leaks[0].Execs != 2 ||
leaks[0].Program != "mosquitto_ctrl" || !strings.Contains(leaks[0].Secret, "-P") {
t.Fatalf("leaks: %+v", leaks)
}
if got["execs_read"] != 3 {
t.Fatalf("read %v exec_create events, want 3 (a start repeats a create and is not counted)", got["execs_read"])
}
if !f.ran("docker events --since 60m --until 0s --filter type=container --filter event=exec_create") {
t.Fatalf("not asked for exec creations only: %+v", f.calls)
}
}
func TestInspectShowsACommandLineWithoutItsSecrets(t *testing.T) {
obj := `[{"Path":"mosquitto_ctrl","Args":["-P","` + adminPassword + `","dynsec","listClients"],"Config":{"Env":["A=b"],"Cmd":["mosquitto_ctrl","-P","` + adminPassword + `"],"Labels":{}}}]`
f := (&fake{}).on("docker container inspect", Ran{Stdout: obj})
got, err := client(f, 1000).Inspect(context.Background(), "mosquitto")
if err != nil {
t.Fatal(err)
}
b, _ := json.Marshal(got)
if strings.Contains(string(b), adminPassword) || !strings.Contains(string(b), "[redacted:") {
t.Fatalf("inspect: %s", b)
}
}
+20
View File
@@ -16,6 +16,8 @@
"docker_list", "docker_list",
"docker_inspect", "docker_inspect",
"docker_logs", "docker_logs",
"docker_secrets_in_logs",
"docker_secrets_in_events",
"docker_stats", "docker_stats",
"docker_start", "docker_start",
"docker_stop", "docker_stop",
@@ -50,6 +52,24 @@
"state": "running", "state": "running",
"boot": "enabled" "boot": "enabled"
}, },
{
"id": "daemon",
"type": "file",
"path": "/etc/docker/daemon.json",
"mode": "0644",
"into": "json",
"content": "{\"live-restore\": true, \"insecure-registries\": [\"${seat:mesh-artifact-store:reach}\"]}\n"
},
{
"id": "runtime",
"type": "service",
"unit": "docker.service",
"state": "running",
"boot": "enabled",
"reload-on": [
"daemon"
]
},
{ {
"id": "prune-service", "id": "prune-service",
"type": "file", "type": "file",
-236
View File
@@ -1,236 +0,0 @@
// fail2ban's own code, in the module (novox/hq ADR 0039). The jails are composed by the mesh from
// the modules a machine runs (to-be 31) and written as declared resources; the daemon is kept
// running by one. This code exists only to read and steer the *live* state the daemon owns: who is
// banned now and until when, and the ban or release an operator asks for — the node-intrusion-
// prevention seat's four verbs (ADR 0179). The daemon's state is fail2ban's, not the mesh's: the
// mesh composes the jails and never writes the ban list.
//
// Spoken through fail2ban-client over the daemon's socket. Client and daemon come from the one
// package this module declares on the machine, and the socket is root's: root is the module's
// concern (ADR 0175 §4), and the runtime loading this bundle runs as the operator's account (to-be
// 38 WP4), so the client is run through sudo without a prompt where the account is not root.
import { execFile } from "node:child_process";
import { accessSync, constants } from "node:fs";
import { isIP } from "node:net";
import { delimiter, join } from "node:path";
import { promisify } from "node:util";
const execFileP = promisify(execFile);
/** A command runner, so the verbs can be tested without a daemon. */
export type Runner = (cmd: string, args: string[]) => Promise<string>;
/** The command as it is run: as given when this process is root, else through sudo without a
* prompt. The daemon's socket answers only to root. */
export function escalated(cmd: string, args: string[], uid: number | undefined = process.getuid?.()): [string, string[]] {
if (uid === 0) return [cmd, args];
return ["sudo", ["-n", cmd, ...args]];
}
/** Whether a tool is on this machine: an executable of that name on the path, or where the
* system keeps its administration. */
export function installed(tool: string, path: string = process.env.PATH ?? ""): boolean {
const dirs = [...path.split(delimiter), "/usr/sbin", "/sbin", "/usr/bin"].filter((d) => d !== "");
return dirs.some((dir) => {
try {
accessSync(join(dir, tool), constants.X_OK);
return true;
} catch {
return false;
}
});
}
export const execRunner: Runner = async (cmd, args) => {
if (!installed(cmd)) throw new Error(`${cmd} is not installed on this machine`);
const [program, argv] = escalated(cmd, args);
try {
const { stdout } = await execFileP(program, argv, { maxBuffer: 16 * 1024 * 1024 });
return stdout;
} catch (err) {
const e = err as { code?: string | number; stderr?: string; stdout?: string; message?: string };
const said = `${e.stdout ?? ""}${e.stderr ?? ""}`.trim();
// What failed is named by how it failed: sudo missing is a spawn error, sudo refusing speaks
// on its own stderr line, and the rest is the client's own answer.
if (program === "sudo") {
if (e.code === "ENOENT") throw new Error(`${cmd} needs root, and sudo is not installed here for the runtime's account to escalate with`);
if (/^sudo:/m.test(said)) throw new Error(`${cmd} needs root and the runtime's account may not run it without a prompt: ${said}`);
}
if (/Failed to access socket path|Is fail2ban running|Permission denied to socket/i.test(said)) {
throw new Error("fail2ban is not running on this machine, or its socket does not answer the runtime's account");
}
// fail2ban-client's own last line is the one a person reads ("Sorry but the jail 'x' does not exist").
const lines = said.split("\n").map((l) => l.trim()).filter(Boolean);
throw new Error(lines.length ? lines[lines.length - 1] : (e.message ?? `${cmd} failed`));
}
};
/** One jail as the daemon reports it. */
export interface JailStatus {
jail: string;
/** What the jail is reading: files or journal matches, as fail2ban names them. */
watching: string[];
/** Addresses with failures counted against them right now, and all failures since the jail started. */
failing: { now: number; total: number };
/** Addresses held right now, and all bans since the jail started. */
banned: { now: number; total: number; addresses: string[] };
}
/** One ban as the daemon holds it. */
export interface Ban {
ip: string;
jail: string;
/** When the ban was placed, in the machine's local time as fail2ban prints it. */
since: string;
/** When the ban ends; "never" for a permanent ban. */
until: string;
}
export interface JailSettings {
jail: string;
bantime: string;
findtime: string;
maxretry: number;
ignoreip: string[];
actions: string[];
/** The log files the jail reads, when it reads files. */
logpath: string[];
/** The journal match the jail reads, when it reads the journal. */
journalmatch: string;
}
export class Fail2banClient {
private readonly run: Runner;
constructor(run: Runner = execRunner) {
this.run = run;
}
/** The daemon as this machine has it, through its own client. */
static onThisMachine(): Fail2banClient {
return new Fail2banClient();
}
private client(...args: string[]): Promise<string> {
return this.run("fail2ban-client", args);
}
/** The jails the daemon runs, by name. */
async jails(): Promise<string[]> {
const out = await this.client("status");
const m = out.match(/Jail list:\s*(.*)/);
if (!m) return [];
return m[1].split(",").map((j) => j.trim()).filter(Boolean);
}
/** Every jail with what it watches and holds, or one jail's detail. */
async status(jail?: string): Promise<{ jails: JailStatus[] }> {
const names = jail ? [jail] : await this.jails();
const jails: JailStatus[] = [];
for (const name of names) {
jails.push(parseJailStatus(name, await this.client("status", name)));
}
return { jails };
}
/** Every address banned now, with the jail holding it and when the ban ends. */
async banned(jail?: string): Promise<{ banned: Ban[] }> {
const names = jail ? [jail] : await this.jails();
const banned: Ban[] = [];
for (const name of names) {
banned.push(...parseBans(name, await this.client("get", name, "banip", "--with-time")));
}
banned.sort((a, b) => a.until.localeCompare(b.until) || a.ip.localeCompare(b.ip));
return { banned };
}
/** Ban one address in one jail now. The daemon's own answer is how many addresses it added. */
async ban(ip: string, jail: string): Promise<{ banned: Ban | null; added: number }> {
address(ip);
name(jail);
const out = await this.client("set", jail, "banip", ip);
const added = Number.parseInt(out.trim(), 10) || 0;
const held = (await this.banned(jail)).banned.find((b) => b.ip === ip) ?? null;
return { banned: held, added };
}
/** Let one address go, from one jail or from every jail. The daemon's answer is how many it released. */
async unban(ip: string, jail?: string): Promise<{ released: number; ip: string; jail: string | "every jail" }> {
address(ip);
let out: string;
if (jail) {
name(jail);
out = await this.client("set", jail, "unbanip", ip);
} else {
out = await this.client("unban", ip);
}
return { released: Number.parseInt(out.trim(), 10) || 0, ip, jail: jail ?? "every jail" };
}
/** One jail's effective settings — the module's own tool, beside the seat's verbs. */
async settings(jail: string): Promise<JailSettings> {
name(jail);
const get = (key: string) => this.client("get", jail, key);
const [bantime, findtime, maxretry, ignoreip, actions, logpath, journalmatch] = await Promise.all([
get("bantime"), get("findtime"), get("maxretry"), get("ignoreip"), get("actions"), get("logpath"),
get("journalmatch"),
]);
return {
jail,
bantime: bantime.trim(),
findtime: findtime.trim(),
maxretry: Number.parseInt(maxretry.trim(), 10),
ignoreip: listed(ignoreip),
actions: actions.split("\n").slice(1).map((l) => l.trim()).filter(Boolean),
logpath: /No file is currently monitored/.test(logpath) ? [] : listed(logpath),
journalmatch: journalmatch.split("\n").slice(1).map((l) => l.trim()).filter(Boolean).join(" "),
};
}
}
/** fail2ban's tree listings: lines like "|- 127.0.0.0/8" and "`- ::1", after a heading. */
function listed(out: string): string[] {
return out
.split("\n")
.map((l) => l.replace(/^[\s|`-]+/, "").trim())
.filter((l, i) => i > 0 && l.length > 0);
}
export function parseJailStatus(jail: string, out: string): JailStatus {
const field = (label: string) => {
const m = out.match(new RegExp(label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + ":\\t?\\s*(.*)"));
return m ? m[1].trim() : "";
};
const num = (label: string) => Number.parseInt(field(label), 10) || 0;
const watching = [field("File list"), field("Journal matches")].filter(Boolean);
return {
jail,
watching,
failing: { now: num("Currently failed"), total: num("Total failed") },
banned: {
now: num("Currently banned"),
total: num("Total banned"),
addresses: field("Banned IP list").split(/\s+/).filter(Boolean),
},
};
}
/** `get <jail> banip --with-time` prints one ban per line: "IP \tsince + seconds = until". */
export function parseBans(jail: string, out: string): Ban[] {
const bans: Ban[] = [];
for (const line of out.split("\n")) {
const m = line.match(/^(\S+)\s+(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) \+ (-?\d+) = (\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}|\S+)/);
if (!m) continue;
bans.push({ ip: m[1], jail, since: m[2], until: Number(m[3]) < 0 ? "never" : m[4] });
}
return bans;
}
function address(ip: string): void {
if (!isIP(ip)) throw new Error(`${JSON.stringify(ip)} is not an address`);
}
function name(jail: string): void {
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(jail)) throw new Error(`${JSON.stringify(jail)} is not a jail's name`);
}
@@ -0,0 +1,407 @@
// fail2ban's own code, in the module (novox/hq ADR 0039). The jails are composed by the mesh from the
// modules a machine runs (to-be 31) and written as declared resources; the daemon is kept running by
// one. This code exists only to read and steer the *live* state the daemon owns: who is banned now
// and until when, and the ban or release an operator asks for — the node-intrusion-prevention seat's
// four verbs (ADR 0179). The daemon's state is fail2ban's, not the mesh's: the mesh composes the
// jails and never writes the ban list.
//
// Spoken through fail2ban-client over the daemon's socket. Client and daemon come from the one
// package this module declares on the machine, and the socket is root's: root is the module's
// concern (ADR 0175 §4), and the runtime launching this binary runs as the operator's account (to-be
// 38 WP4), so the client is run through sudo without a prompt where the account is not root.
package main
import (
"bytes"
"context"
"errors"
"fmt"
"net"
"os"
"os/exec"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"time"
)
// Runner runs one command and answers what it printed, so the verbs can be tested without a daemon.
type Runner func(ctx context.Context, name string, args ...string) (string, error)
// escalated is the command as it is run: as given when this process is root, else through sudo
// without a prompt. The daemon's socket answers only to root.
func escalated(uid int, name string, args []string) (string, []string) {
if uid == 0 {
return name, args
}
return "sudo", append([]string{"-n", name}, args...)
}
// installed is whether a tool is on this machine: an executable of that name on the path, or where
// the system keeps its administration.
func installed(tool, path string) bool {
dirs := append(filepath.SplitList(path), "/usr/sbin", "/sbin", "/usr/bin")
for _, dir := range dirs {
if dir == "" {
continue
}
if info, err := os.Stat(filepath.Join(dir, tool)); err == nil && !info.IsDir() && info.Mode()&0o111 != 0 {
return true
}
}
return false
}
var socketTrouble = regexp.MustCompile(`(?i)Failed to access socket path|Is fail2ban running|Permission denied to socket`)
func execRunner(ctx context.Context, name string, args ...string) (string, error) {
if !installed(name, os.Getenv("PATH")) {
return "", fmt.Errorf("%s is not installed on this machine", name)
}
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
program, argv := escalated(os.Getuid(), name, args)
var stdout, stderr bytes.Buffer
cmd := exec.CommandContext(ctx, program, argv...)
cmd.Stdout, cmd.Stderr = &stdout, &stderr
err := cmd.Run()
if err == nil {
return stdout.String(), nil
}
said := strings.TrimSpace(stdout.String() + stderr.String())
// What failed is named by how it failed: sudo missing is a spawn error, sudo refusing speaks on
// its own stderr line, and the rest is the client's own answer.
if program == "sudo" {
if errors.Is(err, exec.ErrNotFound) {
return "", fmt.Errorf("%s needs root, and sudo is not installed here for the runtime's account to escalate with", name)
}
if regexp.MustCompile(`(?m)^sudo:`).MatchString(said) {
return "", fmt.Errorf("%s needs root and the runtime's account may not run it without a prompt: %s", name, said)
}
}
if socketTrouble.MatchString(said) {
return "", errors.New("fail2ban is not running on this machine, or its socket does not answer the runtime's account")
}
// fail2ban-client's own last line is the one a person reads ("Sorry but the jail 'x' does not exist").
var lines []string
for _, l := range strings.Split(said, "\n") {
if l = strings.TrimSpace(l); l != "" {
lines = append(lines, l)
}
}
if len(lines) > 0 {
return "", errors.New(lines[len(lines)-1])
}
return "", fmt.Errorf("%s failed: %v", name, err)
}
// Counted is a jail's count now and since it started.
type Counted struct {
Now int `json:"now"`
Total int `json:"total"`
}
// Held is what a jail holds: the count now and since it started, and the addresses.
type Held struct {
Now int `json:"now"`
Total int `json:"total"`
Addresses []string `json:"addresses"`
}
// JailStatus is one jail as the daemon reports it.
type JailStatus struct {
Jail string `json:"jail"`
// Watching is what the jail is reading: files or journal matches, as fail2ban names them.
Watching []string `json:"watching"`
// Failing is the addresses with failures counted against them now, and all failures since the
// jail started.
Failing Counted `json:"failing"`
// Banned is the addresses held right now, and all bans since the jail started.
Banned Held `json:"banned"`
}
// Ban is one ban as the daemon holds it.
type Ban struct {
IP string `json:"ip"`
Jail string `json:"jail"`
// Since is when the ban was placed, in the machine's local time as fail2ban prints it.
Since string `json:"since"`
// Until is when the ban ends; "never" for a permanent ban.
Until string `json:"until"`
}
// JailSettings is one jail's effective settings.
type JailSettings struct {
Jail string `json:"jail"`
Bantime string `json:"bantime"`
Findtime string `json:"findtime"`
Maxretry int `json:"maxretry"`
Ignoreip []string `json:"ignoreip"`
Actions []string `json:"actions"`
Logpath []string `json:"logpath"`
Journal string `json:"journalmatch"`
}
// Fail2ban is the daemon as this machine has it, through its own client.
type Fail2ban struct {
Run Runner
}
func (f Fail2ban) client(ctx context.Context, args ...string) (string, error) {
return f.Run(ctx, "fail2ban-client", args...)
}
var jailList = regexp.MustCompile(`Jail list:[ \t]*(.*)`)
// Jails is the jails the daemon runs, by name.
func (f Fail2ban) Jails(ctx context.Context) ([]string, error) {
out, err := f.client(ctx, "status")
if err != nil {
return nil, err
}
m := jailList.FindStringSubmatch(out)
if m == nil {
return []string{}, nil
}
var jails []string
for _, j := range strings.Split(m[1], ",") {
if j = strings.TrimSpace(j); j != "" {
jails = append(jails, j)
}
}
return jails, nil
}
func (f Fail2ban) named(ctx context.Context, jail string) ([]string, error) {
if jail != "" {
return []string{jail}, nil
}
return f.Jails(ctx)
}
// Status is every jail with what it watches and holds, or one jail's detail.
func (f Fail2ban) Status(ctx context.Context, jail string) (map[string][]JailStatus, error) {
names, err := f.named(ctx, jail)
if err != nil {
return nil, err
}
jails := []JailStatus{}
for _, name := range names {
out, err := f.client(ctx, "status", name)
if err != nil {
return nil, err
}
jails = append(jails, parseJailStatus(name, out))
}
return map[string][]JailStatus{"jails": jails}, nil
}
// Banned is every address banned now, with the jail holding it and when the ban ends, soonest to
// end first.
func (f Fail2ban) Banned(ctx context.Context, jail string) (map[string][]Ban, error) {
names, err := f.named(ctx, jail)
if err != nil {
return nil, err
}
banned := []Ban{}
for _, name := range names {
out, err := f.client(ctx, "get", name, "banip", "--with-time")
if err != nil {
return nil, err
}
banned = append(banned, parseBans(name, out)...)
}
sort.SliceStable(banned, func(a, b int) bool {
if banned[a].Until != banned[b].Until {
return banned[a].Until < banned[b].Until
}
return banned[a].IP < banned[b].IP
})
return map[string][]Ban{"banned": banned}, nil
}
// BanOutcome is a ban as held, and how many addresses the daemon said it added.
type BanOutcome struct {
Banned *Ban `json:"banned"`
Added int `json:"added"`
}
// Ban bans one address in one jail now. The daemon's own answer is how many addresses it added.
func (f Fail2ban) Ban(ctx context.Context, ip, jail string) (*BanOutcome, error) {
if err := address(ip); err != nil {
return nil, err
}
if err := jailName(jail); err != nil {
return nil, err
}
out, err := f.client(ctx, "set", jail, "banip", ip)
if err != nil {
return nil, err
}
added, _ := strconv.Atoi(strings.TrimSpace(out))
held, err := f.Banned(ctx, jail)
if err != nil {
return nil, err
}
outcome := &BanOutcome{Added: added}
for _, b := range held["banned"] {
if b.IP == ip {
b := b
outcome.Banned = &b
}
}
return outcome, nil
}
// Released is how many bans the daemon let go, of which address, from where.
type Released struct {
Released int `json:"released"`
IP string `json:"ip"`
Jail string `json:"jail"`
}
// Unban lets one address go, from one jail or from every jail. The daemon's answer is how many it
// released.
func (f Fail2ban) Unban(ctx context.Context, ip, jail string) (*Released, error) {
if err := address(ip); err != nil {
return nil, err
}
var out string
var err error
if jail != "" {
if err := jailName(jail); err != nil {
return nil, err
}
out, err = f.client(ctx, "set", jail, "unbanip", ip)
} else {
out, err = f.client(ctx, "unban", ip)
jail = "every jail"
}
if err != nil {
return nil, err
}
released, _ := strconv.Atoi(strings.TrimSpace(out))
return &Released{Released: released, IP: ip, Jail: jail}, nil
}
// Settings is one jail's effective settings — the module's own tool, beside the seat's verbs.
func (f Fail2ban) Settings(ctx context.Context, jail string) (*JailSettings, error) {
if err := jailName(jail); err != nil {
return nil, err
}
got := map[string]string{}
for _, key := range []string{"bantime", "findtime", "maxretry", "ignoreip", "actions", "logpath", "journalmatch"} {
out, err := f.client(ctx, "get", jail, key)
if err != nil {
return nil, err
}
got[key] = out
}
maxretry, _ := strconv.Atoi(strings.TrimSpace(got["maxretry"]))
s := &JailSettings{
Jail: jail,
Bantime: strings.TrimSpace(got["bantime"]),
Findtime: strings.TrimSpace(got["findtime"]),
Maxretry: maxretry,
Ignoreip: listed(got["ignoreip"]),
Actions: afterHeading(got["actions"]),
Logpath: []string{},
Journal: strings.Join(afterHeading(got["journalmatch"]), " "),
}
if !strings.Contains(got["logpath"], "No file is currently monitored") {
s.Logpath = listed(got["logpath"])
}
return s, nil
}
var treeMarks = regexp.MustCompile("^[\\s|`-]+")
// listed reads fail2ban's tree listings: lines like "|- 127.0.0.0/8" and "`- ::1", after a heading.
func listed(out string) []string {
items := []string{}
for i, l := range strings.Split(out, "\n") {
l = strings.TrimSpace(treeMarks.ReplaceAllString(l, ""))
if i > 0 && l != "" {
items = append(items, l)
}
}
return items
}
// afterHeading is every non-empty line after the first, trimmed.
func afterHeading(out string) []string {
items := []string{}
for i, l := range strings.Split(out, "\n") {
if l = strings.TrimSpace(l); i > 0 && l != "" {
items = append(items, l)
}
}
return items
}
func parseJailStatus(jail, out string) JailStatus {
field := func(label string) string {
m := regexp.MustCompile(regexp.QuoteMeta(label) + `:\t?[ \t]*(.*)`).FindStringSubmatch(out)
if m == nil {
return ""
}
return strings.TrimSpace(m[1])
}
num := func(label string) int {
n, _ := strconv.Atoi(field(label))
return n
}
watching := []string{}
for _, w := range []string{field("File list"), field("Journal matches")} {
if w != "" {
watching = append(watching, w)
}
}
addresses := strings.Fields(field("Banned IP list"))
if addresses == nil {
addresses = []string{}
}
return JailStatus{
Jail: jail,
Watching: watching,
Failing: Counted{Now: num("Currently failed"), Total: num("Total failed")},
Banned: Held{Now: num("Currently banned"), Total: num("Total banned"), Addresses: addresses},
}
}
var banLine = regexp.MustCompile(`^(\S+)\s+(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) \+ (-?\d+) = (\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}|\S+)`)
// parseBans reads `get <jail> banip --with-time`, one ban per line: "IP \tsince + seconds = until".
func parseBans(jail, out string) []Ban {
bans := []Ban{}
for _, line := range strings.Split(out, "\n") {
m := banLine.FindStringSubmatch(line)
if m == nil {
continue
}
until := m[4]
if seconds, _ := strconv.Atoi(m[3]); seconds < 0 {
until = "never"
}
bans = append(bans, Ban{IP: m[1], Jail: jail, Since: m[2], Until: until})
}
return bans
}
func address(ip string) error {
if net.ParseIP(ip) == nil {
return fmt.Errorf("%q is not an address", ip)
}
return nil
}
var jailNamed = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`)
func jailName(jail string) error {
if !jailNamed.MatchString(jail) {
return fmt.Errorf("%q is not a jail's name", jail)
}
return nil
}
@@ -0,0 +1,226 @@
package main
// The intrusion prevention's verbs over a fake daemon, with the shapes fail2ban-client 1.1.0 printed
// on the control node on 2026-10-02 (novox/hq ADR 0179).
import (
"context"
"fmt"
"os"
"reflect"
"strings"
"testing"
)
const statusAll = "Status\n|- Number of jail:\t2\n`- Jail list:\trecidive, sshd\n"
const recidive = "Status for the jail: recidive\n|- Filter\n| |- Currently failed:\t36\n| |- Total failed:\t149\n" +
"| `- File list:\t/var/log/fail2ban.log\n`- Actions\n |- Currently banned:\t9\n |- Total banned:\t13\n" +
" `- Banned IP list:\t195.178.110.30 45.148.10.240 92.118.39.71\n"
const sshd = "Status for the jail: sshd\n|- Filter\n| |- Currently failed:\t5\n| |- Total failed:\t11776\n" +
"| `- Journal matches:\t_SYSTEMD_UNIT=sshd.service + _COMM=sshd\n`- Actions\n |- Currently banned:\t0\n" +
" |- Total banned:\t150\n `- Banned IP list:\t\n"
const withTime = "195.178.110.30 \t2026-09-26 23:18:47 + 604800 = 2026-10-03 23:18:47\n" +
"92.118.39.71 \t2026-09-28 10:33:49 + 604800 = 2026-10-05 10:33:49\n"
func fake(answers map[string]string, calls *[][]string) Runner {
return func(_ context.Context, name string, args ...string) (string, error) {
if calls != nil {
*calls = append(*calls, append([]string{name}, args...))
}
if out, ok := answers[strings.Join(args, " ")]; ok {
return out, nil
}
return "", fmt.Errorf("unexpected %s %s", name, strings.Join(args, " "))
}
}
var ctx = context.Background()
func TestAJailsStatusIsReadIntoNumbersWhatItWatchesAndWhoItHolds(t *testing.T) {
got := parseJailStatus("recidive", recidive)
want := JailStatus{Jail: "recidive", Watching: []string{"/var/log/fail2ban.log"}, Failing: Counted{36, 149},
Banned: Held{9, 13, []string{"195.178.110.30", "45.148.10.240", "92.118.39.71"}}}
if !reflect.DeepEqual(got, want) {
t.Fatalf("%+v", got)
}
j := parseJailStatus("sshd", sshd)
if !reflect.DeepEqual(j.Watching, []string{"_SYSTEMD_UNIT=sshd.service + _COMM=sshd"}) {
t.Errorf("watching %v", j.Watching)
}
if !reflect.DeepEqual(j.Banned, Held{0, 150, []string{}}) {
t.Errorf("banned %+v", j.Banned)
}
}
func TestStatusCoversEveryJailTheDaemonListsOrTheOneNamed(t *testing.T) {
var calls [][]string
f := Fail2ban{Run: fake(map[string]string{"status": statusAll, "status recidive": recidive, "status sshd": sshd}, &calls)}
all, err := f.Status(ctx, "")
if err != nil {
t.Fatal(err)
}
if len(all["jails"]) != 2 || all["jails"][0].Jail != "recidive" || all["jails"][1].Jail != "sshd" {
t.Errorf("%+v", all)
}
one, err := f.Status(ctx, "sshd")
if err != nil || len(one["jails"]) != 1 {
t.Fatalf("%+v %v", one, err)
}
if !reflect.DeepEqual(calls[len(calls)-1], []string{"fail2ban-client", "status", "sshd"}) {
t.Errorf("last call %v", calls[len(calls)-1])
}
}
func TestBansAreReadWithWhenTheyEndAPermanentOneAsNever(t *testing.T) {
bans := parseBans("recidive", withTime+"203.0.113.9 \t2026-10-01 00:00:00 + -1 = never\n")
if len(bans) != 3 {
t.Fatalf("%+v", bans)
}
if bans[0] != (Ban{IP: "195.178.110.30", Jail: "recidive", Since: "2026-09-26 23:18:47", Until: "2026-10-03 23:18:47"}) {
t.Errorf("%+v", bans[0])
}
if bans[2].Until != "never" {
t.Errorf("a permanent ban ends %q", bans[2].Until)
}
if got := parseBans("sshd", "\n"); len(got) != 0 {
t.Errorf("%+v", got)
}
}
func TestBannedGathersEveryJailsBansSoonestToEndFirst(t *testing.T) {
f := Fail2ban{Run: fake(map[string]string{
"status": statusAll,
"get recidive banip --with-time": withTime,
"get sshd banip --with-time": "198.51.100.7 \t2026-10-02 15:06:58 + 600 = 2026-10-02 15:16:58\n",
}, nil)}
got, err := f.Banned(ctx, "")
if err != nil {
t.Fatal(err)
}
var order []string
for _, b := range got["banned"] {
order = append(order, b.IP+"@"+b.Jail)
}
if !reflect.DeepEqual(order, []string{"198.51.100.7@sshd", "195.178.110.30@recidive", "92.118.39.71@recidive"}) {
t.Errorf("%v", order)
}
}
func TestBanAsksByJailAndAnswersTheBanAsHeldRefusingANonAddressFirst(t *testing.T) {
var calls [][]string
f := Fail2ban{Run: fake(map[string]string{
"set recidive banip 198.51.100.7": "1\n",
"get recidive banip --with-time": withTime + "198.51.100.7 \t2026-10-02 17:00:00 + 604800 = 2026-10-09 17:00:00\n",
}, &calls)}
r, err := f.Ban(ctx, "198.51.100.7", "recidive")
if err != nil {
t.Fatal(err)
}
if r.Added != 1 || r.Banned == nil || r.Banned.Until != "2026-10-09 17:00:00" {
t.Errorf("%+v", r)
}
if !reflect.DeepEqual(calls[0], []string{"fail2ban-client", "set", "recidive", "banip", "198.51.100.7"}) {
t.Errorf("first call %v", calls[0])
}
if _, err := f.Ban(ctx, "not-an-ip", "recidive"); err == nil || !strings.Contains(err.Error(), "is not an address") {
t.Errorf("a non-address: %v", err)
}
if _, err := f.Ban(ctx, "198.51.100.7", "a jail; rm"); err == nil || !strings.Contains(err.Error(), "is not a jail's name") {
t.Errorf("a non-name: %v", err)
}
if len(calls) != 2 {
t.Errorf("a refused ban reached the daemon: %v", calls)
}
}
func TestUnbanReleasesFromOneJailOrFromEveryJail(t *testing.T) {
var calls [][]string
f := Fail2ban{Run: fake(map[string]string{"set sshd unbanip 198.51.100.7": "1\n", "unban 198.51.100.7": "2\n"}, &calls)}
one, err := f.Unban(ctx, "198.51.100.7", "sshd")
if err != nil || *one != (Released{1, "198.51.100.7", "sshd"}) {
t.Errorf("%+v %v", one, err)
}
every, err := f.Unban(ctx, "198.51.100.7", "")
if err != nil || *every != (Released{2, "198.51.100.7", "every jail"}) {
t.Errorf("%+v %v", every, err)
}
if !reflect.DeepEqual(calls[1], []string{"fail2ban-client", "unban", "198.51.100.7"}) {
t.Errorf("%v", calls[1])
}
}
func TestAJailsSettingsAreReadFromTheDaemonsListings(t *testing.T) {
f := Fail2ban{Run: fake(map[string]string{
"get sshd bantime": "86400\n", "get sshd findtime": "86400\n", "get sshd maxretry": "3\n",
"get sshd ignoreip": "These IP addresses/networks are ignored:\n|- 127.0.0.0/8\n|- 10.10.0.0/24\n`- ::1\n",
"get sshd actions": "The jail sshd has the following actions:\niptables-allports-dualchain\n",
"get sshd logpath": "No file is currently monitored\n",
"get sshd journalmatch": "Current match filter:\n_SYSTEMD_UNIT=sshd.service + _COMM=sshd\n",
}, nil)}
got, err := f.Settings(ctx, "sshd")
if err != nil {
t.Fatal(err)
}
want := &JailSettings{Jail: "sshd", Bantime: "86400", Findtime: "86400", Maxretry: 3,
Ignoreip: []string{"127.0.0.0/8", "10.10.0.0/24", "::1"}, Actions: []string{"iptables-allports-dualchain"},
Logpath: []string{}, Journal: "_SYSTEMD_UNIT=sshd.service + _COMM=sshd"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("%+v", got)
}
}
func TestTheClientRunsAsGivenByRootAndThroughSudoByAnyoneElse(t *testing.T) {
if p, a := escalated(0, "fail2ban-client", []string{"status"}); p != "fail2ban-client" || !reflect.DeepEqual(a, []string{"status"}) {
t.Errorf("as root: %s %v", p, a)
}
if p, a := escalated(1000, "fail2ban-client", []string{"set", "sshd", "banip", "198.51.100.7"}); p != "sudo" ||
!reflect.DeepEqual(a, []string{"-n", "fail2ban-client", "set", "sshd", "banip", "198.51.100.7"}) {
t.Errorf("as an account: %s %v", p, a)
}
if !installed("sh", "/bin:/usr/bin") || installed("no-such-client-of-the-mesh", "/bin:/usr/bin") {
t.Error("installed is wrong about sh or about a tool nobody has")
}
}
// The tools carry the seat's four verbs under the seat's name, and the module's own under its own.
func TestTheSeatsVerbsAndTheModulesOwnToolAreServed(t *testing.T) {
var names []string
for _, tool := range tools(Fail2ban{Run: fake(nil, nil)}) {
names = append(names, tool.Name)
}
want := []string{"node-intrusion-prevention.status", "node-intrusion-prevention.banned", "node-intrusion-prevention.ban",
"node-intrusion-prevention.unban", "fail2ban_settings"}
if !reflect.DeepEqual(names, want) {
t.Errorf("%v", names)
}
}
// The daemon on this machine, read only — status, bans and one jail's settings — when asked for with
// FAIL2BAN_LIVE=1: the shapes above are what fail2ban-client printed once, and this is what it prints
// now.
func TestTheLiveDaemonReadsBack(t *testing.T) {
if os.Getenv("FAIL2BAN_LIVE") != "1" {
t.Skip("set FAIL2BAN_LIVE=1 to read the daemon on this machine")
}
f := Fail2ban{Run: execRunner}
status, err := f.Status(ctx, "")
if err != nil || len(status["jails"]) == 0 {
t.Fatalf("status: %+v %v", status, err)
}
for _, j := range status["jails"] {
t.Logf("%s: watching %v, failing %d, banned %d now of %d", j.Jail, j.Watching, j.Failing.Now, j.Banned.Now, j.Banned.Total)
if len(j.Watching) == 0 {
t.Errorf("%s watches nothing as read", j.Jail)
}
}
banned, err := f.Banned(ctx, "")
if err != nil {
t.Fatalf("banned: %v", err)
}
t.Logf("%d bans held", len(banned["banned"]))
settings, err := f.Settings(ctx, "sshd")
if err != nil || settings.Maxretry == 0 || len(settings.Ignoreip) == 0 {
t.Fatalf("settings: %+v %v", settings, err)
}
t.Logf("sshd: bantime %s, maxretry %d, ignores %v", settings.Bantime, settings.Maxretry, settings.Ignoreip)
}
@@ -0,0 +1,64 @@
// fail2ban-tools (novox/hq to-be 31, ADR 0179): the intrusion prevention's tools. One binary, launched
// by the machine's tool runtime and speaking MCP to it over stdio through the Go SDK (ADR 0193, ADR
// 0198): the node-intrusion-prevention seat's four verbs — who is banned, the jails' state, ban one,
// let one go — and the module's own reading of a jail's settings. The jails themselves are composed
// by the mesh from the modules a machine runs and written as declared resources; these touch only
// what the running daemon holds.
//
// stdout is the MCP channel; everything this module says, it says on stderr.
package main
import (
"context"
"fmt"
"os"
"strings"
stdio "git.novox.be/novox/mesh-sdk/go"
)
// Seat is the role this module holds.
const Seat = "node-intrusion-prevention"
func main() {
if err := stdio.Serve("", tools(Fail2ban{Run: execRunner})); err != nil {
fmt.Fprintf(os.Stderr, "[fail2ban] %v\n", err)
os.Exit(1)
}
}
func str(description string) map[string]any {
return map[string]any{"type": "string", "description": description}
}
func arg(a map[string]any, k string) string {
v, _ := a[k].(string)
return strings.TrimSpace(v)
}
// verb is one of the seat's verbs: listed as `<seat>.<verb>`, so the runtime serves it on the seat's
// subject. The module's own tools keep their bare names.
func verb(name, description string, input map[string]any, run func(a map[string]any) (any, error)) stdio.Tool {
return stdio.Tool{Name: Seat + "." + name, Description: description, Input: input, Run: run}
}
func tools(f Fail2ban) []stdio.Tool {
ctx := context.Background()
oneJail := map[string]any{"jail": str("one jail (optional)")}
return []stdio.Tool{
verb("status", "Every jail on this machine with what it watches, how many addresses it is counting failures against and holding now, and the totals since it started; one jail's detail when named.",
oneJail, func(a map[string]any) (any, error) { return f.Status(ctx, arg(a, "jail")) }),
verb("banned", "Every address banned on this machine right now, with the jail that holds it, when it was banned and when the ban ends.",
oneJail, func(a map[string]any) (any, error) { return f.Banned(ctx, arg(a, "jail")) }),
verb("ban", "Ban one address in one jail now, for the jail's ban time — an operator's act on the live ban list, which the mesh never writes itself.",
map[string]any{"ip": str("the address"), "jail": str("the jail to hold it (recidive for the long ban)")},
func(a map[string]any) (any, error) { return f.Ban(ctx, arg(a, "ip"), arg(a, "jail")) }),
verb("unban", "Let one address go, from one jail or from every jail when none is named.",
map[string]any{"ip": str("the address"), "jail": str("one jail (optional)")},
func(a map[string]any) (any, error) { return f.Unban(ctx, arg(a, "ip"), arg(a, "jail")) }),
{Name: "fail2ban_settings",
Description: "One jail's effective settings on this machine: ban time, window, tries, the addresses it never bans, its actions and what it reads.",
Input: map[string]any{"jail": str("the jail")},
Run: func(a map[string]any) (any, error) { return f.Settings(ctx, arg(a, "jail")) }},
}
}
+5
View File
@@ -0,0 +1,5 @@
module fail2ban
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=
+6 -3
View File
@@ -116,9 +116,12 @@
{ {
"name": "tools", "name": "tools",
"kind": "bundle", "kind": "bundle",
"language": "typescript", "language": "go",
"entrypoints": [ "system": "arch",
"tools/index.js" "from": "cmd/fail2ban-tools",
"binary": "fail2ban-tools",
"loads": [
"fail2ban-tools"
] ]
} }
] ]
-18
View File
@@ -1,18 +0,0 @@
{
"name": "@novox/module-fail2ban",
"version": "0.1.0",
"description": "fail2ban \u2014 intrusion prevention: the mesh composes the jails and keeps the daemon running; this module holds the node-intrusion-prevention seat and serves its verbs status, banned, ban and unban (novox/hq to-be 31, ADR 0179).",
"type": "module",
"private": true,
"dependencies": {
"@novox/mesh-sdk": "^0.1.1"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.6.0"
},
"scripts": {
"build": "tsc client.ts tools/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --rootDir . --outDir dist",
"test": "node --test --experimental-strip-types 'test/*.test.ts'"
}
}
-114
View File
@@ -1,114 +0,0 @@
// The intrusion prevention's verbs over a fake daemon, with the shapes fail2ban-client 1.1.0 printed
// on the control node on 2026-10-02 (novox/hq ADR 0179).
import { test } from "node:test";
import assert from "node:assert/strict";
import { Fail2banClient, escalated, installed, parseBans, parseJailStatus, type Runner } from "../client.ts";
const STATUS = "Status\n|- Number of jail:\t2\n`- Jail list:\trecidive, sshd\n";
const RECIDIVE =
"Status for the jail: recidive\n|- Filter\n| |- Currently failed:\t36\n| |- Total failed:\t149\n" +
"| `- File list:\t/var/log/fail2ban.log\n`- Actions\n |- Currently banned:\t9\n |- Total banned:\t13\n" +
" `- Banned IP list:\t195.178.110.30 45.148.10.240 92.118.39.71\n";
const SSHD =
"Status for the jail: sshd\n|- Filter\n| |- Currently failed:\t5\n| |- Total failed:\t11776\n" +
"| `- Journal matches:\t_SYSTEMD_UNIT=sshd.service + _COMM=sshd\n`- Actions\n |- Currently banned:\t0\n" +
" |- Total banned:\t150\n `- Banned IP list:\t\n";
const WITH_TIME =
"195.178.110.30 \t2026-09-26 23:18:47 + 604800 = 2026-10-03 23:18:47\n" +
"92.118.39.71 \t2026-09-28 10:33:49 + 604800 = 2026-10-05 10:33:49\n";
function fake(answers: Record<string, string>, calls: string[][] = []): Runner {
return async (cmd, args) => {
calls.push([cmd, ...args]);
const key = args.join(" ");
if (key in answers) return answers[key];
throw new Error(`unexpected ${cmd} ${key}`);
};
}
test("a jail's status is read into numbers, what it watches and who it holds", () => {
const s = parseJailStatus("recidive", RECIDIVE);
assert.deepEqual(s, {
jail: "recidive",
watching: ["/var/log/fail2ban.log"],
failing: { now: 36, total: 149 },
banned: { now: 9, total: 13, addresses: ["195.178.110.30", "45.148.10.240", "92.118.39.71"] },
});
const j = parseJailStatus("sshd", SSHD);
assert.deepEqual(j.watching, ["_SYSTEMD_UNIT=sshd.service + _COMM=sshd"]);
assert.deepEqual(j.banned, { now: 0, total: 150, addresses: [] });
});
test("status covers every jail the daemon lists, or the one named", async () => {
const calls: string[][] = [];
const f = new Fail2banClient(fake({ status: STATUS, "status recidive": RECIDIVE, "status sshd": SSHD }, calls));
const all = await f.status();
assert.deepEqual(all.jails.map((j) => j.jail), ["recidive", "sshd"]);
const one = await f.status("sshd");
assert.equal(one.jails.length, 1);
assert.deepEqual(calls[calls.length - 1], ["fail2ban-client", "status", "sshd"]);
});
test("bans are read with when they were placed and when they end, a permanent one as never", () => {
const bans = parseBans("recidive", WITH_TIME + "203.0.113.9 \t2026-10-01 00:00:00 + -1 = never\n");
assert.equal(bans.length, 3);
assert.deepEqual(bans[0], { ip: "195.178.110.30", jail: "recidive", since: "2026-09-26 23:18:47", until: "2026-10-03 23:18:47" });
assert.equal(bans[2].until, "never");
assert.deepEqual(parseBans("sshd", "\n"), []);
});
test("banned gathers every jail's bans, soonest to end first", async () => {
const f = new Fail2banClient(fake({
status: STATUS,
"get recidive banip --with-time": WITH_TIME,
"get sshd banip --with-time": "198.51.100.7 \t2026-10-02 15:06:58 + 600 = 2026-10-02 15:16:58\n",
}));
const { banned } = await f.banned();
assert.deepEqual(banned.map((b) => `${b.ip}@${b.jail}`), ["198.51.100.7@sshd", "195.178.110.30@recidive", "92.118.39.71@recidive"]);
});
test("ban asks the daemon by jail and answers with the ban as held; a non-address is refused before anything runs", async () => {
const calls: string[][] = [];
const f = new Fail2banClient(fake({
"set recidive banip 198.51.100.7": "1\n",
"get recidive banip --with-time": WITH_TIME + "198.51.100.7 \t2026-10-02 17:00:00 + 604800 = 2026-10-09 17:00:00\n",
}, calls));
const r = await f.ban("198.51.100.7", "recidive");
assert.equal(r.added, 1);
assert.equal(r.banned?.until, "2026-10-09 17:00:00");
assert.deepEqual(calls[0], ["fail2ban-client", "set", "recidive", "banip", "198.51.100.7"]);
await assert.rejects(() => f.ban("not-an-ip", "recidive"), /is not an address/);
await assert.rejects(() => f.ban("198.51.100.7", "a jail; rm"), /is not a jail's name/);
assert.equal(calls.length, 2);
});
test("unban releases from one jail or from every jail", async () => {
const calls: string[][] = [];
const f = new Fail2banClient(fake({ "set sshd unbanip 198.51.100.7": "1\n", "unban 198.51.100.7": "2\n" }, calls));
assert.deepEqual(await f.unban("198.51.100.7", "sshd"), { released: 1, ip: "198.51.100.7", jail: "sshd" });
assert.deepEqual(await f.unban("198.51.100.7"), { released: 2, ip: "198.51.100.7", jail: "every jail" });
assert.deepEqual(calls[1], ["fail2ban-client", "unban", "198.51.100.7"]);
});
test("a jail's settings are read from the daemon's listings", async () => {
const f = new Fail2banClient(fake({
"get sshd bantime": "86400\n", "get sshd findtime": "86400\n", "get sshd maxretry": "3\n",
"get sshd ignoreip": "These IP addresses/networks are ignored:\n|- 127.0.0.0/8\n|- 10.10.0.0/24\n`- ::1\n",
"get sshd actions": "The jail sshd has the following actions:\niptables-allports-dualchain\n",
"get sshd logpath": "No file is currently monitored\n",
"get sshd journalmatch": "Current match filter:\n_SYSTEMD_UNIT=sshd.service + _COMM=sshd\n",
}));
assert.deepEqual(await f.settings("sshd"), {
jail: "sshd", bantime: "86400", findtime: "86400", maxretry: 3,
ignoreip: ["127.0.0.0/8", "10.10.0.0/24", "::1"], actions: ["iptables-allports-dualchain"],
logpath: [], journalmatch: "_SYSTEMD_UNIT=sshd.service + _COMM=sshd",
});
});
test("the client runs as given by root and through sudo without a prompt by anyone else", () => {
assert.deepEqual(escalated("fail2ban-client", ["status"], 0), ["fail2ban-client", ["status"]]);
assert.deepEqual(escalated("fail2ban-client", ["set", "sshd", "banip", "198.51.100.7"], 1000),
["sudo", ["-n", "fail2ban-client", "set", "sshd", "banip", "198.51.100.7"]]);
assert.equal(installed("sh"), true);
assert.equal(installed("no-such-client-of-the-mesh"), false);
});
-62
View File
@@ -1,62 +0,0 @@
// The intrusion prevention's tools: the node-intrusion-prevention seat's four verbs — who is banned,
// the jails' state, ban one, let one go — and the module's own reading of a jail's settings
// (novox/hq to-be 31, ADR 0179). The jails themselves are composed by the mesh from the modules a
// machine runs and written as declared resources; these touch only what the running daemon holds.
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
import { Fail2banClient } from "../client.js";
export function getSeatVerbs(fail2ban: Fail2banClient): ToolDefinition[] {
return [
{
name: "status",
description:
"Every jail on this machine with what it watches, how many addresses it is counting failures against and holding now, and the totals since it started; one jail's detail when named.",
input: { jail: { type: "string", description: "one jail (optional)" } },
run: async (args) => fail2ban.status(args.jail ? String(args.jail) : undefined),
},
{
name: "banned",
description: "Every address banned on this machine right now, with the jail that holds it, when it was banned and when the ban ends.",
input: { jail: { type: "string", description: "one jail (optional)" } },
run: async (args) => fail2ban.banned(args.jail ? String(args.jail) : undefined),
},
{
name: "ban",
description:
"Ban one address in one jail now, for the jail's ban time — an operator's act on the live ban list, which the mesh never writes itself.",
input: {
ip: { type: "string", description: "the address" },
jail: { type: "string", description: "the jail to hold it (recidive for the long ban)" },
},
run: async (args) => fail2ban.ban(String(args.ip ?? ""), String(args.jail ?? "")),
},
{
name: "unban",
description: "Let one address go, from one jail or from every jail when none is named.",
input: {
ip: { type: "string", description: "the address" },
jail: { type: "string", description: "one jail (optional)" },
},
run: async (args) => fail2ban.unban(String(args.ip ?? ""), args.jail ? String(args.jail) : undefined),
},
];
}
export function getFail2banTools(fail2ban: Fail2banClient): ToolDefinition[] {
return [
{
name: "fail2ban_settings",
description:
"One jail's effective settings on this machine: ban time, window, tries, the addresses it never bans, its actions and what it reads.",
input: { jail: { type: "string", description: "the jail" } },
run: async (args) => fail2ban.settings(String(args.jail ?? "")),
},
];
}
const fail2ban = Fail2banClient.onThisMachine();
// The seat's verbs under the seat's name: the runtime serves them on the seat's subjects where this
// module holds it (ADR 0159, 0160). The module's own under its own.
registerModuleTools("node-intrusion-prevention", () => getSeatVerbs(fail2ban));
registerModuleTools("fail2ban", () => getFail2banTools(fail2ban));
-15
View File
@@ -1,15 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true
},
"include": [
"client.ts",
"tools/index.ts"
]
}
+94
View File
@@ -0,0 +1,94 @@
# forticlient
The FortiClient VPN client on the workstations, as a module (novox/hq ADR 0208): its tray in the
operator's session and the vendor's service behind it. It requires `x11-display`, so it is assigned
only where a display server is held on the same machine.
**This is the operator's work VPN.** Nothing of its configuration is the mesh's: no profile, no
credential, no gateway, no certificate is declared, read, printed or stored by the module or its
tools. The tools report running and connected state only.
## Owns
| what | where |
|---|---|
| the vendor's scheduler service, which holds the tunnel | `forticlient.service`, running and enabled |
Nothing else. It holds no seat, makes no contribution and writes no file.
- **The client is kept as found.** `forticlient-vpn` (7.4.3) is not in the official repositories: on
both workstations it is a foreign (AUR) package that repackages the vendor's build, installed
explicitly. The host installs from the official repositories only, so the module cannot declare it.
ADR 0205's pinned archive does not fit: it is a vendor binary set with a root service, a firewall
helper and an install script. It waits for the mesh's package repository (research 027 question 1,
option P2). Until then a fresh workstation installs it by hand.
- **The service is declared, and so depended on.** `forticlient.service` is the package's unit, running
and enabled on both workstations. Declared running and enabled, it is held in that state, and on a
machine without the package the host refuses it by name (*does not exist on this machine*): loud,
never a silent pass. The `asus-zephyrus-g14` module does the same with its foreign daemons. The
module never restarts it: a change to nothing of the module's would, and nothing of the module's
changes.
- **The configuration stays the operator's, and unread.** `/etc/forticlient/`, the client's database
under `/opt/forticlient/`, the account's FortiClient settings, the VPN profiles, saved credentials
and certificates are set in the client's own window. They are found (ADR 0182), and unlike any other
found file, the tools do not even read them.
## How it starts: the vendor's autostart entry, and nothing else
The tray has two processes: `fortitraylauncher`, which starts and watches `fortitray`. The package's
install script links `/etc/xdg/autostart/Fortitray.desktop` to the package's
`/opt/forticlient/Fortitray.desktop` (`Exec=/opt/forticlient/fortitraylauncher`). The session runs it
once at login through the `i3` module's `dex --autostart --environment i3`. **That entry is the tray's
one start.** The module adds no `xinitrc` slot and no `node-display-session` exec, because either would
start it a second time. The link is the vendor's, made by its install script; the mesh does not make
or remove it.
The tunnel is not the tray's: the service's processes hold it, as root. Ending the tray leaves a
connected tunnel connected.
## Tools
They are served by the node's runtime as the operator account (ADR 0175).
| tool | does |
|---|---|
| `forticlient_status` (r) | <ul><li>the installed version, and that it is from outside the official repositories</li><li>the service: active, enabled</li><li>whether the launcher and the tray run: pid, since, and the scope or unit they run in</li><li>what starts the tray at login</li><li>connected or not, as the number of the client's tunnel interfaces that are up</li></ul> |
| `forticlient_restart` (a) | asks the tray and its launcher to end (SIGTERM), forces them after 5 s, and starts the launcher in the operator's session as a transient user unit `mesh-forticlient-tray`, so it outlives the tools runtime. The launcher starts the tray. The service and the tunnel are not touched. Refused plainly when nobody is logged in to the desktop |
| `forticlient_check` (r) | <ul><li>the package is installed</li><li>the service is running and enabled</li><li>exactly one start: the vendor's entry is present and not hidden by an entry of the account, and `dex` is installed</li><li>no window-manager exec</li><li>one launcher and one tray run in a desktop session</li></ul>Being connected is never a finding: that is the operator's to decide. Each finding says what to do |
**What the tools never touch**, held by the tests (a fake machine carries a profile, a gateway, an
address, a secret and a certificate where the client keeps them, and no answer may hold any of them):
- no file under `/etc/forticlient` or the account's FortiClient settings is opened, and under
`/opt/forticlient` only the tray's autostart entry, through its link (it names the launcher and
nothing else);
- the vendor's command-line client and `fortivpn` are never run, and its logs are never read;
- a process is named by its command name only, never by its arguments;
- *connected* is whether an interface named `fctvpn…` is up, from its flags. The interface's name
(it carries an identifier) and its addresses are never answered. A tunnel of a kind that brings up
no such interface (IPsec) is not seen, and the answer says *not connected*.
## What changes when it is assigned
| | laptop | desktop |
|---|---|---|
| package | none: `forticlient-vpn` 7.4.3.5411, explicit, foreign | the same |
| service | none: running and enabled | the same |
| tray | none: dex starts it from the vendor's entry, in the login session's scope | none on disk. **No tray runs now:** that session began before `dex` was installed, and the predecessor's window manager never started it. The next login is the first that starts it |
## Migration (ADR 0182)
Nothing is required on either machine. On the desktop, log out and in once, or run
`forticlient_restart`, and the tray runs from its one start. `forticlient_check` then answers `ok`.
## Leaves as found
Everything of the client's: its configuration and database, the VPN profiles and credentials, its
logs, `/etc/xdg/autostart/Fortitray.desktop` (the vendor's link), the package itself.
## Relies on
- **The package, installed by hand.** Without it the host refuses the service by name.
- **`i3`'s `dex` line for the start**: XDG autostart has no seat. Assigned without `i3`, the tray does
not start. `forticlient_check` says so.
- A display server on the same machine (`x11-display`, ADR 0208 §3).
@@ -0,0 +1,37 @@
package main
// The desktop applications whose bundles carry desktop.go. 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.
import (
"bytes"
"os"
"path/filepath"
"testing"
)
var carriers = []string{"blueman", "forticlient", "nextcloud-client", "nm-applet", "openrazer", "polychromatic", "slack"}
func TestEveryDesktopApplicationCarriesTheSameCopy(t *testing.T) {
compared := 0
for _, module := range carriers {
dir := filepath.Join("..", "..", "..", module, "cmd", module+"-tools")
if _, err := os.Stat(dir); err != nil {
continue
}
for _, f := range []string{"desktop.go", "desktop_test.go", "copies_test.go"} {
mine, err := os.ReadFile(f)
if err != nil {
t.Fatal(err)
}
theirs, err := os.ReadFile(filepath.Join(dir, f))
if err != nil || !bytes.Equal(mine, theirs) {
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")
}
}
@@ -0,0 +1,601 @@
package main
// desktop.go is the same file in every desktop application's bundle (copies_test.go names them and
// holds them to one text): a tray application of the operator's graphical session, seen from the
// node's tool runtime (novox/hq ADR 0208).
//
// The runtime is a system service running as the operator account (ADR 0175): it has the account's
// uid and none of the session's environment. A tool that starts something on the desktop finds the
// session from a process of the account that carries DISPLAY (the window manager first), and starts
// the program under the account's own service manager with `systemd-run --user`, never as its own
// child: the runtime's unit is a cgroup that is emptied whenever the runtime restarts.
//
// Everything a tool touches goes through a Machine: its filesystem root, its commands (a Runner) and
// its signals are injected, so the tests run against a fake /proc and a fake home.
//
// Bounds: one command gets at most CallTimeout (below the runtime's 30 s call limit) and is ended
// with everything it started when it takes longer; each stream is kept to MostOutput; a file is read
// to at most MostRead.
import (
"bufio"
"bytes"
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
"syscall"
"time"
)
// Bounds every command and read is held to.
const (
CallTimeout = 10 * time.Second
MostOutput = 256 << 10
MostRead = 16 << 20
)
// Output is what a command did.
type Output struct {
Stdout string
Stderr string
Code int
// Err is why it did not run to an answer: not installed, ended on its timeout, or the spawn error.
Err error
Cut bool
}
// ErrNotInstalled and ErrTimedOut are what a Runner answers in Output.Err.
var (
ErrNotInstalled = errors.New("not installed")
ErrTimedOut = errors.New("timed out")
// ErrNoSession is answered by a tool that needs the desktop when nobody is logged in to it.
ErrNoSession = errors.New("no graphical session")
)
// Runner runs one command with extra environment, within the context's deadline. Tests replace it.
type Runner func(ctx context.Context, env []string, name string, args ...string) Output
// Machine is what the tools read and act on.
type Machine struct {
Root string // "" on the machine; a fake root in tests
Home string // the operator's home, as the machine names it
UID int
Run Runner
Kill func(pid int, sig syscall.Signal) error
Sleep func(time.Duration)
Now func() time.Time
Timeout time.Duration
}
// NewMachine is the machine the bundle runs on.
func NewMachine() *Machine {
return &Machine{Home: operatorHome(), UID: os.Getuid(), Run: execRun, Kill: syscall.Kill,
Sleep: time.Sleep, Now: time.Now, Timeout: CallTimeout}
}
// operatorHome is the account's home: what the runtime was told, else the process's own.
func operatorHome() string {
if h := strings.TrimSpace(os.Getenv("MESH_OPERATOR_HOME")); h != "" {
return h
}
h, _ := os.UserHomeDir()
return h
}
func (m *Machine) path(p string) string { return filepath.Join(m.Root, p) }
// home is a path under the operator's home, on this machine's filesystem.
func (m *Machine) home(rel ...string) string {
return filepath.Join(append([]string{m.Root, m.Home}, rel...)...)
}
// tilde shows a path under the home as ~/…, so an answer does not carry the account's name.
func (m *Machine) tilde(p string) string {
if m.Home != "" && m.Home != "/" {
h := strings.TrimSuffix(m.Home, "/")
if p == h {
return "~"
}
if strings.HasPrefix(p, h+"/") {
return "~/" + strings.TrimPrefix(p, h+"/")
}
}
return p
}
// cmd runs a command within the machine's timeout (or a shorter one).
func (m *Machine) cmd(timeout time.Duration, env []string, name string, args ...string) Output {
if timeout <= 0 || timeout > m.Timeout {
timeout = m.Timeout
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
return m.Run(ctx, env, name, args...)
}
// failed names how a command failed, or answers nil when it ran and exited 0.
func failed(o Output, name string, args ...string) error {
switch {
case errors.Is(o.Err, ErrNotInstalled):
return fmt.Errorf("%s is not installed on this machine", name)
case errors.Is(o.Err, ErrTimedOut):
return fmt.Errorf("%s gave no answer in time and was ended", name)
case o.Err != nil:
return fmt.Errorf("%s did not run: %v", name, o.Err)
case o.Code != 0:
said := strings.TrimSpace(o.Stderr)
if said == "" {
said = strings.TrimSpace(o.Stdout)
}
if said == "" {
said = "and said nothing"
}
return fmt.Errorf("%s %s exited %d: %s", name, strings.Join(args, " "), o.Code, tail(said, 1000))
}
return nil
}
func tail(s string, n int) string {
if len(s) <= n {
return s
}
return "…" + s[len(s)-n:]
}
type capped struct {
b bytes.Buffer
cut bool
}
func (c *capped) Write(p []byte) (int, error) {
if room := MostOutput - c.b.Len(); room < len(p) {
if room > 0 {
c.b.Write(p[:room])
}
c.cut = true
return len(p), nil
}
return c.b.Write(p)
}
func execRun(ctx context.Context, env []string, name string, args ...string) Output {
path, err := exec.LookPath(name)
if err != nil {
return Output{Code: 127, Err: ErrNotInstalled}
}
cmd := exec.CommandContext(ctx, path, args...)
cmd.Env = append(append(os.Environ(), "LC_ALL=C"), env...)
// 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
var out, errs capped
cmd.Stdout, cmd.Stderr = &out, &errs
err = cmd.Run()
o := Output{Stdout: out.b.String(), Stderr: errs.b.String(), Cut: out.cut || errs.cut}
var exit *exec.ExitError
switch {
case err == nil:
case ctx.Err() == context.DeadlineExceeded:
o.Code, o.Err = 124, ErrTimedOut
case errors.As(err, &exit):
o.Code = exit.ExitCode()
default:
o.Code, o.Err = 127, err
}
return o
}
// readBounded reads a file to at most MostRead bytes.
func readBounded(path string) ([]byte, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
return io.ReadAll(io.LimitReader(f, MostRead))
}
// Proc is one process of the account.
type Proc struct {
PID int `json:"pid"`
Command string `json:"command"`
// StartedIn is the unit or scope it runs in: the login session's scope when the session's start
// (dex, the window manager) started it, a mesh-… unit when a tool restarted it.
StartedIn string `json:"started_in,omitempty"`
Since string `json:"since,omitempty"`
}
// procs are this account's processes named comm, oldest first.
func (m *Machine) procs(comm string) []Proc {
entries, err := os.ReadDir(m.path("/proc"))
if err != nil {
return nil
}
boot := m.bootTime()
var out []Proc
for _, e := range entries {
pid, err := strconv.Atoi(e.Name())
if err != nil {
continue
}
dir := m.path(filepath.Join("/proc", e.Name()))
if readTrimmed(filepath.Join(dir, "comm")) != comm || m.uidOf(dir) != m.UID {
continue
}
p := Proc{PID: pid, Command: strings.TrimSpace(strings.ReplaceAll(readTrimmed(filepath.Join(dir, "cmdline")), "\x00", " "))}
if p.Command == "" {
p.Command = comm
}
if cg := readTrimmed(filepath.Join(dir, "cgroup")); cg != "" {
line := strings.Split(cg, "\n")[0]
p.StartedIn = filepath.Base(line[strings.LastIndexByte(line, ':')+1:])
}
if t, ok := startOf(readTrimmed(filepath.Join(dir, "stat")), boot); ok {
p.Since = t.UTC().Format(time.RFC3339)
}
out = append(out, p)
}
sort.Slice(out, func(i, j int) bool { return out[i].PID < out[j].PID })
return out
}
// procsOf are the account's processes named comm whose program is word. The kernel keeps 15
// characters of a command name, so a longer name can share them with another program's: this bundle's
// own binary among them (polychromatic-tools and polychromatic-tray-applet are both polychromatic-t).
// The program is the first word of the command line, or the second for a script run by its
// interpreter. An empty word keeps every process named comm.
func (m *Machine) procsOf(comm, word string) []Proc {
var out []Proc
for _, p := range m.procs(comm) {
f := strings.Fields(p.Command)
if word == "" || (len(f) > 0 && filepath.Base(f[0]) == word) || (len(f) > 1 && filepath.Base(f[1]) == word) {
out = append(out, p)
}
}
return out
}
// uidOf is the real uid on a process's status, -1 when unreadable.
func (m *Machine) uidOf(dir string) int {
for _, l := range strings.Split(readTrimmed(filepath.Join(dir, "status")), "\n") {
if f := strings.Fields(l); len(f) > 1 && f[0] == "Uid:" {
if n, err := strconv.Atoi(f[1]); err == nil {
return n
}
}
}
return -1
}
func (m *Machine) bootTime() int64 {
for _, l := range strings.Split(readTrimmed(m.path("/proc/stat")), "\n") {
if f := strings.Fields(l); len(f) == 2 && f[0] == "btime" {
n, _ := strconv.ParseInt(f[1], 10, 64)
return n
}
}
return 0
}
// startOf reads a process's start from its stat line (field 22, in clock ticks of 1/100 s since boot).
func startOf(stat string, boot int64) (time.Time, bool) {
i := strings.LastIndexByte(stat, ')')
if i < 0 || boot == 0 {
return time.Time{}, false
}
f := strings.Fields(stat[i+1:])
if len(f) < 20 {
return time.Time{}, false
}
ticks, err := strconv.ParseInt(f[19], 10, 64)
if err != nil {
return time.Time{}, false
}
return time.Unix(boot+ticks/100, 0), true
}
func readTrimmed(path string) string {
b, err := os.ReadFile(path)
if err != nil {
return ""
}
return strings.TrimSpace(string(b))
}
func exists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
// Session is what a tool needs to start something on the operator's desktop.
type Session struct {
Display string `json:"display"`
XAuthority string `json:"xauthority,omitempty"`
Bus string `json:"bus,omitempty"`
RuntimeDir string `json:"runtime_dir,omitempty"`
From string `json:"found_in"`
}
// sessionHolders are the processes whose environment is the session's, best first.
var sessionHolders = []string{"i3", "sway", "i3bar", "picom", "dunst", "xterm"}
// session finds the account's graphical session, or ErrNoSession saying what it looked at.
func (m *Machine) session() (Session, error) {
entries, _ := os.ReadDir(m.path("/proc"))
best, bestRank := -1, len(sessionHolders)+1
var env map[string]string
var from string
for _, e := range entries {
pid, err := strconv.Atoi(e.Name())
if err != nil {
continue
}
dir := m.path(filepath.Join("/proc", e.Name()))
if m.uidOf(dir) != m.UID {
continue
}
raw, err := os.ReadFile(filepath.Join(dir, "environ"))
if err != nil {
continue
}
vars := parseEnviron(raw)
if vars["DISPLAY"] == "" {
continue
}
comm := readTrimmed(filepath.Join(dir, "comm"))
rank := len(sessionHolders)
for i, h := range sessionHolders {
if h == comm {
rank = i
}
}
if rank < bestRank || (rank == bestRank && pid > best) {
best, bestRank, env, from = pid, rank, vars, fmt.Sprintf("process %s (pid %d)", comm, pid)
}
}
if env == nil {
return Session{}, fmt.Errorf("%w for uid %d on this machine: no process of the account carries DISPLAY. "+
"Is anyone logged in to the desktop?", ErrNoSession, m.UID)
}
s := Session{Display: env["DISPLAY"], XAuthority: env["XAUTHORITY"], Bus: env["DBUS_SESSION_BUS_ADDRESS"],
RuntimeDir: env["XDG_RUNTIME_DIR"], From: from}
if s.RuntimeDir == "" {
s.RuntimeDir = fmt.Sprintf("/run/user/%d", m.UID)
}
if s.Bus == "" && exists(m.path(filepath.Join(s.RuntimeDir, "bus"))) {
s.Bus = "unix:path=" + filepath.Join(s.RuntimeDir, "bus")
}
return s, nil
}
// bus is the account's session bus environment, which a logged-in account has with or without a
// desktop: what a command needs to reach the user's service manager or a bus name.
func (m *Machine) bus() []string {
runtime := fmt.Sprintf("/run/user/%d", m.UID)
return []string{"XDG_RUNTIME_DIR=" + runtime, "DBUS_SESSION_BUS_ADDRESS=unix:path=" + runtime + "/bus"}
}
// Env is the session's variables, for a command that draws or speaks to the desktop.
func (s Session) Env() []string {
var env []string
for _, kv := range [][2]string{{"DISPLAY", s.Display}, {"XAUTHORITY", s.XAuthority},
{"DBUS_SESSION_BUS_ADDRESS", s.Bus}, {"XDG_RUNTIME_DIR", s.RuntimeDir}} {
if kv[1] != "" {
env = append(env, kv[0]+"="+kv[1])
}
}
return env
}
func parseEnviron(raw []byte) map[string]string {
env := map[string]string{}
for _, kv := range bytes.Split(raw, []byte{0}) {
if i := bytes.IndexByte(kv, '='); i > 0 {
env[string(kv[:i])] = string(kv[i+1:])
}
}
return env
}
// detach starts a long-lived program under the account's service manager, as a transient unit that
// carries the session's display. A unit left by an earlier start under the same name is stopped
// first, so the fixed name means at most one.
func (m *Machine) detach(s Session, unit string, argv ...string) error {
_ = m.cmd(5*time.Second, s.Env(), "systemctl", "--user", "stop", unit+".service")
call := []string{"--user", "--collect", "--quiet", "--unit=" + unit}
for _, kv := range [][2]string{{"DISPLAY", s.Display}, {"XAUTHORITY", s.XAuthority}} {
if kv[1] != "" {
call = append(call, "--setenv="+kv[0]+"="+kv[1])
}
}
call = append(append(call, "--"), argv...)
return failed(m.cmd(8*time.Second, s.Env(), "systemd-run", call...), "systemd-run", call...)
}
// stop ends every process of the account named in comms: SIGTERM, then SIGKILL for what is still
// there after grace. It answers the pids that ended and those that had to be killed.
func (m *Machine) stop(grace time.Duration, comms ...string) (ended, killed []int) {
var ps []Proc
for _, c := range comms {
ps = append(ps, m.procs(c)...)
}
return m.stopProcs(grace, ps)
}
// stopProcs ends the processes given, as stop does.
func (m *Machine) stopProcs(grace time.Duration, ps []Proc) (ended, killed []int) {
var pids []int
for _, p := range ps {
if m.Kill(p.PID, syscall.SIGTERM) == nil {
pids = append(pids, p.PID)
}
}
alive := func() []int {
var left []int
for _, pid := range pids {
if exists(m.path(filepath.Join("/proc", strconv.Itoa(pid)))) {
left = append(left, pid)
}
}
return left
}
step := 200 * time.Millisecond
for waited := time.Duration(0); waited < grace && len(alive()) > 0; waited += step {
m.Sleep(step)
}
left := alive()
for _, pid := range left {
if m.Kill(pid, syscall.SIGKILL) == nil {
killed = append(killed, pid)
}
}
gone := map[int]bool{}
for _, pid := range left {
gone[pid] = true
}
for _, pid := range pids {
if !gone[pid] {
ended = append(ended, pid)
}
}
return ended, killed
}
// waitFor waits up to d for a process of the account named comm, and answers what it found.
func (m *Machine) waitFor(comm string, d time.Duration) []Proc { return m.waitForOf(comm, "", d) }
// waitForOf waits up to d for a process of the account named comm whose program is word (procsOf).
func (m *Machine) waitForOf(comm, word string, d time.Duration) []Proc {
step := 250 * time.Millisecond
for waited := time.Duration(0); ; waited += step {
if p := m.procsOf(comm, word); len(p) > 0 || waited >= d {
return p
}
m.Sleep(step)
}
}
// desktopEntry reads the [Desktop Entry] group of an XDG desktop file; nil when there is none.
func desktopEntry(path string) map[string]string {
raw, err := readBounded(path)
if err != nil {
return nil
}
out := map[string]string{}
in := false
s := bufio.NewScanner(bytes.NewReader(raw))
for s.Scan() {
l := strings.TrimSpace(s.Text())
switch {
case strings.HasPrefix(l, "["):
in = l == "[Desktop Entry]"
case in && l != "" && !strings.HasPrefix(l, "#"):
if i := strings.IndexByte(l, '='); i > 0 {
out[strings.TrimSpace(l[:i])] = strings.TrimSpace(l[i+1:])
}
}
}
return out
}
// Autostart is what XDG autostart does with one entry: the account's file overrides the system's
// of the same name, and Hidden=true (or the GNOME switch off) means it is not started.
type Autostart struct {
Entry string `json:"entry"`
From string `json:"from"`
Exec string `json:"exec,omitempty"`
Starts bool `json:"starts"`
Because string `json:"because,omitempty"`
}
// autostart resolves one XDG autostart entry by its file name, the account's directory first.
func (m *Machine) autostart(name string) Autostart {
a := Autostart{Entry: name}
user := m.home(".config", "autostart", name)
system := m.path(filepath.Join("/etc/xdg/autostart", name))
var e map[string]string
switch {
case exists(user):
e, a.From = desktopEntry(user), m.tilde(filepath.Join(m.Home, ".config/autostart", name))
case exists(system):
e, a.From = desktopEntry(system), filepath.Join("/etc/xdg/autostart", name)
default:
a.Because = "no such entry in ~/.config/autostart or /etc/xdg/autostart"
return a
}
a.Exec = e["Exec"]
switch {
case strings.EqualFold(e["Hidden"], "true"):
a.Because = "Hidden=true"
case strings.EqualFold(e["X-GNOME-Autostart-enabled"], "false"):
a.Because = "X-GNOME-Autostart-enabled=false"
case a.Exec == "":
a.Because = "the entry has no Exec"
default:
a.Starts = true
}
return a
}
// i3Starts are the window manager's start-up lines (exec, exec_always) that run a program named
// word, in the configuration and its config.d: a second start beside an autostart entry.
func (m *Machine) i3Starts(word string) []string {
files := []string{m.home(".config", "i3", "config")}
more, _ := filepath.Glob(m.home(".config", "i3", "config.d", "*.conf"))
files = append(files, more...)
var out []string
for _, f := range files {
raw, err := readBounded(f)
if err != nil {
continue
}
for n, l := range strings.Split(string(raw), "\n") {
t := strings.TrimSpace(l)
if !strings.HasPrefix(t, "exec ") && !strings.HasPrefix(t, "exec_always ") {
continue
}
for _, w := range strings.Fields(t)[1:] {
if filepath.Base(strings.Trim(w, `"'`)) == word {
out = append(out, fmt.Sprintf("%s:%d: %s", m.tilde(strings.TrimPrefix(f, m.Root)), n+1, t))
break
}
}
}
}
return out
}
// installed asks the package manager for one package's version; "" when it is not installed.
func (m *Machine) installed(pkg string) (string, error) {
o := m.cmd(0, nil, "pacman", "-Q", pkg)
if o.Err != nil {
return "", failed(o, "pacman", "-Q", pkg)
}
if o.Code != 0 {
return "", nil
}
f := strings.Fields(o.Stdout)
if len(f) < 2 {
return "", fmt.Errorf("pacman -Q %s answered %q", pkg, o.Stdout)
}
return f[1], nil
}
// Finding is one thing a check found wrong, and what to do about it.
type Finding struct {
What string `json:"what"`
Do string `json:"do,omitempty"`
}
@@ -0,0 +1,219 @@
package main
// The fake machine the tests run against, and the tests of desktop.go. The same in every desktop
// application's bundle (copies_test.go).
import (
"context"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"syscall"
"testing"
"time"
)
const testHome = "/home/operator"
// fake is a machine with a fake root, a scripted Runner and signals that end fake processes.
type fake struct {
*Machine
t *testing.T
mu sync.Mutex
calls []string
answer func(name string, args []string) Output
// onStart is run when systemd-run starts something, to let a fake process appear.
onStart func(argv []string)
// stubborn pids ignore SIGTERM.
stubborn map[int]bool
signals []string
}
func newFake(t *testing.T) *fake {
t.Helper()
root := t.TempDir()
f := &fake{t: t, stubborn: map[int]bool{}}
f.Machine = &Machine{Root: root, Home: testHome, UID: 1000, Timeout: CallTimeout,
Sleep: func(time.Duration) {}, Now: func() time.Time { return time.Unix(1_800_000_000, 0) }}
f.Run = func(_ context.Context, env []string, name string, args ...string) Output {
f.mu.Lock()
f.calls = append(f.calls, strings.TrimSpace(name+" "+strings.Join(args, " ")))
f.mu.Unlock()
if name == "systemd-run" && f.onStart != nil {
for i, a := range args {
if a == "--" {
f.onStart(args[i+1:])
}
}
}
if f.answer != nil {
return f.answer(name, args)
}
return Output{}
}
f.Kill = func(pid int, sig syscall.Signal) error {
f.signals = append(f.signals, strconv.Itoa(pid)+":"+sig.String())
if sig == syscall.SIGKILL || !f.stubborn[pid] {
return os.RemoveAll(filepath.Join(root, "proc", strconv.Itoa(pid)))
}
return nil
}
f.write("/proc/stat", "cpu 1 2 3\nbtime 1799990000\n")
return f
}
func (f *fake) write(path, content string) {
f.t.Helper()
p := filepath.Join(f.Root, path)
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
f.t.Fatal(err)
}
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
f.t.Fatal(err)
}
}
// proc adds a process of uid with a command name, argv, cgroup and environment.
func (f *fake) proc(pid, uid int, comm string, argv []string, cgroup string, env ...string) {
d := "/proc/" + strconv.Itoa(pid) + "/"
f.write(d+"comm", comm+"\n")
f.write(d+"status", "Name:\t"+comm+"\nUid:\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\n")
f.write(d+"cmdline", strings.Join(argv, "\x00")+"\x00")
f.write(d+"cgroup", "0::/user.slice/user-"+strconv.Itoa(uid)+".slice/"+cgroup+"\n")
f.write(d+"environ", strings.Join(env, "\x00")+"\x00")
// starttime (field 22) is 1000 ticks: 10 s after boot.
f.write(d+"stat", strconv.Itoa(pid)+" ("+comm+") S 1 1 1 0 -1 0 0 0 0 0 0 0 0 0 20 0 1 0 1000 0 0\n")
}
func (f *fake) desktopSession() {
f.proc(3700, 1000, "i3", []string{"i3"}, "session-c1.scope", "DISPLAY=:1", "XAUTHORITY="+testHome+"/.Xauthority")
f.write("/run/user/1000/bus", "")
}
func (f *fake) called(prefix string) bool {
for _, c := range f.calls {
if strings.HasPrefix(c, prefix) {
return true
}
}
return false
}
func TestProcessesAreTheAccountsOwnWithWhereAndWhenTheyStarted(t *testing.T) {
f := newFake(t)
f.proc(10, 1000, "worker", []string{"/usr/bin/worker", "--background"}, "session-c1.scope")
f.proc(11, 1001, "worker", []string{"/usr/bin/worker"}, "session-c2.scope")
f.proc(12, 1000, "other", []string{"other"}, "x.scope")
got := f.procs("worker")
if len(got) != 1 || got[0].PID != 10 || got[0].Command != "/usr/bin/worker --background" ||
got[0].StartedIn != "session-c1.scope" || got[0].Since != time.Unix(1799990010, 0).UTC().Format(time.RFC3339) {
t.Fatalf("%+v", got)
}
}
func TestAProgramIsToldFromAnotherSharingItsCutName(t *testing.T) {
f := newFake(t)
f.proc(10, 1000, "polychromatic-t", []string{"/usr/bin/python", "/usr/bin/polychromatic-tray-applet"}, "s.scope")
f.proc(11, 1000, "polychromatic-t", []string{"polychromatic-tray-applet"}, "s.scope")
f.proc(12, 1000, "polychromatic-t", []string{"/usr/lib/mesh/polychromatic-tools"}, "s.scope")
if got := f.procsOf("polychromatic-t", "polychromatic-tray-applet"); len(got) != 2 || got[0].PID != 10 || got[1].PID != 11 {
t.Fatalf("%+v", got)
}
if got := f.procsOf("polychromatic-t", ""); len(got) != 3 {
t.Fatalf("%+v", got)
}
ended, _ := f.stopProcs(time.Second, f.procsOf("polychromatic-t", "polychromatic-tray-applet"))
if len(ended) != 2 || len(f.procs("polychromatic-t")) != 1 {
t.Fatalf("ended %v; the tools' own process must stay", ended)
}
}
func TestTheSessionIsTheWindowManagersAndNoneIsSaidPlainly(t *testing.T) {
f := newFake(t)
if _, err := f.session(); err == nil || !strings.Contains(err.Error(), "no graphical session") {
t.Fatalf("%v", err)
}
f.proc(50, 1000, "xterm", []string{"xterm"}, "s.scope", "DISPLAY=:9")
f.desktopSession()
f.proc(60, 1001, "i3", []string{"i3"}, "s.scope", "DISPLAY=:5")
s, err := f.session()
if err != nil || s.Display != ":1" || s.XAuthority != testHome+"/.Xauthority" || s.Bus != "unix:path=/run/user/1000/bus" ||
!strings.Contains(s.From, "i3") {
t.Fatalf("%+v %v", s, err)
}
}
func TestStopAsksThenForcesAndDetachStartsUnderTheServiceManager(t *testing.T) {
f := newFake(t)
f.desktopSession()
f.proc(20, 1000, "app", []string{"app"}, "s.scope")
f.proc(21, 1000, "app", []string{"app"}, "s.scope")
f.stubborn[21] = true
ended, killed := f.stop(time.Second, "app")
if len(ended) != 1 || ended[0] != 20 || len(killed) != 1 || killed[0] != 21 {
t.Fatalf("ended %v killed %v (%v)", ended, killed, f.signals)
}
s, _ := f.session()
if err := f.detach(s, "mesh-app", "/usr/bin/app", "--background"); err != nil {
t.Fatal(err)
}
want := "systemd-run --user --collect --quiet --unit=mesh-app --setenv=DISPLAY=:1 --setenv=XAUTHORITY=" + testHome +
"/.Xauthority -- /usr/bin/app --background"
if !f.called("systemctl --user stop mesh-app.service") || !f.called(want) {
t.Fatalf("%q", f.calls)
}
}
func TestAnAutostartEntryOfTheAccountOverridesTheSystemsAndHiddenStartsNothing(t *testing.T) {
f := newFake(t)
if a := f.autostart("x.desktop"); a.Starts || a.Because == "" {
t.Fatalf("%+v", a)
}
f.write("/etc/xdg/autostart/x.desktop", "[Desktop Entry]\nExec=x-applet\n[Desktop Action y]\nExec=other\n")
if a := f.autostart("x.desktop"); !a.Starts || a.Exec != "x-applet" || a.From != "/etc/xdg/autostart/x.desktop" {
t.Fatalf("%+v", a)
}
f.write(testHome+"/.config/autostart/x.desktop", "[Desktop Entry]\nExec=x-applet\nHidden=true\n")
if a := f.autostart("x.desktop"); a.Starts || a.Because != "Hidden=true" || a.From != "~/.config/autostart/x.desktop" {
t.Fatalf("%+v", a)
}
}
func TestAWindowManagerStartIsFoundInTheConfigurationAndItsDropIns(t *testing.T) {
f := newFake(t)
f.write(testHome+"/.config/i3/config", "exec --no-startup-id dex --autostart --environment i3\n# exec app\nbindsym $mod+a exec app\n")
f.write(testHome+"/.config/i3/config.d/50-x.conf", "exec_always --no-startup-id /usr/bin/app --flag\n")
got := f.i3Starts("app")
if len(got) != 1 || got[0] != "~/.config/i3/config.d/50-x.conf:1: exec_always --no-startup-id /usr/bin/app --flag" {
t.Fatalf("%q", got)
}
}
func TestACommandThatFailsIsNamed(t *testing.T) {
if err := failed(Output{Code: 127, Err: ErrNotInstalled}, "dex"); err == nil || !strings.Contains(err.Error(), "dex is not installed") {
t.Fatal(err)
}
if err := failed(Output{Code: 1, Stderr: "nope"}, "pacman", "-Q", "x"); err == nil || !strings.Contains(err.Error(), "pacman -Q x exited 1: nope") {
t.Fatal(err)
}
if err := failed(Output{}, "true"); err != nil {
t.Fatal(err)
}
}
func TestTheRealRunnerBoundsTimeAndOutput(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
if o := execRun(ctx, nil, "sleep", "5"); o.Err != ErrTimedOut {
t.Fatalf("%+v", o)
}
if o := execRun(context.Background(), nil, "no-such-program-here"); o.Err != ErrNotInstalled {
t.Fatalf("%+v", o)
}
o := execRun(context.Background(), nil, "head", "-c", strconv.Itoa(MostOutput+10), "/dev/zero")
if !o.Cut || len(o.Stdout) != MostOutput {
t.Fatalf("cut %v, %d bytes", o.Cut, len(o.Stdout))
}
}
@@ -0,0 +1,205 @@
package main
// FortiClient as the tools see it: the vendor's scheduler service, the tray and the launcher that
// starts it, the tray's XDG autostart entry, and whether a tunnel is up. This is the operator's work
// VPN, so the tools never read its profiles, its credentials, its gateways or its certificates: no file
// under /etc/forticlient or the account's FortiClient settings is opened, and under /opt/forticlient
// only the tray's autostart entry (through its link in /etc/xdg/autostart); the vendor's command-line
// client is never run, its logs are never read, a process is named by its command name only (never its
// arguments), and a tunnel is counted, never named or addressed.
import (
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
)
const (
launcherComm = "fortitraylaunch" // the kernel keeps 15 characters of fortitraylauncher
launcherWord = "fortitraylauncher"
trayComm = "fortitray"
launcherBin = "/opt/forticlient/fortitraylauncher"
entryName = "Fortitray.desktop"
restartAs = "mesh-forticlient-tray"
packageFor = "forticlient-vpn"
serviceUnit = "forticlient.service"
// tunnelPrefix starts the name of the interface the client brings up for a connected tunnel.
tunnelPrefix = "fctvpn"
)
// named keeps a process's command name and drops its arguments.
func named(ps []Proc, comm string) []Proc {
out := []Proc{}
for _, p := range ps {
p.Command = comm
out = append(out, p)
}
return out
}
// Tunnel is whether the client holds a tunnel up: how many of its tunnel interfaces exist and are up.
// Their names and addresses are never answered.
type Tunnel struct {
Connected bool `json:"connected"`
Up int `json:"tunnels_up"`
}
func (m *Machine) tunnel() Tunnel {
t := Tunnel{}
entries, _ := os.ReadDir(m.path("/sys/class/net"))
for _, e := range entries {
if !strings.HasPrefix(e.Name(), tunnelPrefix) {
continue
}
flags, err := strconv.ParseUint(strings.TrimPrefix(readTrimmed(m.path(filepath.Join("/sys/class/net", e.Name(), "flags"))), "0x"), 16, 32)
if err == nil && flags&1 == 1 { // IFF_UP
t.Up++
}
}
t.Connected = t.Up > 0
return t
}
// Service is the vendor's scheduler, which holds the tunnel.
type Service struct {
Active string `json:"active"`
Enabled string `json:"enabled"`
}
func (m *Machine) service() Service {
word := func(verb string) string {
o := m.cmd(5*time.Second, nil, "systemctl", verb, serviceUnit)
if f := strings.Fields(o.Stdout); o.Err == nil && len(f) > 0 {
return f[0]
}
return "unknown"
}
return Service{Active: word("is-active"), Enabled: word("is-enabled")}
}
// foreign says whether the package came from outside the official repositories (pacman -Qm).
func (m *Machine) foreign() bool {
o := m.cmd(0, nil, "pacman", "-Qqm", packageFor)
return o.Err == nil && o.Code == 0 && strings.TrimSpace(o.Stdout) == packageFor
}
// StatusAnswer is what forticlient_status answers.
type StatusAnswer struct {
Installed string `json:"installed,omitempty"`
Foreign bool `json:"outside_official_repositories"`
Service Service `json:"service"`
Launcher []Proc `json:"launcher"`
Tray []Proc `json:"tray"`
StartedBy Autostart `json:"started_by"`
Tunnel Tunnel `json:"tunnel"`
}
// Status reads the client's running state, and nothing of its configuration.
func (m *Machine) Status() (StatusAnswer, error) {
s := StatusAnswer{Service: m.service(), Launcher: named(m.procs(launcherComm), launcherWord),
Tray: named(m.procs(trayComm), trayComm), StartedBy: m.autostart(entryName), Tunnel: m.tunnel()}
v, err := m.installed(packageFor)
if err != nil {
return s, err
}
s.Installed = v
if v != "" {
s.Foreign = m.foreign()
}
return s, nil
}
// RestartAnswer is what forticlient_restart answers.
type RestartAnswer struct {
Ended []int `json:"ended"`
Killed []int `json:"killed,omitempty"`
Running []Proc `json:"running"`
Session Session `json:"session"`
Unit string `json:"unit"`
// Tunnel is after the restart: the tray is only the client's face, the service holds the tunnel.
Tunnel Tunnel `json:"tunnel"`
}
// Restart ends the tray and its launcher and starts the launcher again in the operator's session,
// under the account's service manager. The launcher starts the tray. The tunnel is the service's,
// and is not touched.
func (m *Machine) Restart() (RestartAnswer, error) {
s, err := m.session()
if err != nil {
return RestartAnswer{}, err
}
a := RestartAnswer{Session: s, Unit: restartAs + ".service"}
a.Ended, a.Killed = m.stop(5*time.Second, launcherComm, trayComm)
if err := m.detach(s, restartAs, launcherBin); err != nil {
return a, err
}
a.Running = named(m.waitFor(launcherComm, 4*time.Second), launcherWord)
a.Tunnel = m.tunnel()
if len(a.Running) == 0 {
return a, fmt.Errorf("the launcher was started as %s but no %s process appeared within 4 s",
a.Unit, launcherWord)
}
return a, nil
}
// CheckAnswer is what forticlient_check answers. Being connected or not is never a finding: that is
// the operator's choice, and forticlient_status says which.
type CheckAnswer struct {
OK bool `json:"ok"`
Findings []Finding `json:"findings"`
Starts []string `json:"starts"`
}
// Check verifies what the module promises and relies on: the package (found, not installed by the
// mesh); the service running and enabled; one start for the tray (the vendor's autostart entry, which
// the session's dex runs); and the tray running once in a desktop session.
func (m *Machine) Check() (CheckAnswer, error) {
a := CheckAnswer{Findings: []Finding{}, Starts: []string{}}
add := func(what, do string) { a.Findings = append(a.Findings, Finding{what, do}) }
v, err := m.installed(packageFor)
if err != nil {
return a, err
}
if v == "" {
add("forticlient-vpn is not installed. It is outside the official repositories, so the mesh does not install it",
"install it from the AUR by hand")
}
if s := m.service(); s.Active != "active" || s.Enabled != "enabled" {
add(fmt.Sprintf("the service %s is %s and %s", serviceUnit, s.Active, s.Enabled),
"push the module, which declares it running and enabled")
}
entry := m.autostart(entryName)
if entry.Starts {
a.Starts = append(a.Starts, "XDG autostart: "+entry.From)
if entry.From != "/etc/xdg/autostart/"+entryName {
add("the account's own "+entry.From+" replaces the vendor's entry", "remove it, so the vendor's entry is the one start")
}
} else {
add("the tray does not start with the session ("+entry.Because+")",
"remove ~/.config/autostart/"+entryName+" if it hides the vendor's entry; if the vendor's is gone, reinstall forticlient-vpn, whose install links it")
}
if o := m.cmd(0, nil, "dex", "--version"); o.Err != nil {
add("dex, which runs the XDG autostart entries at login, is not installed", "assign the i3 module, which installs it and runs it")
}
for _, word := range []string{launcherWord, trayComm} {
for _, l := range m.i3Starts(word) {
a.Starts = append(a.Starts, "window manager: "+l)
add("a second start: "+l, "remove the line; the vendor's autostart entry is the tray's one start")
}
}
if _, err := m.session(); err == nil {
for _, p := range []struct{ comm, word string }{{launcherComm, launcherWord}, {trayComm, trayComm}} {
switch running := m.procs(p.comm); {
case len(running) == 0:
add("no "+p.word+" runs in the desktop session", "forticlient_restart")
case len(running) > 1:
add(fmt.Sprintf("%d of %s run", len(running), p.word), "forticlient_restart ends them all and starts one")
}
}
}
a.OK = len(a.Findings) == 0
return a, nil
}
@@ -0,0 +1,142 @@
package main
import (
"encoding/json"
"strings"
"testing"
)
// secrets are what the operator's VPN configuration holds on a real machine. The fake machine carries
// them where the client keeps them, and no answer may.
var secrets = []string{"vpn.example.invalid", "192.0.2.10", "s3cret-psk", "work-profile", "BEGIN CERTIFICATE", "65d16a50"}
func newClient(t *testing.T, running, connected bool) *fake {
f := newFake(t)
f.write("/opt/forticlient/Fortitray.desktop", "[Desktop Entry]\nType=Application\nName=Fortitray\nExec=/opt/forticlient/fortitraylauncher\nNoDisplay=true\n")
f.write("/etc/xdg/autostart/"+entryName, "[Desktop Entry]\nType=Application\nName=Fortitray\nExec=/opt/forticlient/fortitraylauncher\nNoDisplay=true\n")
f.write("/etc/forticlient/config.db", "work-profile vpn.example.invalid 192.0.2.10 s3cret-psk\n")
f.write(testHome+"/.config/FortiClient/state.json", `{"gateway":"vpn.example.invalid","cert":"-----BEGIN CERTIFICATE-----"}`)
f.write("/sys/class/net/wlp3s0/flags", "0x1003\n")
if connected {
f.write("/sys/class/net/fctvpn65d16a50/flags", "0x1091\n")
f.write("/sys/class/net/fctvpn65d16a50/address", "192.0.2.10\n")
}
if running {
f.proc(3857, 1000, launcherComm, []string{launcherBin, "--profile=work-profile"}, "session-c1.scope")
f.proc(4509, 1000, trayComm, []string{"/opt/forticlient/fortitray", "--gateway", "vpn.example.invalid"}, "session-c1.scope")
}
f.answer = func(name string, args []string) Output {
switch {
case name == "pacman" && args[0] == "-Q":
return Output{Stdout: "forticlient-vpn 7.4.3.5411-1\n"}
case name == "pacman" && args[0] == "-Qqm":
return Output{Stdout: "forticlient-vpn\n"}
case name == "systemctl" && args[0] == "is-active":
return Output{Stdout: "active\n"}
case name == "systemctl" && args[0] == "is-enabled":
return Output{Stdout: "enabled\n"}
}
return Output{}
}
return f
}
// carries fails the test when an answer holds anything of the VPN's configuration.
func carries(t *testing.T, answer any) {
t.Helper()
raw, _ := json.Marshal(answer)
for _, s := range secrets {
if strings.Contains(string(raw), s) {
t.Fatalf("the answer carries %q: %s", s, raw)
}
}
}
func TestStatusIsRunningAndConnectedStateAndNothingOfTheConfiguration(t *testing.T) {
f := newClient(t, true, true)
s, err := f.Status()
if err != nil {
t.Fatal(err)
}
if s.Installed != "7.4.3.5411-1" || !s.Foreign || s.Service != (Service{"active", "enabled"}) || len(s.Launcher) != 1 || len(s.Tray) != 1 ||
!s.StartedBy.Starts || !s.Tunnel.Connected || s.Tunnel.Up != 1 {
t.Fatalf("%+v", s)
}
if s.Launcher[0].Command != launcherWord || s.Tray[0].Command != trayComm {
t.Fatalf("processes by command name only: %+v %+v", s.Launcher, s.Tray)
}
carries(t, s)
for _, c := range f.calls {
for _, never := range []string{"forticlient-cli", "fortivpn", "journalctl", "sqlite", "/etc/forticlient", "/opt/forticlient/.config"} {
if strings.Contains(c, never) {
t.Fatalf("ran %q", c)
}
}
}
down := newClient(t, true, false)
down.write("/sys/class/net/fctvpn0/flags", "0x1090\n")
if s, _ := down.Status(); s.Tunnel.Connected || s.Tunnel.Up != 0 {
t.Fatalf("an interface that is down is no tunnel: %+v", s.Tunnel)
}
}
func TestCheckPassesTheVendorsOneStartAndNeverJudgesTheConnection(t *testing.T) {
f := newClient(t, true, false)
f.desktopSession()
c, err := f.Check()
if err != nil || !c.OK || len(c.Starts) != 1 || c.Starts[0] != "XDG autostart: /etc/xdg/autostart/"+entryName {
t.Fatalf("%+v %v", c, err)
}
carries(t, c)
f.write(testHome+"/.config/i3/config", "exec --no-startup-id /opt/forticlient/fortitraylauncher\n")
f.write(testHome+"/.config/autostart/"+entryName, "[Desktop Entry]\nExec=/opt/forticlient/fortitraylauncher\nHidden=true\n")
f.proc(3858, 1000, launcherComm, []string{launcherBin}, "session-c1.scope")
f.answer = func(name string, args []string) Output {
switch name {
case "pacman":
return Output{Code: 1}
case "systemctl":
return Output{Stdout: "inactive\n", Code: 3}
case "dex":
return Output{Code: 127, Err: ErrNotInstalled}
}
return Output{}
}
c, _ = f.Check()
var all []string
for _, x := range c.Findings {
all = append(all, x.What)
}
got := strings.Join(all, "\n")
for _, want := range []string{"not installed", "forticlient.service is inactive", "does not start with the session (Hidden=true)", "dex",
"a second start: ~/.config/i3/config:1", "2 of fortitraylauncher run"} {
if !strings.Contains(got, want) {
t.Errorf("no finding %q in\n%s", want, got)
}
}
carries(t, c)
}
func TestRestartStartsTheLauncherAndLeavesTheTunnel(t *testing.T) {
f := newClient(t, true, true)
if _, err := f.Restart(); err == nil || !strings.Contains(err.Error(), "no graphical session") {
t.Fatalf("without a desktop: %v", err)
}
f.desktopSession()
f.onStart = func(argv []string) { f.proc(9100, 1000, launcherComm, argv, "app.slice/"+restartAs+".service") }
a, err := f.Restart()
if err != nil || len(a.Ended) != 2 || len(a.Running) != 1 || !a.Tunnel.Connected {
t.Fatalf("%+v %v", a, err)
}
if !f.called("systemd-run --user --collect --quiet --unit=" + restartAs + " --setenv=DISPLAY=:1") {
t.Fatalf("%q", f.calls)
}
for _, c := range f.calls {
if strings.Contains(c, serviceUnit) {
t.Fatalf("the restart touched the service: %q", c)
}
}
carries(t, a)
}
@@ -0,0 +1,49 @@
// The forticlient module's Go tools bundle (novox/hq ADR 0188, ADR 0193, ADR 0208): the FortiClient
// VPN client's tray in the operator's session and the vendor's service behind it, served by the node's
// runtime as the operator account. The module holds no seat, so every tool is its own. The tools
// report running and connected state only: never a profile, a credential, a gateway or a certificate.
package main
import (
"fmt"
"os"
stdio "git.novox.be/novox/mesh-sdk/go"
)
func main() {
if err := stdio.Serve("", tools()); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
var machine = NewMachine()
func tools() []stdio.Tool {
return []stdio.Tool{
{
Name: "forticlient_status",
Description: "The VPN client: the installed version and whether it came from outside the official " +
"repositories, the vendor's service (active, enabled), whether the tray and its launcher run " +
"(pid, since, scope or unit), what starts the tray at login, and whether a tunnel is up (a " +
"count; never a name, an address, a gateway or a profile). (r)",
Run: func(map[string]any) (any, error) { return machine.Status() },
},
{
Name: "forticlient_restart",
Description: "End the tray and its launcher (asked first, then forced after 5 s) and start the " +
"launcher again in the operator's desktop session, under the account's service manager. The " +
"tunnel is the service's and is not touched. Needs someone logged in to the desktop. (a)",
Run: func(map[string]any) (any, error) { return machine.Restart() },
},
{
Name: "forticlient_check",
Description: "Check what the module promises and relies on: the package is installed (by hand: it is " +
"outside the official repositories); the service runs and is enabled; the tray has exactly one " +
"start (the vendor's XDG autostart entry, which the session's dex runs; no window-manager exec); " +
"and the tray and its launcher run once in a desktop session. Being connected is not checked. (r)",
Run: func(map[string]any) (any, error) { return machine.Check() },
},
}
}
@@ -0,0 +1,106 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
)
// forticlient's shape (novox/hq ADR 0205, ADR 0207, ADR 0208): no package (the client is outside the
// official repositories and kept as found), the vendor's service declared running and enabled, the X
// display on its own machine, no start of its own (the vendor's autostart entry is the tray's one
// start), nothing of the VPN's configuration, and the Go bundle serving exactly the listed forticlient_
// tools.
type manifest struct {
Module string `json:"module"`
Version string `json:"version"`
Capabilities []string `json:"capabilities"`
Requires []string `json:"requires"`
Tools []string `json:"tools"`
Resources []map[string]any `json:"resources"`
Claims []any `json:"claims"`
Seats []any `json:"seats"`
Shell []any `json:"shell"`
Contributions []struct {
Seat string `json:"seat"`
Kind string `json:"kind"`
Content string `json:"content"`
} `json:"contributions"`
Environment any `json:"environment"`
Build struct {
Artifacts []map[string]any `json:"artifacts"`
} `json:"build"`
}
func readManifest(t *testing.T) (manifest, string) {
t.Helper()
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
if err != nil {
t.Fatal(err)
}
dec := json.NewDecoder(strings.NewReader(string(raw)))
dec.DisallowUnknownFields()
var m manifest
if err := dec.Decode(&m); err != nil {
t.Fatalf("module.json: %v", err)
}
return m, string(raw)
}
func TestTheToolsAgreeWithTheManifest(t *testing.T) {
m, raw := readManifest(t)
served := map[string]bool{}
for _, tool := range tools() {
served[tool.Name] = true
if !strings.HasPrefix(tool.Name, "forticlient_") || strings.TrimSpace(tool.Description) == "" {
t.Errorf("%s: prefixed %s and described", tool.Name, "forticlient_")
}
}
for _, name := range m.Tools {
if !served[name] {
t.Errorf("module.json lists %s, which the bundle does not serve", name)
}
delete(served, name)
}
for name := range served {
t.Errorf("the bundle serves %s, which module.json does not list", name)
}
if len(m.Build.Artifacts) != 1 {
t.Fatalf("%v", m.Build.Artifacts)
}
b := m.Build.Artifacts[0]
if b["kind"] != "bundle" || b["language"] != "go" || b["system"] != "arch" ||
b["from"] != "cmd/forticlient-tools" || b["binary"] != "forticlient-tools" {
t.Errorf("the Go tools bundle: %v", b)
}
s := strings.ToLower(raw)
for _, never := range []string{"/home/", "jochen", "g14", "shanks", "novox.be", "http", "password", "token"} {
if strings.Contains(s, never) {
t.Errorf("module.json names %q", never)
}
}
}
func TestItDeclaresTheServiceAndNothingOfTheConfiguration(t *testing.T) {
m, raw := readManifest(t)
if m.Module != "forticlient" || !reflect.DeepEqual(m.Requires, []string{"x11-display"}) ||
!reflect.DeepEqual(m.Capabilities, []string{"service-manager"}) {
t.Fatalf("%+v", m)
}
if len(m.Resources) != 1 || m.Resources[0]["type"] != "service" || m.Resources[0]["unit"] != serviceUnit ||
m.Resources[0]["state"] != "running" || m.Resources[0]["boot"] != "enabled" || m.Resources[0]["restart-on"] != nil {
t.Fatalf("resources: %v", m.Resources)
}
if m.Claims != nil || m.Seats != nil || m.Environment != nil || m.Shell != nil || m.Contributions != nil {
t.Fatal("no seat, no environment, and no second start")
}
for _, never := range []string{"/etc/forticlient", "/opt/forticlient", "autostart", "\"package\"", "vpn.", "gateway", "profile"} {
if strings.Contains(raw, never) {
t.Errorf("module.json names %s", never)
}
}
}
+5
View File
@@ -0,0 +1,5 @@
module forticlient
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=
+39
View File
@@ -0,0 +1,39 @@
{
"module": "forticlient",
"version": "1",
"capabilities": [
"service-manager"
],
"requires": [
"x11-display"
],
"tools": [
"forticlient_status",
"forticlient_restart",
"forticlient_check"
],
"resources": [
{
"id": "scheduler",
"type": "service",
"unit": "forticlient.service",
"state": "running",
"boot": "enabled"
}
],
"build": {
"artifacts": [
{
"name": "tools",
"kind": "bundle",
"language": "go",
"system": "arch",
"from": "cmd/forticlient-tools",
"binary": "forticlient-tools",
"loads": [
"forticlient-tools"
]
}
]
}
}
+161 -4
View File
@@ -5,6 +5,8 @@
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
import { ConfiguredToken, MintedToken, type TokenSource } from "./token.js"; import { ConfiguredToken, MintedToken, type TokenSource } from "./token.js";
import { protectionBody, type BranchProtection, type ProtectionWanted } from "./protection.js";
export type { BranchProtection, ProtectionWanted } from "./protection.js";
/** A repository, trimmed to what the mesh cares about. */ /** A repository, trimmed to what the mesh cares about. */
export interface GiteaRepo { export interface GiteaRepo {
@@ -17,6 +19,8 @@ export interface GiteaRepo {
description?: string; description?: string;
html_url: string; html_url: string;
default_branch?: string; default_branch?: string;
/** When anything last moved in it — a push, and so a merge. */
updated_at?: string;
} }
/** An issue, with its labels flattened to names. */ /** An issue, with its labels flattened to names. */
@@ -41,8 +45,21 @@ export interface GiteaPull {
merged_at?: string; merged_at?: string;
user?: string; user?: string;
head?: string; head?: string;
/** The commit the pull request's head is at now: what is checked before it merges (novox/hq to-be 45 §9). */
head_sha?: string;
base?: string; base?: string;
updated_at?: string;
html_url: string; html_url: string;
/** The description: where a pull request says it goes after another (`after: <repository>`, novox/hq ADR 0239). */
body?: string;
}
/** A commit status, as the forge keeps it: what a pull request shows beside its head commit. */
export interface CommitStatus {
state: "pending" | "success" | "error" | "failure" | "warning";
context: string;
description: string;
target_url?: string;
} }
export interface GiteaComment { export interface GiteaComment {
@@ -249,10 +266,120 @@ export class GiteaClient {
* `limit` is what is asked for, and a merge that changed more says so rather than being read * `limit` is what is asked for, and a merge that changed more says so rather than being read
* page by page: what the mesh does with a partial list is treat the whole repository as changed, * page by page: what the mesh does with a partial list is treat the whole repository as changed,
* so more pages would buy nothing. */ * so more pages would buy nothing. */
async listPullFiles(owner: string, repo: string, index: number, limit = 100): Promise<{ paths: string[]; truncated: boolean }> { /** Every file a pull request changed, page by page. **The forge caps a page below what is asked**
const files = await this.request<any[]>(`/repos/${owner}/${repo}/pulls/${index}/files?limit=${limit}`); * (fifty, asked for a hundred), so one page read as the whole list dropped files silently, and a module
const paths = (files ?? []).map((f) => String(f?.filename ?? "")).filter((p) => p !== ""); * whose own files moved was not rebuilt (novox/hq issue 252). Read until a page comes back short; past
return { paths, truncated: paths.length >= limit }; * `most` files the list is cut and says so, and the mesh then rebuilds everything built from the
* repository, the safe direction. */
/** Every file a pull request changes, and which of them it deleted: a module whose manifest the merge
* deleted is gone from its source, and the mesh forgets it rather than asking its build (novox/hq ADR
* 0236). The forge says `deleted`; `removed` is read the same. */
async listPullFiles(owner: string, repo: string, index: number, most = 3000): Promise<{ paths: string[]; removed: string[]; truncated: boolean }> {
const paths: string[] = [];
const removed: string[] = [];
let pageSize = 0;
for (let page = 1; ; page++) {
const files = (await this.request<any[]>(`/repos/${owner}/${repo}/pulls/${index}/files?limit=50&page=${page}`)) ?? [];
if (page === 1) pageSize = files.length;
for (const f of files) {
const name = String(f?.filename ?? "");
if (name === "") continue;
paths.push(name);
const status = String(f?.status ?? "");
if (status === "deleted" || status === "removed") removed.push(name);
}
if (files.length === 0 || files.length < pageSize) return { paths, removed, truncated: false };
if (paths.length >= most) return { paths, removed, truncated: true };
}
}
/** The directories holding these files that hold a `module.json` at a commit (novox/hq issue 278).
*
* **Whether a directory is a module is a fact of the repository, not of the merge.** The mesh read a
* changed file as shared code unless its directory was a module it held or the merge also changed
* that directory's manifest; a change to the catalogue's reference module, which no machine holds,
* rebuilt all 103 modules built from the repository. So every directory above a changed file — never
* the root — is looked up at the merge commit, and the ones holding a manifest are said.
*
* `null` when there are more than `most` directories to look at: not said, and the mesh keeps its old
* rule, which rebuilds too much rather than too little. A failed lookup throws, for the same reason. */
async moduleDirsAt(owner: string, repo: string, sha: string, paths: string[], most = 300): Promise<string[] | null> {
const dirs = directoriesAbove(paths);
if (dirs.length > most) return null;
const out: string[] = [];
for (const dir of dirs) {
if (await this.exists(`/repos/${owner}/${repo}/contents/${encodePath(dir + "/module.json")}?ref=${encodeURIComponent(sha)}`)) {
out.push(dir);
}
}
return out;
}
/** Whether a repository holds a file at a commit: true, false for a 404, thrown when the forge cannot say. */
async holdsFile(owner: string, repo: string, sha: string, file: string): Promise<boolean> {
return this.exists(`/repos/${owner}/${repo}/contents/${encodePath(file)}?ref=${encodeURIComponent(sha)}`);
}
/** Whether the forge has something at a path: true for an answer, false for a 404, thrown otherwise. */
private async exists(path: string): Promise<boolean> {
let token = await this.tokens.current();
let res = await this.send(path, {}, token);
if (res.status === 401) {
token = await this.tokens.renew(token);
res = await this.send(path, {}, token);
}
if (res.status === 404) return false;
if (!res.ok) throw new Error(`Gitea API ${path}: ${res.status} ${await res.text()}`);
await res.body?.cancel();
return true;
}
/** Set a commit's status — what a pull request whose head it is shows beside it (novox/hq to-be 45 §9).
* The forge keeps one per context, the newest, so setting it again replaces it. */
async setCommitStatus(owner: string, repo: string, sha: string, status: CommitStatus): Promise<void> {
await this.request(`/repos/${owner}/${repo}/statuses/${sha}`, { method: "POST", body: JSON.stringify(status) });
}
/** A commit's statuses by context, the newest of each: what a merge's head was checked as (novox/hq ADR 0239). */
async commitStatuses(owner: string, repo: string, sha: string): Promise<Record<string, string>> {
const raw = await this.request<any>(`/repos/${owner}/${repo}/commits/${sha}/status`);
const out: Record<string, string> = {};
for (const s of raw?.statuses ?? []) if (s?.context && !out[s.context]) out[s.context] = String(s.status ?? s.state ?? "");
return out;
}
/** Replace a comment's body: the delivery's view, kept current in place (novox/hq ADR 0239). */
async editComment(owner: string, repo: string, id: number, body: string): Promise<{ id: number; html_url: string }> {
const c = await this.request<any>(`/repos/${owner}/${repo}/issues/comments/${id}`, { method: "PATCH", body: JSON.stringify({ body }) });
return { id: Number(c?.id ?? id), html_url: String(c?.html_url ?? "") };
}
// ---- Branch protection ----
/** A repository's branch protection rules. */
async branchProtections(owner: string, repo: string): Promise<BranchProtection[]> {
return (await this.request<BranchProtection[]>(`/repos/${owner}/${repo}/branch_protections`)) ?? [];
}
/** The rule named for a branch, null when it has none. */
async branchProtection(owner: string, repo: string, rule: string): Promise<BranchProtection | null> {
return (await this.branchProtections(owner, repo)).find((p) => (p.rule_name ?? p.branch_name) === rule) ?? null;
}
/** Make a branch require these commit statuses to merge (novox/hq ADR 0237): the rule named for the
* branch is edited — its required statuses replaced by these, everything else it says kept unless
* asked — or created, refusing direct pushes unless asked otherwise. Answers the rule as it is now. */
async setBranchProtection(owner: string, repo: string, branch: string, want: ProtectionWanted): Promise<{ created: boolean; rule: BranchProtection }> {
const had = await this.branchProtection(owner, repo, branch);
const body = protectionBody(want, !had);
if (had) {
const rule = await this.request<BranchProtection>(`/repos/${owner}/${repo}/branch_protections/${encodeURIComponent(branch)}`,
{ method: "PATCH", body: JSON.stringify(body) });
return { created: false, rule };
}
const rule = await this.request<BranchProtection>(`/repos/${owner}/${repo}/branch_protections`,
{ method: "POST", body: JSON.stringify({ rule_name: branch, ...body }) });
return { created: true, rule };
} }
async createPullRequest( async createPullRequest(
@@ -342,6 +469,7 @@ export class GiteaClient {
description: r.description || undefined, description: r.description || undefined,
html_url: r.html_url, html_url: r.html_url,
default_branch: r.default_branch, default_branch: r.default_branch,
updated_at: r.updated_at ?? undefined,
}; };
} }
@@ -367,8 +495,11 @@ export class GiteaClient {
merged_at: p.merged_at ?? undefined, merged_at: p.merged_at ?? undefined,
user: p.user?.login, user: p.user?.login,
head: p.head?.ref, head: p.head?.ref,
head_sha: p.head?.sha ?? undefined,
base: p.base?.ref, base: p.base?.ref,
updated_at: p.updated_at ?? undefined,
html_url: p.html_url, html_url: p.html_url,
body: typeof p.body === "string" ? p.body : undefined,
}; };
} }
} }
@@ -584,3 +715,29 @@ export class GiteaAdmin {
GiteaAdmin.fail(`/admin/users/${username}`, res); GiteaAdmin.fail(`/admin/users/${username}`, res);
} }
} }
/** The repositories that moved at or after a moment: every one when there is no moment yet, and one whose
* update time is not known, so a forge that does not say is asked as before (novox/hq issue 250). */
export function movedSince(repos: GiteaRepo[], floor: string): GiteaRepo[] {
if (!floor) return repos;
const at = Date.parse(floor);
return repos.filter((r) => !r.updated_at || !(Date.parse(r.updated_at) < at));
}
/** Every directory above these files, from the repository's root, the root itself left out; sorted. */
export function directoriesAbove(paths: string[]): string[] {
const dirs = new Set<string>();
for (const raw of paths) {
const parts = raw.replace(/^\/+/, "").split("/");
for (let i = 1; i < parts.length; i++) {
const dir = parts.slice(0, i).join("/");
if (dir !== "") dirs.add(dir);
}
}
return [...dirs].sort();
}
/** A repository path for the forge's URL: each segment escaped, the slashes kept. */
function encodePath(path: string): string {
return path.split("/").map(encodeURIComponent).join("/");
}
+103
View File
@@ -0,0 +1,103 @@
// What the forge's holder does for a delivery (novox/hq ADR 0239): the commit's note under
// refs/notes/mesh-plan, the delivery's view on its pull request, and its statuses — asked by mesh-delivery,
// the delivery's owner, through this module's tools. The forge is this module's; mesh-delivery never writes
// to it itself.
//
// **The note is written in the forge's own repository, as the forge's own user.** The forge's API reads a
// note and writes none, and a push needs a clone and a credential nobody else should hold; the forge's
// container holds the bare repository and git. So the line is appended there, by `git notes append`, as the
// account the forge runs as — the mesh's own forge, never a person's or an agent's hand. Appending is
// idempotent here: a line the note already has is not added again, so asking twice adds nothing.
//
// Pure functions and an injectable runner, so this is tested without a forge.
import { execFile } from "node:child_process";
import { promisify } from "node:util";
/** How a command is run: docker on the forge's machine, or a test's. */
export type Runner = (file: string, args: string[]) => Promise<{ stdout: string; code: number }>;
const execFileP = promisify(execFile);
export const run: Runner = async (file, args) => {
try {
const { stdout } = await execFileP(file, args, { maxBuffer: 4 << 20 });
return { stdout, code: 0 };
} catch (err) {
const e = err as { code?: number; stdout?: string };
return { stdout: e.stdout ?? "", code: typeof e.code === "number" ? e.code : 1 };
}
};
const name = /^[A-Za-z0-9_.-]+$/;
const sha = /^[0-9a-fA-F]{7,64}$/;
const ref = /^[a-z0-9][a-z0-9-]*$/;
/** A note line as it may be written: one line, bounded, nothing that is not text. */
export function noteLine(line: string): string {
const one = String(line ?? "").replace(/[\r\n\t]+/g, " ").replace(/\s+/g, " ").trim();
if (!one) throw new Error("an empty line is no note");
return one.length > 2000 ? one.slice(0, 1999) + "…" : one;
}
/** Where the forge keeps a repository, inside its container. */
export function gitDir(owner: string, repo: string): string {
if (!name.test(owner) || !name.test(repo)) throw new Error(`${owner}/${repo} is not a repository's name`);
return `/data/git/repositories/${owner.toLowerCase()}/${repo.toLowerCase()}.git`;
}
/** The commands the note is read and appended with, in the forge's container, as its user. */
export function noteArgs(container: string, owner: string, repo: string, commit: string, notesRef: string, line?: string): string[] {
if (!name.test(container)) throw new Error(`${container} is not a container's name`);
if (!sha.test(commit)) throw new Error(`${commit} is not a commit`);
if (!ref.test(notesRef)) throw new Error(`${notesRef} is not a notes ref`);
const base = ["exec", "-u", "git", container, "git", "--git-dir", gitDir(owner, repo),
"-c", "user.name=mesh", "-c", "user.email=mesh@mesh.invalid", "notes", `--ref=${notesRef}`];
return line === undefined ? [...base, "show", commit] : [...base, "append", "-m", noteLine(line), commit];
}
/** Append a line to a commit's note, unless the note already holds it. Answers whether it was added. */
export async function appendNote(runner: Runner, container: string, owner: string, repo: string, commit: string,
notesRef: string, line: string): Promise<{ added: boolean; lines: number }> {
const wanted = noteLine(line);
const shown = await runner("docker", noteArgs(container, owner, repo, commit, notesRef));
// No note yet is git's exit 1 with nothing on stdout; anything else unreadable is said.
const lines = shown.code === 0 ? shown.stdout.split("\n").filter((l) => l.trim() !== "") : [];
if (lines.includes(wanted)) return { added: false, lines: lines.length };
const appended = await runner("docker", noteArgs(container, owner, repo, commit, notesRef, wanted));
if (appended.code !== 0) throw new Error(`the note on ${commit.slice(0, 8)} could not be appended (git exited ${appended.code})`);
return { added: true, lines: lines.length + 1 };
}
/** The marker that makes one comment of a pull request the delivery's view. */
export const VIEW_MARKER = "<!-- mesh-delivery:view -->";
/** The view's body, marked: mesh-delivery writes it, this keeps exactly one of them per pull request. */
export function viewBody(body: string): string {
const b = String(body ?? "");
return b.includes(VIEW_MARKER) ? b : `${VIEW_MARKER}\n${b}`;
}
/** Which comment is the view: the first that carries the marker, or none yet. */
export function viewComment<T extends { id: number; body: string }>(comments: T[]): T | undefined {
return comments.find((c) => c.body.includes(VIEW_MARKER));
}
const states = new Set(["pending", "success", "error", "failure", "warning"]);
/** The merge check's contexts (pulls.ts): a branch's protection may require them. */
const mergeCheckContexts = new Set(["mesh/merge-gate", "mesh/repo-check"]);
/** A status mesh-delivery asks for, checked: one of the forge's states, a context of the mesh's own, a
* bounded description. */
export function deliveryStatus(context: string, state: string, description: string, target?: string) {
if (!/^mesh\/[a-z-]+$/.test(context)) throw new Error(`${context} is not a status of the mesh's own`);
// The merge check's statuses are its verdict's, set when the controller says it, and a branch may require
// them: never set by hand, so nothing passes — or blocks — a merge past the check (novox/hq issue 293).
if (mergeCheckContexts.has(context)) throw new Error(`${context} is the merge check's status, set by its verdict only`);
if (!states.has(state)) throw new Error(`${state} is not a status the forge keeps`);
let d = String(description ?? "").replace(/\s+/g, " ").trim();
if (d.length > 140) d = d.slice(0, 139) + "…";
return { state: state as "pending" | "success" | "error" | "failure" | "warning", context, description: d,
...(target && /^https?:\/\//.test(target) ? { target_url: target } : {}) };
}
+209 -10
View File
@@ -3,18 +3,32 @@
// //
// Emits (novox/hq ADR 0041/0042): // Emits (novox/hq ADR 0041/0042):
// module.gitea.repo.created — a repository appeared, however it was made (push, web UI, or tool) // module.gitea.repo.created — a repository appeared, however it was made (push, web UI, or tool)
// module.gitea.pull.merged — a pull request was merged, however it was merged (web UI, API, or tool)
// module.gitea.pull.updated — an open pull request's head moved, opened or pushed to: the mesh checks it
// before it merges (novox/hq to-be 45 §9)
// //
// issue.opened and pull.merged are emitted from the tools (tools/index.ts), at the instant the mesh // module.gitea.pull.closed — a pull request closed unmerged: the delivery it was is stopped (novox/hq ADR 0239)
// takes that action — the natural point, and one process only. repo.created belongs here instead: //
// a repository is usually born from a `git push` or the web UI, which no tool sees, so polling the // Consumes mesh-controller.checked — a pull request's merge check, judged — and sets it as the head
// repo list is the only way to catch every path — and keeping it out of the create-repo tool means // commit's statuses: `mesh/merge-gate`, the modules of the mesh's graph the change touches, and
// the fact is never announced twice from two processes. // `mesh/repo-check`, the repository's own merge-check.sh (novox/hq ADR 0237 as amended); with a comment
// saying why when the gate is not a pass or the repository's own check failed.
//
// Every pull request the forge holds is announced, whatever its repository: the controller holds the
// module graph and decides what is checked — a repository is never asked to opt in.
//
// issue.opened is emitted from its tool (tools/index.ts). repo.created and pull.merged belong here: a
// repository or a merge is as often made by the web UI or a plain API call, which no tool sees, so
// polling is the only way to catch every path — and the only emitter, so a fact is never announced
// twice. The merge tool announced too until novox/hq issue 250, and every merge it made was heard twice.
// //
// The polling is deliberately unhurried: an event a minute late is still an event, whereas hammering // The polling is deliberately unhurried: an event a minute late is still an event, whereas hammering
// the forge for an immediacy nobody asked for is not. // the forge for an immediacy nobody asked for is not.
import { emit } from "@novox/mesh-sdk/events"; import { emit, on } from "@novox/mesh-sdk/events";
import { GiteaClient } from "./client.js"; import { GiteaClient, movedSince } from "./client.js";
import { CHECK_CONTEXT, REPO_CHECK_CONTEXT, commentFor, headsToAnnounce, statusesFor, type Announced, type Checked,
type RepoCheckFacts } from "./pulls.js";
// Without a way to a token — configured, or mintable with the admin account (token.ts) — there is // Without a way to a token — configured, or mintable with the admin account (token.ts) — there is
// nothing to watch; log and stay quiet rather than crash the runtime. With one, the first poll mints // nothing to watch; log and stay quiet rather than crash the runtime. With one, the first poll mints
@@ -84,12 +98,32 @@ function keepAnnounced(): void {
writeFileSync(tmp, JSON.stringify({ announced: [...announced].slice(-2000), since })); writeFileSync(tmp, JSON.stringify({ announced: [...announced].slice(-2000), since }));
renameSync(tmp, mergedRecord); renameSync(tmp, mergedRecord);
} }
// **Only the repositories that moved** (novox/hq issue 250). A merge is a push, and a push moves the
// repository's update time; asking every repository for its pull requests on every tick took longer than the
// tick itself, so ticks piled up and a merge was announced minutes late. A repository unchanged since a
// minute before the last look is skipped — the minute absorbs the forge's clock against this one.
let lastLook = "";
const MARGIN_MS = 60_000;
async function pollMerged(client: GiteaClient): Promise<void> { async function pollMerged(client: GiteaClient): Promise<void> {
const repos = await client.listAllRepos(); const began = new Date().toISOString();
const floor = lastLook && primedMerges ? new Date(Date.parse(lastLook) - MARGIN_MS).toISOString() : "";
const repos = movedSince(await client.listAllRepos(), floor);
let changed = false; let changed = false;
for (const repo of repos) { for (const repo of repos) {
const pulls = await client.listPullRequests(repo.owner, repo.name, { state: "closed", sort: "recentupdate", limit: "20" }); const pulls = await client.listPullRequests(repo.owner, repo.name, { state: "closed", sort: "recentupdate", limit: "20" });
for (const pull of pulls) { for (const pull of pulls) {
// Closed unmerged (novox/hq ADR 0239): announced once, since the watching began, so its delivery stops.
const closedKey = `closed:${repo.full_name}#${pull.number}`;
if (!pull.merged && pull.state === "closed" && !announced.has(closedKey)) {
if (primedMerges && !!pull.updated_at && !!since && pull.updated_at > since) {
await emit("pull.closed", { owner: repo.owner, repo: repo.name, number: pull.number, title: pull.title,
head: pull.head, head_sha: pull.head_sha, base: pull.base, html_url: pull.html_url });
console.log(`[gitea] announced ${repo.full_name}#${pull.number} closed unmerged`);
}
announced.add(closedKey);
changed = true;
continue;
}
if (!pull.merged || !pull.merge_commit_sha || announced.has(pull.merge_commit_sha)) continue; if (!pull.merged || !pull.merge_commit_sha || announced.has(pull.merge_commit_sha)) continue;
// Announced only if merged since the watching began; recorded either way, so it is looked // Announced only if merged since the watching began; recorded either way, so it is looked
// at once. // at once.
@@ -99,11 +133,36 @@ async function pollMerged(client: GiteaClient): Promise<void> {
// directory moved, and without this every module built from a repository is rebuilt for a // directory moved, and without this every module built from a repository is rebuilt for a
// change to any of them (novox/hq 04-ISSUES/131). // change to any of them (novox/hq 04-ISSUES/131).
const changed = await client.listPullFiles(repo.owner, repo.name, pull.number); const changed = await client.listPullFiles(repo.owner, repo.name, pull.number);
// Which of the directories they are in hold a module at the merge commit (novox/hq issue 278): a
// change inside one is that module's, held or not, and only a file in none is shared code. Not
// said when the list is cut or the forge could not be asked; the mesh then keeps its old rule.
let moduleDirs: string[] | null = null;
if (!changed.truncated) {
try {
moduleDirs = await client.moduleDirsAt(repo.owner, repo.name, pull.merge_commit_sha, changed.paths);
} catch (err) {
console.error(`[gitea] ${repo.full_name}#${pull.number}: which directories hold a module could not be read, ` +
`so the mesh reads its files by the old rule — ${err instanceof Error ? err.message : String(err)}`);
}
}
// The head it merged, and what its head was checked as (novox/hq ADR 0239): the delivery it was, made
// from the forge's word when its owner never heard the head.
let headChecks: Record<string, string> | null = null;
if (pull.head_sha) {
try {
headChecks = await client.commitStatuses(repo.owner, repo.name, pull.head_sha);
} catch (err) {
console.error(`[gitea] ${repo.full_name}#${pull.number}: its head's statuses could not be read — ${err instanceof Error ? err.message : err}`);
}
}
await emit("pull.merged", { await emit("pull.merged", {
owner: repo.owner, owner: repo.owner,
repo: repo.name, repo: repo.name,
number: pull.number, number: pull.number,
title: pull.title, title: pull.title,
body: pull.body,
head_sha: pull.head_sha,
...(headChecks ? { head_checks: headChecks } : {}),
head: pull.head, head: pull.head,
base: pull.base, base: pull.base,
merge_commit_sha: pull.merge_commit_sha, merge_commit_sha: pull.merge_commit_sha,
@@ -112,6 +171,10 @@ async function pollMerged(client: GiteaClient): Promise<void> {
html_url: pull.html_url, html_url: pull.html_url,
paths: changed.paths, paths: changed.paths,
paths_truncated: changed.truncated, paths_truncated: changed.truncated,
// Which of them the merge deleted (novox/hq ADR 0236): a module whose manifest went is forgotten,
// not built.
removed: changed.removed,
...(moduleDirs ? { module_dirs: moduleDirs, module_dirs_said: true } : {}),
}); });
// Said, because a trigger that fires silently is indistinguishable from one that did not // Said, because a trigger that fires silently is indistinguishable from one that did not
// fire (novox/hq 04-ISSUES/131) — this line is how an operator knows the mesh was told. // fire (novox/hq 04-ISSUES/131) — this line is how an operator knows the mesh was told.
@@ -124,6 +187,137 @@ async function pollMerged(client: GiteaClient): Promise<void> {
if (!primedMerges) since = new Date().toISOString(); if (!primedMerges) since = new Date().toISOString();
if (!primedMerges || changed) keepAnnounced(); if (!primedMerges || changed) keepAnnounced();
primedMerges = true; primedMerges = true;
lastLook = began;
}
// **Every new head of an open pull request is announced, once** (novox/hq to-be 45 §9): the mesh checks it
// against every machine of its facts before it merges. Kept beside the merges' record, so a restart
// announces nothing twice; the first look on a machine with no record announces only what moved in the
// last day, so a forge's whole backlog is not checked at once.
const pullsRecord = process.env.MESH_GITEA_STATE_DIR ? join(process.env.MESH_GITEA_STATE_DIR, "pulls-announced.json") : null;
let heads: Announced = {};
let primedPulls = false;
if (pullsRecord && existsSync(pullsRecord)) {
try {
heads = (JSON.parse(readFileSync(pullsRecord, "utf8")) as { heads: Announced }).heads ?? {};
primedPulls = true;
} catch {
// An unreadable record is no record: the first look announces only the last day's.
}
}
function keepHeads(): void {
if (!pullsRecord) return;
mkdirSync(join(pullsRecord, ".."), { recursive: true });
const tmp = pullsRecord + ".tmp";
writeFileSync(tmp, JSON.stringify({ heads }));
renameSync(tmp, pullsRecord);
}
let lastPullLook = "";
async function pollPulls(client: GiteaClient): Promise<void> {
const began = new Date().toISOString();
const floor = lastPullLook ? new Date(Date.parse(lastPullLook) - MARGIN_MS).toISOString() : "";
const dayAgo = new Date(Date.now() - 24 * 3600_000).toISOString();
let changed = false;
for (const repo of movedSince(await client.listAllRepos(), floor)) {
const open = await client.listPullRequests(repo.owner, repo.name, { state: "open", sort: "recentupdate", limit: "20" });
for (const pull of headsToAnnounce(repo.full_name, open, heads)) {
const key = `${repo.full_name}#${pull.number}`;
const fresh = primedPulls || (!!pull.updated_at && pull.updated_at > dayAgo);
if (fresh) {
const files = await client.listPullFiles(repo.owner, repo.name, pull.number);
const head = String(pull.head_sha);
// What the controller maps the change onto the mesh's module graph with (novox/hq ADR 0237 as
// amended): which changed directories hold a module at the head — the merge's rule (issue 278) —
// and whether the head holds the repository's own merge-check.sh. Not said when it could not be
// read; the controller then reads the change as touching everything built from the repository.
let moduleDirs: string[] | null = null;
let mergeCheck: boolean | null = null;
try {
if (!files.truncated) moduleDirs = await client.moduleDirsAt(repo.owner, repo.name, head, files.paths);
mergeCheck = await client.holdsFile(repo.owner, repo.name, head, "merge-check.sh");
} catch (err) {
console.error(`[gitea] ${key}: what the head holds could not be read — ${err instanceof Error ? err.message : err}`);
}
await emit("pull.updated", {
owner: repo.owner,
repo: repo.name,
number: pull.number,
title: pull.title,
body: pull.body,
base: pull.base,
head: pull.head,
head_sha: pull.head_sha,
clone_url: repo.clone_url,
html_url: pull.html_url,
paths: files.paths,
paths_truncated: files.truncated,
removed: files.removed,
...(moduleDirs ? { module_dirs: moduleDirs, module_dirs_said: true } : {}),
...(mergeCheck !== null ? { merge_check: mergeCheck, merge_check_said: true } : {}),
});
// Said, so an operator knows the mesh was asked to check it.
console.log(`[gitea] announced ${key} at ${String(pull.head_sha).slice(0, 8)} to be checked before it merges`);
// Pending until the verdict comes, so the pull request says a check is running rather than nothing.
await client
.setCommitStatus(repo.owner, repo.name, String(pull.head_sha), {
state: "pending", context: CHECK_CONTEXT, description: "the mesh is mapping this head onto its module graph",
})
.catch((err) => console.error(`[gitea] ${key}: could not say a check is pending — ${err instanceof Error ? err.message : err}`));
}
heads[key] = String(pull.head_sha);
changed = true;
}
}
if (!primedPulls || changed) keepHeads();
primedPulls = true;
lastPullLook = began;
}
// **The verdict, set where the pull request shows it.** Every verdict is the head commit's status; one
// that is not a pass also leaves the check's own account as a comment, so the reason is read where the
// change is reviewed. An error — the check could not run — is the forge's `error`, never a success.
async function setVerdict(client: GiteaClient, event: { body: unknown }): Promise<void> {
const c = (event.body ?? {}) as Checked;
if (c.group) {
// A delivery group's composed check (novox/hq ADR 0239): its verdict is the group owner's to say, on each
// member's head, as mesh/delivery-group — never this head's merge gate.
return;
}
if (!c.owner || !c.repo || !c.commit || !c.verdict) {
console.error("[gitea] a merge check's verdict named no repository, commit or verdict; ignored");
return;
}
// Each status links to the pull request, where the delivery's view is kept (novox/hq ADR 0239).
let target: string | undefined;
let base: string | undefined;
if (c.number) {
const pull = await client.getPullRequest(c.owner, c.repo, c.number).catch(() => undefined);
target = pull?.html_url || undefined;
base = pull?.base || undefined;
}
const facts = await repoCheckFacts(client, c, base ?? c.plan?.base);
for (const status of statusesFor(c, facts)) {
await client.setCommitStatus(c.owner, c.repo, c.commit, target ? { ...status, target_url: target } : status);
}
const comment = commentFor(c, facts);
if (comment && c.number) await client.addComment(c.owner, c.repo, c.number, comment);
console.log(`[gitea] ${c.owner}/${c.repo}#${c.number ?? "?"} at ${c.commit.slice(0, 8)}: merge check ${c.verdict}`);
}
// **What only the forge knows of a repository check said as a warning** (novox/hq issue 293): whether the
// head holds a merge-check.sh at all, and — when it does not — whether the base branch's protection
// requires mesh/repo-check. Asked only for a warning; unknown is left undefined, which statusesFor reads
// as possibly required: a failure on a status nothing requires blocks nothing, a success on one that is
// required would let an untested repository merge.
async function repoCheckFacts(client: GiteaClient, c: Checked, base: string | undefined): Promise<RepoCheckFacts> {
if (c["repo-check"]?.verdict !== "warning") return {};
const defined = await client.holdsFile(c.owner, c.repo, c.commit, "merge-check.sh").catch(() => undefined);
if (defined !== false) return { defined };
const rules = await client.branchProtections(c.owner, c.repo).catch(() => undefined);
if (!rules) return { defined };
const rule = rules.find((p) => (p.rule_name ?? p.branch_name) === (base || "main"));
const required = !!rule?.enable_status_check && (rule.status_check_contexts ?? []).includes(REPO_CHECK_CONTEXT);
return { defined, required };
} }
if (gitea) { if (gitea) {
@@ -132,6 +326,9 @@ if (gitea) {
// yet, the admin account refused on a restored forge) is one fact, and a recovery is worth a line. // yet, the admin account refused on a restored forge) is one fact, and a recovery is worth a line.
let failing: string | null = null; let failing: string | null = null;
const tick = (fn: () => Promise<void>, everyMs: number): void => { const tick = (fn: () => Promise<void>, everyMs: number): void => {
// **One pass at a time** (novox/hq issue 250): the next pass is scheduled when this one has ended, so a
// pass that outlasts its interval delays the next instead of running beside it — two passes at once
// could each announce the same merge before either recorded it.
const run = (): void => const run = (): void =>
void fn() void fn()
.then(() => { .then(() => {
@@ -142,11 +339,13 @@ if (gitea) {
const why = err instanceof Error ? err.message : String(err); const why = err instanceof Error ? err.message : String(err);
if (why !== failing) console.error(`[gitea] not watching until this clears — ${why}`); if (why !== failing) console.error(`[gitea] not watching until this clears — ${why}`);
failing = why; failing = why;
}); })
setInterval(run, everyMs); .finally(() => setTimeout(run, everyMs));
run(); run();
}; };
tick(() => pollRepos(client), 60_000); tick(() => pollRepos(client), 60_000);
tick(() => pollMerged(client), 30_000); tick(() => pollMerged(client), 30_000);
tick(() => pollPulls(client), 30_000);
await on("mesh-controller.checked", (event) => setVerdict(client, event));
console.log("[gitea] watching for new repositories and merged pull requests"); console.log("[gitea] watching for new repositories and merged pull requests");
} }
+28 -2
View File
@@ -40,7 +40,12 @@
"emits": [ "emits": [
"repo.created", "repo.created",
"issue.opened", "issue.opened",
"pull.merged" "pull.merged",
"pull.updated",
"pull.closed"
],
"consumes": [
"mesh-controller.checked"
], ],
"listens": [ "listens": [
{ {
@@ -85,6 +90,23 @@
"scope": "mesh" "scope": "mesh"
} }
], ],
"data": {
"own": [
{
"id": "data",
"path": "${dir:data}",
"class": "valuable",
"why": "every repository, its issues and attachments, and the package registry"
}
],
"consumers": {
"npm-package-registry": {
"class": "rebuildable",
"in": "data",
"why": "a consumer's packages are published again from its source"
}
}
},
"resources": [ "resources": [
{ {
"id": "mesh-state", "id": "mesh-state",
@@ -182,7 +204,11 @@
"provides": [ "provides": [
{ {
"name": "npm-package-registry", "name": "npm-package-registry",
"scope": "mesh" "scope": "mesh",
"identity": {
"max": 40,
"in": "a Gitea user name"
}
}, },
{ {
"name": "git", "name": "git",
+37
View File
@@ -0,0 +1,37 @@
// A branch's protection (novox/hq ADR 0237): what makes a pull request wait for the mesh's merge check —
// `mesh/merge-gate`, the module graph's gate, and `mesh/repo-check`, the repository's own tests — before it
// may merge. Pure, so it is tested without a forge; the client does the asking (client.ts).
/** A branch protection rule, as the forge keeps it — the fields the mesh reads; the forge sends more. */
export interface BranchProtection {
rule_name?: string;
branch_name?: string;
enable_push?: boolean;
enable_status_check?: boolean;
status_check_contexts?: string[] | null;
required_approvals?: number;
block_admin_merge_override?: boolean;
[field: string]: unknown;
}
/** What a branch's protection is set to. A field not given is left as the rule has it (or the forge's
* default on a new rule), except pushes, which a new rule refuses unless `push` says otherwise. */
export interface ProtectionWanted {
/** The statuses a pull request must have succeeded before it merges, e.g. mesh/merge-gate. Empty: none. */
statusChecks: string[];
/** Whether a person may push to the branch directly. */
push?: boolean;
/** Whether an administrator is stopped from merging past a status that has not succeeded. */
blockAdminOverride?: boolean;
}
/** The forge's body for a wanted protection. */
export function protectionBody(want: ProtectionWanted, creating: boolean): Record<string, unknown> {
const checks = [...new Set(want.statusChecks.map((c) => c.trim()).filter(Boolean))];
const body: Record<string, unknown> = { enable_status_check: checks.length > 0, status_check_contexts: checks };
if (want.push !== undefined) body.enable_push = want.push;
else if (creating) body.enable_push = false;
if (want.blockAdminOverride !== undefined) body.block_admin_merge_override = want.blockAdminOverride;
return body;
}
+203
View File
@@ -0,0 +1,203 @@
// A pull request's merge check (novox/hq to-be 45 §9): what the forge's announcer says when a pull
// request's head moves, and what it sets as the pull request's status when the mesh says the verdict.
//
// **Before merge, never after.** Every check the mesh had ran after a merge, on a machine: a manifest the
// node-engine refuses (issue 236), an identity a real machine's name made too long (263). So each new head
// of an open pull request is announced as `pull.updated`; the controller asks the build seat to check it
// against every machine of the mesh's facts; and the verdict comes back as the controller's `checked`,
// which this sets as the head commit's status — and, when it is not a pass, as a comment saying why.
//
// Pure functions here, so they are tested without a forge; index.ts does the asking and the setting.
import type { CommitStatus, GiteaPull } from "./client.js";
/** The status context a merge check's gate is kept under: one per commit, the newest replacing the last. */
export const CHECK_CONTEXT = "mesh/merge-gate";
/** The status context of the repository's own merge-check.sh, the check's second layer (novox/hq ADR 0237). */
export const REPO_CHECK_CONTEXT = "mesh/repo-check";
/** One layer of a check, judged. */
export interface Layer {
verdict: string;
summary: string;
/** The modules a merge of the change would move, as the controller's planner reckons it… */
modules?: string[];
/** …and those it would build after them because they stand on them. */
dependents?: string[];
}
/** What the controller says as `checked` (mesh-controller internal/link, Checked). */
export interface Checked {
owner: string;
repo: string;
number?: number;
commit: string;
verdict: string;
summary: string;
report?: string;
id: string;
on?: string;
/** The gate — the modules of the mesh's graph the change touches — and the repository's own check. A
* controller from before the layers says neither, and its verdict is the gate's. */
gate?: Layer;
"repo-check"?: Layer;
/** The change plan of the commit checked (novox/hq ADR 0238): what a merge of it would build and send. */
plan?: ChangePlan;
/** Set on a delivery group's composed check (novox/hq ADR 0239): not this head's merge gate. */
group?: string;
}
/** A change plan, as the controller says it (mesh-controller internal/link, ChangePlan). */
export interface ChangePlan {
repository: string;
base: string;
head: string;
moved?: string[];
dependents?: string[];
new?: string[];
unread?: string[];
tiers?: string[][];
machines?: { machine: string; receives?: string[]; waits?: string[] }[];
steps?: string[];
summary: string;
}
/** Whether a plan builds anything: a change that touches the mesh's graph. */
function builds(plan?: ChangePlan): boolean {
return !!plan && ((plan.moved?.length ?? 0) > 0 || (plan.new?.length ?? 0) > 0);
}
/** A change plan as a person reads it on the pull request. */
export function planText(plan: ChangePlan): string {
const lines = [`**Change plan** — ${plan.summary}`];
(plan.tiers ?? []).forEach((tier, i) => lines.push(`- tier ${i}: ${tier.join(", ")}`));
for (const m of plan.machines ?? []) {
const parts: string[] = [];
if (m.receives?.length) parts.push(`receives ${m.receives.join(", ")}`);
if (m.waits?.length) parts.push(`waits for a person: ${m.waits.join(", ")}`);
lines.push(`- ${m.machine}: ${parts.join("; ")}`);
}
for (const step of plan.steps ?? []) lines.push(`- ${step}`);
if (plan.unread?.length) lines.push(`- read by no module's build: ${plan.unread.join(", ")}`);
return lines.join("\n");
}
/** The heads already announced, keyed `owner/repo#number`, so a restart announces nothing twice. */
export type Announced = Record<string, string>;
/** Which open pull requests have a head not yet announced. A pull request merged or closed is not
* open and is never asked about. */
export function headsToAnnounce(full: string, pulls: GiteaPull[], announced: Announced): GiteaPull[] {
return pulls.filter((p) => p.state === "open" && !!p.head_sha && announced[`${full}#${p.number}`] !== p.head_sha);
}
// **A required status is success or it blocks** (novox/hq issue 293). The forge combines `warning` as a
// failure, and a branch whose protection requires mesh/merge-gate or mesh/repo-check — with no
// administrator override — will not merge past one. So the controller's `warning`, a note and never a
// question, is set as `success` with the note in its description; only what a person must decide is a
// `failure`, with why. The mapping, verdict by verdict:
//
// pass → success
// warning (a note: rebuild width, a problem
// already so on the base, a script's own) → success, "pass, with a note: …"
// repo-check, no merge-check.sh, not required → success, "no repository check defined"
// repo-check, no merge-check.sh, required (or
// the protection unreadable) → failure: a person adds one, or lifts the requirement
// fail → failure
// error, or no verdict → error — never a success
/** The forge's state for a verdict: an error is the forge's `error`, never a success; a warning is a note. */
function stateOf(verdict: string): CommitStatus["state"] {
return verdict === "pass" || verdict === "warning" ? "success" : verdict === "fail" ? "failure" : "error";
}
/** How a verdict is said in a status's description: a warning as the pass with a note it is. */
function saidAs(verdict: string): string {
return verdict === "warning" ? "pass, with a note" : verdict || "error";
}
function described(verdict: string, summary: string, modules?: string[], dependents?: string[]): string {
// The forge keeps a short description; the rest is the comment's.
let d = `${saidAs(verdict)}: ${summary}`;
if (modules?.length) d += ` [${modules.join(", ")}${dependents?.length ? ` +${dependents.length} dependent(s)` : ""}]`;
return clipped(d);
}
function clipped(d: string): string {
d = d.replace(/\s+/g, " ").trim();
return d.length > 140 ? d.slice(0, 139) + "…" : d;
}
/** What the forge says of a repository's own check beyond the controller's verdict, asked by index.ts only
* when the verdict is a warning: whether the head holds a merge-check.sh, and whether the base branch's
* protection requires mesh/repo-check. Unknown is undefined. */
export interface RepoCheckFacts {
defined?: boolean;
required?: boolean;
}
/** The description of a required repository check that is not defined. */
export const UNDEFINED_REQUIRED = "fail: mesh/repo-check is required here and this head defines no merge-check.sh — " +
"a person adds one, or lifts the requirement from the branch's protection";
/** The repository check's status: its verdict, unless the repository defines no check at all — then
* success where the check is not required, and a failure for a person where it is (or may be). */
function repoStatus(repo: Layer, facts: RepoCheckFacts): CommitStatus {
if (repo.verdict === "warning" && facts.defined === false) {
return facts.required === false
? { state: "success", context: REPO_CHECK_CONTEXT, description: "no repository check defined" }
: { state: "failure", context: REPO_CHECK_CONTEXT, description: clipped(UNDEFINED_REQUIRED) };
}
return { state: stateOf(repo.verdict), context: REPO_CHECK_CONTEXT, description: described(repo.verdict, repo.summary) };
}
/** Whether the repository check is a failure a person must read: failed, could not run, or required and
* not defined. */
function repoWrong(repo: Layer | undefined, facts: RepoCheckFacts): boolean {
return !!repo && repoStatus(repo, facts).state !== "success";
}
/** The forge's status for the gate. */
export function statusFor(c: Checked): CommitStatus {
const gate = c.gate ?? { verdict: c.verdict, summary: c.summary };
// With a plan, the status says what the change does and how it was judged: "pass: builds gitea → anchor;
// no bus step; every machine composes…".
const description = builds(c.plan)
? described(gate.verdict, `${c.plan!.summary}; ${gate.summary}`)
: described(gate.verdict, gate.summary, gate.modules, gate.dependents);
return { state: stateOf(gate.verdict), context: CHECK_CONTEXT, description };
}
/** Every status a verdict sets: the gate's, and the repository's own check's when it was said. */
export function statusesFor(c: Checked, facts: RepoCheckFacts = {}): CommitStatus[] {
const out = [statusFor(c)];
const repo = c["repo-check"];
if (repo) out.push(repoStatus(repo, facts));
return out;
}
/** The comment a verdict leaves on its pull request, with the check's own account: the change plan of a
* change that builds something (novox/hq ADR 0238), and why, when the gate is not a pass or the
* repository's own check failed or could not run. A repository with no merge-check.sh, touching nothing,
* is said by its statuses alone, not by a comment on every push. */
export function commentFor(c: Checked, facts: RepoCheckFacts = {}): string | null {
const gate = c.gate ?? { verdict: c.verdict, summary: c.summary };
const repo = c["repo-check"];
const wrong = repoWrong(repo, facts);
if (gate.verdict === "pass" && !wrong && !builds(c.plan)) return null;
const lines = [`**Merge check** at \`${c.commit.slice(0, 8)}\``, ""];
lines.push(`- \`${CHECK_CONTEXT}\`: **${saidAs(gate.verdict).toUpperCase()}** — ${gate.summary}` +
(gate.modules?.length ? ` (modules: ${gate.modules.join(", ")}` +
(gate.dependents?.length ? `; built after them: ${gate.dependents.join(", ")}` : "") + ")" : ""));
if (repo) {
const st = repoStatus(repo, facts);
lines.push(`- \`${REPO_CHECK_CONTEXT}\`: ` + (repo.verdict === "warning" && facts.defined === false
? `**${st.state === "success" ? "PASS" : "FAIL"}** — ${st.description.replace(/^fail: /, "")}`
: `**${saidAs(repo.verdict).toUpperCase()}** — ${repo.summary}`));
}
if (builds(c.plan)) lines.push("", planText(c.plan!));
const ran = c.on ? `\n\nRun by the build seat on ${c.on} as \`${c.id}\` (\`builds --log ${c.id}\`).` : "";
const report = c.report ? `\n\n\`\`\`\n${c.report.replace(/```/g, "'''")}\n\`\`\`` : "";
return lines.join("\n") + ran + report;
}
+59
View File
@@ -0,0 +1,59 @@
import assert from "node:assert/strict";
import { test } from "node:test";
// What the forge's holder does for a delivery (novox/hq ADR 0239): a note appended once, in the forge's own
// repository as its own user; one view per pull request; only the mesh's statuses.
test("a note line is appended once, as the forge's user, in its own repository", async () => {
const { appendNote, noteArgs } = await import("../delivery.ts");
const notes: Record<string, string[]> = {};
const calls: string[][] = [];
const runner = async (file: string, args: string[]) => {
calls.push([file, ...args]);
const commit = args[args.length - 1];
if (args.includes("show")) {
const lines = notes[commit];
return lines ? { stdout: lines.join("\n") + "\n", code: 0 } : { stdout: "", code: 1 };
}
const line = args[args.indexOf("-m") + 1];
(notes[commit] ??= []).push(line);
return { stdout: "", code: 0 };
};
const sha = "0123456789abcdef0123456789abcdef01234567";
assert.deepEqual(await appendNote(runner, "gitea", "Novox", "Mesh-Catalog", sha, "mesh-plan", "a -> b\n(merged)"),
{ added: true, lines: 1 });
assert.deepEqual(await appendNote(runner, "gitea", "Novox", "Mesh-Catalog", sha, "mesh-plan", "a -> b (merged)"),
{ added: false, lines: 1 }, "the same line twice adds nothing");
assert.deepEqual(await appendNote(runner, "gitea", "Novox", "Mesh-Catalog", sha, "mesh-plan", "b -> c"),
{ added: true, lines: 2 });
const append = calls.find((c) => c.includes("append"))!;
assert.deepEqual(append.slice(0, 7), ["docker", "exec", "-u", "git", "gitea", "git", "--git-dir"]);
assert.equal(append[7], "/data/git/repositories/novox/mesh-catalog.git");
assert.ok(append.includes("--ref=mesh-plan"));
assert.throws(() => noteArgs("gitea", "novox", "x; rm -rf /", sha, "mesh-plan"), /not a repository/);
assert.throws(() => noteArgs("gitea", "novox", "x", "HEAD", "mesh-plan"), /not a commit/);
assert.throws(() => noteArgs("gitea", "novox", "x", sha, "../commits"), /not a notes ref/);
});
test("a note that cannot be written is said, never read as written", async () => {
const { appendNote } = await import("../delivery.ts");
const runner = async (_file: string, args: string[]) => ({ stdout: "", code: args.includes("append") ? 128 : 1 });
await assert.rejects(appendNote(runner, "gitea", "novox", "x", "abcdef1234567", "mesh-plan", "l"), /could not be appended/);
});
test("one view per pull request, found by its marker; only the mesh's statuses", async () => {
const { viewBody, viewComment, deliveryStatus, VIEW_MARKER } = await import("../delivery.ts");
assert.ok(viewBody("**Delivery**").startsWith(VIEW_MARKER));
assert.equal(viewBody(`${VIEW_MARKER}\nx`), `${VIEW_MARKER}\nx`, "a marked body is kept as it is");
const comments = [{ id: 1, body: "a review" }, { id: 2, body: `${VIEW_MARKER}\nold` }, { id: 3, body: `${VIEW_MARKER}\nlater` }];
assert.equal(viewComment(comments)?.id, 2);
assert.equal(viewComment([{ id: 1, body: "x" }]), undefined);
const s = deliveryStatus("mesh/delivery", "pending", "y".repeat(300), "https://forge.invalid/novox/x/pulls/1");
assert.ok(s.description.length <= 140 && s.target_url);
assert.throws(() => deliveryStatus("ci/other", "success", "x"), /mesh's own/);
assert.throws(() => deliveryStatus("mesh/delivery", "green", "x"), /not a status/);
// The merge check's statuses are its verdict's alone: required by a branch, they are never set by hand.
assert.throws(() => deliveryStatus("mesh/merge-gate", "success", "x"), /merge check's status/);
assert.throws(() => deliveryStatus("mesh/repo-check", "warning", "x"), /merge check's status/);
assert.equal(deliveryStatus("mesh/delivery", "success", "x", "javascript:alert(1)").target_url, undefined);
});
+73
View File
@@ -0,0 +1,73 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { createServer } from "node:http";
import { GiteaClient } from "../client.ts";
test("every page of a pull request's files is read, though the forge caps a page at fifty", async () => {
const total = 59;
const server = createServer((req, res) => {
const url = new URL(req.url ?? "", "http://x");
const page = Number(url.searchParams.get("page") ?? "1");
const start = (page - 1) * 50;
const files = Array.from({ length: Math.max(0, Math.min(50, total - start)) }, (_, i) => ({
filename: `modules/m${start + i}/x`, status: start + i === 3 ? "deleted" : "changed" }));
res.setHeader("content-type", "application/json");
res.end(JSON.stringify(files));
});
await new Promise<void>((r) => server.listen(0, r));
const port = (server.address() as any).port;
const client = new GiteaClient(`http://127.0.0.1:${port}`, "t");
const got = await client.listPullFiles("novox", "mesh-catalog", 60);
server.close();
assert.equal(got.paths.length, total);
assert.equal(got.truncated, false);
assert.equal(new Set(got.paths).size, total);
assert.deepEqual(got.removed, ["modules/m3/x"], "a file the merge deleted is said as deleted");
});
test("a pass asks only the repositories that moved since the last look, every one before the first", async () => {
const { movedSince } = await import("../client.ts");
const repos = [
{ full_name: "a/old", name: "old", owner: "a", private: false, html_url: "", updated_at: "2026-10-05T10:00:00Z" },
{ full_name: "a/new", name: "new", owner: "a", private: false, html_url: "", updated_at: "2026-10-05T16:10:02Z" },
{ full_name: "a/unknown", name: "unknown", owner: "a", private: false, html_url: "" },
];
assert.deepEqual(movedSince(repos, "").map((r) => r.name), ["old", "new", "unknown"]);
assert.deepEqual(movedSince(repos, "2026-10-05T16:09:00.000Z").map((r) => r.name), ["new", "unknown"]);
assert.deepEqual(movedSince(repos, "2026-10-05T18:10:02+02:00").map((r) => r.name), ["new", "unknown"], "an offset is a moment, not a string");
});
test("the directories above a merge's files that hold a module at the commit are said, the root never", async () => {
const { directoriesAbove } = await import("../client.ts");
assert.deepEqual(directoriesAbove(["modules/showcase/index.ts", "modules/showcase/daemon/x.ts", "README.md", "/modules/lib/a.go"]),
["modules", "modules/lib", "modules/showcase", "modules/showcase/daemon"]);
const asked: string[] = [];
const server = createServer((req, res) => {
const url = new URL(req.url ?? "", "http://x");
asked.push(`${url.pathname}@${url.searchParams.get("ref")}`);
const held = ["/api/v1/repos/novox/mesh-catalog/contents/modules/showcase/module.json"];
res.statusCode = held.includes(url.pathname) ? 200 : 404;
res.end(res.statusCode === 200 ? "{}" : '{"message":"not found"}');
});
await new Promise<void>((r) => server.listen(0, r));
const port = (server.address() as any).port;
const client = new GiteaClient(`http://127.0.0.1:${port}`, "t");
const got = await client.moduleDirsAt("novox", "mesh-catalog", "abc", ["modules/showcase/index.ts", "modules/lib/x.go"]);
const capped = await client.moduleDirsAt("novox", "mesh-catalog", "abc", ["a/b/c/d.ts"], 2);
server.close();
assert.deepEqual(got, ["modules/showcase"], "a directory holding a manifest is a module; one holding none is not");
assert.ok(asked.every((a) => a.endsWith("@abc")), "looked up at the merge commit");
assert.equal(capped, null, "past the bound it is not said, and the mesh keeps its old rule");
});
test("a lookup the forge refuses is thrown, not read as no module", async () => {
const server = createServer((_req, res) => {
res.statusCode = 500;
res.end("down");
});
await new Promise<void>((r) => server.listen(0, r));
const port = (server.address() as any).port;
const client = new GiteaClient(`http://127.0.0.1:${port}`, "t");
await assert.rejects(client.moduleDirsAt("novox", "mesh-catalog", "abc", ["modules/x/y.ts"]));
server.close();
});
+21
View File
@@ -0,0 +1,21 @@
import assert from "node:assert/strict";
import { test } from "node:test";
// A branch's protection (novox/hq ADR 0237): what the operator's agent sets so a pull request waits for the
// mesh's merge check before it merges.
test("the statuses asked for replace the rule's, and an empty list requires none", async () => {
const { protectionBody } = await import("../protection.ts");
assert.deepEqual(protectionBody({ statusChecks: ["mesh/merge-gate", " mesh/repo-check", "", "mesh/merge-gate"] }, false),
{ enable_status_check: true, status_check_contexts: ["mesh/merge-gate", "mesh/repo-check"] },
"an edited rule keeps its pushes as they are, and each status once");
assert.deepEqual(protectionBody({ statusChecks: [] }, false), { enable_status_check: false, status_check_contexts: [] });
});
test("a new rule refuses direct pushes unless asked; an administrator's override only when said", async () => {
const { protectionBody } = await import("../protection.ts");
assert.equal(protectionBody({ statusChecks: ["mesh/merge-gate"] }, true).enable_push, false);
assert.equal(protectionBody({ statusChecks: ["mesh/merge-gate"], push: true }, true).enable_push, true);
assert.equal(protectionBody({ statusChecks: ["mesh/merge-gate"] }, true).block_admin_merge_override, undefined);
assert.equal(protectionBody({ statusChecks: ["mesh/merge-gate"], blockAdminOverride: true }, false).block_admin_merge_override, true);
});
+145
View File
@@ -0,0 +1,145 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { createServer } from "node:http";
// A pull request's merge check (novox/hq to-be 45 §9): each new head announced once, and the verdict set
// where the pull request shows it — an error never as a success.
test("each open pull request's new head is announced once, and nothing closed or merged", async () => {
const { headsToAnnounce } = await import("../pulls.ts");
const pulls = [
{ number: 1, title: "a", state: "open", merged: false, head_sha: "aaa", html_url: "" },
{ number: 2, title: "b", state: "open", merged: false, head_sha: "bbb", html_url: "" },
{ number: 3, title: "c", state: "closed", merged: true, head_sha: "ccc", html_url: "" },
{ number: 4, title: "d", state: "open", merged: false, html_url: "" },
];
const announced = { "novox/mesh-catalog#1": "aaa", "novox/mesh-catalog#2": "old" };
assert.deepEqual(headsToAnnounce("novox/mesh-catalog", pulls, announced).map((p) => p.number), [2],
"only a head not announced, of a pull request that is open");
});
test("a verdict is the head commit's status; an error is the forge's error, never a success", async () => {
const { statusFor, commentFor, CHECK_CONTEXT } = await import("../pulls.ts");
const base = { owner: "novox", repo: "mesh-catalog", number: 7, commit: "0123456789abcdef", id: "build-1", on: "laptop" };
assert.equal(statusFor({ ...base, verdict: "pass", summary: "every machine composes" }).state, "success");
// A warning is a note, never a blocking state: the forge combines `warning` as a failure (issue 293).
const wide = statusFor({ ...base, verdict: "warning", summary: "a merge rebuilds 14 module(s)" });
assert.equal(wide.state, "success");
assert.match(wide.description, /^pass, with a note: a merge rebuilds 14 module/);
assert.equal(statusFor({ ...base, verdict: "fail", summary: "x" }).state, "failure");
assert.equal(statusFor({ ...base, verdict: "error", summary: "the check could not run" }).state, "error");
assert.equal(statusFor({ ...base, verdict: "", summary: "" }).state, "error", "no verdict is no pass");
const long = statusFor({ ...base, verdict: "fail", summary: "y".repeat(500) });
assert.ok(long.description.length <= 140 && long.context === CHECK_CONTEXT);
assert.equal(commentFor({ ...base, verdict: "pass", summary: "fine" }), null, "a pass leaves no comment");
const said = commentFor({ ...base, verdict: "fail", summary: "lemurs refused", report: "fails:\n - ```x```" }) ?? "";
assert.match(said, /mesh\/merge-gate`: \*\*FAIL\*\*/);
assert.match(said, /01234567/);
assert.match(said, /builds --log build-1/);
assert.ok(!said.slice(said.indexOf("```") + 3, said.lastIndexOf("```")).includes("```"), "the report cannot close its own block");
});
test("each layer is its own status: the gate with the modules it judged, the repository's own check beside it", async () => {
const { statusesFor, commentFor, CHECK_CONTEXT, REPO_CHECK_CONTEXT } = await import("../pulls.ts");
const base = { owner: "novox", repo: "mesh-catalog", number: 7, commit: "0123456789abcdef", id: "build-1" };
const both = statusesFor({ ...base, verdict: "pass", summary: "every machine composes",
gate: { verdict: "pass", summary: "every machine composes", modules: ["gitea", "keycloak"], dependents: ["node-tools"] },
"repo-check": { verdict: "fail", summary: "its merge-check.sh failed: FAIL x" } });
assert.deepEqual(both.map((s) => [s.context, s.state]), [[CHECK_CONTEXT, "success"], [REPO_CHECK_CONTEXT, "failure"]]);
assert.match(both[0].description, /gitea, keycloak \+1 dependent/);
assert.match(commentFor({ ...base, verdict: "pass", summary: "", gate: { verdict: "pass", summary: "" },
"repo-check": { verdict: "fail", summary: "its merge-check.sh failed" } }) ?? "", /mesh\/repo-check`: \*\*FAIL/);
// Nothing of the graph touched, no script: a pass and a warning, and no comment on every push.
const quiet = { ...base, verdict: "pass", summary: "the change touches no module of the mesh's graph",
gate: { verdict: "pass", summary: "the change touches no module of the mesh's graph" },
"repo-check": { verdict: "warning", summary: "the repository declares no merge-check.sh" } };
assert.deepEqual(statusesFor(quiet, { defined: false, required: false }).map((s) => [s.state, s.description]),
[["success", "pass: the change touches no module of the mesh's graph"], ["success", "no repository check defined"]]);
assert.equal(commentFor(quiet, { defined: false, required: false }), null);
// A repository outside the mesh, touching nothing: the gate alone, a pass.
assert.equal(statusesFor({ ...base, verdict: "pass", summary: "x", gate: { verdict: "pass", summary: "x" } }).length, 1);
// A controller from before the layers: its verdict is the gate's.
assert.deepEqual(statusesFor({ ...base, verdict: "warning", summary: "wide" }).map((s) => [s.context, s.state]),
[[CHECK_CONTEXT, "success"]]);
});
// **A required status is success or it blocks** (novox/hq issue 293): branch protection requires
// mesh/merge-gate (and mesh/repo-check on the core repositories) with no administrator override, and the
// forge combines `warning` as a failure. No verdict ever sets `warning` on either; a note is a success that
// says it; only what a person must decide is a failure, with why.
test("no verdict sets warning on a required check; only a person's decision fails", async () => {
const { statusesFor, commentFor, UNDEFINED_REQUIRED, REPO_CHECK_CONTEXT } = await import("../pulls.ts");
const base = { owner: "novox", repo: "photos", number: 3, commit: "0123456789abcdef", id: "build-2" };
const noScript = { verdict: "warning", summary: "the repository declares no merge-check.sh" };
const quiet = { ...base, verdict: "pass", summary: "x", gate: { verdict: "pass", summary: "x" }, "repo-check": noScript };
for (const verdict of ["pass", "warning", "fail", "error", ""]) {
for (const facts of [{}, { defined: true }, { defined: false }, { defined: false, required: true }, { defined: false, required: false }]) {
const c = { ...base, verdict, summary: "s", gate: { verdict, summary: "s" }, "repo-check": { verdict, summary: "s" } };
for (const s of statusesFor(c, facts)) assert.notEqual(s.state, "warning", `${verdict} ${JSON.stringify(facts)} → ${s.context}`);
}
}
// A repository without a merge-check.sh where repo-check is not required: success, said.
assert.deepEqual(statusesFor(quiet, { defined: false, required: false })[1],
{ state: "success", context: REPO_CHECK_CONTEXT, description: "no repository check defined" });
// Where it is required — or the protection could not be read — a person decides: a failure, with why.
for (const facts of [{ defined: false, required: true }, { defined: false }]) {
const s = statusesFor(quiet, facts)[1];
assert.equal(s.state, "failure");
assert.ok(s.description.length <= 140 && UNDEFINED_REQUIRED.startsWith(s.description.replace(/…$/, "")));
assert.match(s.description, /^fail: mesh\/repo-check is required/);
assert.match(commentFor(quiet, facts) ?? "", /mesh\/repo-check`: \*\*FAIL\*\* — mesh\/repo-check is required/);
}
// A script that defines its check and said a warning itself: a note, a success.
const noted = statusesFor({ ...quiet, "repo-check": { verdict: "warning", summary: "2 tests skipped" } }, { defined: true })[1];
assert.deepEqual([noted.state, noted.description], ["success", "pass, with a note: 2 tests skipped"]);
// The gate's notes — a wide rebuild, a problem already so on the base — are successes that say so.
const already = statusesFor({ ...base, verdict: "warning", summary: "s",
gate: { verdict: "warning", summary: "the module check's problems were all so on main already" } })[0];
assert.deepEqual([already.state, already.description],
["success", "pass, with a note: the module check's problems were all so on main already"]);
assert.match(commentFor({ ...base, verdict: "warning", summary: "wide", gate: { verdict: "warning", summary: "wide" } }) ?? "",
/PASS, WITH A NOTE/);
});
test("a commit status is set on the commit, under the merge check's context", async () => {
const { GiteaClient } = await import("../client.ts");
let seen: { path: string; body: any } | null = null;
const server = createServer((req, res) => {
let raw = "";
req.on("data", (c) => (raw += c));
req.on("end", () => {
seen = { path: `${req.method} ${req.url}`, body: JSON.parse(raw) };
res.statusCode = 201;
res.end("{}");
});
});
await new Promise<void>((r) => server.listen(0, r));
const port = (server.address() as any).port;
const client = new GiteaClient(`http://127.0.0.1:${port}`, "t");
await client.setCommitStatus("novox", "mesh-catalog", "abc123", { state: "failure", context: "mesh/merge-gate", description: "x" });
server.close();
assert.equal(seen!.path, "POST /api/v1/repos/novox/mesh-catalog/statuses/abc123");
assert.equal(seen!.body.state, "failure");
assert.equal(seen!.body.context, "mesh/merge-gate");
});
test("a change plan is the gate's result: said on the status and, when it builds something, as a comment", async () => {
const { statusFor, commentFor } = await import("../pulls.ts");
const plan = {
repository: "novox/mesh-catalog", base: "main", head: "0123456789abcdef", moved: ["gitea"],
tiers: [["gitea"]], machines: [{ machine: "anchor", receives: ["gitea"] }], steps: [],
summary: "builds gitea → anchor; no bus step",
};
const c = { owner: "novox", repo: "mesh-catalog", number: 7, commit: "0123456789abcdef", id: "b", verdict: "pass",
summary: "every machine composes", gate: { verdict: "pass", summary: "every machine composes", modules: ["gitea"] }, plan };
assert.equal(statusFor(c).description, "pass: builds gitea → anchor; no bus step; every machine composes");
const said = commentFor(c) ?? "";
assert.match(said, /Change plan\*\* — builds gitea → anchor/);
assert.match(said, /- anchor: receives gitea/);
// A plan that builds nothing, passing: the statuses say it, no comment.
const nothing = { ...c, plan: { ...plan, moved: [], tiers: [], machines: [], summary: "builds nothing" } };
assert.equal(commentFor(nothing), null);
});
+2
View File
@@ -410,6 +410,8 @@ test("the tools register once there is a way to a token, and the first call mint
"gitea_get_file", "gitea_get_file",
"gitea_list_branches", "gitea_list_branches",
"gitea_delete_branch", "gitea_delete_branch",
"gitea_branch_protection_get", "gitea_branch_protection_set",
"gitea_note_append", "gitea_delivery_view", "gitea_commit_status",
"gitea_list_labels", "gitea_create_label", "gitea_list_labels", "gitea_create_label",
"gitea_api", "gitea_api",
], ],
+119 -28
View File
@@ -1,16 +1,31 @@
// gitea's tools — moved here from the shared sdk (novox/hq ADR 0039), importing gitea's own client. // gitea's tools — moved here from the shared sdk (novox/hq ADR 0039), importing gitea's own client.
// They return structured data; the mesh serves them through the sdk's tool harness. // They return structured data; the mesh serves them through the sdk's tool harness.
// //
// Two tools emit an event at the natural point of the action they take (novox/hq ADR 0041/0042): // One tool emits an event at the natural point of the action it takes (novox/hq ADR 0041/0042):
// create-issue emits issue.opened, merge-pull-request emits pull.merged — the mesh's own hand on // create-issue emits issue.opened. pull.merged and repo.created are deliberately NOT emitted here: a
// the forge, announced the instant it moves. repo.created is deliberately NOT emitted here: repos // merge or a repository is as often made in the web UI or by a plain API call as by these tools, so the
// are far more often born from a `git push` or the web UI than from this tool, so the events // events entrypoint (index.ts) owns both by polling, which catches every path. Announcing a merge here
// entrypoint (index.ts) owns that one by polling, which catches every path without this tool and // as well announced every merge made through this tool twice — the tool's at once, the poll's moments
// the poll double-announcing the same repo from two processes. // later (novox/hq issue 250).
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
import { emit } from "@novox/mesh-sdk/events"; import { emit } from "@novox/mesh-sdk/events";
import { GiteaClient } from "../client.js"; import { GiteaClient, type BranchProtection } from "../client.js";
import { appendNote, deliveryStatus, run, viewBody, viewComment } from "../delivery.js";
/** The forge's container, where its repositories and git are: the note is written there (novox/hq ADR 0239). */
const forgeContainer = process.env.MESH_GITEA_CONTAINER || "gitea";
/** A protection rule as a person reads it: what it guards, not every field the forge keeps. */
export function summarised(p: BranchProtection) {
return {
rule: p.rule_name ?? p.branch_name,
push: p.enable_push ?? false,
required_statuses: p.enable_status_check ? (p.status_check_contexts ?? []) : [],
required_approvals: p.required_approvals ?? 0,
admin_may_override: !(p.block_admin_merge_override ?? false),
};
}
/** Coerce a comma-separated label string into names; empty/absent yields none. */ /** Coerce a comma-separated label string into names; empty/absent yields none. */
function parseLabels(raw: unknown): string[] { function parseLabels(raw: unknown): string[] {
@@ -228,29 +243,11 @@ export function getGiteaTools(gitea: GiteaClient): ToolDefinition[] {
const number = Number(args.number); const number = Number(args.number);
const method = args.method ? String(args.method) : "merge"; const method = args.method ? String(args.method) : "merge";
const deleteBranch = args.delete_branch === undefined ? true : Boolean(args.delete_branch); const deleteBranch = args.delete_branch === undefined ? true : Boolean(args.delete_branch);
// Read the PR first, so the merged event carries a title and branches, not just a number.
const pull = await gitea.getPullRequest(owner, repo, number);
await gitea.mergePullRequest(owner, repo, number, method, deleteBranch); await gitea.mergePullRequest(owner, repo, number, method, deleteBranch);
// Read it again: the merge commit only exists now, and it is what a build is made from. // The merge commit only exists now; answered so the caller can follow what is built from it.
// pull.merged is the events entrypoint's to announce (index.ts), once, within its poll.
const merged = await gitea.getPullRequest(owner, repo, number); const merged = await gitea.getPullRequest(owner, repo, number);
// And what it changed, so the mesh rebuilds the modules whose own files moved rather than return { merged: true, number, method, deleted_branch: deleteBranch, merge_commit_sha: merged.merge_commit_sha };
// every module built from the repository (novox/hq 04-ISSUES/131).
const changed = await gitea.listPullFiles(owner, repo, number);
await emit("pull.merged", {
owner,
repo,
number,
title: pull.title,
head: pull.head,
base: pull.base,
merge_commit_sha: merged.merge_commit_sha,
merged_at: merged.merged_at,
method,
html_url: pull.html_url,
paths: changed.paths,
paths_truncated: changed.truncated,
});
return { merged: true, number, method, deleted_branch: deleteBranch };
}, },
}, },
@@ -379,6 +376,100 @@ export function getGiteaTools(gitea: GiteaClient): ToolDefinition[] {
}, },
}, },
// ---- Branch protection (novox/hq ADR 0237) ----
{
name: "gitea_branch_protection_get",
description: "A repository's branch protection: the rule for one branch (null when it has none) or every rule — whether direct pushes are refused, which commit statuses a pull request must have succeeded to merge (e.g. mesh/merge-gate), approvals, and whether an administrator may merge past them.",
input: {
owner: { type: "string", description: "the repository owner" },
repo: { type: "string", description: "the repository name" },
branch: { type: "string", description: "the branch (rule name); every rule when not given" },
},
run: async (args) => {
const owner = String(args.owner), repo = String(args.repo);
if (!args.branch) return { rules: (await gitea.branchProtections(owner, repo)).map(summarised) };
const rule = await gitea.branchProtection(owner, repo, String(args.branch));
return { branch: String(args.branch), rule: rule ? summarised(rule) : null };
},
},
{
name: "gitea_branch_protection_set",
description: "Make a branch require commit statuses before a pull request merges into it — the mesh's merge check sets `mesh/merge-gate` (the module graph's gate) and `mesh/repo-check` (the repository's own tests). Edits the branch's rule (its required statuses replaced by these, everything else kept unless given) or creates one, which refuses direct pushes unless push=true. Answers the rule before and after.",
input: {
owner: { type: "string", description: "the repository owner" },
repo: { type: "string", description: "the repository name" },
branch: { type: "string", description: "the branch to protect, e.g. main" },
status_checks: { type: "string", description: "comma-separated statuses required to merge, e.g. mesh/merge-gate,mesh/repo-check; empty requires none" },
push: { type: "boolean", description: "whether a person may push to the branch directly (a new rule refuses it when not given)" },
block_admin_override: { type: "boolean", description: "stop an administrator merging past a status that has not succeeded (left as it is when not given)" },
},
run: async (args) => {
const owner = String(args.owner), repo = String(args.repo), branch = String(args.branch ?? "").trim();
if (!branch) throw new Error("name the branch to protect");
const before = await gitea.branchProtection(owner, repo, branch);
const { created, rule } = await gitea.setBranchProtection(owner, repo, branch, {
statusChecks: String(args.status_checks ?? "").split(","),
push: args.push === undefined ? undefined : Boolean(args.push),
blockAdminOverride: args.block_admin_override === undefined ? undefined : Boolean(args.block_admin_override),
});
return { branch, created, before: before ? summarised(before) : null, after: summarised(rule) };
},
},
// ---- A delivery's note, view and statuses (novox/hq ADR 0239), asked by mesh-delivery ----
{
name: "gitea_note_append",
description: "Append one line to a commit's git note under refs/notes/<ref> (mesh-plan for a delivery), written in the forge's own repository as the forge's own account; a line the note already holds is not added again. `git log --notes=mesh-plan` shows it.",
input: {
owner: { type: "string", description: "the repository owner" },
repo: { type: "string", description: "the repository name" },
sha: { type: "string", description: "the commit" },
ref: { type: "string", description: "the notes ref's name (default mesh-plan)" },
line: { type: "string", description: "the line, one line" },
},
run: async (args) => {
const said = await appendNote(run, forgeContainer, String(args.owner), String(args.repo), String(args.sha),
String(args.ref ?? "mesh-plan") || "mesh-plan", String(args.line ?? ""));
return { commit: String(args.sha), ...said };
},
},
{
name: "gitea_delivery_view",
description: "Keep a delivery's view on its pull request: one comment, marked as the delivery's, created the first time and edited in place after — the page every status of the delivery links to.",
input: {
owner: { type: "string", description: "the repository owner" },
repo: { type: "string", description: "the repository name" },
number: { type: "number", description: "the pull request's number" },
body: { type: "string", description: "the view, markdown" },
},
run: async (args) => {
const owner = String(args.owner), repo = String(args.repo), number = Number(args.number);
const body = viewBody(String(args.body ?? ""));
const existing = viewComment(await gitea.listComments(owner, repo, number));
if (existing) return { edited: await gitea.editComment(owner, repo, existing.id, body) };
return { created: await gitea.addComment(owner, repo, number, body) };
},
},
{
name: "gitea_commit_status",
description: "Set one of the mesh's statuses on a commit (mesh/delivery, mesh/delivery-group): pending, success, error, failure or warning, a short description, and the page it links to.",
input: {
owner: { type: "string", description: "the repository owner" },
repo: { type: "string", description: "the repository name" },
sha: { type: "string", description: "the commit" },
context: { type: "string", description: "the status's name, mesh/…" },
state: { type: "string", description: "pending | success | error | failure | warning" },
description: { type: "string", description: "one line" },
target_url: { type: "string", description: "the page it links to (the pull request)" },
},
run: async (args) => {
const status = deliveryStatus(String(args.context), String(args.state), String(args.description ?? ""),
args.target_url ? String(args.target_url) : undefined);
await gitea.setCommitStatus(String(args.owner), String(args.repo), String(args.sha), status);
return { set: status };
},
},
// ---- Labels ---- // ---- Labels ----
{ {
name: "gitea_list_labels", name: "gitea_list_labels",
+1 -1
View File
@@ -8,5 +8,5 @@
"skipLibCheck": true, "skipLibCheck": true,
"noEmit": true "noEmit": true
}, },
"include": ["client.ts", "token.ts", "index.ts", "provisioner/index.ts", "tools/index.ts"] "include": ["client.ts", "token.ts", "pulls.ts", "protection.ts", "index.ts", "provisioner/index.ts", "tools/index.ts"]
} }
+15
View File
@@ -19,6 +19,16 @@
"why": "the dashboards. Also 3000 inside, like the forge - which is the mesh's port assignment earning its keep" "why": "the dashboards. Also 3000 inside, like the forge - which is the mesh's port assignment earning its keep"
} }
], ],
"data": {
"own": [
{
"id": "data",
"path": "${dir:data}",
"class": "valuable",
"why": "dashboards, users and alert rules"
}
]
},
"resources": [ "resources": [
{ {
"id": "mesh-state", "id": "mesh-state",
@@ -81,6 +91,11 @@
"type": "container", "type": "container",
"name": "grafana", "name": "grafana",
"image": "grafana/grafana@sha256:ac461fb352abc50da10a51c7d02462e9c05488f11f53f14b3ad79a8145f638a0", "image": "grafana/grafana@sha256:ac461fb352abc50da10a51c7d02462e9c05488f11f53f14b3ad79a8145f638a0",
"health": {
"kind": "http",
"endpoint": "web",
"path": "/"
},
"ports": [ "ports": [
"3000" "3000"
], ],
+11
View File
@@ -34,6 +34,17 @@
"why": "the bundled go2rtc's WebRTC port, which camera streams to a browser use" "why": "the bundled go2rtc's WebRTC port, which camera streams to a browser use"
} }
], ],
"data": {
"own": [
{
"id": "config",
"path": "${dir:config}",
"class": "valuable",
"active": "1d",
"why": "the house's configuration, automations and history; written all the time"
}
]
},
"resources": [ "resources": [
{ {
"id": "mesh-state", "id": "mesh-state",
+34
View File
@@ -0,0 +1,34 @@
# hostname
The machine's names (novox/hq ADR 0199, ADR 0223): it holds the node seat `node-hostname` and owns
the two files that say what a machine is called — `/etc/hostname` and the machine's own lines in
`/etc/hosts`. It was `hosts`, holding `node-hosts-file`; the seat was renamed, and the old name
resolves to it as an alias.
## What it writes
- **`/etc/hostname`, whole**: the module's `hostname` setting, and nothing else. There is no
default. A machine's name is the operator's: the mesh's name for a machine and the name it calls
itself need not be the same, and writing the mesh's name silently would rename a machine. Set it
per machine — `settings set hostname '{"hostname": "<name>"}' --node <node>` — before the module
is assigned there; without it the module is left out of that machine's declaration, naming the
key. A mesh-wide `{"hostname": "${machine:name}"}` makes every machine call itself by its mesh
name, and a machine's own setting still overrides it.
- **The machine's own lines in `/etc/hosts`**, as the mesh's marked region at the start of the file:
`localhost` and `127.0.1.1` with the machine's mesh name. Every other line is the operator's, kept
byte for byte and given back when the module goes.
## When a new name takes effect
At the machine's **next boot**. The kernel's name is set from `/etc/hostname` when the machine
starts; writing the file changes what `hostnamectl` reports as the static name and nothing that is
running. The module declares nothing that would set it live: a graphical session's X authority is
keyed by the name the session started under, so changing it underneath a running session refuses
every new window until the person logs in again. Reboot when that is acceptable, or run
`hostnamectl hostname <name>` by hand.
## Its verbs
`<node>/node-hostname.entries`, `.add` and `.remove`: the hosts file's lines, each marked whose it
is; add one address and its names to the operator's lines; remove a name or an address from them.
They change the machine's file and nothing else. `/etc/hostname` has no verb — it is the setting.
@@ -0,0 +1,331 @@
// The hosts file's own code (novox/hq ADR 0199): read /etc/hosts as the machine has it, and change the
// operator's lines — every line outside a `# BEGIN … / # END …` block — leaving every block, the mesh's
// and any other tool's, byte for byte. The mesh writes this module's block; these verbs never touch it.
//
// Root is the module's concern (ADR 0175 §4): the runtime launching this binary runs as the operator's
// account, so the file is written through sudo without a prompt where the account is not root, as the
// packet filter's is.
package main
import (
"bytes"
"context"
"errors"
"fmt"
"net/netip"
"os"
"os/exec"
"path/filepath"
"regexp"
"slices"
"strings"
"time"
)
// HostsPath is where the file is. The manifest's resource names the same path; a test holds the two
// together.
const HostsPath = "/etc/hosts"
// Operator is the owner of every line outside a block.
const Operator = "operator"
// Runner runs one command as root and answers what it printed, so the writes can be tested without a
// machine.
type Runner func(ctx context.Context, name string, args ...string) (string, error)
// 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...)
}
func execRunner(ctx context.Context, name string, args ...string) (string, error) {
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
program, argv := escalated(os.Getuid(), name, args)
var stdout, stderr bytes.Buffer
cmd := exec.CommandContext(ctx, program, argv...)
cmd.Stdout, cmd.Stderr = &stdout, &stderr
err := cmd.Run()
if err == nil {
return stdout.String(), nil
}
said := strings.TrimSpace(stdout.String() + stderr.String())
if program == "sudo" {
if errors.Is(err, exec.ErrNotFound) {
return "", fmt.Errorf("%s needs root, and sudo is not installed here for the runtime's account to escalate with", name)
}
if regexp.MustCompile(`(?m)^sudo:`).MatchString(said) {
return "", fmt.Errorf("%s needs root and the runtime's account may not run it without a prompt: %s", name, said)
}
}
if said != "" {
return "", fmt.Errorf("%s: %s", name, said)
}
return "", fmt.Errorf("%s failed: %v", name, err)
}
// Line is one line of the file, as a reader sees it.
type Line struct {
// Text is the line exactly as it is in the file.
Text string `json:"text"`
// Owner is whose it is: the block's id (`mesh hostname.own`, or another tool's) or "operator".
Owner string `json:"owner"`
// Address and Names are an entry's; absent for a comment or a blank line.
Address string `json:"address,omitempty"`
Names []string `json:"names,omitempty"`
}
var (
begin = regexp.MustCompile(`^#\s*BEGIN\s+(.+?)\s*$`)
end = regexp.MustCompile(`^#\s*END\s+(.+?)\s*$`)
)
// isAddress is whether s is an IPv4 or IPv6 address, as a hosts file's first field must be.
func isAddress(s string) bool {
_, err := netip.ParseAddr(s)
return err == nil
}
// Parse is every line of a hosts file, each marked whose it is.
func Parse(text string) []Line {
out := []Line{}
block := ""
for _, raw := range strings.Split(text, "\n") {
if block == "" {
if m := begin.FindStringSubmatch(raw); m != nil {
block = m[1]
out = append(out, Line{Text: raw, Owner: block})
continue
}
}
owner := block
if owner == "" {
owner = Operator
}
line := Line{Text: raw, Owner: owner}
entry, _, _ := strings.Cut(raw, "#")
if fields := strings.Fields(entry); len(fields) >= 2 && isAddress(fields[0]) {
line.Address, line.Names = fields[0], fields[1:]
}
out = append(out, line)
if m := end.FindStringSubmatch(raw); block != "" && m != nil && m[1] == block {
block = ""
}
}
// A trailing newline splits into one empty last element; it is the file's ending, not a line.
if n := len(out); n > 0 && out[n-1].Text == "" && strings.HasSuffix(text, "\n") {
out = out[:n-1]
}
return out
}
var label = regexp.MustCompile(`^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$`)
// Refused input says why, so a caller is one edit from right.
func checkAddress(address string) error {
if !isAddress(address) {
return fmt.Errorf("%q is not an IPv4 or IPv6 address", address)
}
return nil
}
func checkName(name string) error {
bad := fmt.Errorf("%q is not a host name", name)
if len(name) < 1 || len(name) > 253 {
return bad
}
for _, l := range strings.Split(strings.TrimSuffix(name, "."), ".") {
if !label.MatchString(l) {
return bad
}
}
return nil
}
// WithAdded is the file with one address and its names added to the operator's lines; unchanged when
// they are already there.
func WithAdded(text, address string, names []string) (string, error) {
if err := checkAddress(address); err != nil {
return "", err
}
if len(names) == 0 {
return "", errors.New("add names at least one name for the address")
}
for _, n := range names {
if err := checkName(n); err != nil {
return "", err
}
}
have := map[string]bool{}
for _, l := range Parse(text) {
if l.Owner == Operator && l.Address == address {
for _, n := range l.Names {
have[n] = true
}
}
}
var missing []string
for _, n := range names {
if !have[n] && !slices.Contains(missing, n) {
missing = append(missing, n)
}
}
if len(missing) == 0 {
return text, nil
}
body := text
if body != "" && !strings.HasSuffix(body, "\n") {
body += "\n"
}
return body + address + "\t" + strings.Join(missing, " ") + "\n", nil
}
// WithRemoved is the file with one name, or every line of one address, taken out of the operator's
// lines, and how many lines it touched. Blocks are never touched: a name only the mesh or another tool
// writes is refused, naming whose it is.
func WithRemoved(text, what string) (string, int, error) {
byAddress := isAddress(what)
if !byAddress {
if err := checkName(what); err != nil {
return "", 0, err
}
}
matches := func(l Line) bool {
if byAddress {
return l.Address == what
}
return slices.Contains(l.Names, what)
}
lines := Parse(text)
removed := 0
kept := []string{}
for _, l := range lines {
if l.Owner != Operator || l.Address == "" || !matches(l) {
kept = append(kept, l.Text)
continue
}
removed++
if byAddress {
continue
}
var rest []string
for _, n := range l.Names {
if n != what {
rest = append(rest, n)
}
}
if len(rest) > 0 {
kept = append(kept, l.Address+"\t"+strings.Join(rest, " "))
}
}
if removed == 0 {
for _, l := range lines {
if l.Owner != Operator && matches(l) {
return "", 0, fmt.Errorf("%s is written by %s, not the operator; it is not this verb's to remove", what, l.Owner)
}
}
}
return strings.Join(kept, "\n") + "\n", removed, nil
}
// HostsFile is the machine's hosts file.
type HostsFile struct {
Path string
Run Runner
}
// Entries is the file's lines, each marked whose.
type Entries struct {
Path string `json:"path"`
Lines []Line `json:"lines"`
}
func (h HostsFile) read() (string, error) {
b, err := os.ReadFile(h.Path)
return string(b), err
}
// Entries is every line of the file, each marked whose it is.
func (h HostsFile) Entries() (*Entries, error) {
text, err := h.read()
if err != nil {
return nil, err
}
return &Entries{Path: h.Path, Lines: Parse(text)}, nil
}
// Added is whether add changed the file, and the line it wrote.
type Added struct {
Added bool `json:"added"`
Line string `json:"line,omitempty"`
}
// Add adds one address and its names to the operator's lines.
func (h HostsFile) Add(ctx context.Context, address string, names []string) (*Added, error) {
before, err := h.read()
if err != nil {
return nil, err
}
after, err := WithAdded(before, address, names)
if err != nil {
return nil, err
}
if after == before {
return &Added{Added: false}, nil
}
if err := h.write(ctx, after); err != nil {
return nil, err
}
return &Added{Added: true, Line: strings.TrimSpace(after[len(before):])}, nil
}
// Removed is how many of the operator's lines remove touched.
type Removed struct {
Removed int `json:"removed"`
}
// Remove takes one name, or every line of one address, out of the operator's lines.
func (h HostsFile) Remove(ctx context.Context, what string) (*Removed, error) {
before, err := h.read()
if err != nil {
return nil, err
}
after, removed, err := WithRemoved(before, what)
if err != nil {
return nil, err
}
if removed > 0 {
if err := h.write(ctx, after); err != nil {
return nil, err
}
}
return &Removed{Removed: removed}, nil
}
// write puts the file in place whole, so a reader never sees half of it: the content is staged in a
// private copy, installed as root beside the file — the same directory, so the same filesystem — and
// renamed over it.
func (h HostsFile) write(ctx context.Context, content string) error {
dir, err := os.MkdirTemp("", "hosts-")
if err != nil {
return err
}
defer os.RemoveAll(dir)
staged := filepath.Join(dir, "hosts")
if err := os.WriteFile(staged, []byte(content), 0o644); err != nil {
return err
}
beside := filepath.Join(filepath.Dir(h.Path), "."+filepath.Base(h.Path)+".hostname-tools")
if _, err := h.Run(ctx, "install", "-m", "0644", staged, beside); err != nil {
return err
}
if _, err := h.Run(ctx, "mv", "-f", beside, h.Path); err != nil {
_, _ = h.Run(ctx, "rm", "-f", beside)
return err
}
return nil
}
@@ -0,0 +1,313 @@
package main
// The hosts file's verbs over files shaped like the workstation's on 2026-10-03 (novox/hq ADR 0199):
// distribution lines, an operator's development names, the mesh's block and another tool's.
import (
"context"
"encoding/json"
"os"
"os/exec"
"path/filepath"
"reflect"
"strings"
"testing"
)
const file = "# Static table lookup for hostnames.\n" +
"127.0.0.1\tlocaldev.example.com\n" +
"127.0.0.1 a.example.com b.example.com\n" +
"# BEGIN mesh hostname.own\n" +
"127.0.0.1\tlocalhost\n" +
"::1\tlocalhost\n" +
"# END mesh hostname.own\n" +
"# BEGIN other-tool\n" +
"192.0.2.7\tproject.test\n" +
"# END other-tool\n"
func blocks(text string) []string {
var out []string
for _, l := range Parse(text) {
if l.Owner != Operator {
out = append(out, l.Text)
}
}
return out
}
func TestEveryLineSaysWhoseItIs(t *testing.T) {
lines := Parse(file)
if len(lines) != 10 {
t.Fatalf("%d lines: %+v", len(lines), lines)
}
want := Line{Text: "127.0.0.1\tlocaldev.example.com", Owner: Operator, Address: "127.0.0.1", Names: []string{"localdev.example.com"}}
if !reflect.DeepEqual(lines[1], want) {
t.Errorf("%+v", lines[1])
}
if lines[0].Address != "" || lines[0].Owner != Operator {
t.Errorf("a comment is the operator's and no entry: %+v", lines[0])
}
if lines[3].Owner != "mesh hostname.own" || lines[4].Owner != "mesh hostname.own" || lines[6].Owner != "mesh hostname.own" {
t.Errorf("the mesh's block, its markers included: %+v", lines[3:7])
}
if lines[8].Owner != "other-tool" || !reflect.DeepEqual(lines[8].Names, []string{"project.test"}) {
t.Errorf("%+v", lines[8])
}
if lines[5].Address != "::1" {
t.Errorf("an IPv6 entry: %+v", lines[5])
}
}
func TestAnEntryWithATrailingCommentKeepsItsNames(t *testing.T) {
l := Parse("10.0.0.1 nas.lan # the box upstairs")[0]
if l.Address != "10.0.0.1" || !reflect.DeepEqual(l.Names, []string{"nas.lan"}) {
t.Errorf("%+v", l)
}
}
func TestAnUnclosedBlockHoldsTheRestOfTheFile(t *testing.T) {
lines := Parse("# BEGIN x\n10.0.0.1 a.test\n# END y\n10.0.0.2 b.test\n")
for _, l := range lines {
if l.Owner != "x" {
t.Errorf("%+v", l)
}
}
}
func TestAddAppendsAnOperatorLineAndIsANoOpWhenTheNamesAreThere(t *testing.T) {
after, err := WithAdded(file, "192.0.2.9", []string{"lab.test", "www.lab.test"})
if err != nil {
t.Fatal(err)
}
if !strings.HasSuffix(after, "192.0.2.9\tlab.test www.lab.test\n") {
t.Errorf("%q", after)
}
if !reflect.DeepEqual(blocks(after), blocks(file)) {
t.Errorf("blocks changed")
}
if same, _ := WithAdded(file, "127.0.0.1", []string{"a.example.com"}); same != file {
t.Errorf("a name already there changed the file")
}
if some, _ := WithAdded(file, "127.0.0.1", []string{"a.example.com", "c.example.com"}); !strings.HasSuffix(some, "127.0.0.1\tc.example.com\n") {
t.Errorf("%q", some)
}
if ended, _ := WithAdded("127.0.0.1 localhost", "192.0.2.1", []string{"x.test"}); ended != "127.0.0.1 localhost\n192.0.2.1\tx.test\n" {
t.Errorf("a file without a last newline: %q", ended)
}
if empty, _ := WithAdded("", "192.0.2.1", []string{"x.test"}); empty != "192.0.2.1\tx.test\n" {
t.Errorf("an empty file: %q", empty)
}
}
func TestAddDoesNotCountANameOnlyABlockHasAsTheOperators(t *testing.T) {
after, err := WithAdded(file, "127.0.0.1", []string{"localhost"})
if err != nil {
t.Fatal(err)
}
if !strings.HasSuffix(after, "# END other-tool\n127.0.0.1\tlocalhost\n") {
t.Errorf("%q", after)
}
}
func TestAddRefusesWhatIsNotAnAddressOrAHostName(t *testing.T) {
for _, c := range []struct {
address string
names []string
says string
}{
{"not-an-ip", []string{"x.test"}, "not an IPv4 or IPv6 address"},
{"192.0.2.9", []string{"bad name\n10.0.0.1 evil"}, "not a host name"},
{"192.0.2.9", []string{"-lead.test"}, "not a host name"},
{"192.0.2.9", []string{strings.Repeat("a", 64) + ".test"}, "not a host name"},
{"192.0.2.9", []string{strings.Repeat("abcdefgh.", 30)}, "not a host name"},
{"192.0.2.9", []string{}, "at least one name"},
} {
if _, err := WithAdded(file, c.address, c.names); err == nil || !strings.Contains(err.Error(), c.says) {
t.Errorf("%q %q: %v", c.address, c.names, err)
}
}
if _, err := WithAdded(file, "2001:db8::1", []string{"v6.test."}); err != nil {
t.Errorf("an IPv6 address and a rooted name: %v", err)
}
}
func TestRemoveTakesOneNameOrOneAddressAndBlocksStayByteForByte(t *testing.T) {
one, n, err := WithRemoved(file, "a.example.com")
if err != nil || n != 1 {
t.Fatalf("%d %v", n, err)
}
if !strings.Contains(one, "127.0.0.1\tb.example.com\n") || strings.Contains(one, "a.example.com") {
t.Errorf("%q", one)
}
if !reflect.DeepEqual(blocks(one), blocks(file)) {
t.Errorf("blocks changed")
}
all, n, err := WithRemoved(file, "127.0.0.1")
if err != nil || n != 2 {
t.Fatalf("%d %v", n, err)
}
if !strings.Contains(all, "# BEGIN mesh hostname.own\n127.0.0.1\tlocalhost\n") {
t.Errorf("the mesh's own localhost is not the operator's to remove: %q", all)
}
if !reflect.DeepEqual(blocks(all), blocks(file)) {
t.Errorf("blocks changed")
}
}
func TestRemoveRefusesANameOnlyABlockWritesNamingWhose(t *testing.T) {
if _, _, err := WithRemoved(file, "project.test"); err == nil || !strings.Contains(err.Error(), "written by other-tool") {
t.Errorf("%v", err)
}
if _, _, err := WithRemoved(file, "::1"); err == nil || !strings.Contains(err.Error(), "written by mesh hostname.own") {
t.Errorf("%v", err)
}
if _, n, err := WithRemoved(file, "nowhere.test"); err != nil || n != 0 {
t.Errorf("%d %v", n, err)
}
if _, _, err := WithRemoved(file, "bad name"); err == nil {
t.Errorf("a name that is neither an address nor a host name is refused")
}
}
func TestTheFileIsWrittenAsRootThroughSudoWhereTheAccountIsNotRoot(t *testing.T) {
if p, a := escalated(1000, "install", []string{"x"}); p != "sudo" || !reflect.DeepEqual(a, []string{"-n", "install", "x"}) {
t.Errorf("%s %v", p, a)
}
if p, a := escalated(0, "install", []string{"x"}); p != "install" || !reflect.DeepEqual(a, []string{"x"}) {
t.Errorf("%s %v", p, a)
}
}
// runPlain runs a command as given, unescalated: the test's file is the test's own.
func runPlain(ctx context.Context, name string, args ...string) (string, error) {
out, err := exec.CommandContext(ctx, name, args...).CombinedOutput()
return string(out), err
}
func TestTheVerbsWriteTheFileWholeBesideItAndRenameItOver(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "hosts")
if err := os.WriteFile(path, []byte(file), 0o644); err != nil {
t.Fatal(err)
}
var calls [][]string
h := HostsFile{Path: path, Run: func(ctx context.Context, name string, args ...string) (string, error) {
calls = append(calls, append([]string{name}, args...))
return runPlain(ctx, name, args...)
}}
ctx := context.Background()
added, err := h.Add(ctx, "192.0.2.9", []string{"lab.test"})
if err != nil || !added.Added || added.Line != "192.0.2.9\tlab.test" {
t.Fatalf("%+v %v", added, err)
}
beside := filepath.Join(dir, ".hosts.hostname-tools")
if len(calls) != 2 || calls[0][0] != "install" || calls[0][len(calls[0])-1] != beside ||
!reflect.DeepEqual(calls[1], []string{"mv", "-f", beside, path}) {
t.Errorf("%v", calls)
}
if again, _ := h.Add(ctx, "192.0.2.9", []string{"lab.test"}); again.Added || len(calls) != 2 {
t.Errorf("an add already there wrote the file: %+v %v", again, calls)
}
got, err := h.Entries()
if err != nil {
t.Fatal(err)
}
last := got.Lines[len(got.Lines)-1]
if last.Owner != Operator || last.Address != "192.0.2.9" {
t.Errorf("add then entries shows the line as the operator's: %+v", last)
}
removed, err := h.Remove(ctx, "lab.test")
if err != nil || removed.Removed != 1 {
t.Fatalf("%+v %v", removed, err)
}
if b, _ := os.ReadFile(path); string(b) != file {
t.Errorf("add then remove gives the file back: %q", b)
}
if _, err := os.Stat(beside); !os.IsNotExist(err) {
t.Errorf("the staged copy beside the file is left: %v", err)
}
if info, _ := os.Stat(path); info.Mode().Perm() != 0o644 {
t.Errorf("mode %v", info.Mode())
}
}
func TestThePathTheCodeWritesIsThePathTheManifestsResourceDeclares(t *testing.T) {
raw, err := os.ReadFile("../../module.json")
if err != nil {
t.Fatal(err)
}
var m struct {
Claims []struct {
Name string `json:"name"`
Serves []string `json:"serves"`
} `json:"claims"`
Resources []struct {
ID string `json:"id"`
Path string `json:"path"`
} `json:"resources"`
}
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
found := false
for _, r := range m.Resources {
if r.ID == "own" {
found = true
if r.Path != HostsPath {
t.Errorf("the manifest writes %s, the code %s", r.Path, HostsPath)
}
}
}
if !found {
t.Errorf("no resource own")
}
var served []string
for _, tool := range tools(HostsFile{}) {
served = append(served, strings.TrimPrefix(tool.Name, Seat+"."))
if tool.Description == "" || tool.Run == nil {
t.Errorf("%s", tool.Name)
}
}
if len(m.Claims) != 1 || m.Claims[0].Name != Seat || !reflect.DeepEqual(m.Claims[0].Serves, served) {
t.Errorf("the manifest serves %+v, the binary %v", m.Claims, served)
}
}
func TestNamesAreSplitOnSpacesAndCommasOrTakenAsAList(t *testing.T) {
if got := namesArg(map[string]any{"names": " a.test, b.test c.test"}); !reflect.DeepEqual(got, []string{"a.test", "b.test", "c.test"}) {
t.Errorf("%v", got)
}
if got := namesArg(map[string]any{"names": []any{"a.test", "b.test"}}); !reflect.DeepEqual(got, []string{"a.test", "b.test"}) {
t.Errorf("%v", got)
}
if got := namesArg(map[string]any{}); len(got) != 0 {
t.Errorf("%v", got)
}
}
// /etc/hostname is the module's whole file (novox/hq ADR 0223), and what it says is the operator's
// `hostname` setting — never the mesh's name for the machine written silently: on the mesh this was
// built for, three of four machines call themselves something else, and renaming a machine is the
// operator's to decide.
func TestTheMachinesNameIsTheOperatorsSetting(t *testing.T) {
raw, err := os.ReadFile("../../module.json")
if err != nil {
t.Fatal(err)
}
var m struct {
Resources []map[string]any `json:"resources"`
}
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
for _, r := range m.Resources {
if r["path"] != "/etc/hostname" {
continue
}
if r["id"] != "name" || r["into"] != nil || r["content"] != "${setting:hostname}\n" {
t.Errorf("/etc/hostname is not written whole from the hostname setting: %v", r)
}
return
}
t.Error("the module does not write /etc/hostname")
}
@@ -0,0 +1,83 @@
// hostname-tools (novox/hq ADR 0199, ADR 0223): the tools of the machine's names. One binary,
// launched by the machine's tool runtime and speaking MCP to it over stdio through the Go SDK (ADR
// 0193, ADR 0198): the node-hostname seat's three verbs — the hosts file's lines with whose each
// is, add an operator's line, remove one. They change the machine's file and nothing else; the
// controller holds none of it. /etc/hostname has no verb: it is the module's resource, set by the
// module's `hostname` setting.
//
// stdout is the MCP channel; everything this module says, it says on stderr.
package main
import (
"context"
"fmt"
"os"
"regexp"
"strings"
stdio "git.novox.be/novox/mesh-sdk/go"
)
// Seat is the role this module holds.
const Seat = "node-hostname"
func main() {
if err := stdio.Serve("", tools(HostsFile{Path: HostsPath, Run: execRunner})); err != nil {
fmt.Fprintf(os.Stderr, "[hostname] %v\n", err)
os.Exit(1)
}
}
func str(description string) map[string]any {
return map[string]any{"type": "string", "description": description}
}
func arg(a map[string]any, k string) string {
v, _ := a[k].(string)
return strings.TrimSpace(v)
}
var separators = regexp.MustCompile(`[\s,]+`)
// namesArg is the names given, separated by spaces or commas — or, from a caller that sends a list,
// the list.
func namesArg(a map[string]any) []string {
var raw []string
switch v := a["names"].(type) {
case string:
raw = separators.Split(v, -1)
case []any:
for _, n := range v {
if s, ok := n.(string); ok {
raw = append(raw, separators.Split(s, -1)...)
}
}
}
names := []string{}
for _, n := range raw {
if n != "" {
names = append(names, n)
}
}
return names
}
// verb is one of the seat's verbs: listed as `<seat>.<verb>`, so the runtime serves it on the seat's
// subject, as <node>/node-hostname.<verb>.
func verb(name, description string, input map[string]any, run func(a map[string]any) (any, error)) stdio.Tool {
return stdio.Tool{Name: Seat + "." + name, Description: description, Input: input, Run: run}
}
func tools(h HostsFile) []stdio.Tool {
ctx := context.Background()
return []stdio.Tool{
verb("entries", "Every line of this machine's /etc/hosts, each marked whose it is: the operator's, or the block of the module or tool that writes it.",
nil, func(map[string]any) (any, error) { return h.Entries() }),
verb("add", "Add one address and its names to the operator's lines of this machine's /etc/hosts — a name for this machine's own programs, not the mesh's. Nothing changes when they are already there.",
map[string]any{"address": str("the IPv4 or IPv6 address"), "names": str("the names for it, separated by spaces")},
func(a map[string]any) (any, error) { return h.Add(ctx, arg(a, "address"), namesArg(a)) }),
verb("remove", "Remove one name, or every line of one address, from the operator's lines of this machine's /etc/hosts. A line a module writes is refused, naming the module.",
map[string]any{"name": str("a host name, or an address to remove every line of")},
func(a map[string]any) (any, error) { return h.Remove(ctx, arg(a, "name")) }),
}
}
+5
View File
@@ -0,0 +1,5 @@
module hostname
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=
+48
View File
@@ -0,0 +1,48 @@
{
"module": "hostname",
"version": "1",
"claims": [
{
"name": "node-hostname",
"scope": "node",
"serves": [
"entries",
"add",
"remove"
]
}
],
"resources": [
{
"id": "own",
"type": "file",
"path": "/etc/hosts",
"mode": "0644",
"into": "block",
"at": "start",
"content": "# The machine's own names (module hostname, novox/hq ADR 0199, ADR 0223). Every line outside this\n# block is the operator's: kept across every push, changed through the node-hostname verbs add and\n# remove, and given back when this module goes. The mesh's names are not here: the mesh's resolver\n# answers them.\n127.0.0.1\tlocalhost\n::1\tlocalhost\n127.0.1.1\t${machine:name}\n"
},
{
"id": "name",
"type": "file",
"path": "/etc/hostname",
"mode": "0644",
"content": "${setting:hostname}\n"
}
],
"build": {
"artifacts": [
{
"name": "tools",
"kind": "bundle",
"language": "go",
"system": "arch",
"from": "cmd/hostname-tools",
"binary": "hostname-tools",
"loads": [
"hostname-tools"
]
}
]
}
}

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