Compare commits

...
Author SHA1 Message Date
mesh-admin 86bcbc48ad claude-code: tips is a preference, set in one call like any module's
mesh/merge-gate head 86bcbc48ad78 merged into trunk 0d723484a84a — fail: builds claude-code → ace, g14, novox, shanks; no bus step; a manifest the change t…
mesh/repo-check head 86bcbc48ad78 merged into trunk 0d723484a84a — fail: its merge-check.sh failed: files asking for a setting without saying whether it is…
mesh/delivery stopped: the pull request closed unmerged
Turning Claude Code's tips off meant a change to the trusted managed_settings layer, proposed for the operator's approval. Tips decide how a session looks, not what it may do, so the module declares `tips` as a preference in an untrusted file of its own and renders it as spinnerTipsEnabled; the controller's settings verb sets it for the mesh or a node, with its history.
2026-10-13 12:15:49 +00:00
mesh-admin 0d723484a8 Merge pull request 'lab: clone the forge with the credential the mesh binds and seals to the lab (issue 624)' (#272) from fix/624-lab-reads-the-forge-with-its-credential into main 2026-10-13 12:07:06 +00:00
jochen 8d8dcd5ebb lab: a read-only forge account of its own, and no credential left on disk after a stop (hq issue 624, review 196)
mesh/merge-gate head 8d8dcd5ebb86 merged into trunk cd239283207a — pass: builds gitea, lab → novox, shanks; no bus step; every machine composes with the ch…
mesh/repo-check head 8d8dcd5ebb86 merged into trunk cd239283207a — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
The lab's credential came from the package team, which also writes the @novox packages, and the
branches a run clones are not reviewed yet. The lab now contributes {"access": "read"} to its
npm-package-registry requirement, and gitea's provisioner puts such a consumer on a team of its own,
readers: code read on the org's repositories, package read, no write, and off the package team. The
build agent, which publishes, stays on packages.

A stop's SIGTERM to the run's process group killed the wrapper before it removed the credential file.
The run's script now removes it as soon as its clones are done and traps every way it ends, and a run's
start and lab_check remove what a crash or a reboot left.
2026-10-13 14:00:59 +02:00
mesh-admin cd23928320 Merge pull request 'licence manager: say what moved a binding, why, and who asked (issue 625)' (#270) from fix/625-a-licence-move-says-what-why-and-who into main 2026-10-13 11:59:02 +00:00
mesh-admin 8922c89059 Merge pull request 'dev-tools: give every node the toolchains to build and test the mesh's code (issue 626)' (#269) from fix/626-every-node-has-the-dev-tools into main 2026-10-13 11:56:18 +00:00
jochen 789d2d9d64 lab: clone the forge with the credential the mesh binds and seals to the lab (hq issue 624)
mesh/merge-gate out of date: judged against trunk 19ecad261cf2, the trunk is now 8922c890595b; judged again on a recheck or when it is merged
mesh/repo-check out of date: judged against trunk 19ecad261cf2, the trunk is now 8922c890595b; judged again on a recheck or when it is merged
mesh/delivery superseded: a newer head of the same pull request
mesh-tools-go is private, and the lab cloned over HTTP with no credential: a run failed at its checkout,
"could not read Username". The lab now requires the package registry, as the build agent does; the
forge's provisioner binds it an account and seals that account's password, and a run offers them to
the forge's own origin through git's credential store, from a file beside the run and removed when it
ends: never in a URL, the run's script, an argument or the log. lab_check reads every repository a run
clones with that same credential and names one it cannot read.
2026-10-13 13:54:16 +02:00
mesh-admin 1725ffc799 licence manager: a login's adoption is said beside the bindings it made, not first (issue 625)
mesh/merge-gate head 1725ffc799bd merged into trunk 8922c890595b — pass: builds claude-code, claude-licence-manager → ace, g14, novox, shanks; no bus step;…
mesh/repo-check head 1725ffc799bd merged into trunk 8922c890595b — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
The bindings a login makes are now announced as licence.bound before the adoption's own event, so the test asks for each event rather than for adoption to come first.
2026-10-13 11:49:30 +00:00
mesh-admin a72eb3dbb2 licence manager: say what moved a binding, why, and who asked (issue 625)
mesh/merge-gate head a72eb3dbb28d merged into trunk 19ecad261cf2 — pass: builds claude-code, claude-licence-manager → ace, g14, novox, shanks; no bus step;…
mesh/repo-check head a72eb3dbb28d merged into trunk 19ecad261cf2 — fail: its merge-check.sh failed: --- FAIL: TestAManagerWithNoLicenceAdoptsTheNewestLogin…
mesh/delivery superseded: a newer head of the same pull request
A switch of g14 back to another licence on 2026-10-13 left no trace: the manager logged no line and sent no event for bind, switch or release, and its audit kept only the verb's name. Every change of what a consumer holds now goes to the journal, the audit and a licence.<what> event with the same fields: consumer, licence before and after, generation, why (the verb or the rule) and by (the caller the runtime proved, or the manager itself). The node is told why in the bindings state and says it when it applies. The audit and binding tables become readable through the store's reader. The SDK moves to the version that proves the caller (issue 365).
2026-10-13 11:45:52 +00:00
mesh-admin c0726ac264 dev-tools: give every node the toolchains to build and test the mesh's code (issue 626)
mesh/merge-gate head c0726ac2641b merged into trunk 19ecad261cf2 — pass: builds new: modules/dev-tools, sent nowhere; no bus step; every machine composes w…
mesh/repo-check head c0726ac2641b merged into trunk 19ecad261cf2 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
Every node is a development machine: an agent builds a change and runs its tests there before opening a pull request, and the lab tests it afterwards as QA. No module declared Go or npm, so no node could do the first part.
2026-10-13 11:42:12 +00:00
mesh-admin 19ecad261c Merge pull request 'lab: install each checkout where its lock file is, check out mesh-tools-go, and have lab_check catch both (issue 624)' (#268) from fix/624-lab-run-installs-what-each-checkout-has into main 2026-10-13 11:11:58 +00:00
jochen c6a9c52dc7 Install each lab checkout where its lock file is, check out mesh-tools-go, and have lab_check catch both (hq issue 624)
mesh/merge-gate head c6a9c52dc7f2 merged into trunk 45d01e77c26d — pass: builds lab → shanks; no bus step; every machine composes with the change as it did…
mesh/repo-check head c6a9c52dc7f2 merged into trunk 45d01e77c26d — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
lab_run ran npm ci at the root of mesh-sdk, mesh-tools and mesh-lab; mesh-tools keeps its package in
node-tools/ with no lock file at its root, so every run failed at its setup in nine seconds. A run now
installs (and, for the SDK and the base, builds) where a package-lock.json is: the root, or each
directory one level down; a package.json with no lock file fails the run naming it. It checks out
mesh-tools-go, which genesis builds since issue 616, and tells the beds where its checkouts are
(MESH_LAB_REPOS). lab_check asks the forge for every repository a run clones and applies the same install
rule to the ones it installs, so a run's setup failure is found before a run.
2026-10-13 11:55:01 +02:00
mesh-admin 45d01e77c2 Merge pull request 'fail2ban: know the own-addresses list in the form fail2ban prints it (hq issue 238)' (#267) from fix/238-fail2bans-own-file-form into main 2026-10-13 09:18:26 +00:00
jochen c78d0a0134 fail2ban: know the own-addresses list in the form fail2ban prints it (hq issue 238)
mesh/merge-gate head c78d0a01342c merged into trunk f35b06750452 — pass: builds fail2ban → ace, g14, novox, shanks; no bus step; every machine composes wit…
mesh/repo-check head c78d0a01342c merged into trunk f35b06750452 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
fail2ban reads the ignoreip entry file:/var/lib/... and prints it back as file://var/lib/...,
in get <jail> ignoreip and in its "Ignore ... by ..." log lines alike. The watch compared the
text, so on ace it said every jail lacked the list while each held it, failing fail2ban's health
and its walk; and it would never have counted a failure from the operator's own address. Compare
the path instead, with a test on fail2ban's real form.
2026-10-13 11:14:24 +02:00
mesh-admin f35b067504 Merge pull request 'fail2ban: never ban the operator's own addresses, and say a failure from one (hq issue 238, ADR 0339)' (#266) from fix/238-the-operators-own-addresses into main 2026-10-13 09:05:49 +00:00
jochen 93d58f3740 fail2ban: never ban the operator's own addresses, and say a failure from one (hq issue 238, ADR 0339)
mesh/merge-gate head 93d58f3740a0 merged into trunk 5cd608ba8898 — pass: builds fail2ban → ace, g14, novox, shanks; no bus step; every machine composes wit…
mesh/repo-check head 93d58f3740a0 merged into trunk 5cd608ba8898 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery failed: its walk failed, carrying its own commit: a gate on a first machine (what it carried put back), a build, a machine
An agent polling the forge from the operator's home got that home banned for a day and then four
weeks, and nothing in the house reached the control-node. Every jail spared only private ranges.

The mesh-wide setting own-addresses reaches the machine as a mergeable file whose own content is
the empty list, so the module composes while it is unset. A new watch process (root) writes its
entries into a list every jail's base ignoreip names as file:, which fail2ban reloads without a
restart; it checks every jail names that list and keeps each failure a jail ignored because of it
for a day. The new fail2ban_health tool judges the watch, so the controller raises a warning
naming node, jail, address and count. The ban verb refuses an address on the setting or on the
jail's ignore list before the daemon is asked.
2026-10-13 10:54:49 +02:00
mesh-admin 5cd608ba88 Merge pull request 'Deliver a merge whose walk said its modules matched what runs, instead of stopping it as stranded (hq issue 611)' (#265) from fix/611-an-unchanged-build-is-delivered into main 2026-10-13 08:25:47 +00:00
jochen 2f5e97030a Deliver a merge whose walk said its modules matched what runs, instead of stopping it as stranded (hq issue 611)
mesh/merge-gate head 2f5e97030a5c merged into trunk bc3b59227f46 — pass: builds mesh-delivery → novox; no bus step; every machine composes with the change …
mesh/repo-check head 2f5e97030a5c merged into trunk bc3b59227f46 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
mesh/delivery-group group fix/611-an-unchanged-build-is-delivered delivered: every member is delivered
A walk that moved nothing because every module's recorded build source held none of the changed files now
says so per module (delivery.unchanged). Such a merge moves nothing (ADR 0270 §1): it is delivered, naming
the modules, not stopped 20 minutes later with the operator asked about a change already on every machine.
A module the walk neither built nor said unchanged still stops it (issue 474).
2026-10-13 08:18:25 +02:00
mesh-admin bc3b59227f Merge pull request 'A password, token or API key is the vault's, never a setting (hq issue 342)' (#264) from fix/342-a-secret-is-never-a-setting into main 2026-10-13 05:52:05 +00:00
jochen eb42806379 Make grafana prefer the vault's password, and let the secrets list only shrink (hq issue 342)
mesh/merge-gate head eb4280637906 merged into trunk 53e51864969f — pass: builds baserow, gitea, grafana, home-assistant, icecast, influxdb, letta, mailu, n…
mesh/repo-check head eb4280637906 merged into trunk 53e51864969f — pass: THE CHANGE ALTERS ITS OWN CHECK (merge-check.sh): main's version judged it; the ch…
mesh/delivery delivered
mesh/delivery-group group fix/342-a-secret-is-never-a-setting delivered: every member is delivered
grafana's client logged in with a token setting ahead of the vault's admin password,
and its runtime config let a layer set token. The token setting is now ignored and
said, token is protected, and the vault's password comes first.

checks/secrets/known could gain a line hiding a newly unprotected key. merge-check.sh
now judges it against the trunk's copy at the merge base, and refuses an added line.
2026-10-13 07:43:08 +02:00
jochen fe2040c65e Keep every password, token and API key the vault's, never a setting (hq issue 342)
mesh/merge-gate head fe2040c65ea2 merged into trunk 53e51864969f — pass: builds baserow, gitea, grafana, home-assistant, icecast, influxdb, letta, mailu, n…
mesh/repo-check head fe2040c65ea2 merged into trunk 53e51864969f — pass: THE CHANGE ALTERS ITS OWN CHECK (merge-check.sh): main's version judged it; the ch…
mesh/delivery-group group fix/342-a-secret-is-never-a-setting ready: every member ready, and composed together they pass
mesh/delivery superseded: a newer head of the same pull request
A setting outranked the vault: baserow, grafana, letta and unifi merged a layer's
password over the ${secret:...} in their runtime config, and the gitea,
home-assistant, icecast, influxdb, mailu and nextcloud clients read the settings
file before the vault's file. A secret in a setting cannot be rotated and is read
back by the settings verb.

The four files now keep their password protected, so the controller refuses a
setting of it. The six clients ignore a secret in the settings file and say so
once, by key and never with the value; icecast reads its admin password from the
vault's file. checks/secrets refuses a mergeable file whose secret a layer could
replace; searxng's nested server.secret_key is listed as known until the
controller protects a nested key.
2026-10-13 07:32:19 +02:00
mesh-admin 53e5186496 Merge pull request 'build-agent: the package registry is optional, and genesis hands it the forge it raised (hq issue 121, ADR 0336)' (#263) from fix/121-genesis-package-registry into main 2026-10-13 03:37:08 +00:00
jochen e0bc919d49 Let genesis assign the build agent before the package registry exists (hq issue 121, ADR 0336)
mesh/merge-gate head e0bc919d4945 merged into trunk f88e3452f8b8 — pass: builds build-agent → ace, g14, novox, shanks; no bus step; every machine composes …
mesh/repo-check head e0bc919d4945 merged into trunk f88e3452f8b8 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery covered: a later merge that contains it was delivered: novox/mesh-catalog@2f5e97030a5c (merged as 5cd608ba into main, walk plan-17918799551…
mesh/delivery-group group fix/121-genesis-package-registry delivered: every member is delivered
build-agent's npm-package-registry becomes optional, so genesis's step 13 is no
longer refused with 'nothing provides npm-package-registry'. Until gitea is
assigned, the agent on genesis's node reads the genesis-package-registry
own-secret the installer seals at the packages step; it is issued outside the
mesh, so no other machine is given one. The binding and its credential are
composed only while bound (ADR 0330 §6), and no file names them otherwise.
2026-10-13 02:50:39 +02:00
mesh-admin f88e3452f8 Merge pull request 'betterbird: own Betterbird's whole configuration as settings, shown without a credential (hq issue 586, ADR 0329)' (#262) from fix/586-betterbird-owns-its-configuration into main 2026-10-13 00:25:41 +00:00
mesh-admin 827b89dbf1 Merge pull request 'Mark grafana's and home-assistant's soft requirements optional (hq issue 166, ADR 0330)' (#259) from fix/166-optional-requirements into main 2026-10-13 00:22:35 +00:00
jochen c9a711c259 betterbird: write the account lists only while the profile is closed, and never propose over an unreadable layer (hq issue 586, review 121)
mesh/merge-gate head c9a711c259ed merged into trunk 827b89dbf109 — pass: builds betterbird, sent nowhere; no bus step; 2 wait(s) for a person; every machin…
mesh/repo-check head c9a711c259ed merged into trunk 827b89dbf109 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
writeAccountLists checked only for a running Betterbird and wrote without a sync;
it now waits for the profile's lock too and writes atomically. readLayer read any
answer that was not JSON as an empty layer, and a proposal replaces the layer
whole, so a changed wording would have dropped accounts; it now refuses. The
README says the rollout order that leaves no node on Betterbird's defaults.
2026-10-13 02:12:06 +02:00
jochen 1835f0efad betterbird: own Betterbird's whole configuration as settings, shown without a credential (hq issue 586, ADR 0329)
mesh/merge-gate head 1835f0efad0f merged into trunk 39e7a06e6293 — pass: builds betterbird, sent nowhere; no bus step; 2 wait(s) for a person; every machin…
mesh/repo-check head 1835f0efad0f merged into trunk 39e7a06e6293 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery superseded: a newer head of the same pull request
The module owned only the accounts' server fields, so identities, signatures,
calendars, address books, filters and tags could differ between desktop nodes
unseen, and the operator's own preferences were shipped in the catalogue.

prefs becomes one entry per preference (a node's layer overrides one key) and the
defaults file keeps no value of the operator's; identities, calendars,
address-books, filters and tags become settings, adopted like accounts, filters
owned whole per account, and what Betterbird rewrites written only while it is
closed. New tools list each kind, derive the layers from every desktop node and
propose one at a time, and show drift; every answer passes a credential check.
2026-10-13 02:03:04 +02:00
mesh-admin 39e7a06e62 Merge pull request 'lab: runs name the one genesis bundle (issue 146)' (#261) from fix/146-lab-names-the-one-bundle into main 2026-10-12 23:22:05 +00:00
jochen 84911b94f3 Give the lab's runs the one genesis bundle (hq issue 146, ADR 0331)
mesh/merge-gate head 84911b94f3be merged into trunk 8f80cd471634 — pass: builds lab → shanks; no bus step; every machine composes with the change as it did…
mesh/repo-check head 84911b94f3be merged into trunk 8f80cd471634 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
mesh-host now carries one genesis bundle, foundation-first-node.lock; the -nats one the
lab's runs named is deleted, so every run would have raised from a file that is gone.
2026-10-13 01:18:08 +02:00
jochen 4193eacec4 Mark grafana's and home-assistant's soft requirements optional (hq issue 166, ADR 0330)
mesh/merge-gate head 4193eacec459 merged into trunk 39e7a06e6293 — pass: builds grafana, home-assistant → ace; no bus step; every machine composes with the…
mesh/repo-check head 4193eacec459 merged into trunk 39e7a06e6293 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
grafana runs without influxdb and home-assistant without the Servarr apps or a broker, yet a mesh
without them could not assign either. Their requirements are now optional: bound when a provider
runs, absent otherwise, with nothing installed for them. grafana's data source and its credential are
delivered only while influxdb-api is bound, into directories it mounts, so an absent file never
becomes a directory docker made in its place.
2026-10-13 00:41:30 +02:00
mesh-admin 8f80cd4716 Merge pull request 'Name the setting keys searxng, keycloak and betterbird files take (hq issue 168, ADR 0326)' (#252) from fix/168-files-name-the-settings-they-take into main 2026-10-12 22:37:05 +00:00
mesh-admin 43a9155d5a Merge pull request 'claude-code: a session asks the operator on Telegram and reads the answer by a key (issue 589, hq ADR 0332)' (#258) from fix/589-a-session-asks-the-operator into main 2026-10-12 21:30:22 +00:00
jochen c4eebce598 Keep a question's slot until it expires while the router has not recorded it (issue 589, review 107)
mesh/merge-gate head c4eebce598cb merged into trunk 9c9a76d220f8 — pass: builds claude-code → ace, g14, novox, shanks; no bus step; every machine composes …
mesh/repo-check head c4eebce598cb merged into trunk 9c9a76d220f8 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
Freeing the slot two minutes after asking could let a backlogged router still show
the question after the session read it as ended, and let a third question in. The
seat's queue offers work again after an error and the router drops a cancel of an
ask it has not recorded, so a cancel cannot take it back; once expired, the router
refuses it if taken late. The slot test now uses a stale revision, so a plain
write in place of the compare-and-set fails it.
2026-10-12 23:24:41 +02:00
jochen 14999f1d3e Keep no secret-shaped question, and hold the two-question cap across nodes (issue 589, review 105)
mesh/merge-gate head 14999f1d3ecb merged into trunk 9c9a76d220f8 — pass: builds claude-code → ace, g14, novox, shanks; no bus step; every machine composes …
mesh/repo-check head 14999f1d3ecb merged into trunk 9c9a76d220f8 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery superseded: a newer head of the same pull request
A question was kept on the bus before the router judged its words, so a token in
it reached a bucket every node reads: every word, the options' too, is now held
to the secret shapes and invisible characters before anything is kept, and a
refused question keeps none of its words. The cap was check-then-create and
failed open: each open question now holds one of two slot keys taken by create
or compare-and-set, refusing when a slot cannot be read. A question the router
never recorded frees its slot after two minutes instead of at expiry. by is
required; wording matches ADR 0332.
2026-10-12 23:15:05 +02:00
jochen e4e0ce7619 Say the router's refusal of a proposal only once its discard is kept (issue 589)
mesh/merge-gate head e4e0ce761901 merged into trunk 9c9a76d220f8 — pass: builds claude-code → ace, g14, novox, shanks; no bus step; every machine composes …
mesh/repo-check head e4e0ce761901 merged into trunk 9c9a76d220f8 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery superseded: a newer head of the same pull request
The refusal test failed on a loaded build agent: Propose returned on hearing the
refusal before HeardEnd had written the discard, so a reader right after saw
nothing decided. The question path keeps the same order.
2026-10-12 22:51:21 +02:00
jochen 9446bec2f9 Let an agent session ask the operator on Telegram and read the answer (issue 589, hq ADR 0332)
mesh/merge-gate head 9446bec2f969 merged into trunk 9c9a76d220f8 — pass: builds claude-code → ace, g14, novox, shanks; no bus step; every machine composes …
mesh/repo-check head 9446bec2f969 merged into trunk 9c9a76d220f8 — fail: its merge-check.sh failed: --- FAIL: TestTheRoutersRefusalIsSaidAndDiscards (0.08s)
mesh/delivery superseded: a newer head of the same pull request
A session that needs a decision could only ask in a terminal the operator may
not watch. claude_code_ask raises the question under this module's name at the
level approve, so only a channel that proves who answered carries it;
claude_code_answer reads the chosen option back by its key from the router's own
record, held to the question kept in the new questions state.
2026-10-12 22:44:45 +02:00
mesh-admin 9c9a76d220 Merge pull request 'Say how the build agent and the route proxy are ready (hq issue 544)' (#257) from fix/544-the-build-agent-says-it-is-ready into main 2026-10-12 20:00:06 +00:00
mesh-admin ac2d4dc46f Merge pull request 'mesh-issues: a fix's time from the move to fixing to delivered, and loud over its budget (issue 403, hq ADR 0313)' (#250) from fix/403-fix-time into main 2026-10-12 19:41:32 +00:00
jschoubben d09dbe33ac Say how the build agent and the route proxy are ready (novox/hq issue 544)
mesh/merge-gate head d09dbe33ac0f merged into trunk ac2d4dc46f4c — pass: builds build-agent, route-proxy → ace, g14, novox, shanks; no bus step; every mach…
mesh/repo-check head d09dbe33ac0f merged into trunk ac2d4dc46f4c — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
mesh/delivery-group group fix/544-the-build-agent-says-it-is-ready delivered: every member is delivered
Neither server declared health, so each walk that moved them waited out the
60 s grace on every machine. The build agent is ready when 'mesh-builder ready'
says it took its build work; the route proxy when its http port accepts.
2026-10-12 21:33:13 +02:00
jochen 005f802504 Merge remote-tracking branch 'origin/main' into fix/403-fix-time
mesh/merge-gate head 005f802504fc merged into trunk eb5f33f2b475 — pass: builds mesh-delivery, mesh-issues → novox; no bus step; every machine composes wit…
mesh/repo-check head 005f802504fc merged into trunk eb5f33f2b475 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery failed: its walk failed, carrying its own commit: a gate on a first machine (what it carried put back), a build, a machine
mesh/delivery-group group fix/403-fix-time failed: a member failed
# Conflicts:
#	modules/mesh-delivery/cmd/mesh-delivery/model.go
2026-10-12 21:28:55 +02:00
mesh-admin eb5f33f2b4 Merge pull request 'betterbird: ship the operator's approved configuration, and show and fix drift from it (hq issue 345)' (#256) from fix/345-betterbird-configuration into main 2026-10-12 19:22:14 +00:00
jochen c4b4bac757 betterbird: never report the theme as fixed by clearing prefs.js, and never manage an account's name (hq issue 345, review 96)
mesh/merge-gate head c4b4bac757c5 merged into trunk 0863ec2a059d — pass: builds betterbird, sent nowhere; no bus step; 1 wait(s) for a person; every machin…
mesh/repo-check head c4b4bac757c5 merged into trunk 0863ec2a059d — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
Betterbird keeps the active theme in its add-on database, and extensions.activeThemeID only mirrors it.
So clearing the preference changed nothing, and the drift report then hid that. The theme is now read
from extensions.json against default-theme@mozilla.org. Another theme is drift, and its remedy is the
add-on manager: config_apply never clears it and keep refuses it.

A credential's, an account's or a server's name in the defaults file is refused by code: it is never
answered or cleared, and the check names it. Apply also refuses while the profile's lock is there, and
syncs prefs.js before the rename. A saved default of a built-in preference is no longer drift.
2026-10-12 21:09:25 +02:00
mesh-admin 0863ec2a05 Merge pull request 'A merged pull request is announced with its branch, so its delivery group is found (hq issue 578)' (#255) from fix/578-a-group-waits-for-its-controller-member into main 2026-10-12 19:07:03 +00:00
jochen 854c85393c betterbird: ship the operator's approved configuration, and show and fix drift from it (hq issue 345)
mesh/merge-gate out of date: judged against trunk 2fe5601397ae, the trunk is now 0863ec2a059d; judged again on a recheck or when it is merged
mesh/repo-check out of date: judged against trunk 2fe5601397ae, the trunk is now 0863ec2a059d; judged again on a recheck or when it is merged
mesh/delivery superseded: a newer head of the same pull request
A module that installs Betterbird but leaves every node to its own settings does not set Betterbird up.
The operator approved the configuration on 2026-10-12, and it now ships in three layers. The policy
locks what must never differ: telemetry, add-on updates, the vendor's notices and the start page. A
defaults file carries the laptop's setup, so a fresh node gets it untouched. The setting prefs stays a
node's exceptions.

A default loses to a value the profile saved. betterbird_config shows that drift, betterbird_check
reports it, and betterbird_config_apply clears it while Betterbird is quit. keep proposes a node layer
instead of writing user.js. betterbird_prefs now reads Betterbird's real defaults from omni.ja, and
withholds of mailnews.* only what names an account. The default-client ask is decided from its own
policy entry, not from the first thing the policy lacks.
2026-10-12 20:58:39 +02:00
mesh-admin 2fe5601397 Merge pull request 'gitea: announce every open pull request's new head, not only the newest 20 (hq issue 579)' (#254) from fix/579-every-open-pull-request-is-announced into main 2026-10-12 18:58:27 +00:00
jschoubben 01d2ff7a46 Announce a merged pull request's branch, not its pull ref, so its delivery group is found (hq issue 578)
mesh/merge-gate head 01d2ff7a46bc merged into trunk 2fe5601397ae — pass: builds gitea → novox; no bus step; every machine composes with the change as it di…
mesh/repo-check head 01d2ff7a46bc merged into trunk 2fe5601397ae — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
The merge tool deletes the branch after merging; the forge then reads head.ref as refs/pull/<n>/head and only
head.label names the branch. pull.merged carried the pull ref, so the controller found no group for the merge,
cut mesh-catalog #251's walk at once and walked it before mesh-controller #271's, against the group's order.
2026-10-12 20:57:14 +02:00
jschoubben 961e57f7e4 Page the announcer through every open pull request updated since its last look
mesh/merge-gate head 961e57f7e403 merged into trunk 22071dabfc7b — pass: builds gitea → novox; no bus step; every machine composes with the change as it di…
mesh/repo-check head 961e57f7e403 merged into trunk 22071dabfc7b — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
A pass read only the 20 most recently updated open pull requests of a
repository, so a new head beyond them went unannounced and its merge check
never started (novox/hq issue 579). Pages stop at the floor; a sweep reads all.
2026-10-12 20:52:27 +02:00
mesh-admin 22071dabfc Merge pull request 'betterbird: lock the ask to be the default mail client off (hq issue 345)' (#253) from fix/345-no-default-client-check into main 2026-10-12 18:23:49 +00:00
jochen 481e44cdaa betterbird: lock the ask to be the default mail client off, so it never shows at a start (hq issue 345)
mesh/merge-gate head 481e44cdaa29 merged into trunk 23ea0f431faf — pass: builds betterbird, sent nowhere; no bus step; 1 wait(s) for a person; every machin…
mesh/repo-check head 481e44cdaa29 merged into trunk 23ea0f431faf — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
Betterbird's own default asks at every start with a window to become the default for mail, news, feeds and
calendar files. The policy locks mail.shell.checkDefaultClient off, which wins over a profile's own value, and
betterbird_check says when a node would still ask.
2026-10-12 20:17:38 +02:00
mesh-admin 23ea0f431f Merge pull request 'A verdict goes out of date when its trunk moves, and the merge tool refuses it (hq ADR 0323, issue 341)' (#251) from feat/341-a-merge-check-judges-the-merge into main 2026-10-12 16:11:28 +00:00
jschoubben 41b6e10077 Send the announced base head with a check mesh-delivery asks
mesh/merge-gate pass: builds gitea, 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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery delivered
mesh/delivery-group group feat/341-a-merge-check-judges-the-merge delivered: every member is delivered
The controller names it as the trunk commit of a verdict no build judges; a
recheck without it was answered with none (novox/hq ADR 0323).
2026-10-12 17:52:20 +02:00
jochen 2f55adc57c Name the setting keys searxng, keycloak and betterbird files take (hq issue 168, ADR 0326)
mesh/merge-gate head 2f55adc57c60 merged into trunk 43a9155d5afe — pass: builds betterbird, keycloak, searxng → ace; no bus step; 3 wait(s) for a person; e…
mesh/repo-check head 2f55adc57c60 merged into trunk 43a9155d5afe — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
Every setting reached every mergeable file: searxng's endpoints landed in
its settings.yml, keycloak's issuer in its tools' config, betterbird's
prefs would land in its accounts file. Each file now names the keys it
reads, so the controller lays only those over it and calls the rest
stray. Merge only after the controller that knows takes-settings
(mesh-controller #272) has walked: an older one hands the field to the
node-engine, which refuses it.
2026-10-12 17:47:27 +02:00
jschoubben 434506f9b1 Serve the merge tool beside the one writer, and close the review's gaps
mesh/delivery-group group feat/341-a-merge-check-judges-the-merge rejected: a member's own check failed
mesh/delivery superseded: a newer head of the same pull request
mesh/merge-gate pass: builds gitea, 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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
The merge tool's pending bypassed the merge check's one writer from the tools'
bundle (issue 456); it is now served beside it. A verdict no build judges names
the announced base head, so the merge tool no longer refuses it for ever. A
late verdict keeps its account, a trunk is read again in each head's turn so a
fresh verdict is not put out of date, and a delivered member counts when a
rebased member's group is composed again (novox/hq ADR 0323).
2026-10-12 17:46:39 +02:00
jschoubben 82cca650a2 Put a verdict out of date when its trunk moves, and refuse a merge on one
mesh/delivery-group group feat/341-a-merge-check-judges-the-merge checking: 0 of 3 member(s) ready
mesh/merge-gate pass: builds gitea, 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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery superseded: a newer head of the same pull request
A merge check's verdict now names the trunk commit it judged (novox/hq ADR
0323, issue 341), but the forge kept it green after another merge moved the
trunk, the merge tool merged on it, and mesh-delivery kept the delivery ready.
The forge's holder puts both statuses of every open pull request into a moved
trunk back to pending with the reason and runs nothing; the merge tool compares
the verdict's trunk commit with the trunk's head, asks the check once and never
merges by itself; mesh-delivery sends a ready delivery back to proposed, counts
only a verdict for the trunk's head, and composes a group again for a rebased
member instead of holding it.
2026-10-12 17:29:58 +02:00
mesh-admin cd2b6aacd8 Merge pull request 'mesh-reads: a node's files and accounts read as an account that holds no secret (hq ADR 0310, issue 418)' (#247) from fix/418-node-reads-2 into main 2026-10-12 14:52:18 +00:00
jochen aed7e0c968 Read the mesh's comment verdicts as approvals, and warn on no fix timed from the log (issue 403, review 69)
mesh/delivery-group group fix/403-fix-time checking: composed check out of date: novox/mesh-controller@7ca5a45ecc5a was judged against trunk ?, the trunk is no…
mesh/merge-gate out of date: judged against trunk 2fe5601397ae, the trunk is now 0863ec2a059d; judged again on a recheck or when it is merged
mesh/repo-check out of date: judged against trunk 2fe5601397ae, the trunk is now 0863ec2a059d; judged again on a recheck or when it is merged
mesh/delivery superseded: a newer head of the same pull request
The forge refuses an approval of one's own pull request, so the independent
reviews are comment reviews opening with "Verdict: approve at <sha>"; read
only APPROVED, the review phase was never measured. A fix whose start was
read back from the log was fixed before fix times were measured: it is kept
and shown, never warned on. A carried value at fixing after one at diagnosing
starts at its worker's active (the move), not its since (the diagnosis).
2026-10-12 16:16:04 +02:00
jochen 43df7ebc5a Measure each fix from the move to fixing to delivered, and say one over its budget (issue 403)
mesh/merge-gate pass: builds mesh-delivery, mesh-issues → 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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery-group group fix/403-fix-time ready: every member ready, and composed together they pass
mesh/delivery superseded: a newer head of the same pull request
hq ADR 0313: the operator's five minutes start when an agent moves the issue
to fixing, not at the merge. mesh-delivery says on each transition, and in
checks, when the pull request opened, when it merged and the class of what it
moves. mesh-issues keeps the first move to fixing, the fix time with its four
phases once the last fix is delivered, serves fix-times, and says
issue-tracker.fix.<class>.over-budget on the operator's channel, cleared by the
next fix of that class within budget.
2026-10-12 15:50:12 +02:00
mesh-admin c5e1bc7bb4 Merge pull request 'mesh-issues: a resolved issue ends its fix-stuck warning, and a closed issue's fix is never said stuck (hq issue 547)' (#239) from fix/547-resolved-ends-stuck into main 2026-10-12 13:21:49 +00:00
mesh-admin 6190b40175 Merge pull request 'artifact-store-tools: an image a definition pins, under any name the store has had, is named by a definition (hq issue 323)' (#249) from fix/323-named-by-a-definition-2 into main 2026-10-12 13:08:31 +00:00
mesh-admin edf39a3b74 Merge pull request 'betterbird: Betterbird from its pinned release, Owl by policy, accounts declared without credentials and adopted, and a reminder test that is visibly a test (hq issue 345, ADR 0316, ADR 0317)' (#240) from fix/345-betterbird-module into main 2026-10-12 12:54:53 +00:00
jochen 15983e2f96 betterbird: an account's own aliases and identities that do not send by SMTP share no outgoing server, and the pins name their vendors on purpose (review 36, review 35 of mesh-controller #251)
mesh/merge-gate pass: builds betterbird, sent nowhere; no bus step; 1 wait(s) for a person; every machine composes with the change as it did without (4 of …
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery failed: its walk failed, carrying its own commit: a gate on a first machine (what it carried put back), a build, a machine
The shared-server check refused an adopted account for its own alias, and
counted Owl's, local folders' and feeds' identities as users of the default
server, though they never send by SMTP. The controller now judges an
upstream pin's host like any name, so each pin declares its vendor.
2026-10-12 14:49:21 +02:00
jochen 263c86d2e5 betterbird: withhold account values in the user.js answer, never write a shared outgoing server, and say a newer Owl as a fact (review 34, hq issue 345, ADR 0317)
The user.js answer carried declared addresses and hosts (ADR 0316 §16). An
adopted identity's outgoing server may be shared with an account the module
does not own, whose sending the declared values would change at every start:
refused with the remedy, since ADR 0317 §2 leaves the identity's server and
the lists as they are; a recorded adoption follows the identity's current
server. Owl installed by policy cannot be removed, so a newer Owl than the
pin is a fact whose remedy is moving the pin.
2026-10-12 14:49:21 +02:00
jochen ec4ab7afe7 betterbird: install Betterbird from its pinned release, ship Owl by policy, and declare the accounts without credentials (hq issue 345, ADR 0316, ADR 0317)
A module that cannot set Betterbird up is of little use (the operator,
2026-10-12). The module now declares the community package absent and
unpacks the pinned upstream release where that package had it, so the same
profile opens; places the enterprise policy that turns Betterbird's updater
off and force-installs Owl from the xpi the mesh pinned by its vendor's
digest (never in a repository); and takes the operator's accounts from the
trusted setting accounts, without any password or token: an account the
profile already holds is adopted under its own keys, a new one is created
under the module's mesh_ keys, an ambiguous one is refused with the remedy,
and the account lists are only written into. betterbird_accounts says the
profile's accounts by count and proposes the setting built from them for the
operator's Approve. Every new build waits for the operator's push. The
reminder test is visibly a test (MESH TEST band, event name) and its
description says it opens a window on the operator's screen.
2026-10-12 14:49:21 +02:00
jochen 0e6f382bb7 betterbird: keep the reminder test's update check off so no About window takes the screen (hq issue 345)
A fresh profile has never checked for updates, so Betterbird checked at the
test's start, found a newer build published and opened its About window on the
operator's screen instead of leaving the reminder to show. The test's own
preferences now also come last in its user.js, so nothing declared or carried
overrides them.
2026-10-12 14:49:21 +02:00
jochen d745a4d31e artifact-store-tools: say an image a definition pins is named by a definition, under any name the store has had (issue 323)
mesh/merge-gate pass: builds artifact-store-tools → 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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery delivered
No record names an image the mesh did not make, so one a definition pins under an
older name of the store read as unrecorded, the same word as an image nothing
uses. The tool now reads every definition through the catalogue and says
named-by-a-definition with the module. The store's older names are the mesh's
setting older-store-names, since a catalogue names no installation.
2026-10-12 14:47:06 +02:00
jochen 994cab533b mesh-reads: read a node's files and accounts as an account that holds no secret (hq ADR 0310, issue 418)
mesh/merge-gate pass: builds new: modules/mesh-reads, sent nowhere; 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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery delivered
Reading a file, searching one, listing a directory, the owner and mode of each
component of a path and looking up an account went through the login shell's
execute as the operator's account, which sees the operator's keys and the
agent's credentials. This module holds node-reads on every node and runs as
mesh-reads (no login, no group beside its own). It refuses the node's secret
places by name, judging the file actually opened rather than the name asked,
so a flipped link reaches nothing refused; withholds any line shaped like a
secret through mesh-sdk's outward rule and every line of a BEGIN/END block;
and bounds every answer. Replaces mesh-catalog #244 (reviews 55), whose first
commit carried a built binary.
2026-10-12 14:32:22 +02:00
mesh-admin 2772120758 Merge pull request 'mesh-delivery: say how many owed effects were tried since the start (hq issue 407)' (#246) from fix/407-owed-effects-are-counted into main 2026-10-12 12:23:36 +00:00
mesh-admin 13a7d40ac5 Merge pull request 'mesh-knowledge: a record may change a drafted clause, and a record whose clauses others link by number is never redrafted (hq issue 494, ADR 0321)' (#245) from fix/494-change-a-drafted-clause into main 2026-10-12 12:18:34 +00:00
jschoubben a3ad3c5061 Count the owed effects mesh-delivery tried, so a window's writes can be judged against them
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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery delivered
delivery_status said only what is owed now; issue 326's rule needs the tries made in a window.
It now says the tries since this owner started, by how each ended, and the last try's time.
(novox/hq issue 407)
2026-10-12 14:16:58 +02:00
mesh-admin 5b75499ea6 Merge pull request 'mesh-delivery: say an unanswered line's delivery state as data (hq issue 451)' (#243) from fix/451-a-recheck-is-the-step into main 2026-10-12 12:14:45 +00:00
mesh-admin 0366208274 Merge pull request 'nats: say whether a user's grant is in force on the running server (review of hq issue 353)' (#166) from feat/353-the-bus-says-a-grant-is-in-force into main 2026-10-12 12:13:25 +00:00
jochen 9f893b6305 mesh-knowledge: let a record change a drafted clause, and never redraft a record whose clauses others link by number
mesh/merge-gate pass: builds mesh-knowledge → 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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery delivered
A changes link onto a drafted clause was refused, so records that really
change such a rule could only cite it (ADR 0294, 0301, 0320); and return
renumbered ADR 0281's clauses, pointing other records' links at other
clauses. The changed drafted clause keeps its number and stays unserved;
return refuses a record whose clauses another record changed or links by
number. Issue 494, ADR 0321.
2026-10-12 14:05:37 +02:00
mesh-admin d1baca9b94 Merge pull request 'mesh-delivery: a stop a later merge covered reads covered, and stays in deliveries while stalled lists it (hq issue 480)' (#242) from fix/480-a-covered-stop-reads-covered into main 2026-10-12 11:56:09 +00:00
mesh-admin 3a5c86b5d6 Merge pull request 'messenger: a replaced ask's own message ends with it, and a cleared record owes nothing (hq issue 548)' (#241) from fix/548-replaced-ask-message into main 2026-10-12 11:50:29 +00:00
jschoubben befe451f6d mesh-delivery: say an unanswered line's delivery state as data (hq issue 451)
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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery delivered
mesh/delivery-group group fix/451-a-recheck-is-the-step delivered: every member is delivered
The controller advises a recheck for a ready or rejected delivery whose
check answered once, and must read that state from the line, not its words.
2026-10-12 13:47:52 +02:00
jschoubben 7ad54356fd Say in the deliveries verb's description that a stop stalled lists stays listed
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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery delivered
2026-10-12 13:46:55 +02:00
jschoubben 69f76a53e7 Read a stop a later merge covered as covered, and keep it in deliveries while stalled lists it
A stop for a walk that never built its modules (issue 474) kept saying "error: stopped" on the forge and in
show after a later delivered merge carried its change to every machine. The stop stays final, as a covered
superseded member stays superseded (issue 442); its status, view, note and show now say covered and by which
merge, told once and from what is kept, so a restart or a stop covered before this tells it too. deliveries
dropped such a stop after a day while stalled still listed it; it now lists it as long as stalled does.

novox/hq issue 480, points 1 and 3.
2026-10-12 13:46:23 +02:00
mesh-admin b82aa1691c Merge pull request 'mesh-knowledge: the review asks changes for a decision nothing would cite, so its acceptance never turns the module unhealthy (hq issue 504)' (#237) from fix/504-propose-refuses-uncited-record into main 2026-10-12 11:44:58 +00:00
jochen 7e27259a64 messenger: end a replaced ask's own message, and drop what a cleared record owed (issue 548)
mesh/merge-gate pass: builds messenger → 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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery delivered
A replaced ask's message was left open as if the newer ask took it over, but
the newer ask opens a message of its own, so the old one kept offering answers
nobody takes. It now ends on every channel with 'Replaced by a newer question',
and the minute's sweep ends any such message left from before. A record that
clears no longer keeps what a walk owed of it (the review of PR 238).
2026-10-12 13:44:08 +02:00
mesh-admin 3fe19c9c0e Merge pull request 'messenger: a walk's held messages are told only once its message was sent, handed on only as a first hold would take them, and its start and end compared on the controller's clock (hq issue 538)' (#238) from fix/538-walk-hold-told-handover-clock into main 2026-10-12 11:41:47 +00:00
jochen 424fc5a281 messenger: keep what a walk held owed in the state until its message was sent, so a restart says a held warning too (issue 538, review 39)
mesh/merge-gate pass: builds messenger → 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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery delivered
A warning held on purpose and lost with the walk's message at a restart was turned
into history and never told. Name the walk in the told line from its own item.
2026-10-12 13:35:48 +02:00
jochen f067fab9db mesh-issues: end a fix-stuck warning when its issue is resolved, and never say a closed issue's fix stuck (issue 547)
mesh/merge-gate pass: builds mesh-issues → 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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery delivered
A resolved issue's worker proved the acceptance live, so a fix whose own walk failed
and that a later merge carried is not waited for. Only wontfix ended the warning, so
16 warnings stayed open on the operator's channel and the sweep raised new ones for
resolved issues.
2026-10-12 13:31:51 +02:00
jochen 56a9a1b082 mesh-knowledge README: say a failed whole-set rule is a warning to the operator and leaves the module healthy (hq issue 504)
mesh/merge-gate pass: builds mesh-knowledge → 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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery delivered
2026-10-12 13:27:06 +02:00
jochen df94be0e60 messenger: mark a walk's held messages told only once its message was sent, hand on only what a first hold would take, compare a walk's start and end on the controller's clock (issue 538)
mesh/merge-gate pass: builds messenger → 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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery superseded: a newer head of the same pull request
A message lost with a restart before it was sent left what it carried marked told,
so it reached the operator only as its reminder hours later. A later walk took over
silenced, already said or asked messages that ADR 0308 section 3 never holds. A start
capped at the router's now was compared with the controller's moments.
2026-10-12 13:26:41 +02:00
jochen 8f5e8bcb6e mesh-knowledge: tell a failed whole-set rule to the operator as a warning naming the records, and leave the module healthy (hq issue 504)
mesh/delivery superseded: a newer head of the same pull request
mesh/merge-gate pass: builds mesh-knowledge → 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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
The operator decided on 2026-10-12 that a rule of the record set that fails
is a fault of the records, which their authors repair, not the module
failing: as issue 508 decided for a proposal never reviewed, health says
only whether the module works, and a finding must not hold the module's
own rollouts. The findings go through the notice the knowledge base
already sends (operator-channel.notify, a warning), once per finding,
kept as told across restarts, and cleared when the rule holds again.
2026-10-12 13:25:09 +02:00
jochen ca3c3e6dfd mesh-knowledge tests: set the local zone once before any goroutine, not under a running bus server (race found by the repository check on #237)
inZone wrote time.Local while the bus test's nats server read it from a
goroutine that outlives its test; the race detector the repository check
now runs failed TestAReviewOwedOutlivesARestartAndItsLastTryIsSaid.
2026-10-12 13:25:09 +02:00
jochen 2051d8b80b mesh-knowledge: ask changes in the review for a decision nothing would cite, so its acceptance never turns the module unhealthy (hq issue 504)
The whole-set rule cited ran only after acceptance: the review approved a
decision with no home and no record linking it, the rule then failed, and
the module's health turned red for a fault its author could only repair
after the fact. hq held the rule at the record's own merge. The review now
judges it ahead, before the judgement: such a decision stays proposed with
a changes finding naming the rule. It is not refused at propose, because
no record can link a number only reserved.
2026-10-12 13:25:09 +02:00
mesh-admin 3e84581927 Merge pull request 'messenger: hold what a delivery walk touches, tell its end once, and say nothing again for a restart (hq ADR 0308, issues 532, 531)' (#230) from adr-0308-hold-during-walks into main 2026-10-12 11:21:16 +00:00
mesh-admin 4d9cb0827f Merge pull request 'gitea forge bundle: the parent the forge used, protection rules as the forge matches them, size and time bounds, one HTTP client (issue 539)' (#236) from fix/539-forge-commit-hardening into main 2026-10-12 10:58:07 +00:00
jochen ac90b35b49 guard.ts: cover every branch for a rule Go's regexp reads otherwise, and hold both matchers to one table (issue 539)
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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery delivered
Review 30 found guard.ts missing branches the forge protects: Go reads \d, \w,
\pL, a class opening with ] or ^, and [[:digit:]] otherwise than JavaScript.
A rule using any of them now covers every branch, so the guard refuses more
than the forge, never less. test/rule-cases.json holds the forge's answers,
read by the Go and TypeScript tests alike.
2026-10-12 12:51:22 +02:00
jochen f2d765320a gitea forge bundle: name the parent the forge used, match protection rules as the forge does, bound a commit's size and a search's time (issue 539)
The forge's contents and diffpatch endpoints take a branch, never a commit to
build on, so the parent is read from the commit the forge made, and a branch
that moved under the commit is said. Blobs are read at the commit read first,
not at the branch name. Protection rules match as Gitea 1.27 matches them
([...], {a,b}, escapes, a plain name in any case), in the Go bundle and in
guard.ts alike. One commit carries at most 4 MiB per file or patch and 16 MiB
together; code search stops at its deadline and says so. The HTTP client the
two Go bundles copied is one package, internal/forgehttp.
2026-10-12 12:51:22 +02:00
mesh-admin c5abdf35ed Merge pull request 'A module whose tests could not run fails the repository check (issue 385)' (#231) from fix/385-an-untestable-module-fails-the-check into main 2026-10-12 10:48:11 +00:00
mesh-admin 9d6bd2d475 Merge pull request 'gitea: a recheck's pending goes through the one writer of mesh/merge-gate (issue 456)' (#234) from fix/456-a-recheck-writes-through-the-one-writer into main 2026-10-12 10:32:00 +00:00
mesh-admin d06733c490 Merge pull request 'mesh-knowledge: the bus test passes on any day (issue 540)' (#235) from fix/540-knowledge-test-clock into main 2026-10-12 10:31:18 +00:00
jochen 7628b0e5b9 mesh-knowledge: give the bus test's bundle a fixed clock, so it passes on any day (issue 540)
mesh/delivery delivered
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
The bundle dated a reservation from the real day while the proposal kept the
fixture's 2026-10-11, so the date rule refused it on every later day.
2026-10-12 12:23:38 +02:00
jschoubben 5053e96d4e Route a recheck's pending through the gitea module's one writer of mesh/merge-gate
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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery delivered
The recheck's pending was written from the tools' bundle, its own process, past the
writer's per-head chain: the first run's verdict mark stayed set, so a rechecked head
never said where its new run builds, and an old run's verdict in flight or redelivered
could land over the pending. The status tool is now served from the watcher's bundle
and writes the merge check's pending through the writer, and a verdict whose ask
(checked.id) predates the check now pending is dropped, weighed after a restart against
the forge's own history of the gate (novox/hq issue 456).
2026-10-12 12:20:23 +02:00
mesh-admin b707f27911 Merge pull request 'gitea: an agent's commit on a branch of its own, and the reads around it (issue 375)' (#233) from fix/375-a-gitea-commit-tool into main 2026-10-12 10:14:05 +00:00
jschoubben 4fdb047cc8 nats: say whether a user's grant is in force on the running server, not only written (hq issue 353)
mesh/merge-gate pass: builds nats, sent nowhere; merging this needs a person's bus upgrade; 1 wait(s) for a person; every machine composes with the change …
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery delivered
nats_user_can read the user list on the machine. A list written is not a list the bus enforces until
the server reloads it: on 2026-10-09 the controller asked between the write and the reload's effect.
The answer now carries in_force — allowed in the file, and the server's config_load_time after the
file's write — with why when it is not, and anything not known is not in force.
2026-10-12 12:13:03 +02:00
jochen a92c08fd5b messenger: keep what a walk held until it is said, and open no walk whose end came first (review of #230)
mesh/merge-gate pass: builds messenger → 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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery delivered
- No channel ready at a walk's end or bound: what it held is left unsent and tried
  again every minute, as any other message, instead of lost with the walk.
- At a walk's end or bound, a message another open walk touches is held on by
  that walk, even one started after it was held (hq ADR 0308 §2).
- A walk read back past its forget is released at its bound first, then forgotten.
- A walk's end is kept a day: a start heard after it (same started_at, or not
  later than the end) opens nothing, also after the router restarts; a later
  start under the same id still opens.
- A walk's plain end, when its words cannot be said, leaves what it held to be
  said on its own instead of marking it told.
- The restart pass no longer skips a held message: one with an open ask is never
  held (ADR 0308 §3), so its answers are made to work again.
2026-10-12 12:07:38 +02:00
jochen 9eb2ea8d69 gitea: let an agent put a commit on a branch of its own, and read history, compares, reviews, directories and code (issue 375)
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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery delivered
An agent could not get a finished change onto the forge without someone
pushing by hand, so the branch / pull request / approval rule could not be
followed. A Go bundle serves gitea_commit, held to gitea_api's rule (never the
default branch, never a protected one), and the summarised reads agents
otherwise made through gitea_api as raw JSON.
2026-10-12 12:06:23 +02:00
jochen de8a866189 Fail the check when a module's tests could not run, and take a pinned SDK from its clone beside (issue 385)
mesh/delivery delivered
mesh/delivery-group group fix/385-an-untestable-module-fails-the-check delivered: every member is delivered
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: THE CHANGE ALTERS ITS OWN CHECK (merge-check.sh, merge-check-typescript.sh): main's version judged it; the change's judges the pull r…
A module whose dependencies could not be fetched was said NOT TESTED and the
repository check passed, so a merge on it would have landed untested code.
The build seat clones the SDK beside every check already: a forge requirement
the module cache lacks is now taken from that clone through a go.mod of its
own, and a module that still cannot be built fails the check by name and why.
A TypeScript module whose dependencies the toolchain lacks fails the same way.
2026-10-12 12:03:50 +02:00
jschoubben 991ffcfda0 messenger: run a walk's bound from its started_at, as the controller's walk events say it (hq ADR 0308 §5)
mesh/merge-gate pass: builds messenger → 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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery superseded: a newer head of the same pull request
2026-10-12 11:56:30 +02:00
jschoubben 2927cb2ceb messenger: hold what a delivery walk touches and tell its end once, and say nothing again for a restart (hq ADR 0308, issues 532, 531)
A delivery's own restarts reached the operator as notifications while it ran, and every restart of the
tool runner popped the four silenced messages up again on the desk, twice. The router now reads the
controller's walk start and end on plan-moved, holds what an open walk touches until its end or bound,
tells the end as one message, and makes the restart pass once per restart from its own state, leaving
silenced, withheld, answered and held messages as they are.
2026-10-12 11:55:35 +02:00
mesh-admin da296cacc6 Merge pull request 'mesh-delivery: give a waiting walk its word or say what holds it (issues 529, 362)' (#229) from fix/529-a-waiting-walk-is-answered-or-named into main 2026-10-12 09:55:20 +00:00
jschoubben 1de89cd702 Name a walk of nothing-to-move merges instead of letting it go (issue 529)
mesh/delivery delivered
mesh/delivery-group group fix/529-a-waiting-walk-is-answered-or-named delivered: every member is delivered
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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
The review showed the give-the-word rule skipped a failed or missing check
and group order, and let go moves nothing judged; to-be 47 has no such path.
Such a walk is now said, by stalled with walks, as held by nothing, why.
2026-10-12 11:33:51 +02:00
jschoubben 2bee70a478 Give a waiting walk its word or say what holds it (issues 529, 362)
mesh/delivery superseded: a newer head of the same pull request
mesh/delivery-group group fix/529-a-waiting-walk-is-answered-or-named checking: 0 of 2 member(s) ready
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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
A merge whose plan sends nothing is delivered at once, yet the controller
can still open a walk for it; no live delivery then held the walk and it
waited for a person (the nzbgeek case). Give such a walk its word.

Name each held delivery in the status, and let stalled say, for every
waiting walk, what holds it, so the controller's plans line points at the
person's decision instead of a hand start.
2026-10-12 11:24:30 +02:00
mesh-admin 6546c05f6e Merge pull request 'mesh-issues: the issues are kept in the log changes, and the bucket is their fold (hq ADR 0300, issues 501, 502)' (#228) from adr-0300-issues-on-a-log into main 2026-10-12 08:46:10 +00:00
jochen d413da203c mesh-issues: resume an interrupted carry by revision and fault one issue instead of the whole start, so no kept value is lost and the tracker always starts (review of PR 228)
mesh/delivery delivered
mesh/merge-gate pass: builds mesh-issues → 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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
An open's lost answer is judged by the issue's first entry; an entry that does not
fold faults its issue on start; a value written beside the log is never folded over;
one issue is kept in at most 252 KiB so its whole operation fits a log entry.
2026-10-12 10:35:57 +02:00
jochen d33f3e3f71 mesh-issues: pin mesh-sdk at main 9747f3e, where the log and the history are both merged
mesh/merge-gate pass: builds mesh-issues → 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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery superseded: a newer head of the same pull request
2026-10-12 10:22:19 +02:00
jochen dcc7510cd8 mesh-issues: carry each value the issues bucket kept, read through the runtime's history, so the log begins with all the bucket still knew (hq ADR 0300 §6, ADR 0307)
mesh/merge-gate pass: builds mesh-issues → 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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery superseded: a newer head of the same pull request
2026-10-12 10:20:51 +02:00
jochen 39da2cd793 mesh-issues: keep the issues bucket at 8 values until the carry can read them, so the bus drops nothing before it is carried (hq ADR 0300 §6)
mesh/merge-gate fail: builds mesh-issues → novox; no bus step; a manifest the change touches fails the module check: modules/mesh-issues/module.json: this …
mesh/repo-check fail: its merge-check.sh failed: files asking for a setting without saying whether it is trusted: 25
mesh/delivery superseded: a newer head of the same pull request
2026-10-12 02:43:58 +02:00
jochen 983d434623 mesh-issues: keep every issue in the log changes and the bucket as its fold, so the record outlives the bucket's cap and a week of events (hq ADR 0300, issues 501, 502)
mesh/merge-gate fail: builds mesh-issues → novox; no bus step; a manifest the change touches fails the module check: modules/mesh-issues/module.json: this …
mesh/repo-check fail: its merge-check.sh failed: files asking for a setting without saying whether it is trusted: 25
mesh/delivery superseded: a newer head of the same pull request
Every write appends one operation at the issue's last sequence before the
bucket is written; a refused append writes nothing. The start carries what
the bucket holds, refolds what is behind and indexes every issue in memory,
so list answers from memory with a ranked search and a before cursor.
2026-10-12 02:41:54 +02:00
mesh-admin 8a92a47bf7 Merge pull request 'Hold a leaf delivery to three minutes in times (hq ADR 0304 decision 4, issue 525)' (#227) from feat/525-leaf-budget-three-minutes into main 2026-10-12 00:02:25 +00:00
jschoubben 2711e812c8 Hold a leaf delivery to three minutes in times (hq ADR 0304 decision 4, issue 525)
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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery delivered
mesh/delivery-group group feat/525-leaf-budget-three-minutes delivered: every member is delivered
ADR 0304 lowered a leaf module's delivery budget from five minutes to
three; times says each class against the controller's same budget.
2026-10-12 01:45:14 +02:00
mesh-admin a0fb555f79 Merge pull request 'Read the tunnel through sudo where the tool runner is not root (hq issue 522)' (#226) from fix/522-tunnel-escalates into main 2026-10-11 22:07:54 +00:00
jschoubben b1280f81a2 Read the tunnel through sudo where the tool runner is not root (hq issue 522)
mesh/merge-gate pass: builds netcheck → 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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery delivered
On novox the tool runner runs as the operator's account, and wg show
answered 'Operation not permitted'. Root is the module's concern: the
command runs as given when the process is root, else through sudo -n,
which the sudo module allows the runtime's account, as nftables does.
2026-10-12 00:05:04 +02:00
mesh-admin d2dff99bcf Merge pull request 'Show the tunnel's peers as each machine sees them, and say on the bus when they move (hq issue 522)' (#225) from feat/522-tunnel-peers into main 2026-10-11 21:55:14 +00:00
jschoubben c31e29f9a6 Keep watching the tunnel after a failed look, and remember peers' names
mesh/merge-gate pass: builds netcheck → ace, g14; 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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery delivered
2026-10-11 23:51:40 +02:00
jschoubben 4fc769ed29 Show the tunnel's peers as each machine sees them, and say on the bus when they move (hq issue 522)
mesh/delivery superseded: a newer head of the same pull request
mesh/merge-gate pass: builds netcheck → ace, g14; 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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
ace's tunnel leg lost most packets while its own check called the tunnel
healthy, and nothing on the mesh could say which address and port the
hub saw for each peer, which is what tells a home router's port mix-up
from anything else. netcheck_tunnel answers each peer's endpoint, last
handshake and traffic, never a key, and netcheck now says when a
peer's endpoint changes or its handshake goes stale or fresh again.
2026-10-11 23:50:17 +02:00
mesh-admin 11be8a548a Merge pull request 'mesh-delivery: a stop ends only its own change on a shared walk (hq issue 459, ADR 0299)' (#221) from feat/459-a-stop-re-walks-the-others into main 2026-10-11 19:49:16 +00:00
mesh-admin f7fdb5e87b Merge pull request 'restic: nothing declared has no overdue night; a count's age is judged by awake time (issue 489)' (#224) from fix/489-nothing-declared-is-healthy into main 2026-10-11 19:31:05 +00:00
jschoubben 7f05fad58a restic: judge no night overdue where nothing is declared, and a count's age by the time the machine was awake
mesh/merge-gate pass: builds restic → 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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery delivered
A machine that backs up nothing read as missing its night after 26 hours up, and a laptop's count, last
written before it slept, read as stale on waking until the next look (novox/hq issue 489).
2026-10-11 21:10:11 +02:00
mesh-admin fca2f92216 Merge pull request 'mesh-issues: reserve's description says it asks the knowledge base (issue 493)' (#223) from fix/493-reserve-says-what-it-reads into main 2026-10-11 19:07:27 +00:00
jschoubben c0710e2db7 Say that reserve asks the knowledge base for the highest number, not hq (hq issue 493)
mesh/merge-gate pass: builds mesh-issues → 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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery delivered
Since #214 reserve reads knowledge_highest; its description still named hq's folders.
2026-10-11 20:53:21 +02:00
mesh-admin 3fa076829f Merge pull request 'mesh-knowledge, claude-code: a long record is reviewed within a bound sized to it, and a proposal waiting on its author leaves the module healthy (hq issue 508)' (#222) from fix/508-a-long-record-is-reviewed into main 2026-10-11 18:41:53 +00:00
jschoubben fcab25604e Review a long record within a bound sized to it, and let a proposal waiting on its author leave the knowledge base healthy (hq issue 508)
mesh/merge-gate pass: builds claude-code, mesh-knowledge → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (…
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery delivered
A judgement over one bus call's 30 seconds is collected by its ticket from
claude-code, so an eleven-clause record can be reviewed; a proposal out of
review tries is told to the operator, not raised as the module's fault, so
it no longer holds the module's own rollouts.
2026-10-11 20:32:33 +02:00
jschoubben 3d7f9a3e66 mesh-delivery: say a shared-module leave-out truly, hold back only what was stopped by an act, and say a held walk where it is read (review of #221)
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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery delivered
mesh/delivery-group group feat/459-a-stop-re-walks-the-others delivered: every member is delivered
A merge left out for sharing a module read as containing the stopped merge; the
safety net also held back merges stranded by their walk, which nobody stopped;
a held walk said why only in the log; and a stop of a merge in a batch did not
ask the batch.
2026-10-11 20:27:55 +02:00
jschoubben 8d7f60c9e2 mesh-delivery: never ship a stopped merge walked again, and stop only once the walk is (review of #221, hq issue 459)
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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery-group group feat/459-a-stop-re-walks-the-others checking: 2 of 3 member(s) ready
mesh/delivery superseded: a newer head of the same pull request
A merge walked again into a batch and then stopped by its group stayed in that
batch, which was later let go. Any walk or batch that names a stopped
delivery's merge is now asked to leave it out and is never let go; a refused
leave-out stops the whole walk. A person's stop marks the delivery stopped only
after its walk is, and the answer says what stays on the machines and what
will still carry the change.
2026-10-11 20:16:37 +02:00
jschoubben aeab5b80b4 mesh-delivery: a stop ends only its own change on a shared walk (hq issue 459, ADR 0299)
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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery-group group feat/459-a-stop-re-walks-the-others checking: 2 of 3 member(s) ready
mesh/delivery superseded: a newer head of the same pull request
A group stop ended the stopped member's walk, and with it every other delivery
the batch put on that walk. It now asks delivery-stop to leave the stopped
merges out, so the others are walked again; a person's stop on a shared walk
first says what ending the walk would take down and acts only on a way.
2026-10-11 19:59:43 +02:00
mesh-admin 977e7a6fd2 Merge pull request 'mesh-knowledge: a review owed says when it is due, survives a restart, and its last try is told to the operator (hq issue 497)' (#219) from fix/497-a-review-owed-outlives-a-restart into main 2026-10-11 17:49:34 +00:00
jschoubben c394ecd9fd mesh-knowledge: give a never-reviewed notice its resolved line when raised, and test a refused clear and local time in a set zone (review of #219)
mesh/merge-gate pass: builds mesh-knowledge → 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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery failed: its walk failed, carrying its own commit: a gate on a first machine (what it carried put back), a build, a machine
The operator's channel edits a cleared message with the resolved line it was raised with, so one sent only with
the clear was never shown.
2026-10-11 19:41:45 +02:00
jschoubben d45761678a mesh-knowledge: clear a never-reviewed notice once the record moves on, key it by version and run of tries, say times locally (review of #219)
mesh/merge-gate pass: builds mesh-knowledge → 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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery superseded: a newer head of the same pull request
A notice left open kept telling the operator a record was proposed and not in force after it was proposed again
or accepted, and a later version's notice merged into it.
2026-10-11 19:36:14 +02:00
mesh-admin 9f63231db9 Merge pull request 'mesh-issues: take every condition key the controller raises and every delivery id mesh-delivery gives (hq issue 475)' (#218) from fix/475-a-delivery-condition-can-be-tracked into main 2026-10-11 17:28:34 +00:00
jschoubben 98d494619f mesh-issues: take a delivery's id as mesh-delivery forms it, owner/repo@commit (hq issue 475)
mesh/merge-gate pass: builds mesh-issues → 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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery delivered
The delivery link's own pattern refused the slash and the @ every delivery id
carries, so no delivery could be linked either. Mirror mesh-delivery's IDOf.
2026-10-11 19:24:23 +02:00
jschoubben 9cfb5f9edb mesh-knowledge: keep when an owed review is due, say it in the history, and tell the operator once after its last try (hq issue 497)
mesh/merge-gate pass: builds mesh-knowledge → 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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery superseded: a newer head of the same pull request
A failed review's try count was already kept with the version, but not when it was asked again, so a 10-minute
pause read as a review lost to a restart; and after the last try only the history and the health said so.
2026-10-11 19:24:07 +02:00
jschoubben 7714f164c3 mesh-issues: take every condition key the controller raises, a delivery's with its slash among them (hq issue 475)
mesh/delivery superseded: a newer head of the same pull request
mesh/merge-gate pass: builds mesh-issues → 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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
The tracker's own pattern refused the slash a delivery's id carries, so no issue
could track a delivery's condition and the operator's alert kept saying no issue
tracks it. Mirror the controller's key rule instead of a pattern of our own; a
router test proves a slashed key is matched and named end to end.
2026-10-11 19:22:18 +02:00
mesh-admin fccad8c0a6 Merge pull request 'mesh-issues: say a delivered fix left in fixing, and flag an abandoned issue (hq issue 483, ADR 0294)' (#212) from fix/483-a-delivered-fix-is-said into main 2026-10-11 16:52:58 +00:00
mesh-admin 58f077e296 Merge pull request 'restic: a backup taken by hand outlives the next thinning (hq issue 453)' (#215) from fix/453-a-hand-backup-is-kept into main 2026-10-11 16:13:45 +00:00
mesh-admin 0a55831992 Merge pull request 'docker: the module's state is in the mesh's directory, never /var/lib/docker (hq issue 452)' (#217) from fix/452-docker-state-is-its-own into main 2026-10-11 16:07:35 +00:00
jschoubben 7e6889a646 docker: keep the module's state in the mesh's directory, never Docker's own
mesh/merge-gate pass: builds docker → 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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery delivered
#205's "place": "." resolved to /var/lib/docker, where the node-engine places
nothing (issue 339), so ace refused the apply and put the module back. The state
directory now says "place": "mesh", <root>/mesh/docker, and a test holds every
directory and path of the module out of the container runtimes' data (novox/hq
issue 452).
2026-10-11 18:01:24 +02:00
jschoubben 7211227c76 restic: let a restore point taken by hand go only after a night recorded good, say a missing hand= as a file to push, and say what a failed thinning already let go (novox/hq issue 453)
mesh/merge-gate pass: builds restic → 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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery delivered
2026-10-11 17:57:43 +02:00
mesh-admin b687a98770 Merge pull request 'mesh-knowledge: the review is shown every link kind propose accepts, and a research effort's status apart from its state (hq issue 492)' (#216) from fix/492-the-review-sees-what-propose-accepts into main 2026-10-11 11:12:39 +00:00
jschoubben a6e2881448 mesh-knowledge: the review is shown every link kind propose accepts and a research effort's status apart from its state (hq issue 492)
mesh/merge-gate pass: builds mesh-knowledge → 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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery delivered
The first live review sent research 046 back for a touches link the rules never named and a status proposed
that is the record's state, not the effort's. The rules now list LinkKinds, the one list propose holds a link
to, each with its meaning.
2026-10-11 13:07:29 +02:00
mesh-admin 73367192c1 Merge pull request 'Nothing reads decision records from hq: records_decisions retired, reserve asks the knowledge base, an empty import refuses (hq issue 488)' (#214) from fix/488-nothing-reads-records-from-hq into main 2026-10-11 10:55:19 +00:00
mesh-admin 28a9821c2e Merge pull request 'mesh-knowledge: the import keeps every field, takes clauses only from the Decision section's own list, and can repair what it held (hq issue 487)' (#213) from fix/487-the-import-keeps-every-field into main 2026-10-11 10:55:15 +00:00
mesh-admin 94bc18bc3f Merge pull request 'mesh-knowledge: the knowledge base reviews a proposal itself, and serves no review verb (hq issue 425)' (#211) from feat/425-the-knowledge-base-reviews-itself into main 2026-10-11 10:55:10 +00:00
jschoubben 1b7f79f761 mesh-issues: a counter not started never starts without the knowledge base's answer, floor or no floor (review of #214)
mesh/merge-gate pass: builds mesh-issues, mesh-knowledge, records → novox; no bus step; every machine composes with the change as it did without (4 of 4 co…
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery delivered
2026-10-11 12:44:45 +02:00
jschoubben 156fda4b33 mesh-knowledge: refresh repairs records the knowledge base drafted, keeping their clauses while their source is unchanged; ADR 0114 and 0148 as fixtures (review of #213)
mesh/merge-gate pass: builds mesh-knowledge → 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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery covered: a later merge that contains it was delivered: novox/mesh-catalog@1b7f79f76177 (merged as 73367192 into main, walk plan-17917161183…
2026-10-11 12:44:08 +02:00
jschoubben 6fb827605a mesh-knowledge: bound every review failure per record, make one out of tries loud, and let only its author revise a pending insight (review of #211)
mesh/merge-gate pass: builds mesh-knowledge → 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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery covered: a later merge that contains it was delivered: novox/mesh-catalog@1b7f79f76177 (merged as 73367192 into main, walk plan-17917161183…
2026-10-11 12:41:24 +02:00
jschoubben 879bc4bca2 restic: keep a backup taken by hand past the next thinning, until a later night and keep-hand-taken-days, so the restore point taken against a risky change is still there when it is needed (novox/hq issue 453)
mesh/merge-gate pass: builds restic → 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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery superseded: a newer head of the same pull request
2026-10-11 12:28:17 +02:00
jschoubben d6148a5ba7 mesh-issues: call an issue its worker left abandoned, not a stale claim (hq ADR 0294)
mesh/merge-gate pass: builds mesh-issues → 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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery delivered
The glossary keeps claim for a module's on a seat.
2026-10-11 12:26:57 +02:00
jschoubben 2369098303 Nothing reads decision records from hq: records_decisions retired, reserve asks the knowledge base, an empty import refuses (hq issue 488)
mesh/merge-gate pass: builds mesh-issues, mesh-knowledge, records → novox; no bus step; every machine composes with the change as it did without (4 of 4 co…
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery superseded: a newer head of the same pull request
Once the records leave hq (issue 426), records_decisions would answer every topic empty, reserve would read 0
and hand out 0001 from a counter not started, and import would say it created nothing as a success. reserve now
starts and raises its counters above mesh-knowledge's knowledge_highest, the floor alone starting one (with a
warning) when it is silent and nothing spent with neither.
2026-10-11 12:25:58 +02:00
jschoubben 3aaccba4e2 mesh-issues: read merged from the pull request, not the state, so a closed one is withdrawn (hq issue 483)
mesh/delivery superseded: a newer head of the same pull request
mesh/merge-gate pass: builds mesh-issues → 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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh-delivery says stopped for a pull request closed unmerged too; read as
merged, it was said stuck to the operator for good. Cite ADR 0284 for the
worker rules, which the comments gave as 0283.
2026-10-11 12:24:45 +02:00
jschoubben 601eed06e7 mesh-issues: test that a pull request closed unmerged is withdrawn, not stuck (hq issue 483) 2026-10-11 12:23:42 +02:00
mesh-admin e2d25b2248 Merge pull request 'docker: log rotation is a preference, 100m × 10 by default, judged before daemon.json takes it (hq issue 452)' (#205) from fix/452-log-rotation-is-a-preference into main 2026-10-11 10:21:53 +00:00
mesh-admin 0557be174a Merge pull request 'restic: a failed backup, a failed thinning and a refused retention raise a condition (hq issue 454)' (#210) from fix/454-a-failed-backup-is-raised into main 2026-10-11 10:18:25 +00:00
jschoubben 1cc0ab369f mesh-knowledge: the import keeps every field and takes clauses only from the Decision section's own list, and can repair what it held (hq issue 487)
mesh/merge-gate pass: builds mesh-knowledge → 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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery superseded: a newer head of the same pull request
The import at hq f0892019 lost reconstructed, amended and a research effort's touches, held rests-on only as a
citation, took eight records' clauses from a numbered list inside a ### subsection, and left the 11 records with
no Decision section clause-less with nothing queued. Each is held now; refresh repairs a record only the import
ever wrote, and never one changed since.
2026-10-11 12:18:03 +02:00
jschoubben d81e7d063b mesh-issues: say a delivered fix left in fixing, and flag a stale claim (hq issue 483)
mesh/merge-gate pass: builds mesh-issues → 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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery superseded: a newer head of the same pull request
The operator found 13 issues in fixing whose fix was delivered hours before:
their pull requests were linked, not in fixed-by, so their delivery was never
measured, and nothing said an issue left there or a worker gone.
2026-10-11 12:16:59 +02:00
jschoubben cbe9fa7a2c restic: raise a night that never happens, and have the mesh write the retention readable (novox/hq issue 454)
mesh/merge-gate pass: builds restic → 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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery delivered
A hung night, a lock held for hours or a lock that cannot be taken left no
failed night behind, so it stayed healthy. The night's process now counts
how long the machine was up since its newest good night, by the monotonic
clock so a sleeping laptop is not blamed, and past 26 hours it is raised.

The retention file is written 0644 and root's instead of read through
sudo: the account reads it and still cannot change it. An unreadable file
names its cause, and the healthy answer says only what was seen.
2026-10-11 12:14:54 +02:00
jschoubben c4ea58aeb0 mesh-issues: test that a delivered fix and a stale claim are said (hq issue 483)
Each test fails on main: a linked pull request is never measured, nothing
warns of a delivered fix left in fixing, and list has no flag to find either.
2026-10-11 12:14:42 +02:00
jschoubben ebba63d71f mesh-knowledge: review every proposal in a judgement of its own, and serve no review verb (hq issue 425, ADR 0290 as updated)
mesh/merge-gate pass: builds mesh-knowledge → 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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery superseded: a newer head of the same pull request
The mesh proves which machine called, not which agent on it (hq issue 365), so a review verb could not tell a
reviewer from the author. Each version proposed is now judged by claude-code.claude_code_judge in a separate
call that sees only the record, the records it links and the rules: approve accepts (or asks the operator for a
needs-operator record), changes keeps it proposed with the findings for its author, and a judgement that cannot
be had or read leaves it proposed, says so, and never counts as approval.
2026-10-11 12:06:18 +02:00
jschoubben d649309895 restic: raise a failed backup, a failed thinning and a refused retention as the module's health (novox/hq issue 454)
mesh/merge-gate pass: builds restic → 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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery superseded: a newer head of the same pull request
A failed night was a journal line only, so a typo in a retention setting
let the repository grow until a disk filled. The night and the measuring
now run as processes the node-engine judges: the night by backup_health,
which is unhealthy for a module not backed up, a night that did not run,
a failed thinning or a missing retention file, and waits naming the
setting when one is refused; the measuring by its unit. A file lock
keeps a night and the tool runtime's now and restore apart.

The retention file is root's alone, so the account reads it through sudo:
until now every night would have refused it as unreadable. Every machine
has the file, so a missing one is refused instead of thinned by 14/8/6.
backed-up says the retention in force and the last thinning per module.
2026-10-11 11:56:23 +02:00
mesh-admin 300406e0dd Merge pull request 'mesh-knowledge: run watch as the account the health tool reads as (hq issue 479)' (#209) from fix/479-the-watch-verdict-is-readable into main 2026-10-11 09:55:36 +00:00
mesh-admin 83c64dcd34 Merge pull request 'mesh-delivery: a merge whose walk ended before its word is settled from that walk's record (hq issue 474)' (#208) from fix/474-a-delivery-settles-from-an-ended-walk into main 2026-10-11 09:45:06 +00:00
jschoubben 9d0f93bba8 mesh-delivery: hold a stranded stop for a later merge on its way, and keep it loud (review, hq issue 474)
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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery failed: its walk failed, carrying its own commit: a gate on a first machine (what it carried put back), a build, a machine
A stop is final, so a later merge whose queued walk would carry the modules
now holds it off; a stopped one stays in stalled until a later delivered merge
carries them. A walk counts only modules it sent, and an old walk record is read
again rather than taken as building nothing.
2026-10-11 11:38:08 +02:00
mesh-admin 56689847d2 Merge pull request 'mesh-issues: record who acted as the caller the runtime proves (hq issue 365)' (#207) from fix/365-a-tool-call-names-its-caller into main 2026-10-11 09:35:37 +00:00
jschoubben a3f4387e26 Run mesh-knowledge's watch as the account the health tool reads as (hq issue 479)
mesh/merge-gate pass: builds mesh-knowledge → 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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery delivered
The watch ran as root and wrote its verdict root's and 0600 into the state
directory, which the mesh gives to the account the runtime runs the bundle as;
knowledge_health could not open it, so the module read unhealthy with nothing
wrong. Run watch as that account, as zfs's watch does.
2026-10-11 11:35:02 +02:00
jschoubben 084acc042b mesh-delivery: a merge whose walk ended before its word is settled from that walk's record (hq issue 474)
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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery superseded: a newer head of the same pull request
A batch's walk the controller judged to move nothing is done when it is cut,
and a merge it carried stayed published waiting for a start that cannot come.
Its walk's record now settles it: delivered or covered when that walk built its
modules, covered when a later delivered merge carried them (ADR 0270), and
otherwise stopped with why once the cover test says none did.
2026-10-11 11:31:43 +02:00
jschoubben f134ed5910 mesh-issues and mesh-knowledge: pin mesh-sdk at its merge of CallerOf; mesh-knowledge reads the caller from the runtime (hq issue 365)
mesh/merge-gate pass: builds mesh-issues, mesh-knowledge → 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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery covered: a later merge that contains it was delivered: novox/mesh-catalog@a3f4387e2630 (merged as 300406e0 into main, walk plan-17917125412…
Both pin mesh-sdk/go at 4cf45b5 (mesh-sdk#20 merged). mesh-knowledge's one
reading of the caller, callerOf, now answers stdio.CallerOf, so a call on the
caller-named subject is recorded as its caller's bus user and any other as
unproven, never from the arguments.
2026-10-11 11:26:45 +02:00
jschoubben 66068febb0 mesh-issues: record who acted as the caller the runtime proves (hq issue 365)
The tool runner now takes a call's caller from the subject it arrived on and
hands it to the module through the SDK (stdio.CallerOf); the tracker records
that bus user on what each verb writes instead of unproven. Never from the
arguments, which still refuse any caller, and unproven where the runtime proves
none. Needs mesh-sdk's go/caller.go (pinned at its branch head until merged).
2026-10-11 11:22:41 +02:00
mesh-admin 9355efa00b Merge pull request 'mesh-knowledge: the knowledge base, first slice (hq issue 425, ADR 0290)' (#206) from feat/425-the-knowledge-base into main 2026-10-11 03:48:50 +00:00
jschoubben ec00e13e22 Hold a decision record to the words rules only from 2026-10-11 (review of #206)
mesh/merge-gate pass: builds new: modules/mesh-knowledge, sent nowhere; no bus step; every machine composes with the change as it did without (4 of 4 compo…
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery delivered
hq's words check never read a decision record, so a correction to any decision
of October 7 to 10 was refused for words it was allowed to write. The links an
accepted record holds are kept by its correction without being judged again.
2026-10-11 05:14:37 +02:00
jschoubben 10ab959d0f Tighten who writes and reviews a record, date the rules, bound history (review of #206)
mesh/merge-gate pass: builds new: modules/mesh-knowledge, sent nowhere; no bus step; every machine composes with the change as it did without (4 of 4 compo…
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery superseded: a newer head of the same pull request
A revision came from anyone and a review was refused only to the first author,
so a co-writer could approve, and a record proposed before callers are proven
would later be reviewable by its own unproven author. Now a revision is taken
only from the proven author, a reserved number only from whoever reserved it,
and a review is refused from any writer of any version or for any record with
an unproven writer. The rules held records from no date, so a correction to an
older record was refused by rules hq never held it to; each dated rule now holds
from hq's own start date, and a new record cannot be dated before its
reservation. history and the question to ask are bounded like the other answers.
2026-10-11 05:10:02 +02:00
mesh-admin 9afc253e85 Merge pull request 'mesh-issues: the tracker hands out decision and research numbers (hq issue 424)' (#204) from fix/424-the-tracker-hands-out-decision-and-research-numbers into main 2026-10-11 02:59:55 +00:00
jschoubben ba7b455719 Add mesh-knowledge, the knowledge base's first slice (hq issue 425, ADR 0290)
mesh/merge-gate pass: builds new: modules/mesh-knowledge, sent nowhere; no bus step; every machine composes with the change as it did without (4 of 4 compo…
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery superseded: a newer head of the same pull request
An agent learns what a decision says only by reading whole records. This module
holds every decision record and research effort whole in a database in the store,
with clauses that carry their own status and source, typed links read from both
ends, and every version. It answers ask, get and holds within a stated bound, and
takes a record through reserve, propose (refused on any of the 29 per-record
rules), review by someone other than the author, and acceptance, on the
operator's proven warrant when the record needs it. The 4 whole-set rules make
the module unhealthy when they fail. The import reads hq at a commit through the
records module, never overwrites, and changes nothing when run again. Prose
decisions get drafted clauses that a second judgement must confirm before they
are served.
2026-10-11 04:58:35 +02:00
jschoubben 9a1a97cc43 docker: make log rotation a preference, 100m x 10 by default, judged by a step before daemon.json takes it (novox/hq issue 452)
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/merge-gate pass: builds docker → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/delivery failed: its walk failed, carrying its own commit: a gate on a first machine (what it carried put back), a build, a machine
The controller pastes a setting into daemon.json as text and the runtime is its
only reader, so a bad size would surface as a runtime that does not start and a
quote could add any key. A step refuses the value first and gates the module.
The defaults are the one hand-set machine's, so it does not change; nothing
restarts the runtime, which takes the keys at its next start.
2026-10-11 04:43:14 +02:00
jschoubben 9d4d9d5cd2 mesh-issues: say in reserve's answer when hq could not be read, and grant the import its records calls (review of #204)
mesh/merge-gate pass: builds mesh-issues → 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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery delivered
A number handed out unchecked against hq is a failure that must be loud, so
the answer carries a warning, not only the log. The import already calls
records_status and records_read; invokes now names them.
2026-10-11 04:30:28 +02:00
jschoubben 350a597c47 mesh-issues: hand out decision and research numbers, so sessions stop taking them by reading hq (hq issue 424)
mesh/merge-gate pass: builds mesh-issues → 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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery superseded: a newer head of the same pull request
A new verb, issue-tracker.reserve {kind, for} -> {kind, number}: one counter
per sequence beside the issue counter, advanced the same way, started above
what hq's record holds, a floor setting for open branches and the
reservations kept, and raised when hq holds a higher number. A reserved
number is spent forever, kept with who, when and for what, and said as the
event reserved (hq ADR 0275 rule 8, ADR 0290 rule 5).
2026-10-11 04:25:48 +02:00
mesh-admin 33ee89794e Merge pull request 'restic: the backup retention is a preference, today's 14/8/6 its default (hq issue 416, first half)' (#200) from fix/416-retention-and-log-rotation-are-preferences into main 2026-10-11 02:24:36 +00:00
mesh-admin 27cb95e98f Merge pull request 'mesh-delivery: a member after a superseded one waits while a later merge can carry it (hq issue 445)' (#203) from fix/445-a-carried-member-lets-the-next-take-its-turn into main 2026-10-11 02:18:42 +00:00
mesh-admin 5eca660fd0 Merge pull request 'mesh-delivery: give an unanswered line's checks as data (issue 443)' (#201) from fix/443-a-stalled-lines-times-are-local into main 2026-10-11 01:52:00 +00:00
jschoubben 15a24fb63e mesh-delivery: share a carrier's walk only when it delivers the whole superseded member
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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery delivered
A turn given on one carrier's waiting walk let the next member go while
another module of the member before it waited on a walk that could still
fail, and the stop then took the shared walk down. The turn is now shared
only when that one walk carries every module not yet carried, each by a
merge the forge said contains the member's; an unknown answer is asked and
waited for (novox/hq issue 445, re-review of #203).
2026-10-11 03:46:32 +02:00
jschoubben f7ba413c10 mesh-delivery: a member on its carrier's walk takes its turn, and a waiting member says for whom
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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery superseded: a newer head of the same pull request
Under ADR 0276 the merge that carries a superseded member is often folded
into the next member's own walk, which waits for that member's turn: waiting
there waited for itself until the carrier's bound. The members on that walk
now take their turn, as members sharing one walk do. A member that waits says
which merge it waits for and until when, and `stalled` uses that bound; a
carrier still in a batch is bounded by the window and one walk; a member every
module of which was carried is reached, never stopped with no reason
(novox/hq issue 445, review of #203).
2026-10-11 03:41:52 +02:00
jschoubben 51a85c5998 mesh-delivery: a member after a superseded one waits while a later merge can carry it
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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery superseded: a newer head of the same pull request
Superseded is no failure: the later merge that took its walk over may still
deliver its change, and a covered member counts as delivered (ADR 0270). So the
members after it wait while such a merge is open within its own bound, take
their turn once its delivered walk covers the member, and are stopped, saying
why, only when none can carry it (novox/hq issue 445).
2026-10-11 03:34:04 +02:00
mesh-admin 87b0d3a528 Merge pull request 'A pending merge check says where it is: in the build queue since when, then building on which machine (hq issue 446)' (#202) from fix/446-a-pending-check-says-where-it-is into main 2026-10-11 01:27:06 +00:00
mesh-admin a5b4a757ec Merge pull request 'mesh-delivery: say a ready or rejected delivery whose check has not answered on the forge (hq issue 441)' (#199) from fix/441-a-held-delivery-without-its-check-is-said into main 2026-10-11 01:26:37 +00:00
jschoubben 1cf56688c1 Make the gitea module the one writer of mesh/merge-gate, so a verdict always lands last (hq issue 446)
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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery failed: its walk failed, carrying its own commit: a gate on a first machine (what it carried put back), a build, a machine
mesh-delivery read the gate as pending and then wrote "building on", and the gitea module could write the verdict in
between: the forge keeps the newest status, so a pass read pending and a failure hid under "building". The gitea
module now hears the build seat's started itself and puts every write of a head's gate on one chain, marking a
verdict the moment it is heard; a start from before the pending words were last set is not the current check's.
2026-10-11 03:23:52 +02:00
jschoubben 4254d17322 Say where a pending merge check is: queued since when, then building on which machine (hq issue 446)
mesh/merge-gate pass: builds gitea, 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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery superseded: a newer head of the same pull request
The forge's holder set mesh/merge-gate pending as "the mesh is mapping this head onto its module graph" and nothing
changed it until the verdict, so a ten-minute controller check read as a stalled forge (issue 423). The holder now
says the head waits in the build queue since the local time it was announced; mesh-delivery follows the build
seat's started event and says the machine building it and since when, never over a verdict the forge holds.
2026-10-11 03:14:06 +02:00
jschoubben 30788c46a1 mesh-delivery: give an unanswered line's checks as data, so the controller says their times in the operator's zone (novox/hq issue 443)
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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery delivered
mesh/delivery-group group fix/443-a-stalled-lines-times-are-local delivered: every member is delivered
2026-10-11 03:10:22 +02:00
jschoubben ecf6d7803f restic: say in each retention setting that lowering it deletes restore points for good, so the operator sees the cost before setting it (novox/hq issue 416)
mesh/merge-gate pass: builds restic → 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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery delivered
2026-10-11 03:09:46 +02:00
mesh-admin ebd940fcd0 Merge pull request 'mesh-cli: build on the SDK that reads the engine's refusal after a failed write (hq issue 444)' (#198) from fix/444-mesh-cli-hears-the-refusal into main 2026-10-11 01:04:52 +00:00
jschoubben 39d5b25f95 restic: take the retention from the machine's settings, so a disk can keep more or fewer restore points without a new build (novox/hq issue 416)
mesh/merge-gate pass: builds restic → 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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery superseded: a newer head of the same pull request
The keep-daily, keep-weekly and keep-monthly preferences default to
today's 14, 8 and 6. The night reads them from a file in its state and
refuses anything but a count from 1 to 9999: it then thins nothing and
says which setting is wrong, never handing a raw value to restic.
2026-10-11 03:04:52 +02:00
jschoubben 9bd8903ed2 mesh-delivery: test that an unanswered line clears when its delivery moves on or its pull request closes (issue 441)
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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery failed: its walk failed, carrying its own commit: a gate on a first machine (what it carried put back), a build, a machine
The review asked for the clearing paths to be pinned: a recheck or a merge
clears the line before the forge is read again, and a pull request gone from
the open list is no longer said.
2026-10-11 03:03:43 +02:00
jschoubben 5855c41f9d mesh-delivery: say a ready or rejected delivery whose check has not answered on the forge (issue 441)
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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery superseded: a newer head of the same pull request
Ready and rejected have no bound, and the watch for unchecked heads skipped
every head with a delivery, so a lost or lagging status write left a pull
request unable to merge with nothing said. Such a head is now said on issue
438's rule: an unanswered line past the table's bound for a check asked and
not answered.
2026-10-11 03:00:23 +02:00
jschoubben 4870c9171e mesh-cli: build on the SDK that reads the engine's refusal after a failed write, so a caller hears busy, not broken pipe (novox/hq issue 444)
mesh/merge-gate pass: builds mesh-cli → 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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery delivered
2026-10-11 02:56:55 +02:00
mesh-admin c6cf5638dd Merge pull request 'mesh-delivery: a group whose members all ended ends, never delivering (hq issue 442)' (#197) from fix/442-a-group-whose-members-ended-ends into main 2026-10-11 00:55:59 +00:00
mesh-admin bc473a7bc8 Merge pull request 'mesh-delivery: say a head whose merge check started and never answered (issue 438)' (#195) from fix/438-a-check-that-never-answered-is-said into main 2026-10-11 00:47:19 +00:00
jschoubben e3f29e6bb4 mesh-delivery: say where GroupState and groupTurns still differ, keep the stopped line whole
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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery delivered
Review of #197: a carried superseded member is reached in GroupState but
still stops the members after it in groupTurns (hq issue 445); cite ADR
0239 decision 4, which GroupState extends; shorten the stopped reason so
the member's id survives the forge's 140-character status line. Tests
pin one forge ask per pair and CoveredBy/ContainedIn across a read-back.
2026-10-11 02:45:43 +02:00
jschoubben 036544ee0f mesh-delivery: a closed group whose members all ended ends, never delivering
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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery superseded: a newer head of the same pull request
A superseded member counts as reached when a later delivered walk on its
trunk carried every module it moves (ADR 0270's covered test); otherwise
the group reads stopped, as groupTurns already treats it. Five groups
read "delivering, 1 of 2 delivered" for days with nothing in progress
(novox/hq issue 442).
2026-10-11 02:39:45 +02:00
jschoubben 450892498d mesh-delivery: an unanswered line names its checks in a field of their own (issue 438)
mesh/merge-gate pass: builds gitea, 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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery delivered
mesh/delivery-group group fix/438-a-check-that-never-answered-is-said delivered: every member is delivered
The controller's words for the line (mesh-controller #213) name the checks waited
on; a field spares them reading the sentence.
2026-10-11 02:25:48 +02:00
jschoubben 1f48a29495 mesh-delivery: a check that answers keeps the hour running, and a started head is unanswered (issue 438)
mesh/merge-gate pass: builds gitea, 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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery superseded: a newer head of the same pull request
Review of #195: the clock restarted on any change in what a head waits on, so a
check answering pushed the line back by the time it took; it now restarts only on
a newer ask. A head whose check started was announced, so its line no longer says
unannounced; pending times are given in UTC like the module's other lines.
2026-10-11 02:22:16 +02:00
mesh-admin b9de001833 Merge pull request 'mounts: list says an armed automount waits, not mounted (issue 437)' (#196) from fix/437-an-armed-automount-is-not-mounted into main 2026-10-11 00:21:31 +00:00
jschoubben 520763e98d mounts: list says an armed automount waits, not mounted, as the look already judges it (issue 437)
mesh/merge-gate pass: builds mounts → 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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery delivered
The autofs line an armed automount leaves in mountinfo made List say
"mounted" while nothing was mounted. One judgement, Mounted.IsAutomount,
now serves List, the look and the checks before a mount.
2026-10-11 02:17:47 +02:00
jschoubben 9d766b675d mesh-delivery: say a head whose merge check started and never answered (issue 438)
mesh/merge-gate pass: builds gitea, 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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery superseded: a newer head of the same pull request
The watch for unchecked heads counted any merge-check status as checked, pending
included, so a head left with a pending mesh/merge-gate, or never given
mesh/repo-check, waited unable to merge with nothing saying so. gitea_open_pulls
now gives each head status with its state and time, and the watch says a head
whose required check stays pending or unset past the delivery table's bound for
a check asked and not answered.
2026-10-11 02:16:34 +02:00
mesh-admin 372b6c2323 Merge pull request 'mounts: list reads the root mount's unit (issue 374)' (#194) from fix/374-mounts-list-reads-the-root-mounts-unit into main 2026-10-11 00:13:58 +00:00
jschoubben 99df6b999b mounts: pass unit names to systemctl show after --, so the root mount's -.mount is not read as an option
mesh/merge-gate pass: builds mounts → 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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery delivered
systemctl refused the whole call on -.mount, and list answered every mount
without its unit state (novox/hq issue 374). The test fake now refuses a
dash-led argument as systemctl does, so it no longer agrees with the mistake.
2026-10-11 02:02:18 +02:00
mesh-admin d856892ced Merge pull request 'gitea: a merge names the head it was reviewed at (issue 381)' (#193) from fix/381-a-merge-names-the-head-it-was-reviewed-at into main 2026-10-10 23:56:30 +00:00
jschoubben c87a4421b9 gitea: a read-back that fails after the merge says the merge happened (review of issue 381)
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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery delivered
The second review found the gap it was asked to look for: everything
after the forge's merge could throw — a forge 5xx, a timeout, a commit
named without parents — and the caller got a bare error that never said
the merge had happened.

From the merge on, a read-back that fails or cannot be done is said
beside merged: true, in read_back; only a proven other head is an error,
and that error says the merge happened. A parent named without its sha
is a failed read-back, never an empty head said to have landed.

A fast-forward's commit is taken as the head only when the forge names
it so. That it does is assumed, not seen on this forge, so another sha
is said in read_back rather than raised as another head having landed.

The method's description lists all five methods and says which two can
be read back. The fast-forward test now gives the merge commit a
different second parent, so reading it as a merge would fail there.
2026-10-11 01:35:43 +02:00
jschoubben 9ef810d9c3 gitea: twelve characters name a head, and what landed is read from the merge commit (review of issue 381)
mesh/delivery superseded: a newer head of the same pull request
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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
The review of #193 approved it with five nits, all taken.

Seven characters of a sha can be matched by a commit made to match them
in seconds, so an author who force-pushes could have a review merge a
commit nobody read. A head is now named by at least 12 characters, up to
64 for a sha-256 repository.

What landed was read from the forge's record of the pull request, which
is the very thing a race leaves behind. It is now read from the merge
commit: its second parent for a merge, the commit itself for a
fast-forward. A squash or a rebase makes new commits that name no head,
and the answer says it cannot be read back instead of staying silent.

A merge of another head than the one handed over was reported as a
field beside merged: true, which a caller reading only that would miss.
It is now an error that says the merge happened and what landed.

The refusal no longer asserts that the head moved after the review,
which it cannot know; it says the head is not the one named.
2026-10-11 01:33:17 +02:00
jschoubben c32eb6237d gitea: a merge names the head it was reviewed at (issue 381)
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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery superseded: a newer head of the same pull request
mesh-controller#197 was reviewed at 065cfa0d, force-pushed to 3ced96f
at 12:14 UTC, checked green there at 12:22, and merged at 3ced96f at
12:26. The merge tool kept its promise that only a checked head is
merged, but it took no argument for the head the review approved, so it
merged whatever the head was and a commit nobody reviewed landed.

gitea_merge_pull_request now takes `head`, the commit the caller
reviewed (a sha, or at least its first 7 characters), and refuses with
nothing sent when it is missing, is not a commit, or is not the pull
request's head — naming the head it has now. The forge is handed the
same head as head_commit_id, so a push between the read and the merge
is the forge's to refuse. The answer reads back what was merged and says
so loudly if the forge merged another head anyway.

`head` is required, not optional: an optional guard is the shape that
let this happen. A caller that leaves it out is told what to name.
2026-10-11 01:28:52 +02:00
mesh-admin fa6360c1d3 Merge pull request 'messenger: read a condition's message before it is sent (issue 422)' (#192) from fix/422-a-condition-message-read-before-it-is-sent into main 2026-10-10 22:54:34 +00:00
jschoubben 15546f9d83 messenger: count the store's writes, and name an ask's record when it is the only one held (review of issue 422)
mesh/merge-gate pass: builds messenger → 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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery delivered
The review's two nits. A write of bytes identical to what is stored was
invisible to a comparison of content, so the test store counts its
writes and the preview's tests read that count: a persist from inside
Preview now fails five of them instead of one. And a router holding an
ask's record and no condition said it held none, rather than saying what
that key is; the branches are the other way round, with a test that
holds nothing else.
2026-10-11 00:49:38 +02:00
jschoubben d00cc8baf4 messenger: the preview is the sending path's own walk (review of issue 422)
mesh/merge-gate pass: builds messenger → 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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery superseded: a newer head of the same pull request
The review of #192 found three faults, all verified by running.

The tests did not prove the claim the change leads with: a probe that
sent, queued, recorded and wrote from inside Preview still passed,
because the condition they used was a warning needing nothing, which is
held rather than sent. They now hold an urgent condition, snapshot what
a send would move — the channels, the queue, the history, the refusals,
the stored record and the record held — assert none of it moved, and
tick once more. Every one of those probes now fails the tests.

`cleared` and `silenced` said wording no send would carry: an open
record has no clearing time, so a condition open four hours cleared
"after a moment", and no silence, so a silence read "until Mon 1 Jan
00:17". A clearing is now composed as if it cleared now, which the
answer says; a silence is refused unless the condition is under one.

The preview composed only its own words, while a send reduces a message
whose words the content rule refuses. The escalation walk is now one
function, allowedWords, which `words` calls for a send and the preview
calls alone — so the words read are the words that would leave, and
reading them records no refusal and states nothing on the bus.
withTracking hands back the line it put in, so the preview reports that
line instead of deciding the rule a second time; and a record that is
not a condition, or one carrying an open ask, is refused or says that a
channel's message carries more.
2026-10-11 00:43:40 +02:00
jschoubben b7b34ddad7 messenger: read a condition's message before it is sent (issue 422)
mesh/merge-gate pass: builds messenger → 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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery superseded: a newer head of the same pull request
The words a condition reaches the operator with are composed in the
router, and nothing kept them: a send's history row holds a key and an
outcome, never text, and a notification still on the desk is not in the
notifier's history. So a wording could only be proven by waiting for a
send and reading the operator's screen, which is why proving issue 400's
tracking line stalled.

messenger_preview composes what a send would carry for a condition the
router holds open — the same words, the same tracking line, the same
verdict — and says whether the content rule would let them out. It
sends, queues and records nothing.
2026-10-11 00:30:32 +02:00
mesh-admin 7534167fd0 Merge pull request 'mounts: an SMB source's username in its sources entry, its password as its own one-line secret, and a missing one waits (issue 386, ADR 0283)' (#190) from feat/386-a-wait-for-the-operator into main 2026-10-10 20:18:32 +00:00
mesh-admin d91985cc35 Merge pull request 'Say in a condition's message which issue tracks it and where it stands (issue 400)' (#191) from feat/400-alert-names-its-issue into main 2026-10-10 20:09:42 +00:00
jochen 12619ec140 Check that tracking says no event and writes nothing, as ADR 0285 states
mesh/merge-gate pass: builds mesh-issues, messenger → 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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery delivered
mesh/delivery-group group feat/400-alert-names-its-issue delivered: every member is delivered
2026-10-10 21:51:30 +02:00
jochen a7fc89ef04 Name issues not closed first, and say a quiet worker before a decision, as ADR 0285 says
mesh/delivery superseded: a newer head of the same pull request
mesh/merge-gate pass: builds mesh-issues, messenger → 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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
The router's order put a wontfix issue before an open one, and a stale
worker on an issue waiting for a decision said both; review of #191.
2026-10-10 21:50:54 +02:00
jochen 63dba87587 Say in a condition's message which issue tracks it and where that issue stands (issue 400)
mesh/delivery-group group feat/400-alert-names-its-issue checking: 0 of 2 member(s) ready
mesh/delivery superseded: a newer head of the same pull request
mesh/merge-gate pass: builds mesh-issues, messenger → 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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
The operator could not tell from a notification whether its cause was
known, tracked and being fixed, or whether they had to act. The issue
tracker gains the verb tracking, answering for every condition key an
issue links which issues link it and where each stands; the operator
channel's router asks it outside the work and says one line from it in
every condition's message, per hq ADR 0285.
2026-10-10 21:44:53 +02:00
jochen a12b5908aa mounts: name a malformed entry of sources by its position, never by its name
mesh/merge-gate pass: builds mounts → 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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery delivered
A password holding '=' and a space or comma splits the entry, and the tail
read as name=... was said as "the source <tail>", carrying part of the
secret into the health answer and the operator's conditions (review of
mesh-catalog#190, issue 386).
2026-10-10 21:38:17 +02:00
jochen 58ec352b8c mounts: say nothing an entry of sources holds when it cannot be read
mesh/delivery superseded: a newer head of the same pull request
mesh/merge-gate pass: builds mounts → 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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
A password typed into an entry by mistake, in another scheme or split by a
space or a comma, was quoted back in the problem and so reached the status
and the health answer. An entry is now named by its source's name or its
position only; a user with a control character or a second @ is refused
(review of mesh-catalog#190, issue 386).
2026-10-10 21:33:47 +02:00
jochen 6f78899827 mounts: take an SMB source's username from its entry of sources, not a setting of its own
The live module check refused smb-users: a key naming a user is the
operator's value and may have no default, and a setting without one would
refuse to compose mounts on every node until set. The username now goes in
the source's URL (smb://<user>@<host>/<share>), percent-decoded; a
password there is refused unsaid, and a source without a user waits for
sources (hq ADR 0283, issue 386).
2026-10-10 21:30:31 +02:00
jochen c620173966 mounts: read a password only from the directory the configuration names, and say a domain goes in the username
mesh/merge-gate fail: builds mounts → shanks; no bus step; a manifest the change touches fails the module check: modules/mounts/module.json: this manifest …
mesh/repo-check fail: its merge-check.sh failed: files asking for a setting without saying whether it is trusted: 24
mesh/delivery superseded: a newer head of the same pull request
A configuration without the passwords directory would have read a path
relative to the process's working directory; it is now refused as a fault.
Review of mesh-catalog#190 (issue 386, ADR 0283).
2026-10-10 21:14:03 +02:00
mesh-admin 61dcdf03a0 Merge pull request 'mesh-issues: an issue says who works on it, and resolves only from fixing (hq issue 399)' (#189) from feat/399-fixing-status into main 2026-10-10 19:13:32 +00:00
jochen bbb3161ade mounts: take an SMB source's username as a setting and its password as its own secret, and wait for them
mesh/merge-gate fail: builds mounts → shanks; no bus step; a manifest the change touches fails the module check: modules/mounts/module.json: this manifest …
mesh/repo-check fail: its merge-check.sh failed: files asking for a setting without saying whether it is trusted: 24
mesh/delivery superseded: a newer head of the same pull request
The one INI secret smb-credentials could not be given through the desk
prompt, which takes one hidden line, and a missing login was said as a
fault: the first-node gate put the build back and the operator was told
nothing was theirs to do. The username is now the setting smb-users and
each password the one-line secret smb-password-<source>; a source missing
either answers waits through mesh-sdk's health answer (hq ADR 0283,
issue 386, issue 379).
2026-10-10 21:11:16 +02:00
jochen a73b04843d mesh-issues: a take-over note records who acted, not the worker named; tests for the remaining rules (hq issue 399)
mesh/merge-gate pass: builds mesh-issues → 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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery delivered
mesh/delivery-group group feat/399-fixing-status delivered: every member is delivered
The note's by is who acted as the runtime proves it (ADR 0275 §5); the
note's text names both workers. Tests for a resolved issue corrected, link
and set keeping the worker current, and a take-over reason with a secret's
shape. The ready-to-verify note says an issue resolves from fixing.
2026-10-10 21:10:36 +02:00
mesh-admin 6f2c08c66e Merge pull request 'mesh-delivery: delivery time per delivery and the times verb (hq ADR 0282 slice 1, issue 382)' (#188) from feat/382-delivery-times into main 2026-10-10 19:07:24 +00:00
jochen 11afde55d6 mesh-issues: an issue says who works on it, and resolves only from fixing (hq issue 399)
mesh/delivery-group group feat/399-fixing-status checking: 0 of 2 member(s) ready
mesh/merge-gate pass: builds mesh-issues → 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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery superseded: a newer head of the same pull request
Two agents could start on one issue without seeing each other, and an issue
could be resolved without anyone having said they started its fix. A move to
diagnosing or fixing now names by, the worker as who@node; get and list show
the worker with since and stale; another worker is refused while it is
current unless take-over says why (noted); a worker untouched for
worker-stale-after (24h) may be taken over from without a reason. Resolved is
reached only from fixing. The moved event carries by, since and caller.
Fixing counts as not closed for the triage and the duplicate check.
hq ADR 0283.
2026-10-10 21:04:32 +02:00
jochen 08dab94bb0 mesh-delivery: an unreliable time names its phases with no number; tests that it is never counted and that phases short of the walk's end are unreliable
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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery delivered
2026-10-10 20:56:50 +02:00
jochen 662c4105d3 mesh-delivery: cut a walk's phases at the delivery's own merge, never a negative time; moments out of order are unreliable, not a number (hq issue 382)
mesh/delivery superseded: a newer head of the same pull request
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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
2026-10-10 20:55:21 +02:00
jochen 383e698254 mesh-delivery: rebase only a first phase that starts at the walk's merge; a class without a budget is said unknown
mesh/merge-gate fail: builds mesh-delivery → novox; no bus step; a manifest the change touches fails the module check: mesh-delivery claims mesh-delivery a…
mesh/repo-check fail: its merge-check.sh failed: files asking for a setting without saying whether it is trusted: 25
mesh/delivery superseded: a newer head of the same pull request
2026-10-10 20:16:46 +02:00
jochen 404efb764b mesh-delivery: keep each delivery's time from its merge and answer times per class (hq ADR 0282, issue 382)
mesh/merge-gate fail: builds mesh-delivery → novox; no bus step; a manifest the change touches fails the module check: mesh-delivery claims mesh-delivery a…
mesh/repo-check fail: its merge-check.sh failed: files asking for a setting without saying whether it is trusted: 25
mesh/delivery superseded: a newer head of the same pull request
The operator's budget (a leaf module running everywhere within five minutes
of its merge, a core module within ten) cannot be held to without measuring
it. Each delivery now keeps its delivery time, class and phases from the
walk the controller says; 'times' answers the week's median and worst per
class, the deliveries over budget and their longest phase, and one
delivery's breakdown; 'deliveries' is headed by the same line. A phase the
walk could not measure stays unknown, never a zero. Measurement only.
2026-10-10 20:14:19 +02:00
mesh-admin a14fa306f2 Merge pull request 'mesh-delivery: write a delivery only when it changed (hq issue 326)' (#187) from fix/326-write-a-delivery-only-when-it-changed into main 2026-10-10 17:50:53 +00:00
jochen 7eb96f2a1d mesh-delivery: write a delivery only when it changed (hq issue 326)
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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery delivered
keep wrote the whole delivery on every call, and Reconcile passes every walk
the controller keeps through settleAndKeep every 30 seconds, so the
deliveries bucket was rewritten about once per walk per pass with nothing
changed: a key's revision no longer meant the delivery moved, and a watcher
of the bucket would receive a stream of non-changes.

keep now remembers each delivery as it last wrote it, encoded, and skips the
write when the delivery encodes exactly the same and is not waiting to be
written again. Anything a delivery holds that changes - its state, steps,
walk record, owed effects and their tries - changes its encoding, so every
change is still written; a failed write stays dirty and Flush writes it
again. Events, notes, views and forge statuses are owed and done as before.
A retry of an owed effect still writes its new try count: it is a change.

The test fails before (five passes over unmoved walks wrote five times) and
passes after; a walk that moves is still written and said.
2026-10-10 19:46:25 +02:00
mesh-admin 63412ec36a Merge pull request 'mesh-issues: a verdict naming issue 0 is none, and the tracker's own module is a registered owner (issue 396)' (#186) from fix/triage-issue-zero-is-none into main 2026-10-10 17:22:25 +00:00
jochen 383194488b mesh-issues: only a duplicate or related verdict naming 0 is none (review, issue 396)
mesh/merge-gate pass: builds mesh-issues → 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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery delivered
2026-10-10 18:28:45 +02:00
jochen 6bd69ebadc mesh-issues: a verdict naming issue 0 is none, and the tracker's own module is a registered owner (issue 396)
mesh/delivery superseded: a newer head of the same pull request
mesh/merge-gate pass: builds mesh-issues → 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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
The model says 'no issue' as related to 0; the triage took that as an issue not
offered and failed the issue instead of triaging it.
2026-10-10 18:27:53 +02:00
mesh-admin 7e7dfbfb1a Merge pull request 'mesh-issues: an issue knows whether its fix is delivered, says it ready to verify, and a fix stuck after its merge is said (hq ADR 0281)' (#185) from feat/mesh-issues-delivery into main 2026-10-10 16:19:55 +00:00
jochen 01bde68f96 mesh-issues: an issue knows whether its fix is delivered, says it ready to verify, and a fix stuck after its merge is said (hq ADR 0281)
mesh/merge-gate pass: builds mesh-issues → 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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery delivered
The operator approved measuring from the delivery's own events whether each fix an
issue names reached the machines; delivered is not resolved, and a merged fix that
does not arrive is said on the issue and the operator's channel instead of found.
2026-10-10 18:12:52 +02:00
mesh-admin 0b4ae8097f Merge pull request 'mesh-issues: triage every new issue — severity, needs, suggested owner, dependencies — and ready when what it depends on is delivered (hq ADR 0279)' (#184) from feat/mesh-issues-triage into main 2026-10-10 16:11:57 +00:00
jochen 9bb8253609 mesh-issues: triage every new issue — severity, needs, suggested owner, dependencies — and say an issue ready when what it depends on is delivered (hq ADR 0279)
mesh/merge-gate pass: builds mesh-issues → 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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery delivered
The operator asked for the judgement that already reads the backlog to fill what an
issuer cannot know, and for dependencies between issues, an issue being workable
only once what it waits for is on the machines. The triage fills only empty values,
adds links and removes none; readiness follows mesh-delivery's own events, said once
per spell, through a duplicate to its original, and loud when a delivery cannot be asked.
2026-10-10 17:56:08 +02:00
mesh-admin 105cc7c0de Merge pull request 'dnsmasq answers or is restarted, binds once; tools for DNS, sockets and the runtime's memory (hq issue 348)' (#161) from fix/the-resolver-answers-or-is-restarted into main 2026-10-10 15:48:12 +00:00
jochen a10f1844f2 Merge branch 'main' into fix/the-resolver-answers-or-is-restarted
mesh/merge-gate pass: builds dnsmasq, docker, netcheck → ace, g14, novox, shanks; no bus step; 2 wait(s) for a person; every machine composes with the chan…
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery delivered
2026-10-10 17:45:33 +02:00
mesh-admin 25c7bee3b8 Merge pull request 'mesh-issues: the duplicate check on every new issue, and groom for the backlog; claude-code: claude_code_judge (hq ADR 0278)' (#183) from feat/mesh-issues-groom into main 2026-10-10 15:34:47 +00:00
jochen a5c38892a7 mesh-issues: review — one judgement at a time with new issues first, one verdict applied at a time, a resolved issue left alone on redelivery, the pass's answer a copy (hq ADR 0278)
mesh/merge-gate pass: builds claude-code, mesh-issues → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 o…
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery delivered
An independent review found a pass could starve new issues' checks, two writers
could apply one verdict twice, a judged issue resolved by a person could still be
linked, and the dry run's answer raced its pass.
2026-10-10 17:20:07 +02:00
jochen f58840b8a6 mesh-issues: check every new issue for a duplicate; claude-code: one judgement with no tools (hq ADR 0278)
mesh/merge-gate pass: builds claude-code, mesh-issues → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 o…
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery superseded: a newer head of the same pull request
The operator asked for duplicate issues to be consolidated as they are opened.
The tracker consumes its own opened event and asks the agent on its node, through
claude-code's new claude_code_judge, whether an older issue not resolved asks the
same question; it closes only the newer issue of a certain duplicate, keeping its
report on the older one, and fails loudly when it cannot judge. groom runs the
same judgement over the backlog as a dry run, applied on confirm.
2026-10-10 17:11:54 +02:00
mesh-admin fb77be42ca Merge pull request 'A channel shows nothing from its memory after a restart; the router says again what is still open (issue 384)' (#182) from fix/issue-384-restart-replays-nothing into main 2026-10-10 14:17:04 +00:00
jochen 45361b013a Review: keep the desk's notification ids so lost answers are said again in place (issue 384)
mesh/merge-gate pass: builds desk-channel, messenger → 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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery delivered
dunst.started says the notifier's bundle started again: the notifications stay on screen and only the
answers they waited for are lost. Forgetting their ids made the router's message a second popup beside a
dead one. The desk now keeps the ids and says answers-lost-at; the router's message replaces each
notification in place.

Whether a message is said again is now its own (last said before the answers were lost), so a router
that started after the loss still says it and a repeated standing says nothing. It is sent to the desk
directly, not through the outbox, whose failure path moves a warning to Telegram; a failed send is tried
again at the next standing.
2026-10-10 16:13:57 +02:00
mesh-admin aabb83d6c9 Merge pull request 'mesh-issues: import hq's issue folders at their own numbers (hq ADR 0275, to-be 53 slice 3)' (#180) from feat/mesh-issues-import into main 2026-10-10 14:04:23 +00:00
jochen cd85104988 A channel shows nothing from its memory after a restart; the router says again what is still open (issue 384)
mesh/merge-gate pass: builds desk-channel, messenger → 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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery superseded: a newer head of the same pull request
On 2026-10-10 the desk's notifier started again on two machines and the desk showed, from its own
memory, nine old popups: resolved, silenced and cancelled ones among them. Its memory held every handle
ever shown with buttons, and the router's edits (silenced, resolved) had reached only the newest handle
of each problem, so the rest stood as if open.

A channel's memory serves only to replace or edit a message in place. A notifier that started again lost
its screen: the desk forgets that machine's notification ids and says in its standing since when the
screen is new (shows-since). The router, which alone knows what is still open and unanswered, says that
again on that kind: one message per problem under its handle with the words and answers of now, a
silenced one edited to say so with no answers and no urgency, nothing that ended. Each move of
shows-since is acted on once, and waits while the desk cannot send rather than going to Telegram.
Telegram re-shows nothing on a restart (its messages stay in the chat) and says no shows-since.

An edit that offers nothing and reaches no screen its message is on is taken with nothing to change;
one that offers answers is said as failed, so the router says it anew.
2026-10-10 16:02:37 +02:00
jochen d05308a419 mesh-issues: import hq's issue folders at their own numbers, and an issue with a folder is whole in the tracker (hq ADR 0275, to-be 53 slice 3)
mesh/merge-gate pass: builds mesh-issues → 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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery delivered
2026-10-10 16:00:19 +02:00
mesh-admin 3b497bd71f Merge pull request 'mounts: the SMB login is typed as one line at the desk's prompt (issue 379)' (#179) from fix/mounts-one-line-smb-login into main 2026-10-10 13:55:03 +00:00
jochen a058cad735 mounts: the SMB login is typed as one line at the desk's prompt (issue 379)
mesh/merge-gate pass: builds mounts → 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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery failed: its walk failed, carrying its own commit: a gate on a first machine (what it carried put back), a build, a machine
The secret smb-credentials was an INI file alone, which the desk's one-line
prompt (secret ask, hq ADR 0277) cannot type: on the desktop the Pi's source
said "line 1 is not key=value inside a [section]". A login is now also one line,
`[recalbox] username=root password=…`, password last and to the end of the
line so it may hold spaces and `=`; the file form still reads, and a file may
mix both. A line the module cannot read is refused with the form it expected,
never with what was typed, and a login without a username is not answered. The
source's "login is not given" says the desk verb and the line to type.
2026-10-10 15:50:56 +02:00
mesh-admin 1fcf46fbfc Merge pull request 'rofi: the desk's secret prompt names who asked (hq ADR 0277)' (#177) from feat/0277-the-desk-prompt-names-who-asked into main 2026-10-10 13:34:21 +00:00
jochen 4b9558f195 rofi: the desk's secret prompt names who asked (hq ADR 0277)
mesh/merge-gate pass: builds rofi → g14, 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, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery delivered
The controller's secret-ask verb opens the launcher's hidden prompt from anywhere on the mesh, so the
operator must know whose ask they are typing into. The prompt takes an optional asked_by, the
controller's word on the bus's caller held to a name's characters and spaces, and says it; a sentence,
markup or a line break is refused, so the prompt's words stay the launcher's.
2026-10-10 15:17:57 +02:00
jschoubben a6a9a818d2 Answer the review of hq issue 348's catalogue change
mesh/merge-gate pass: builds dnsmasq, docker, netcheck → ace, g14, novox, shanks; no bus step; 2 wait(s) for a person; every machine composes with the chan…
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery superseded: a newer head of the same pull request
- The dnsmasq watch restarts only a unit that runs, without waiting for
  its start; a unit stopped, starting or failed is held and said (C2).
- dnsmasq_resolve asks only an address the resolver listens on (C3).
- Tests for the watch loop keeping its budget, a watch that wrote nothing
  or stopped looking, and a unit not running (C4).
- A restart is remembered as said only once emitted, restarts made before
  the bundle started are said, and whether one helped is said (C5).
- docker_events reads the runtime's whole buffer: complete when it is not
  full or reaches past the window, never a quiet machine read as lost (C6).
- netcheck: refused read with errors.Is; tests for addresses and ports,
  and for a container named as docker knows it.
2026-10-09 15:25:45 +02:00
jschoubben a0049f3e4e dnsmasq: bind each address once, at start, after it exists (hq issue 348)
bind-dynamic closes every listener whose interface a re-read of the
machine's addresses did not find, and a re-read during a bridge teardown
fails (dnsmasq 2.93 network.c:832-875, reproduced in a network namespace:
53 releases in 120 parallel teardowns). bind-interfaces never closes a
listener; a drop-in makes the unit wait for the private address and retry
a start that found it missing.
2026-10-09 15:04:45 +02:00
jochen 12de28c81d docker: read the resolver file a container really uses (hq issue 348)
On 2026-10-09 the control node's route-proxy failed every lookup against 10.10.0.1 alone for twenty
minutes while the machine's file listed both mesh resolvers; its copy lives under the runtime's
root-only directory and no tool read it. docker_resolv_conf reads /etc/resolv.conf as the container
sees it through the runtime's archive (docker cp, no exec), parses it, and says whether it lists the
same resolvers as the machine.
2026-10-09 15:04:45 +02:00
jochen 0f6986aead netcheck: ask one named resolver, and list what listens here (hq issue 348)
During the 2026-10-09 outage the control node's resolver stayed active while it no longer listened on
its private address, and nothing on the mesh could ask that address a question or show which sockets
were bound. netcheck_dns takes server (and tcp) to ask one resolver alone and say its response code;
netcheck_listening reads the kernel's socket tables, as ss -lunt does, with the owning program where
the tool's account may see it and a sentence where it may not.
2026-10-09 15:04:45 +02:00
jochen 3d8b52c9f4 docker: say where the runtime's event history begins, so a short history never reads as a quiet one (hq issue 348)
docker_events asked for three hours across the 2026-10-09 resolver outage on the control node answered
zero events: the runtime keeps only its last ~256 events in memory, and health-check execs filled them
with the last ten minutes. The answer now carries held, oldest_held and complete, and a history line
when the window reaches further back than what the runtime still holds. The type filter moves into the
tool, since the history's start is only known from all of it.
2026-10-09 15:04:45 +02:00
jschoubben 2b2397abe0 dnsmasq: ask the resolver a real question, and restart it when it stops answering (hq issue 348)
On 2026-10-09 the control node's dnsmasq stayed active and running, its
journal silent, and refused every question on its private address for
twenty minutes; nothing in the module looked past the unit.

The module is now Go (its TypeScript tools were never served): a root
watch asks the machine's own name on every listen address over UDP and
TCP every 15s, restarts the unit after two failing looks (2 min apart,
3 an hour), and says whether that helped. dnsmasq_health is the watch
process's declared check, so a restart that did not help becomes the
module's unhealthy condition; the service is judged by its unit.
2026-10-09 15:04:45 +02:00
407 changed files with 68508 additions and 1782 deletions
+8
View File
@@ -7,9 +7,17 @@ modules/jetbrains-toolbox/cmd/toolbox-tools/toolbox-tools
modules/messenger/cmd/messenger/messenger
modules/mesh-watcher/cmd/mesh-watcher/mesh-watcher
modules/claude-code/cmd/claude-code/claude-code
modules/dnsmasq/cmd/dnsmasq-tools/dnsmasq-tools
modules/mesh-delivery/cmd/mesh-delivery/mesh-delivery
# ...and the same built from the module's root (go build ./cmd/<name>), which names it after the module.
modules/messenger/messenger
modules/claude-code/claude-code
modules/mesh-watcher/mesh-watcher
modules/dnsmasq/dnsmasq
modules/mesh-delivery/mesh-delivery
modules/mesh-issues/cmd/mesh-issues/mesh-issues
modules/mounts/cmd/mounts/mounts
modules/mesh-knowledge/cmd/mesh-knowledge/mesh-knowledge
modules/mesh-knowledge/mesh-knowledge
modules/mesh-reads/mesh-reads
modules/mesh-reads/cmd/mesh-reads/mesh-reads
+3
View File
@@ -0,0 +1,3 @@
module git.novox.be/novox/mesh-catalog/checks/deps
go 1.22
+255
View File
@@ -0,0 +1,255 @@
// Command deps makes a module's dependencies available to the catalogue's check, or says why it cannot
// (novox/hq issue 385).
//
// A module's tests run only when its dependencies can be fetched. The build seat clones what a check
// needs with the forge credential and forgets that credential before the check runs, so a module pinning
// an SDK commit the seat's module cache does not hold cannot fetch it from the forge. The SDK is cloned
// beside the check all the same (MESH_CHECK_BESIDE, mesh-controller's besideRefs): every requirement of the
// mesh's own forge is taken from the clone of its repository there, at the commit or the tag its version
// names, through a go.mod of its own (-modfile) — the module's own files are not changed, and no forge
// credential is added.
//
// Usage: deps -beside <dir> -work <dir> <module dir>. It prints the go.mod to build the module with, or
// nothing when the module fetches what it needs; when it cannot, it prints why and exits 1, and the check
// fails: a module whose tests could not run is a failure, never a note.
package main
import (
"archive/tar"
"bytes"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
)
// forge is the prefix of the Go modules of the mesh's own forge: <forge><repository>[/<directory>].
const forge = "git.novox.be/novox/"
func main() {
beside := flag.String("beside", "", "where the repositories beside the check are cloned (MESH_CHECK_BESIDE)")
work := flag.String("work", os.TempDir(), "where the go.mod and the sources taken from beside are written")
flag.Parse()
if flag.NArg() != 1 {
fmt.Println("usage: deps -beside <dir> -work <dir> <module dir>")
os.Exit(2)
}
modfile, err := resolve(flag.Arg(0), *beside, *work)
if err != nil {
fmt.Println(err)
os.Exit(1)
}
if modfile != "" {
fmt.Println(modfile)
}
}
// resolve is the go.mod the module at dir builds with: "" when its own fetches everything, else one
// beside it in work that takes every requirement of the mesh's forge from its clone beside the check.
func resolve(dir, beside, work string) (string, error) {
out, err := goIn(dir, "mod", "download")
if err == nil {
return "", nil
}
fetching := lastLine(out)
required, err := forgeRequirements(dir)
if err != nil {
return "", err
}
if len(required) == 0 {
return "", fmt.Errorf("its dependencies cannot be fetched: %s", fetching)
}
if beside == "" {
return "", fmt.Errorf("its dependencies cannot be fetched (%s), and nothing was cloned beside the check to "+
"take %s from", fetching, repositories(required))
}
into, err := os.MkdirTemp(work, filepath.Base(dir)+"-")
if err != nil {
return "", err
}
modfile := filepath.Join(into, "go.mod")
if err := copyFile(filepath.Join(dir, "go.mod"), modfile); err != nil {
return "", err
}
if err := copyFile(filepath.Join(dir, "go.sum"), filepath.Join(into, "go.sum")); err != nil &&
!errors.Is(err, os.ErrNotExist) {
return "", err
}
for _, r := range required {
source, err := export(beside, r, into)
if err != nil {
return "", fmt.Errorf("its dependencies cannot be fetched (%s), and %s@%s cannot be taken from the "+
"clone beside the check: %w", fetching, r.Path, r.Version, err)
}
if out, err := goIn(dir, "mod", "edit", "-replace", r.Path+"@"+r.Version+"="+source, modfile); err != nil {
return "", fmt.Errorf("%s@%s cannot be taken from the clone beside the check: %s", r.Path, r.Version,
lastLine(out))
}
}
if out, err := goIn(dir, "mod", "download", "-modfile="+modfile); err != nil {
return "", fmt.Errorf("its dependencies cannot be fetched even with %s taken from the clone beside the "+
"check: %s", repositories(required), lastLine(out))
}
return modfile, nil
}
// requirement is one module the module requires.
type requirement struct {
Path string
Version string
}
// forgeRequirements is every requirement of the module at dir that comes from the mesh's own forge.
func forgeRequirements(dir string) ([]requirement, error) {
out, err := goIn(dir, "mod", "edit", "-json")
if err != nil {
return nil, fmt.Errorf("its go.mod cannot be read: %s", lastLine(out))
}
var mod struct{ Require []requirement }
if err := json.Unmarshal(out, &mod); err != nil {
return nil, fmt.Errorf("its go.mod cannot be read: %w", err)
}
var on []requirement
for _, r := range mod.Require {
if strings.HasPrefix(r.Path, forge) {
on = append(on, r)
}
}
return on, nil
}
// repositoryOf splits a module path of the forge into its repository and the directory in it.
func repositoryOf(path string) (string, string) {
repo, sub, _ := strings.Cut(strings.TrimPrefix(path, forge), "/")
return repo, sub
}
func repositories(rs []requirement) string {
var names []string
for _, r := range rs {
repo, _ := repositoryOf(r.Path)
names = append(names, repo)
}
return strings.Join(names, ", ")
}
// pseudo is a pseudo-version's commit: v0.1.14-0.20261012082113-9747f3e1b1ac → 9747f3e1b1ac.
var pseudo = regexp.MustCompile(`[-.]\d{14}-([0-9a-f]{12})$`)
// refOf is the ref a version names in its repository: a pseudo-version's commit, else the tag a module in
// a directory is released under (<directory>/<version>).
func refOf(sub, version string) string {
version = strings.TrimSuffix(version, "+incompatible")
if m := pseudo.FindStringSubmatch(version); m != nil {
return m[1]
}
if sub != "" {
return sub + "/" + version
}
return version
}
// export writes the module r names, at its version, from its repository's clone beside the check into
// into, and answers its directory.
func export(beside string, r requirement, into string) (string, error) {
repo, sub := repositoryOf(r.Path)
clone := filepath.Join(beside, repo)
if _, err := os.Stat(filepath.Join(clone, ".git")); err != nil {
return "", fmt.Errorf("no clone of %s is beside the check", repo)
}
ref := refOf(sub, r.Version)
commit, err := exec.Command("git", "-C", clone, "rev-parse", "--verify", "--quiet", ref+"^{commit}").Output()
if err != nil {
return "", fmt.Errorf("the clone of %s holds no %s (a commit or tag not pushed to the forge?)", repo, ref)
}
at := strings.TrimSpace(string(commit))
args := []string{"-C", clone, "archive", "--format=tar", at}
if sub != "" {
args = append(args, "--", sub)
}
var stderr bytes.Buffer
archive := exec.Command("git", args...)
archive.Stderr = &stderr
raw, err := archive.Output()
if err != nil {
return "", fmt.Errorf("git archive of %s at %s: %v %s", repo, ref, err, strings.TrimSpace(stderr.String()))
}
root := filepath.Join(into, repo+"@"+at[:12])
if err := untar(raw, root); err != nil {
return "", err
}
source := filepath.Join(root, filepath.FromSlash(sub))
if _, err := os.Stat(filepath.Join(source, "go.mod")); err != nil {
return "", fmt.Errorf("%s at %s has no go.mod in %q", repo, ref, sub)
}
return source, nil
}
// untar writes a tar's directories and regular files under root.
func untar(raw []byte, root string) error {
r := tar.NewReader(bytes.NewReader(raw))
for {
h, err := r.Next()
if err == io.EOF {
return nil
}
if err != nil {
return err
}
name := filepath.Clean(filepath.FromSlash(h.Name))
if name == "." || strings.HasPrefix(name, "..") || filepath.IsAbs(name) {
continue
}
path := filepath.Join(root, name)
switch h.Typeflag {
case tar.TypeDir:
if err := os.MkdirAll(path, 0o755); err != nil {
return err
}
case tar.TypeReg:
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
body, err := io.ReadAll(r)
if err != nil {
return err
}
if err := os.WriteFile(path, body, 0o644); err != nil {
return err
}
}
}
}
func copyFile(from, to string) error {
raw, err := os.ReadFile(from)
if err != nil {
return err
}
return os.WriteFile(to, raw, 0o644)
}
// goIn runs the go command in dir.
func goIn(dir string, args ...string) ([]byte, error) {
cmd := exec.Command("go", args...)
cmd.Dir = dir
var stderr bytes.Buffer
cmd.Stderr = &stderr
out, err := cmd.Output()
if err != nil {
return append(out, stderr.Bytes()...), err
}
return out, nil
}
// lastLine is the last line a command said, which is where go says what failed.
func lastLine(out []byte) string {
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
return strings.TrimSpace(lines[len(lines)-1])
}
+177
View File
@@ -0,0 +1,177 @@
package main
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
// offline is a Go that fetches nothing: what the build seat is once the forge credential is gone, for a pin
// its module cache does not hold (novox/hq issue 385).
func offline(t *testing.T) {
t.Helper()
t.Setenv("GOPROXY", "off")
t.Setenv("GOFLAGS", "")
t.Setenv("GOWORK", "off")
// Not GOPRIVATE: a private module is fetched directly by git, around GOPROXY, with whatever credential
// the machine running the test holds.
t.Setenv("GOPRIVATE", "")
t.Setenv("GONOPROXY", "")
t.Setenv("GOSUMDB", "off")
t.Setenv("GOMODCACHE", t.TempDir())
}
func git(t *testing.T, dir string, args ...string) string {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = dir
cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t", "GIT_COMMITTER_NAME=t",
"GIT_COMMITTER_EMAIL=t@t", "GIT_AUTHOR_DATE=2026-10-12T08:21:13Z", "GIT_COMMITTER_DATE=2026-10-12T08:21:13Z")
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out)
}
return strings.TrimSpace(string(out))
}
func write(t *testing.T, path, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
// sdkBeside is the SDK's repository cloned beside a check, its Go module under go/, one commit tagged
// go/v0.1.7. It answers the beside root and the commit.
func sdkBeside(t *testing.T) (string, string) {
t.Helper()
beside := t.TempDir()
repo := filepath.Join(beside, "mesh-sdk")
write(t, filepath.Join(repo, "go", "go.mod"), "module git.novox.be/novox/mesh-sdk/go\n\ngo 1.22\n")
write(t, filepath.Join(repo, "go", "sdk", "sdk.go"), "package sdk\n\nfunc Hello() string { return \"hello\" }\n")
write(t, filepath.Join(repo, "README.md"), "the SDK\n")
git(t, repo, "init", "--quiet")
git(t, repo, "add", ".")
git(t, repo, "commit", "--quiet", "-m", "sdk")
git(t, repo, "tag", "go/v0.1.7")
return beside, git(t, repo, "rev-parse", "HEAD")
}
// moduleRequiring is a catalogue module whose code and test use the SDK at the version given.
func moduleRequiring(t *testing.T, version string) string {
t.Helper()
dir := filepath.Join(t.TempDir(), "messenger")
write(t, filepath.Join(dir, "go.mod"), "module git.novox.be/novox/mesh-catalog/modules/messenger\n\ngo 1.22\n\n"+
"require git.novox.be/novox/mesh-sdk/go "+version+"\n")
write(t, filepath.Join(dir, "main.go"), "package main\n\nimport \"git.novox.be/novox/mesh-sdk/go/sdk\"\n\n"+
"func main() { println(sdk.Hello()) }\n")
return dir
}
func builds(t *testing.T, dir, modfile string) {
t.Helper()
args := []string{"vet"}
if modfile != "" {
args = append(args, "-modfile="+modfile)
}
cmd := exec.Command("go", append(args, "./...")...)
cmd.Dir = dir
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("the module does not build with %q: %v\n%s", modfile, err, out)
}
}
// A module that needs nothing it cannot fetch is left as it is: no other go.mod.
func TestAModuleThatFetchesItsDependenciesNeedsNothingBeside(t *testing.T) {
offline(t)
dir := filepath.Join(t.TempDir(), "plain")
write(t, filepath.Join(dir, "go.mod"), "module example.com/plain\n\ngo 1.22\n")
write(t, filepath.Join(dir, "main.go"), "package main\n\nfunc main() {}\n")
modfile, err := resolve(dir, "", t.TempDir())
if err != nil || modfile != "" {
t.Fatalf("resolve = %q, %v; want nothing", modfile, err)
}
}
// The case of issue 385: the module pins an SDK commit the build seat cannot fetch. It is taken from the
// SDK's clone beside the check, and the module builds against it.
func TestAPinnedSDKCommitIsTakenFromTheCloneBeside(t *testing.T) {
offline(t)
beside, commit := sdkBeside(t)
dir := moduleRequiring(t, "v0.1.14-0.20261012082113-"+commit[:12])
modfile, err := resolve(dir, beside, t.TempDir())
if err != nil {
t.Fatalf("resolve: %v", err)
}
if modfile == "" {
t.Fatal("resolve said the module fetches its dependencies, with nothing to fetch them from")
}
builds(t, dir, modfile)
if raw, _ := os.ReadFile(filepath.Join(dir, "go.mod")); strings.Contains(string(raw), "replace") {
t.Fatalf("the module's own go.mod was changed:\n%s", raw)
}
}
// A released SDK is found by the tag it is released under: go/<version>.
func TestATaggedSDKIsTakenFromTheCloneBeside(t *testing.T) {
offline(t)
beside, _ := sdkBeside(t)
dir := moduleRequiring(t, "v0.1.7")
modfile, err := resolve(dir, beside, t.TempDir())
if err != nil {
t.Fatalf("resolve: %v", err)
}
builds(t, dir, modfile)
}
// A commit the clone beside does not hold (a branch never pushed) cannot be tested, and says which.
func TestACommitTheCloneBesideDoesNotHoldIsSaid(t *testing.T) {
offline(t)
beside, _ := sdkBeside(t)
dir := moduleRequiring(t, "v0.1.14-0.20261010132341-4f3efc98e0c0")
_, err := resolve(dir, beside, t.TempDir())
if err == nil {
t.Fatal("resolve found a commit nothing holds")
}
for _, want := range []string{"git.novox.be/novox/mesh-sdk/go@v0.1.14-0.20261010132341-4f3efc98e0c0", "4f3efc98e0c0"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("the reason %q does not name %s", err, want)
}
}
}
// With nothing cloned beside (a check by hand without the forge), the reason says that.
func TestNoCloneBesideIsSaid(t *testing.T) {
offline(t)
_, commit := sdkBeside(t)
dir := moduleRequiring(t, "v0.1.14-0.20261012082113-"+commit[:12])
for _, beside := range []string{"", t.TempDir()} {
_, err := resolve(dir, beside, t.TempDir())
if err == nil {
t.Fatalf("resolve with %q beside found what is not there", beside)
}
if !strings.Contains(err.Error(), "mesh-sdk") {
t.Errorf("the reason %q does not name the repository it needed", err)
}
}
}
func TestTheRefOfAVersion(t *testing.T) {
cases := []struct{ sub, version, want string }{
{"go", "v0.1.14-0.20261012082113-9747f3e1b1ac", "9747f3e1b1ac"},
{"go", "v0.0.0-20261012082113-9747f3e1b1ac", "9747f3e1b1ac"},
{"go", "v0.1.7", "go/v0.1.7"},
{"", "v1.2.0", "v1.2.0"},
{"", "v2.0.0+incompatible", "v2.0.0"},
}
for _, c := range cases {
if got := refOf(c.sub, c.version); got != c.want {
t.Errorf("refOf(%q, %q) = %q, want %q", c.sub, c.version, got, c.want)
}
}
}
+3
View File
@@ -0,0 +1,3 @@
module git.novox.be/novox/mesh-catalog/checks/secrets
go 1.22
+4
View File
@@ -0,0 +1,4 @@
# Secrets nested below a key a mergeable file takes for its other values, which the controller cannot
# protect alone yet (it protects top-level keys): "<module> <resource> <dotted key>". This list only shrinks:
# a line that no longer names a finding is refused, and so is one the trunk's copy does not hold (hq issue 342).
searxng settings server.secret_key
+244
View File
@@ -0,0 +1,244 @@
// Command secrets refuses, in every module's manifest, a mergeable file that a setting could fill with a
// password, a token or an API key in place of the vault's (novox/hq issue 342).
//
// A password is not a setting: it comes from the key vault, and it can be rotated there. A file that says
// `"merge": "json"` takes the keys of the mesh-wide and the node's layers over its own content, so a key the
// content fills with `${secret:<name>}` is one a layer could replace, and the vault's value would lose to it
// until somebody removed it by hand. The controller refuses a setting of a key the file keeps as `protected`,
// naming the layer and the key, never the value. This check holds every such file to that: each top-level key
// whose value holds `${secret:…}`, anywhere below it, is either listed in the file's `protected`, or left out
// of the file's `takes-settings` (novox/hq ADR 0326), so that no layer reaches it.
//
// A secret nested below a key the file takes for its other values (`server.secret_key` below `server`) cannot
// be protected alone today: the controller protects top-level keys. Such a file is named in the file `known`
// beside this check, one line per key, until the controller refuses a nested key. The list only shrinks: a
// line there that no longer names a finding is refused, and so is a line the trunk's copy (the merge base's,
// given with -base) does not hold. While the trunk holds no copy, the lines in seed are the trunk's.
//
// go run . [-base <the trunk's known>] <catalogue root>
//
// Exits 1 on a finding.
package main
import (
"bufio"
"encoding/json"
"flag"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
)
// Finding is one key of one mergeable file a layer could fill in place of the vault.
type Finding struct {
Module, ID, Key string
}
func (f Finding) String() string { return f.Module + " " + f.ID + " " + f.Key }
// Check reads every manifest under root/modules and answers each secret-bearing key a layer could replace.
func Check(root string) ([]Finding, error) {
manifests, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
if err != nil {
return nil, err
}
sort.Strings(manifests)
var out []Finding
for _, path := range manifests {
raw, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var m struct {
Module string `json:"module"`
Resources []map[string]any `json:"resources"`
}
if err := json.Unmarshal(raw, &m); err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
for _, r := range m.Resources {
if how, _ := r["merge"].(string); how == "" {
continue
}
id, _ := r["id"].(string)
content, _ := r["content"].(string)
var doc map[string]any
if strings.TrimSpace(content) == "" {
continue
}
if err := json.Unmarshal([]byte(content), &doc); err != nil {
return nil, fmt.Errorf("%s: %s merges as JSON and its content is not JSON: %w", m.Module, id, err)
}
protected := set(r["protected"])
takes, names := r["takes-settings"]
taken := set(takes)
for _, key := range sortedKeys(doc) {
for _, at := range secretsBelow(key, doc[key]) {
switch {
case protected[key]:
case names && !taken[key]:
default:
out = append(out, Finding{m.Module, id, at})
}
}
}
}
}
return out, nil
}
// secretsBelow answers the dotted path of every string at or below value that names a vault secret.
func secretsBelow(at string, value any) []string {
switch v := value.(type) {
case string:
if strings.Contains(v, "${secret:") {
return []string{at}
}
case map[string]any:
var out []string
for _, k := range sortedKeys(v) {
out = append(out, secretsBelow(at+"."+k, v[k])...)
}
return out
case []any:
var out []string
for i, e := range v {
out = append(out, secretsBelow(fmt.Sprintf("%s[%d]", at, i), e)...)
}
return out
}
return nil
}
func set(v any) map[string]bool {
out := map[string]bool{}
list, _ := v.([]any)
for _, e := range list {
if s, ok := e.(string); ok {
out[s] = true
}
}
return out
}
func sortedKeys(m map[string]any) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
// Known reads the lines of the known list: "<module> <resource> <key>", # comments and blank lines skipped.
func Known(path string) (map[string]bool, error) {
f, err := os.Open(path)
if os.IsNotExist(err) {
return map[string]bool{}, nil
}
if err != nil {
return nil, err
}
defer f.Close()
out := map[string]bool{}
s := bufio.NewScanner(f)
for s.Scan() {
line := strings.TrimSpace(s.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
out[strings.Join(strings.Fields(line), " ")] = true
}
return out, s.Err()
}
// seed is the known list as it was first written, standing for the trunk's copy until the trunk holds one.
var seed = map[string]bool{
"searxng settings server.secret_key": true,
}
// Added answers the lines of known that base does not hold: a line may leave the list, never join it.
func Added(known, base map[string]bool) []string {
var out []string
for line := range known {
if !base[line] {
out = append(out, line)
}
}
sort.Strings(out)
return out
}
// Judge splits the findings into those refused and the known lines that no longer name one.
func Judge(found []Finding, known map[string]bool) (refused []Finding, stale []string) {
seen := map[string]bool{}
for _, f := range found {
seen[f.String()] = true
if !known[f.String()] {
refused = append(refused, f)
}
}
for line := range known {
if !seen[line] {
stale = append(stale, line)
}
}
sort.Strings(stale)
return refused, stale
}
// remedy says how a finding is put right.
func remedy(key string) string {
top, _, nested := strings.Cut(key, ".")
if !nested {
return fmt.Sprintf("list %q in the file's \"protected\", so the controller refuses a setting of it", top)
}
return fmt.Sprintf("the controller protects a top-level key only; list %q in the file's \"protected\" if "+
"nothing else below it is the operator's, or leave it out of the file's \"takes-settings\"", top)
}
func main() {
basePath := flag.String("base", "", "the trunk's copy of known (the merge base's); seed when absent")
flag.Parse()
root := "."
if flag.NArg() > 0 {
root = flag.Arg(0)
}
found, err := Check(root)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
known, err := Known("known")
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
base := seed
if *basePath != "" {
if base, err = Known(*basePath); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
added := Added(known, base)
for _, line := range added {
fmt.Printf("checks/secrets/known gains %q, which the trunk's copy does not hold: the list only shrinks — "+
"protect the key instead (novox/hq issue 342)\n", line)
}
refused, stale := Judge(found, known)
for _, f := range refused {
fmt.Printf("%s: %s fills %s with a vault secret, and a setting could replace it (novox/hq issue 342): %s\n",
f.Module, f.ID, f.Key, remedy(f.Key))
}
for _, line := range stale {
fmt.Printf("checks/secrets/known names %q, which no longer holds a secret a setting could replace: "+
"remove the line\n", line)
}
if len(refused) > 0 || len(stale) > 0 || len(added) > 0 {
os.Exit(1)
}
fmt.Printf("secrets: no setting can replace a vault secret in a mergeable file (%d known, nested)\n", len(known))
}
+125
View File
@@ -0,0 +1,125 @@
package main
import (
"os"
"path/filepath"
"testing"
)
func manifest(t *testing.T, root, module, resources string) {
t.Helper()
dir := filepath.Join(root, "modules", module)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
body := `{"module":"` + module + `","resources":[` + resources + `]}`
if err := os.WriteFile(filepath.Join(dir, "module.json"), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
func TestASecretASettingCouldReplaceIsRefused(t *testing.T) {
root := t.TempDir()
// Refused: the key holds the vault's secret and nothing keeps a layer off it.
manifest(t, root, "a", `{"id":"cfg","type":"file","merge":"json","content":"{\"password\": \"${secret:admin}\"}"}`)
// Refused: nested below a key the file takes.
manifest(t, root, "b", `{"id":"cfg","type":"file","merge":"json","takes-settings":["server"],`+
`"content":"{\"server\": {\"secret_key\": \"${secret:s}\", \"port\": 1}}"}`)
// Kept: protected.
manifest(t, root, "c", `{"id":"cfg","type":"file","merge":"json","protected":["password"],`+
`"content":"{\"password\": \"${secret:admin}\", \"user\": \"admin\"}"}`)
// Kept: the file names its keys, and not this one.
manifest(t, root, "d", `{"id":"cfg","type":"file","merge":"json","takes-settings":["ui"],`+
`"content":"{\"token\": \"${secret:t}\", \"ui\": {}}"}`)
// Kept: a file that does not merge takes no setting.
manifest(t, root, "e", `{"id":"cfg","type":"file","content":"{\"token\": \"${secret:t}\"}"}`)
// Refused: protecting another key does not protect this one; a list element counts too.
manifest(t, root, "f", `{"id":"cfg","type":"file","merge":"json","protected":["user"],`+
`"content":"{\"users\": [{\"password\": \"${secret:p}\"}]}"}`)
found, err := Check(root)
if err != nil {
t.Fatal(err)
}
want := []string{"a cfg password", "b cfg server.secret_key", "f cfg users[0].password"}
if len(found) != len(want) {
t.Fatalf("found %v, want %v", found, want)
}
for i, f := range found {
if f.String() != want[i] {
t.Fatalf("found %v, want %v", found, want)
}
}
}
func TestTheKnownListOnlyShrinks(t *testing.T) {
found := []Finding{{"b", "cfg", "server.secret_key"}, {"a", "cfg", "password"}}
refused, stale := Judge(found, map[string]bool{"b cfg server.secret_key": true, "gone cfg token": true})
if len(refused) != 1 || refused[0].String() != "a cfg password" {
t.Fatalf("refused %v", refused)
}
if len(stale) != 1 || stale[0] != "gone cfg token" {
t.Fatalf("stale %v", stale)
}
}
func TestTheCatalogueHoldsNoSecretASettingCouldReplace(t *testing.T) {
found, err := Check("../..")
if err != nil {
t.Fatal(err)
}
known, err := Known("known")
if err != nil {
t.Fatal(err)
}
refused, stale := Judge(found, known)
if len(refused) > 0 || len(stale) > 0 {
t.Fatalf("refused %v, stale %v", refused, stale)
}
}
func TestALineTheTrunkDoesNotHoldIsRefused(t *testing.T) {
trunk := map[string]bool{"b cfg server.secret_key": true}
// A key unprotected on the branch, and its line added to the list: refused, though it matches a finding.
branch := map[string]bool{"b cfg server.secret_key": true, "a cfg password": true}
if added := Added(branch, trunk); len(added) != 1 || added[0] != "a cfg password" {
t.Fatalf("added %v", added)
}
// A line removed is no addition.
if added := Added(map[string]bool{}, trunk); len(added) != 0 {
t.Fatalf("added %v", added)
}
// Until the trunk holds a copy, the seed stands for it, and the list as committed is within it.
known, err := Known("known")
if err != nil {
t.Fatal(err)
}
if added := Added(known, seed); len(added) != 0 {
t.Fatalf("known holds lines the seed does not: %v", added)
}
}
func TestTheCheckRefusesALineAddedAgainstTheTrunk(t *testing.T) {
root := t.TempDir()
manifest(t, root, "a", `{"id":"cfg","type":"file","merge":"json","content":"{\"password\": \"${secret:admin}\"}"}`)
dir := t.TempDir()
base := filepath.Join(dir, "base")
if err := os.WriteFile(base, []byte("# the trunk's\n"), 0o644); err != nil {
t.Fatal(err)
}
branch := map[string]bool{"a cfg password": true}
found, err := Check(root)
if err != nil {
t.Fatal(err)
}
trunk, err := Known(base)
if err != nil {
t.Fatal(err)
}
refused, stale := Judge(found, branch)
if len(refused) != 0 || len(stale) != 0 {
t.Fatalf("the line hides the finding from Judge alone: %v %v", refused, stale)
}
if added := Added(branch, trunk); len(added) != 1 {
t.Fatalf("an added line passed: %v", added)
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
70
66
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
+7 -3
View File
@@ -9,14 +9,17 @@
#
# 1. its dependencies are the toolchain's own (/app/node_modules: the mesh's SDK, Node's types and what the
# runtime carries), copied beside it, never fetched: the check holds no credential for the package
# registry. A module that depends on a package the toolchain does not hold is said NOT CHECKED, by name;
# registry. A module that depends on a package the toolchain does not hold **fails the check**, by name:
# it could not be type-checked or tested, and what could not run is a failure, never a note (novox/hq
# issue 385);
# 2. every source that is not a test is type-checked with the toolchain's compiler and the flags a build
# compiles with — the same check the build makes, before the merge rather than in the walk;
# 3. its tests (test/*.test.ts) run under Node's own runner with types stripped, as its own `npm test` does
# after its build: the sources are compiled to dist/ and beside themselves first, so a test that imports
# the compiled module, or a source that imports its sibling by its .js name, finds it.
#
# Said, never passed silently: a module with no tests is said NOT TESTED on every run that touches it.
# Said, never passed silently: a module with no tests is said NOT TESTED on every run that touches it —
# nothing of it could fail. A module whose tests exist and could not run fails (novox/hq issue 385).
#
# By hand: MESH_CHECK_MODULES=gitea sh merge-check-typescript.sh, in the toolchain image (it reads
# /app/node_modules), or with TOOLCHAIN_MODULES pointing at a node_modules that holds the same.
@@ -54,7 +57,8 @@ for m in $touched; do
require("fs").existsSync(process.argv[2] + "/" + n));
console.log(names.filter((n) => n !== "typescript" && !held(n)).join(" "));' "$copy/package.json" "$deps")
if [ -n "$missing" ]; then
echo "NOT CHECKED: $d depends on $missing, which the toolchain does not hold and the check cannot fetch"
echo "FAILED: $d could not be checked — it depends on $missing, which the toolchain does not hold and the check cannot fetch (novox/hq issue 385)"
failed="$failed $m"
continue
fi
mkdir -p "$copy/node_modules"
+39 -6
View File
@@ -14,8 +14,11 @@
# 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 is said as not
# tested, never passed silently. A module written in TypeScript is type-checked and tested by
# toolchain has a C compiler. A requirement of the mesh's own forge the module cache does not hold (a
# pinned SDK commit) is taken from its repository's clone beside the check (checks/deps); a module whose
# dependencies still cannot be had **fails the check**, by name and why — its tests could not run, and a
# test that could not run is a failure, never a note (novox/hq issue 385). A module written in
# TypeScript is type-checked and tested by
# merge-check-typescript.sh, declared above, in the TypeScript toolchain (novox/hq issues 302, 311);
# 3. the words (novox/hq ADR 0244): no word the glossary retired for the tools, as copied in
# retired-words, in any text a module's tools can show an agent — every string in its own code that is
@@ -27,11 +30,27 @@
# 5. the agent's home (novox/hq ADR 0266): no module unpacks an archive, or writes into a file, under
# ${machine:agent-home} — the node-engine does both after checking the path, and the agent account owns
# that home and could swap a link in between.
# 6. the secrets (novox/hq issue 342): no mergeable file fills a key with a vault secret that a setting could
# replace — the key is `protected`, or left out of the file's `takes-settings`. checks/secrets/known names
# the nested ones the controller cannot protect alone yet, and only shrinks.
set -eu
(cd checks/words && go test -count=1 . && go run . ../..)
# The agent's home (novox/hq ADR 0266): no archive, and no file written into, under it.
(cd checks/agenthome && go test -count=1 . && go run . ../..)
# The secrets (novox/hq issue 342): a password is not a setting, so no setting can replace the vault's. The
# known list only shrinks: judged against the trunk's copy at the merge base, a line it does not hold is refused.
secrets_base=""
secrets_ref="origin/${MESH_CHECK_BASE:-main}"
if merge_base=$(git merge-base HEAD "$secrets_ref" 2>/dev/null); then
if git cat-file -e "$merge_base:checks/secrets/known" 2>/dev/null; then
secrets_base=$(mktemp)
git show "$merge_base:checks/secrets/known" >"$secrets_base"
fi
else
echo "NOT COMPARED WITH THE TRUNK: no merge base with $secrets_ref; checks/secrets/known is held to the list it was first written with"
fi
(cd checks/secrets && go test -count=1 . && if [ -n "$secrets_base" ]; then go run . -base "$secrets_base" ../..; else go run . ../..; fi)
# The lists that narrow the paths check may not grow unseen: the base branch's copies are what they are
# judged against (MESH_CHECK_BASE, main when unset), read from the clone the check runs in.
base_lists=""
@@ -77,16 +96,26 @@ if [ -n "${MESH_CHECK_MODULES:-}" ]; then
else
touched=$(printf '%s\n' "${MESH_CHECK_CHANGED:-}" | tr ',' '\n' | sed -n 's#^modules/\([^/]*\)/.*#\1#p' | sort -u)
fi
# A module's dependencies: fetched, or a forge requirement taken from its clone beside the check
# (MESH_CHECK_BESIDE); its tests run against them (checks/deps, novox/hq issue 385). The helper's own test
# runs first.
(cd checks/deps && go test -count=1 .)
deps=$(mktemp -d)
trap 'rm -rf "$deps"' EXIT
(cd checks/deps && go build -o "$deps/deps" .)
untestable=""
for m in $touched; do
[ -d "modules/$m" ] || continue
if [ ! -f "modules/$m/go.mod" ]; then
[ -f "modules/$m/package.json" ] && echo "modules/$m is TypeScript: merge-check-typescript.sh type-checks and tests it"
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"
if ! modfile=$(GOPRIVATE=git.novox.be "$deps/deps" -beside "${MESH_CHECK_BESIDE:-}" -work "$deps" "modules/$m"); then
echo "FAILED: modules/$m could not be tested — $modfile"
untestable="$untestable modules/$m"
continue
fi
[ -n "$modfile" ] && echo "modules/$m takes its forge dependencies from the clones beside the check"
echo "testing modules/$m"
unformatted=$(cd "modules/$m" && gofmt -l .)
if [ -n "$unformatted" ]; then
@@ -95,6 +124,10 @@ for m in $touched; do
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 ./...)
(cd "modules/$m" && CGO_ENABLED=$cgo GOPRIVATE=git.novox.be go vet ${modfile:+"-modfile=$modfile"} ./... &&
CGO_ENABLED=$cgo GOPRIVATE=git.novox.be go test ${modfile:+"-modfile=$modfile"} $race -count=1 ./...)
done
if [ -n "$untestable" ]; then
echo "the check fails: the tests of$untestable could not run (novox/hq issue 385)"
exit 1
fi
+22 -4
View File
@@ -1,7 +1,8 @@
# artifact-store-tools
Tools that say what the mesh's artifact store holds, and what the controller's records say of it
(novox/hq ADR 0251 §1–3, to-be 51). It declares no resources and changes nothing on its machine.
(novox/hq ADR 0251 §1–3, to-be 51). It changes nothing on its machine; its only resources are its state
directory and the file the mesh writes there from its setting `older-store-names`.
## Why a module beside the store
@@ -18,7 +19,7 @@ on that machine.
|---|---|---|
| `artifact_store_repositories` | r | every repository: tags and what each names, how many manifests (tagged or not), size |
| `artifact_store_usage` | r | the store's size, the largest repositories, bytes shared between repositories, bytes no manifest marks (what the nightly collector frees next) |
| `artifact_store_references` | r | what the controller's records say of each manifest, counted and sized by state and repository; each manifest listed when one repository is asked; what the records keep that the store does not hold |
| `artifact_store_references` | r | what the controller's records and the catalogue's definitions say of each manifest, counted and sized by state and repository; each manifest listed when one repository is asked; what the records keep that the store does not hold |
| `artifact_store_collect` | a | a dry run unless `dry_run` is false: what the controller would let go of, and the bytes the nightly collector would free then and now. A real run needs `why` and asks the controller's `collect` |
### How the store is read
@@ -43,6 +44,7 @@ it lists. The store's collector removes every blob no manifest marks, so these n
| state | what | removable |
|---|---|---|
| `kept` | a definition names it, or one of the five most recent builds of a module the mesh holds; `why` says which | no |
| `named-by-a-definition` | no record names it, and a definition in the catalogue pins it by digest, under the store's current name or an older one; `why` names the module (hq issue 323) | no |
| `holder-of-kept-archive` | the manifest that keeps a kept archive's blob (hq issue 253) | no |
| `eligible` | the mesh made it and keeps it for no reason | through the controller's `collect` |
| `holder-of-eligible-archive` | the manifest that keeps an eligible archive's blob | with its archive |
@@ -56,6 +58,20 @@ its own right; the controller lets an index go with the platform manifests only
counted as the index's. Two indexes naming one platform give it the more keeping state. An index no
record names leaves its platform manifests unrecorded with it.
**What the definitions pin** (hq issue 323). A definition may pin an image of the store the mesh did not
make: pushed before the mesh built it, and pinned under the store's current name or a name it had before.
No record of the controller names it, yet a machine runs it, so `artifact_store_references` reads every
definition the catalogue holds (`mesh-catalog.catalog_modules`, then `catalog_module` for each, eight at
a time and within twenty seconds) and finds every `<name>/<repository>@sha256:…` in any of its strings,
under each of the store's names: `artifact-store://`, the store's address as the controller answers it,
and the names it had before. Those are the mesh's setting `older-store-names` (space-separated, `none` by
default, set with the controller's `settings`), which the mesh writes to the module's `store-names` file: a
catalogue names no installation. When they cannot be read, the answer's first line says so. A manifest no record
names that a definition pins is `named-by-a-definition`; one the records name keeps the records' state. A
definition read at its commit is kept, so the next call asks only for the list. Definitions that could
not be read are listed under `definitions.unread`, and the answer's first line says that what they pin
reads as unrecorded until they are.
**Unrecorded copies of upstream images** are listed in `unrecorded_mirrors`: each manifest at the top of
a repository only the build's mirror writes (`upstream/<host>/<path>`, or a module's `<module>/on-<argument>`
from before hq ADR 0257) that no record names, with what it and its platforms mark alone. They are the
@@ -88,6 +104,8 @@ with slashes, a cut listing refused), manifests read with one absent or unreadab
digest never becoming a path outside the blobs, the mark (an index, a shared blob, a blob nothing marks),
every state, what the records keep that the store lacks, bytes freed counting a blob shared with a kept
manifest as kept, a dry run never confirming, a real run without `why` refused before anything is asked,
the fall-back when the references let go of cannot be carried, escalation through `sudo -n`, and that the
tools served are exactly the manifest's `tools` and its `invokes` exactly the two controller verbs.
the fall-back when the references let go of cannot be carried, a manifest a definition pins under the
store's current or an older name, a definition not read said first, escalation through `sudo -n`, and that the
tools served are exactly the manifest's `tools` and its `invokes` exactly the two controller verbs and the
catalogue's two tools.
Any command other than the two read-only scripts fails the test.
@@ -7,12 +7,15 @@ import (
// The states a manifest the store holds can be in, as the records see it (novox/hq to-be 51).
const (
StateKept = "kept"
StateHolderKept = "holder-of-kept-archive"
StateEligible = "eligible"
StateHolderEligible = "holder-of-eligible-archive"
StateCollectedPresent = "let-go-yet-present"
StateNamedDocument = "named-document"
StateKept = "kept"
StateHolderKept = "holder-of-kept-archive"
StateEligible = "eligible"
StateHolderEligible = "holder-of-eligible-archive"
StateCollectedPresent = "let-go-yet-present"
StateNamedDocument = "named-document"
// StateNamedByDefinition is a manifest no record names that a definition pins by digest, under the
// store's current name or an older one (novox/hq issue 323): a machine may run it, so it is kept.
StateNamedByDefinition = "named-by-a-definition"
StateUnrecorded = "unrecorded"
collectionRemovesThese = "eligible and holder-of-eligible-archive, through the controller's collect; nothing else"
)
@@ -45,8 +48,8 @@ var mirrorRepository = regexp.MustCompile(`^(upstream/[a-z0-9][a-z0-9._/-]*|[a-z
// keeping orders the states a platform manifest may take from the indexes naming it: the most keeping
// wins, so a platform two indexes name is as kept as the more kept of them.
var keeping = map[string]int{
StateKept: 0, StateNamedDocument: 1, StateHolderKept: 2, StateEligible: 3, StateHolderEligible: 4,
StateCollectedPresent: 5,
StateKept: 0, StateNamedByDefinition: 1, StateNamedDocument: 2, StateHolderKept: 3, StateEligible: 4,
StateHolderEligible: 5, StateCollectedPresent: 6,
}
// recordIndex finds a record by repository and digest.
@@ -64,8 +67,9 @@ func indexRecords(r *Records) recordIndex {
return idx
}
// Classify gives every manifest the store holds one state.
func Classify(v *View, recs *Records) []Entry {
// Classify gives every manifest the store holds one state. A manifest no record names that a definition
// pins is named by a definition; defs may be nil, when the definitions were not read.
func Classify(v *View, recs *Records, defs *Definitions) []Entry {
idx := indexRecords(recs)
var out []Entry
for _, name := range v.L.RepoNames() {
@@ -99,6 +103,12 @@ func Classify(v *View, recs *Records) []Entry {
e.State = StateNamedDocument
}
}
if e.State == StateUnrecorded && defs != nil {
for _, p := range defs.Pins[Key(name, d)] {
e.State = StateNamedByDefinition
e.Why = append(e.Why, p.why())
}
}
out = append(out, e)
}
}
@@ -0,0 +1,343 @@
package main
import (
"context"
"encoding/json"
"fmt"
"os"
"regexp"
"sort"
"strings"
"sync"
"time"
)
// What the definitions pin in the store (novox/hq issue 323). A definition may pin an image of the store by
// digest that the mesh did not make: one pushed before the mesh built it, pinned under the store's current
// name or under a name the store had before. No record of the controller names such a manifest, and yet a
// machine runs it, so it is said to be named by a definition and never unrecorded.
// NamesFileEnv names the file the mesh writes from the setting older-store-names: a line `older=` with the
// names the store had before, as a definition may still write them — host names, or host:port, separated by
// spaces — or `none`. A catalogue names no installation, so the names are the mesh's setting, not the
// manifest's.
const NamesFileEnv = "MESH_ARTIFACT_STORE_NAMES_FILE"
// CatalogueModules and CatalogueModule are the catalogue's tools these definitions are read through.
const (
CatalogueModules = "mesh-catalog.catalog_modules"
CatalogueModule = "mesh-catalog.catalog_module"
)
// DefinitionsTimeout bounds reading every definition, below the runtime's thirty-second call limit; what
// was read is kept, so a call that runs out reads only the rest the next time.
const DefinitionsTimeout = 20 * time.Second
// definitionReaders is how many definitions are asked at once.
const definitionReaders = 8
// StoreNames are the names a definition may pin an image of the store under: the scheme the mesh writes
// (artifact-store://), the store's address now, and the names it had before.
type StoreNames struct {
Current []string
Older []string
}
// NamesOf is the store's names: its address as the controller answers it, and the older ones given.
func NamesOf(address, older string) StoreNames {
n := StoreNames{Current: []string{"artifact-store://"}}
if a := strings.Trim(strings.TrimSpace(address), "/"); a != "" {
n.Current = append(n.Current, a+"/")
}
for _, o := range strings.FieldsFunc(older, func(r rune) bool { return r == ',' || r == ' ' || r == '\n' || r == '\t' }) {
if o = strings.Trim(o, "/"); o != "" && o != "none" {
n.Older = append(n.Older, o+"/")
}
}
return n
}
// OlderNames reads the older names from the file the mesh writes, and says why when it cannot.
func OlderNames() (string, string) {
path := os.Getenv(NamesFileEnv)
if path == "" {
return "", NamesFileEnv + " is not set, so no older name of the store was read"
}
raw, err := os.ReadFile(path)
if err != nil {
return "", "the store's older names could not be read: " + err.Error()
}
for _, line := range strings.Split(string(raw), "\n") {
if v, ok := strings.CutPrefix(strings.TrimSpace(line), "older="); ok {
return v, ""
}
}
return "", path + " holds no line older=, so no older name of the store was read"
}
// Pin is one definition pinning a manifest of the store by digest.
type Pin struct {
Module string `json:"module"`
// Name is the store's name it is pinned under, as written: "artifact-store://", an address, or an older name.
Name string `json:"name"`
Older bool `json:"older,omitempty"`
}
// pinned is one pin found in a manifest: where in the store, and under which name.
type pinned struct {
Repository, Digest, Name string
Older bool
}
var pinShapes sync.Map // name -> *regexp.Regexp
// pinShape is `<name><repository>@sha256:<hex>`, the name standing on its own: not the tail of a longer host.
func pinShape(name string) *regexp.Regexp {
if re, ok := pinShapes.Load(name); ok {
return re.(*regexp.Regexp)
}
re := regexp.MustCompile(`(?:^|[^A-Za-z0-9.-])` + regexp.QuoteMeta(name) +
`([a-z0-9][a-z0-9._/-]*)@(sha256:[0-9a-f]{64})`)
pinShapes.Store(name, re)
return re
}
// PinsIn finds every image of the store a manifest pins by digest, in any string it holds: a resource's
// image, a build's base, a file's content.
func PinsIn(manifest json.RawMessage, names StoreNames) []pinned {
var doc any
if json.Unmarshal(manifest, &doc) != nil {
return nil
}
seen := map[string]bool{}
var out []pinned
var walk func(any)
walk = func(x any) {
switch v := x.(type) {
case map[string]any:
for _, e := range v {
walk(e)
}
case []any:
for _, e := range v {
walk(e)
}
case string:
for _, group := range []struct {
names []string
older bool
}{{names.Current, false}, {names.Older, true}} {
for _, name := range group.names {
for _, m := range pinShape(name).FindAllStringSubmatch(v, -1) {
p := pinned{Repository: strings.Trim(m[1], "/"), Digest: m[2], Name: name, Older: group.older}
if k := p.Repository + "@" + p.Digest + " " + p.Name; !seen[k] {
seen[k] = true
out = append(out, p)
}
}
}
}
}
}
walk(doc)
return out
}
// Definitions is what the catalogue's definitions pin in the store, and what of them could not be read.
type Definitions struct {
// Pins are by Key(repository, digest).
Pins map[string][]Pin
// Read is how many definitions were read; Unread says, per module, why its definition was not.
Read int
Unread map[string]string
// NotAsked says why the catalogue could not be asked at all, when it could not.
NotAsked string
// NamesNotRead says why the store's older names could not be read, when they could not.
NamesNotRead string
Names StoreNames
}
// Catalogue reads the definitions through the catalogue's tools, and keeps each definition it read by its
// module and commit, which never change together.
type Catalogue struct {
Ask Asking
mu sync.Mutex
kept map[string][]pinned // "<module>@<commit>|<names>" -> its pins
}
type catalogueEntry struct {
Module string `json:"module"`
Commit string `json:"commit"`
}
// Read asks the catalogue for every module and reads each definition, bounded by DefinitionsTimeout.
func (c *Catalogue) Read(ctx context.Context, names StoreNames) *Definitions {
defs := &Definitions{Pins: map[string][]Pin{}, Unread: map[string]string{}, Names: names}
if c == nil || c.Ask == nil {
defs.NotAsked = "this bundle has no way to ask the catalogue"
return defs
}
raw, err := c.Ask(CatalogueModules, map[string]any{})
if err != nil {
defs.NotAsked = "the catalogue's modules could not be asked: " + err.Error()
return defs
}
answer, output, ok := answerOf(raw)
if !ok {
defs.NotAsked = "the catalogue refused its modules: " + lastLine(output)
return defs
}
var list struct {
Modules []catalogueEntry `json:"modules"`
Error string `json:"error"`
}
if err := json.Unmarshal(answer, &list); err != nil || list.Error != "" || list.Modules == nil {
defs.NotAsked = "the catalogue's modules answer is not readable: " + firstOf(list.Error, errText(err), lastLine(output))
return defs
}
c.mu.Lock()
if c.kept == nil {
c.kept = map[string][]pinned{}
}
c.mu.Unlock()
nameKey := strings.Join(names.Current, ",") + ";" + strings.Join(names.Older, ",")
ctx, cancel := context.WithTimeout(ctx, DefinitionsTimeout)
defer cancel()
type result struct {
module string
pins []pinned
why string
}
results := make(chan result, len(list.Modules))
work := make(chan catalogueEntry)
var wg sync.WaitGroup
for i := 0; i < definitionReaders; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for m := range work {
pins, why := c.definition(m, names, nameKey)
results <- result{m.Module, pins, why}
}
}()
}
go func() {
defer close(work)
for _, m := range list.Modules {
select {
case work <- m:
case <-ctx.Done():
return
}
}
}()
go func() { wg.Wait(); close(results) }()
got := map[string]bool{}
collect:
for {
select {
case r, open := <-results:
if !open {
break collect
}
got[r.module] = true
if r.why != "" {
defs.Unread[r.module] = r.why
continue
}
defs.Read++
for _, p := range r.pins {
k := Key(p.Repository, p.Digest)
defs.Pins[k] = append(defs.Pins[k], Pin{Module: r.module, Name: p.Name, Older: p.Older})
}
case <-ctx.Done():
break collect
}
}
for _, m := range list.Modules {
if !got[m.Module] {
defs.Unread[m.Module] = fmt.Sprintf("not read within %d s", int(DefinitionsTimeout/time.Second))
}
}
for k := range defs.Pins {
sort.Slice(defs.Pins[k], func(i, j int) bool { return defs.Pins[k][i].Module < defs.Pins[k][j].Module })
}
return defs
}
// definition reads one module's definition, or answers it from what was read before.
func (c *Catalogue) definition(m catalogueEntry, names StoreNames, nameKey string) ([]pinned, string) {
key := m.Module + "@" + m.Commit + "|" + nameKey
if m.Commit != "" {
c.mu.Lock()
pins, had := c.kept[key]
c.mu.Unlock()
if had {
return pins, ""
}
}
args := map[string]any{"module": m.Module}
if m.Commit != "" {
args["commit"] = m.Commit
}
raw, err := c.Ask(CatalogueModule, args)
if err != nil {
return nil, "could not be asked: " + err.Error()
}
answer, output, ok := answerOf(raw)
if !ok {
return nil, "refused: " + lastLine(output)
}
var one struct {
Module *struct {
Manifest json.RawMessage `json:"manifest"`
} `json:"module"`
Error string `json:"error"`
}
if err := json.Unmarshal(answer, &one); err != nil || one.Error != "" || one.Module == nil || len(one.Module.Manifest) == 0 {
return nil, "not readable: " + firstOf(one.Error, errText(err), "no manifest in the answer")
}
pins := PinsIn(one.Module.Manifest, names)
if m.Commit != "" {
c.mu.Lock()
c.kept[key] = pins
c.mu.Unlock()
}
return pins, ""
}
// why says which definitions name a manifest, and under which of the store's names.
func (p Pin) why() string {
if p.Older {
return fmt.Sprintf("a definition names it: %s, under %s, an older name of the store", p.Module, shownName(p.Name))
}
return "a definition names it: " + p.Module
}
func firstOf(s ...string) string {
for _, x := range s {
if strings.TrimSpace(x) != "" {
return x
}
}
return ""
}
func errText(err error) string {
if err == nil {
return ""
}
return err.Error()
}
// shownName is a name of the store as a person reads it: a host without the slash a pin puts after it, the
// scheme as written.
func shownName(name string) string {
if strings.HasSuffix(name, "://") {
return name
}
return strings.TrimSuffix(name, "/")
}
@@ -0,0 +1,259 @@
package main
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
)
// A definition may pin an image of the store the mesh did not make, under a name the store had before
// (novox/hq issue 323): three apps the anchor runs are pinned so. No record names them; a definition does.
const oldName = "registry-api.example.org"
// container is a definition whose one container runs image.
func container(image string) string {
return fmt.Sprintf(`{"module":"x","resources":[{"id":"server","type":"container","image":%q}]}`, image)
}
func TestAnImageADefinitionPinsUnderAnOlderNameOfTheStoreIsNamedByADefinition(t *testing.T) {
v := view(t, world())
names := NamesOf("store:5000", oldName)
defs := &Definitions{Pins: map[string][]Pin{}}
for module, manifest := range map[string]string{
// m-stranger: no record names it; the invoicing app pins it under the store's older name.
"invoicing": container(oldName + "/app/server@" + d("m-stranger")),
// m-old: the records say eligible; a definition pinning it does not change what the records say.
"website": container("store:5000/app/server@" + d("m-old")),
} {
for _, p := range PinsIn(json.RawMessage(manifest), names) {
defs.Pins[Key(p.Repository, p.Digest)] = append(defs.Pins[Key(p.Repository, p.Digest)], Pin{Module: module, Name: p.Name, Older: p.Older})
}
}
got := stateByName(Classify(v, withFill(records()), defs))
stranger := got["app/server "+d("m-stranger")]
if stranger.State != StateNamedByDefinition {
t.Fatalf("a manifest a definition pins under an older name reads %q, want %q", stranger.State, StateNamedByDefinition)
}
if want := []string{"a definition names it: invoicing, under " + oldName + ", an older name of the store"}; !reflect.DeepEqual(stranger.Why, want) {
t.Errorf("why %q, want %q", stranger.Why, want)
}
if s := got["app/server "+d("m-old")].State; s != StateEligible {
t.Errorf("a recorded manifest a definition pins reads %q: the records decide what the mesh made", s)
}
// Without the definitions it is what it was: unrecorded.
if s := stateByName(Classify(v, withFill(records()), nil))["app/server "+d("m-stranger")].State; s != StateUnrecorded {
t.Errorf("without definitions %q", s)
}
}
func TestPinsAreFoundInEveryStringAndOnlyUnderTheStoresNames(t *testing.T) {
names := NamesOf("store:5000", oldName+", older.example/")
manifest := `{"resources":[
{"type":"container","image":"` + oldName + `/team/www@` + d("www") + `"},
{"type":"file","content":"IMAGE=older.example/team/tool@` + d("tool") + `\n"},
{"type":"container","image":"artifact-store://mod/art@` + d("built") + `"},
{"type":"container","image":"store:5000/mod/art@` + d("addressed") + `"},
{"type":"container","image":"not` + oldName + `/team/www@` + d("lookalike") + `"},
{"type":"container","image":"docker.io/library/nginx@` + d("upstream") + `"},
{"type":"container","image":"` + oldName + `/team/www:latest"}],
"build":{"on":{"base":"` + oldName + `/team/www@` + d("www") + `"}}}`
var got []string
for _, p := range PinsIn(json.RawMessage(manifest), names) {
got = append(got, fmt.Sprintf("%s@%s %s %v", p.Repository, p.Digest, p.Name, p.Older))
}
want := map[string]bool{
"team/www@" + d("www") + " " + oldName + "/ true": true,
"team/tool@" + d("tool") + " older.example/ true": true,
"mod/art@" + d("built") + " artifact-store:// false": true,
"mod/art@" + d("addressed") + " store:5000/ false": true,
}
if len(got) != len(want) {
t.Fatalf("found %d pins, want %d (one per image and name; not a longer host's tail, not another registry, not a tag): %v", len(got), len(want), got)
}
for _, g := range got {
if !want[g] {
t.Errorf("found %s", g)
}
}
}
func TestAPlatformTakesTheStateOfAnIndexADefinitionNames(t *testing.T) {
v := mirrorWorld()
defs := &Definitions{Pins: map[string][]Pin{
Key("route-proxy/on-go_base", d("i-pre")): {{Module: "route-proxy", Name: oldName + "/", Older: true}},
}}
got := stateByName(Classify(v, withFill(mirrorRecords()), defs))
for _, name := range []string{"i-pre", "p-pre"} {
if s := got["route-proxy/on-go_base "+d(name)].State; s != StateNamedByDefinition {
t.Errorf("%s reads %q, want %q", name, s, StateNamedByDefinition)
}
}
if e := got["route-proxy/on-go_base "+d("p-pre")]; e.Index != d("i-pre") {
t.Errorf("p-pre takes its state from %q", e.Index)
}
if s := got["route-proxy/on-go_base "+d("p-lost")].State; s != StateUnrecorded {
t.Errorf("a platform nothing names reads %q", s)
}
for _, um := range UnrecordedMirrors(v, Classify(v, withFill(mirrorRecords()), defs)) {
if strings.Contains(um.Reference, hexOf(d("i-pre"))) {
t.Errorf("a copy a definition names is offered to the mirrors verb: %v", um)
}
}
}
// olderNames writes the file the mesh writes from the setting older-store-names.
func olderNames(t *testing.T, names string) {
t.Helper()
path := filepath.Join(t.TempDir(), "store-names")
if err := os.WriteFile(path, []byte("# written by the mesh\nolder="+names+"\n"), 0o600); err != nil {
t.Fatal(err)
}
t.Setenv(NamesFileEnv, path)
}
func TestTheOlderNamesAreReadFromTheFileTheMeshWrites(t *testing.T) {
olderNames(t, oldName+" older.example")
got, why := OlderNames()
if why != "" || !reflect.DeepEqual(NamesOf("", got).Older, []string{oldName + "/", "older.example/"}) {
t.Errorf("read %q (%s)", got, why)
}
olderNames(t, "none")
if got, why := OlderNames(); why != "" || len(NamesOf("", got).Older) != 0 {
t.Errorf("none read as %q (%s)", got, why)
}
t.Setenv(NamesFileEnv, filepath.Join(t.TempDir(), "absent"))
if _, why := OlderNames(); why == "" {
t.Error("an absent file was not said")
}
}
func TestOlderNamesNotReadAreSaidFirst(t *testing.T) {
t.Setenv(NamesFileEnv, "")
a := &asked{definitions: map[string]string{"invoicing": container(oldName + "/app/server@" + d("m-stranger"))}}
out, err := references(t, a)(map[string]any{"repository": "app/server"})
if err != nil {
t.Fatal(err)
}
m := out.(map[string]any)
if w := m["said"].([]string)[0]; !strings.Contains(w, NamesFileEnv) {
t.Errorf("first said %q", w)
}
if s := manifestsOf(t, out)["app/server "+d("m-stranger")].State; s != StateUnrecorded {
t.Errorf("m-stranger reads %q without the older names", s)
}
}
// references is one artifact_store_references tool, kept across calls as the runtime keeps the bundle.
func references(t *testing.T, a *asked) func(map[string]any) (any, error) {
f := world()
s := &Store{Container: "mesh-registry", Run: f.run(t)}
for _, tl := range Tools(s, Controller{Ask: a.ask}, &Catalogue{Ask: a.ask}) {
if tl.Name == "artifact_store_references" {
return tl.Run
}
}
t.Fatal("no artifact_store_references")
return nil
}
func manifestsOf(t *testing.T, out any) map[string]Entry {
t.Helper()
entries, ok := out.(map[string]any)["manifests"].([]Entry)
if !ok {
t.Fatalf("no manifests in %v", out)
}
return stateByName(entries)
}
func TestReferencesReadTheDefinitionsUnderTheStoresOlderNames(t *testing.T) {
olderNames(t, oldName)
a := &asked{definitions: map[string]string{
"invoicing": container(oldName + "/app/server@" + d("m-stranger")),
"grafana": container("grafana/grafana@" + d("upstream")),
}}
run := references(t, a)
out, err := run(map[string]any{"repository": "app/server"})
if err != nil {
t.Fatal(err)
}
e := manifestsOf(t, out)["app/server "+d("m-stranger")]
if e.State != StateNamedByDefinition || len(e.Why) != 1 || !strings.Contains(e.Why[0], "invoicing") || !strings.Contains(e.Why[0], oldName) {
t.Errorf("m-stranger: %+v", e)
}
said := out.(map[string]any)["definitions"].(map[string]any)
if said["read"] != 2 || !reflect.DeepEqual(said["older_store_names"], []string{oldName}) ||
!reflect.DeepEqual(said["store_names"], []string{"artifact-store://", "store:5000"}) {
t.Errorf("definitions said %v", said)
}
if w := out.(map[string]any)["said"].([]string)[0]; strings.Contains(w, "could not be read") {
t.Errorf("a warning with every definition read: %s", w)
}
// A definition read at its commit is kept: the next call asks only for the list.
before := 0
for _, c := range a.calls {
if c == CatalogueModule {
before++
}
}
if _, err := run(map[string]any{"repository": "app/server"}); err != nil {
t.Fatal(err)
}
after := 0
for _, c := range a.calls {
if c == CatalogueModule {
after++
}
}
if before != 2 || after != 2 {
t.Errorf("asked catalog_module %d times, then %d: each definition at its commit is read once", before, after)
}
}
func TestADefinitionNotReadIsSaidFirstAndLeavesWhatItPinsUnrecorded(t *testing.T) {
olderNames(t, oldName)
a := &asked{
definitions: map[string]string{"invoicing": container(oldName + "/app/server@" + d("m-stranger")), "grafana": container("x")},
failing: map[string]bool{"invoicing": true},
}
out, err := references(t, a)(map[string]any{"repository": "app/server"})
if err != nil {
t.Fatal(err)
}
m := out.(map[string]any)
if s := manifestsOf(t, out)["app/server "+d("m-stranger")].State; s != StateUnrecorded {
t.Errorf("m-stranger reads %q with its definition unread", s)
}
if w := m["said"].([]string)[0]; !strings.Contains(w, "1 definitions could not be read") {
t.Errorf("first said %q", w)
}
if u := m["definitions"].(map[string]any)["unread"].(map[string]string); u["invoicing"] == "" || len(u) != 1 {
t.Errorf("unread %v", u)
}
// The catalogue not answering at all: the records are still answered, and the warning stands first.
a = &asked{noCatalogue: true}
out, err = references(t, a)(map[string]any{})
if err != nil {
t.Fatal(err)
}
m = out.(map[string]any)
if w := m["said"].([]string)[0]; !strings.Contains(w, "the definitions could not be read") || !strings.Contains(w, "no responders") {
t.Errorf("first said %q", w)
}
if m["definitions"].(map[string]any)["not_asked"] == nil {
t.Errorf("definitions %v", m["definitions"])
}
}
func TestNoCatalogueIsSaidAndNothingIsAsked(t *testing.T) {
defs := (&Catalogue{}).Read(context.Background(), NamesOf("", ""))
if defs.NotAsked == "" || defs.Read != 0 {
t.Errorf("%+v", defs)
}
}
@@ -24,7 +24,7 @@ func main() {
UID: os.Getuid(),
}
// An empty name serves as the module the runtime names (MESH_SERVED_MODULE): artifact-store-tools.
if err := stdio.Serve("", Tools(s, Controller{Ask: stdio.Ask})); err != nil {
if err := stdio.Serve("", Tools(s, Controller{Ask: stdio.Ask}, &Catalogue{Ask: stdio.Ask})); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
@@ -65,7 +65,7 @@ func TestAPlatformManifestTakesTheStateOfTheIndexNamingIt(t *testing.T) {
for i := range recs.References {
fill(&recs.References[i])
}
got := stateByName(Classify(v, recs))
got := stateByName(Classify(v, recs, nil))
up := "upstream/docker.io/library/golang "
for name, want := range map[string]string{
up + d("p-amd"): StateEligible, // only the eligible index names it
@@ -92,7 +92,7 @@ func TestAPlatformManifestTakesTheStateOfTheIndexNamingIt(t *testing.T) {
if len(mirrors) != 2 || !got["route-proxy/on-go_base "+d("i-pre")].Mirror || !got["route-proxy/on-go_base "+d("p-lost")].Mirror {
t.Errorf("offered as unrecorded copies: %v", mirrors)
}
um := UnrecordedMirrors(v, Classify(v, recs))
um := UnrecordedMirrors(v, Classify(v, recs, nil))
if len(um) != 2 || um[0].Reference != "artifact-store://route-proxy/on-go_base@"+d("i-pre") || um[0].OnlyTheir != 1+1+1000 {
t.Errorf("unrecorded copies: %+v", um)
}
@@ -104,7 +104,7 @@ func TestLettingAnIndexGoTakesItsOwnPlatformsAndSaysWhatStaysAndNamesOne(t *test
for i := range recs.References {
fill(&recs.References[i])
}
entries := Classify(v, recs)
entries := Classify(v, recs, nil)
old := "artifact-store://upstream/docker.io/library/golang@" + d("i-old")
gone := LetGoKeys(v, []string{old}, Spared(v, entries))
up := "upstream/docker.io/library/golang"
@@ -215,7 +215,7 @@ func TestTheMarkIsTheCollectorsIncludingAnIndexAndSharedBlobs(t *testing.T) {
func TestEveryStateIsGiven(t *testing.T) {
v := view(t, world())
got := map[string]string{}
for _, e := range Classify(v, withFill(records())) {
for _, e := range Classify(v, withFill(records()), nil) {
got[e.Repository+" "+e.Digest] = e.State
}
want := map[string]string{
@@ -239,7 +239,7 @@ func TestALetGoReferenceStillPresentIsSaidSo(t *testing.T) {
v := view(t, world())
r := records()
r.References = append(r.References, Record{Reference: "artifact-store://app/server@" + d("m-stranger"), State: "collected"})
for _, e := range Classify(v, withFill(r)) {
for _, e := range Classify(v, withFill(r), nil) {
if e.Digest == d("m-stranger") && e.State != StateCollectedPresent {
t.Errorf("state %s", e.State)
}
@@ -270,7 +270,7 @@ func TestBytesFreedCountASharedBlobAsKept(t *testing.T) {
func TestOnlyTheirBytesLeaveOutWhatAnotherStateMarks(t *testing.T) {
v := view(t, world())
entries := Classify(v, withFill(records()))
entries := Classify(v, withFill(records()), nil)
sums := Summarise(v, entries, entries)
el := sums[StateEligible]
if el.OnlyTheirBytes != 500+v.L.Blobs[d("m-old")] {
@@ -284,6 +284,11 @@ type asked struct {
calls []string
args []map[string]any
fail map[string]bool
// definitions are the catalogue's modules and their manifests; failing, the modules whose
// definition cannot be read; noCatalogue, the catalogue not answering at all.
definitions map[string]string
failing map[string]bool
noCatalogue bool
}
func (a *asked) ask(key string, body any) (json.RawMessage, error) {
@@ -299,6 +304,31 @@ func (a *asked) ask(key string, body any) (json.RawMessage, error) {
switch key {
case "seat:mesh-controller.artifacts":
answer = records()
case CatalogueModules:
if a.noCatalogue {
return nil, fmt.Errorf("no responders")
}
ms := []map[string]any{}
for name := range a.definitions {
ms = append(ms, map[string]any{"module": name, "commit": "c-" + name, "path": "modules/" + name})
}
answer = map[string]any{"modules": ms}
case CatalogueModule:
name, _ := args["module"].(string)
if a.failing[name] {
return nil, fmt.Errorf("no answer")
}
if args["commit"] != "c-"+name {
return nil, fmt.Errorf("asked %s at %v", name, args["commit"])
}
manifest, has := a.definitions[name]
if !has {
answer = map[string]any{"error": "the catalogue holds no " + name}
break
}
// The catalogue's tool answers through the runtime as the protocol's content list.
raw, _ := json.Marshal(map[string]any{"module": map[string]any{"module": name, "manifest": json.RawMessage(manifest)}})
return json.Marshal(map[string]any{"content": []map[string]any{{"type": "text", "text": string(raw)}}})
case "seat:mesh-controller.collect":
if args["confirm"] == "true" {
answer = CollectAnswer{LetGo: []string{"artifact-store://app/server@" + d("m-old")}}
@@ -315,7 +345,7 @@ func (a *asked) ask(key string, body any) (json.RawMessage, error) {
func tool(t *testing.T, a *asked, name string) func(map[string]any) (any, error) {
f := world()
s := &Store{Container: "mesh-registry", Run: f.run(t)}
for _, tl := range Tools(s, Controller{Ask: a.ask}) {
for _, tl := range Tools(s, Controller{Ask: a.ask}, &Catalogue{Ask: a.ask}) {
if tl.Name == name {
return tl.Run
}
@@ -386,7 +416,7 @@ func TestTheToolsServedAreTheToolsTheManifestNames(t *testing.T) {
t.Fatal(err)
}
var served []string
for _, tl := range Tools(&Store{}, Controller{}) {
for _, tl := range Tools(&Store{}, Controller{}, &Catalogue{}) {
if !strings.HasPrefix(tl.Name, "artifact_store_") || tl.Description == "" || tl.Run == nil {
t.Errorf("tool %q", tl.Name)
}
@@ -398,7 +428,7 @@ func TestTheToolsServedAreTheToolsTheManifestNames(t *testing.T) {
if !reflect.DeepEqual(served, listed) {
t.Fatalf("served %v, manifest %v", served, listed)
}
want := []string{"seat:mesh-controller.artifacts", "seat:mesh-controller.collect"}
want := []string{"seat:mesh-controller.artifacts", "seat:mesh-controller.collect", CatalogueModules, CatalogueModule}
if !reflect.DeepEqual(m.Invokes, want) {
t.Errorf("invokes %v", m.Invokes)
}
@@ -13,7 +13,7 @@ import (
// Tools are the artifact store's tools (novox/hq ADR 0251 §1). The first three only read; artifact_store_collect
// only reads too, unless it is a real run, and then it asks the controller, which decides and deletes.
func Tools(s *Store, c Controller) []stdio.Tool {
func Tools(s *Store, c Controller, cat *Catalogue) []stdio.Tool {
ctx := context.Background()
repoArg := map[string]any{"type": "string", "description": "one repository, as the store names it (<module>/<artifact>)"}
return []stdio.Tool{
@@ -53,7 +53,9 @@ func Tools(s *Store, c Controller) []stdio.Tool {
{
Name: "artifact_store_references",
Description: "What the controller's records say of each manifest the artifact store holds: kept (a definition " +
"names it, or one of the five most recent builds of a module the mesh holds), the holder of a kept archive, " +
"names it, or one of the five most recent builds of a module the mesh holds), named by a definition (no " +
"record names it, and a definition in the catalogue pins it by digest, under the store's current name or " +
"an older one: the module is said), the holder of a kept archive, " +
"eligible (the mesh made it and keeps it for no reason), the holder of an eligible archive, let go yet present, " +
"a named document the controller keeps under a tag, or unrecorded — no record names it. A platform manifest " +
"takes the state of the recorded index naming it. Counted and sized by " +
@@ -67,11 +69,19 @@ func Tools(s *Store, c Controller) []stdio.Tool {
if err != nil {
return nil, err
}
// The definitions are read while the store is: each bounded below the runtime's call limit.
read := make(chan *Definitions, 1)
go func() {
older, why := OlderNames()
defs := cat.Read(ctx, NamesOf(recs.Store, older))
defs.NamesNotRead = why
read <- defs
}()
v, err := s.View(ctx)
if err != nil {
return nil, err
}
return References(v, recs, repo)
return References(v, recs, <-read, repo)
},
},
{
@@ -116,7 +126,7 @@ func Tools(s *Store, c Controller) []stdio.Tool {
if err != nil {
return map[string]any{"controller": answer, "bytes_not_worked_out": "the records could not be read: " + err.Error()}, nil
}
return Collected(v, answer, dry, Classify(v, recs)), nil
return Collected(v, answer, dry, Classify(v, recs, nil)), nil
},
},
}
@@ -225,11 +235,11 @@ func Usage(v *View, top int) map[string]any {
}
// References answers artifact_store_references.
func References(v *View, recs *Records, only string) (map[string]any, error) {
func References(v *View, recs *Records, defs *Definitions, only string) (map[string]any, error) {
if only != "" && v.L.Repos[only] == nil {
return nil, fmt.Errorf("the store holds no repository %q", only)
}
entries := Classify(v, recs)
entries := Classify(v, recs, defs)
byRepo := map[string][]Entry{}
for _, e := range entries {
byRepo[e.Repository] = append(byRepo[e.Repository], e)
@@ -243,7 +253,7 @@ func References(v *View, recs *Records, only string) (map[string]any, error) {
}
whole := Summarise(v, entries, entries)
said := []string{}
for _, state := range []string{StateKept, StateHolderKept, StateEligible, StateHolderEligible, StateCollectedPresent, StateNamedDocument, StateUnrecorded} {
for _, state := range []string{StateKept, StateNamedByDefinition, StateHolderKept, StateEligible, StateHolderEligible, StateCollectedPresent, StateNamedDocument, StateUnrecorded} {
if s := whole[state]; s != nil {
said = append(said, fmt.Sprintf("%s: %d manifests marking %s, %s of it marked by nothing else", state, s.Manifests, human(s.Bytes), human(s.OnlyTheirBytes)))
}
@@ -276,9 +286,57 @@ func References(v *View, recs *Records, only string) (map[string]any, error) {
if u := unreadNote(v); u != nil {
out["unread"] = u
}
out["definitions"] = definitionsSaid(defs)
if warn := definitionsWarning(defs); warn != "" {
// Said first: what it leaves out is a manifest a machine may run, read as unrecorded.
out["said"] = append([]string{warn}, said...)
}
return out, nil
}
// definitionsSaid is how the definitions were read: how many, under which names, and what was not read.
func definitionsSaid(defs *Definitions) map[string]any {
if defs == nil {
return map[string]any{"read": 0, "not_asked": "the definitions were not read"}
}
var current, older []string
for _, n := range defs.Names.Current {
current = append(current, shownName(n))
}
for _, n := range defs.Names.Older {
older = append(older, shownName(n))
}
out := map[string]any{"read": defs.Read, "store_names": nonNil(current), "older_store_names": nonNil(older)}
if defs.NotAsked != "" {
out["not_asked"] = defs.NotAsked
}
if defs.NamesNotRead != "" {
out["older_store_names_not_read"] = defs.NamesNotRead
}
if len(defs.Unread) > 0 {
out["unread"] = defs.Unread
}
return out
}
// definitionsWarning says, when any definition was not read, that a manifest it pins reads as unrecorded.
func definitionsWarning(defs *Definitions) string {
switch {
case defs == nil:
return ""
case defs.NotAsked != "":
return "the definitions could not be read (" + defs.NotAsked + "), so a manifest a definition pins " +
"and no record names reads as unrecorded here: ask again before reading unrecorded as unused"
case defs.NamesNotRead != "":
return defs.NamesNotRead + ", so a manifest a definition pins under an older name of the store and no " +
"record names reads as unrecorded here"
case len(defs.Unread) > 0:
return fmt.Sprintf("%d definitions could not be read (listed in definitions.unread), so a manifest one "+
"of them pins and no record names reads as unrecorded here: ask again — what was read is kept", len(defs.Unread))
}
return ""
}
// Collected answers artifact_store_collect: the controller's answer, and what the store's collector frees.
func Collected(v *View, a *CollectAnswer, dry bool, entries []Entry) map[string]any {
refs := a.LetGo
+28 -2
View File
@@ -3,7 +3,9 @@
"version": "1",
"invokes": [
"seat:mesh-controller.artifacts",
"seat:mesh-controller.collect"
"seat:mesh-controller.collect",
"mesh-catalog.catalog_modules",
"mesh-catalog.catalog_module"
],
"tools": [
"artifact_store_repositories",
@@ -11,6 +13,29 @@
"artifact_store_references",
"artifact_store_collect"
],
"settings": {
"older-store-names": {
"kind": "preference",
"default": "none",
"why": "the names the artifact store had before, as a definition may still pin an image under them: host names or host:port, separated by spaces; none when it had no other. artifact_store_references reads every definition under these as well as under the store's current name, so an image a definition pins under one is said to be named by a definition (novox/hq issue 323)"
}
},
"resources": [
{
"id": "state",
"type": "directory",
"mode": "0700",
"place": "."
},
{
"id": "store-names",
"type": "file",
"path": "${dir:state}/store-names",
"mode": "0600",
"trusted": false,
"content": "# The names the artifact store had before, from the setting older-store-names, written by the mesh.\n# artifact_store_references reads every definition under these too (novox/hq issue 323).\n# Replaced on every push; change the setting, never this file.\nolder=${setting:older-store-names}\n"
}
],
"build": {
"artifacts": [
{
@@ -24,7 +49,8 @@
"artifact-store-tools"
],
"env": {
"MESH_ARTIFACT_STORE_CONTAINER": "mesh-registry"
"MESH_ARTIFACT_STORE_CONTAINER": "mesh-registry",
"MESH_ARTIFACT_STORE_NAMES_FILE": "${dir:state}/store-names"
}
}
]
+2 -1
View File
@@ -103,7 +103,8 @@
"path": "${dir:mesh-state}/config.json",
"mode": "0600",
"content": "{\n \"password\": \"${secret:admin}\",\n \"host\": \"${bound:route:name}\"\n}\n",
"merge": "json"
"merge": "json",
"protected": ["password"]
}
],
"build": {
+278 -51
View File
@@ -1,8 +1,12 @@
# betterbird
Betterbird, the mail and calendar client of g14 and shanks (a Thunderbird fork, with the operator's
Exchange calendar through the Owl add-on), as a module (novox/hq issue 345, ADR 0208). It requires
`x11-display`, so it is assigned only where a display server is held on the same machine.
Exchange calendar through the Owl add-on), as a module (novox/hq issue 345, issue 586, ADR 0208, ADR 0316,
ADR 0317, ADR 0329, to-be 56). It requires `x11-display`, so it is assigned only where a display server is held
on the same machine. It installs Betterbird from a pinned upstream release, ships Owl with it through
Betterbird's enterprise policy, and owns Betterbird's whole configuration as settings: the preferences, the
mail accounts without their credentials, the identities with their signatures, the CalDAV calendars, the
CardDAV address books, the filters and the tags.
It exists because a symptom had no tools: on a workstation, a calendar reminder appeared as a
transparent window with nothing in it (2026-10-09). Nothing in the mesh could say which window that
@@ -11,74 +15,285 @@ issue 345.
## Owns
| what | where |
|---|---|
| the module's directory | `~/.config/betterbird/` |
| the module's `user.js`, filled from the setting `prefs` | `~/.config/betterbird/user.js` |
| what | where | class |
|---|---|---|
| Betterbird, the upstream release (`archive`, pinned by URL and sha256) | `/opt/betterbird` | owned, whole |
| the enterprise policy (`install/distribution/policies.json`) | `/opt/betterbird/distribution` | owned, whole |
| Owl's xpi (`owl`, pinned by the vendor's sha256) | `/opt/betterbird/owl` | owned, whole |
| the module's defaults (`install/defaults-pref/mesh-defaults.js`: the preferences kept at Betterbird's own default, no value of the operator's) and the spelling dictionaries' default (`vendor-prefs.js`) | `/opt/betterbird/defaults/pref` | owned: its two files |
| the tools' program, for the start's preparation | `/usr/local/lib/mesh-betterbird` | owned, whole |
| the start | `/usr/local/bin/betterbird` | owned, whole |
| the launcher entry | `/usr/local/share/applications/eu.betterbird.Betterbird.desktop` | owned, whole |
| the module's directory, with the settings as the node-engine writes them (`prefs.json`, `accounts.json`, `configuration.json`, `tags.json`), the records of what was adopted or created, and the filter files it replaced (`kept/`) | `~/.config/betterbird/` | owned, whole |
| the profile's `user.js`, placed by the tools | the profile | the module's, or never touched |
| the lists in the profile's `prefs.js` (accounts, outgoing servers, an adopted account's identities, the calendars' order), and the preferences of what the module created and the settings no longer hold | the profile | written into: the module's keys only, only while Betterbird is closed |
| each declared account's `msgFilterRules.dat` (setting `filters`) | the account's directory in the profile | owned, whole, only while Betterbird is closed |
| the profile's own values of the preferences the module manages, in `prefs.js` | the profile | cleared only by `betterbird_config_apply` with apply, Betterbird quit |
| the profile: mail, address books, calendars, logins | `~/.thunderbird/` | found (ADR 0182) |
Nothing else. It holds no seat and adds no start.
**The community package `betterbird-bin` is declared absent** (ADR 0180), before anything is placed in
`/opt/betterbird`: the node-engine removes it with `pacman -R`, then unpacks the release where the package
had it. Betterbird chooses its profile by its installation's path, so the same profile opens after the
switch. A package left in place (its removal failed) holds the path, and the release is refused there by
the node-engine rather than mixed in. The module creates no link: the start, a launcher entry naming the
icon in the installation, and the dictionaries as a default preference replace the package's links.
- **No package.** g14 and shanks both run `betterbird-bin` 153.1.0esr-1, installed explicitly from the
AUR: `pacman` counts it as foreign. The node-engine installs packages from the official
repositories only, so the module does not declare it, and **installing and upgrading Betterbird stay
the operator's** (an AUR helper). `betterbird_status` says the version and that it is from the AUR;
`betterbird_check` says when it is missing.
- **No start.** Betterbird is started by the operator from the launcher (on the workstation measured,
it runs in the login session's scope, started by hand, with no autostart entry and no line in i3's
configuration). `betterbird_restart` starts it as the transient user unit `mesh-betterbird`, so its
output then goes to the journal.
- **The profile is Betterbird's** (ADR 0182): `~/.thunderbird/` holds the mail, the address books,
the calendars' caches, the logins and the keys. The module declares none of it.
**Every new build waits for the operator** (`upgrade: record`): the first push to a node removes the
package and replaces what a running Betterbird has open, so the operator quits Betterbird, then pushes.
An upgrade is a change to the pins, reviewed and delivered like any other (ADR 0316 §5).
## The user.js: how a preference is declared
**The pins.** The release is `betterbird-153.4.0esr-bb10.en-US.linux-x86_64.tar.xz` (81,620,684 bytes) and
Owl is 1.5.6.1 (430,201 bytes), each pinned by sha256 in `module.json`. The build seat fetches each once,
refuses it when the digest differs, keeps it under its sha256, and packs it into the artifact store; a node
receives the mesh's archive by digest and never asks the vendor. **Owl is never in this repository**: its
licence forbids copying it to third parties, and its vendor serves only its newest version, at one
address. Keeping the pinned copy in the mesh's private artifact store for the operator's own machines was
the operator's call (2026-10-12, ADR 0316 §6).
Betterbird reads `user.js` in its profile when it starts, and a preference set there wins over the
one it saved in `prefs.js`. The profile's directory has a random name per machine
(`<random>.default-default`), which a manifest cannot name. So:
**Owl's range.** Owl declares which Betterbird versions it supports (`strict_min_version` to
`strict_max_version`: 115 to 156 for 1.5.6.1). `owlSupports` in `manifest_test.go` holds the range read
from each pinned xpi, and the module's tests refuse a Betterbird pin outside it: moving Owl's pin means
reading the new range there (ADR 0316 §8).
1. the node-engine writes `~/.config/betterbird/user.js` from the module's setting `prefs`, one line of
`user_pref("name", value);` statements (default: a comment, no preference);
2. the module's tools copy that file whole into the profile: `betterbird_user_js` with `apply`, or
`betterbird_restart`, which places it before it starts Betterbird again;
3. Betterbird reads it at that start.
**The policy** turns Betterbird's own updater off (`DisableAppUpdate`, which Betterbird's update check
honours) and installs Owl into every profile from `file:///opt/betterbird/owl/owl.xpi` as
`force_installed`, with `updates_disabled`. A profile that already runs another Owl keeps it: the policy
installs the pinned one where Owl is missing. A newer Owl in the profile (g14 runs 1.5.7) is said as a
fact by `betterbird_status`; Owl installed by policy cannot be removed in Betterbird, so the remedy is
moving the pin. An older one is a finding of `betterbird_check`, with the same remedy.
**Betterbird never asks to be the default client.** The policy locks `mail.shell.checkDefaultClient` off
(`Preferences`, `Status: locked`): Betterbird's own default (`all-thunderbird.js`) is to ask at every start,
with a window to make it the default for mail, news, feeds and calendar files, while that preference is true
(`messenger.js`). Betterbird 153 has no policy of its own for it (its `policies-schema.json`), and its
`Preferences` policy accepts the `mail.` prefix. A lock, not a default: the window's own "always check" box
writes the preference into the profile, which wins over a default; a lock wins over the profile and greys the
box out in the settings. `betterbird_check` says when Betterbird would ask (novox/hq issue 345). **Owl's licence stays the person's**: the mesh holds no licence, key or payment (ADR 0316 §9).
**No start of its own.** Betterbird is started by the operator from the launcher. The start
(`/usr/local/bin/betterbird`) first runs `betterbird-tools prepare`, which, with no Betterbird of the
account running, places the user.js and writes what Betterbird rewrites itself (the lists, the removals, the
filter files); then it
starts Betterbird either way. `betterbird_restart` starts it as the transient user unit
`mesh-betterbird`, so its output then goes to the journal.
## The preferences: locks, the setting prefs, and Betterbird's own defaults
Betterbird's preferences come in three layers (novox/hq issue 345, issue 586). **The operator's own choices
live in the module's configuration, never in the catalogue**: the module ships only its policy's locks and the
names it keeps at Betterbird's own default.
| layer | where | who changes it | holds |
|---|---|---|---|
| **locked** | `/opt/betterbird/distribution/policies.json` (`install/distribution/`) | only a reviewed catalogue change | what must never differ on a node; Betterbird's settings show it greyed out |
| **declared** | the setting `prefs`, mesh-wide with a node's layer over it, written to `~/.config/betterbird/prefs.json` and placed in the profile's `user.js` | the operator, through the settings layers (trusted: the terminal or the operator's warrant) | the operator's setup, and a node's exceptions, such as a font size for a larger screen |
| **Betterbird's default** | Betterbird's own defaults; `mesh-defaults.js` names those the module manages there | a catalogue change | the preferences kept at Betterbird's default |
A locked value wins over everything; a declared one (in `user.js`) wins over the profile's `prefs.js` at every
start; `prefs.js` wins over a default.
**The setting `prefs` is one entry per preference** (issue 586): `{"prefs": {"mail.uifontsize": 14, …}}`, the
value true, false, a whole number or a string. The node-engine merges the layers key by key, so a node's layer
overrides one preference, or takes one out with `null`. The operator's setup approved on 2026-10-12 (compact
density, 14-point text, the vertical view with multi-line cards and threads, no new-mail pop-up, sound or tray
icon, the calendar's day from 09:00 to 20:00, reminders missed while asleep shown, chat off, remote content
blocked, Do Not Track; 21 preferences) moved from the catalogue's defaults file into the mesh-wide layer of
`prefs`, set on the operator's Approve.
**The order that leaves no node on Betterbird's own defaults** (issue 586): merge this build (the `record`
upgrade policy holds it back from the nodes); propose the mesh-wide layer through the controller's `settings`
verb with `propose`, carrying the current `accounts` verbatim and `prefs` with the 21 values; the operator
approves; only then push the desktop nodes. Caveat: between that approval and a node's push, any other send to a
desktop node writes the map's text into the old build's user.js, and `betterbird_check` there says the user.js is
refused until the push.
**Locked** (the policy): Betterbird's updater off (`DisableAppUpdate`); telemetry off (`DisableTelemetry`);
add-ons change only through the mesh's pins (`ExtensionUpdate` false); and three preferences locked off:
`mail.shell.checkDefaultClient` (the ask to be the default client), `mail.inappnotifications.enabled` (the
vendor's in-app notices), and `mailnews.start_page.enabled` (the start page, which otherwise loads from the
vendor at every start).
**Kept at Betterbird's default** (`// keeps Betterbird's default: <name>` in `mesh-defaults.js`): the theme,
system colours, always-underlined links, manual spam marking, and the composer's attachment pane. A value the
profile saved for one of them is drift.
**Drift.** `betterbird_config` shows each managed preference (locked, declared, kept) through the layers, and
flags **drift**: a declared value the profile's `user.js` does not hold yet (remedy: `betterbird_user_js` with
apply, then `betterbird_restart`), or a value the profile saved where the module keeps Betterbird's default.
`betterbird_check` reports it.
- `betterbird_config_apply` with `apply` takes a kept preference's saved value out of `prefs.js`, so Betterbird's
default holds at the next start. It acts only while Betterbird is quit (no main process, and no `lock` in the
profile), and says what it cleared.
- **The theme is the exception.** Betterbird keeps the active theme in its add-on database (`extensions.json`),
and `extensions.activeThemeID` only mirrors it. Another theme is drift whose remedy is to enable the default
theme in the add-on manager. `betterbird_config_apply` never clears it, and `keep` refuses it.
- `betterbird_config` with `keep` proposes this node's layer of `prefs` holding the profile's values of the named
preferences beside what the layer holds, through the controller's settings verb, for the operator's Approve.
**One owner per value.** `prefs` refuses a preference whose name can hold a credential, an account's
(`mail.account.`, `mail.identity.`, `mail.server.`, `mail.smtpserver.` and the account lists, ADR 0316 §11), and
those the settings below own: `calendar.registry.`, `calendar.list.`, `ldap_2.servers.` and `mailnews.tags.`
(ADR 0329 §6).
## The user.js
Betterbird reads `user.js` in its profile when it starts, and a preference set there wins over the one it saved
in `prefs.js`. The profile's directory has a random name per machine (`<random>.default-default`), which a
manifest cannot name. So the tools compose the user.js from the settings — `prefs`, then the declared accounts,
then the declared identities, calendars, address books and tags under the keys each resolved to — and place it
in the profile: at the start from the launcher, `betterbird_user_js` with `apply`, or `betterbird_restart`.
**The profile is found** as the one the running Betterbird holds open; with none running, as the
installation's default in `profiles.ini` (`[Install…] Default`), else the profile marked `Default=1`.
On the workstation measured these differ: `Profile1` is marked `Default=1`, and the installation's
default, the one in use, is the other.
**A user.js the module did not write is never overwritten.** The module's begins with the line
`// Betterbird's user.js (module betterbird`; any other is the operator's, and `apply` refuses with
what to do. The tools refuse a declared file that holds anything but comments and `user_pref`
statements, or that sets a preference whose name can hold a credential.
`// Betterbird's user.js (module betterbird`; any other is the operator's, and `apply` refuses with what to do.
The file is **trusted** (it carries no `"trusted": false`): a preference can weaken how the mail client
checks a server, so the setting is changed only at the controller's terminal, never through a verb
(novox/hq issue 339).
## The accounts: declared without credentials (ADR 0316 §10–§16, ADR 0317)
The setting `accounts` reaches the machine as `~/.config/betterbird/accounts.json`, a trusted mergeable
file: a map of accounts by name, each with `address`, `from-name` and `label` (optional), `incoming` (`kind`
imap or pop3, `host`, `port`, `security` tls, starttls or none, `sign-in` password, encrypted-password,
oauth, kerberos, ntlm or tls-certificate, `user`) and `outgoing` (the same, without a kind; its sign-in may
be none), and, only in a node's own layer, `key`. **Never a password or a token**: the whole file is
refused when any key in it is shaped like one, and an account signs in at its first use, in Betterbird's
own window, where Betterbird keeps the result in its login store. The work account through Owl is never
declared: the person creates it in Owl's setup (ADR 0316 §13).
At every apply (the start from the launcher, `betterbird_user_js` with apply, `betterbird_restart`), each
declared account, in its place in the setting, is (ADR 0317 §1):
1. **kept by its recorded keys**, when the module recorded it on this node for this profile;
2. **its creation**, when it is under the module's own keys (`account_mesh_<name>`, `id_mesh_<name>`,
`server_mesh_<name>`, `smtp_mesh_<name>`);
3. **adopted by the key a node's layer names** (`key`), when that account is in the list, its default
identity has the address and it is not Owl's;
4. **adopted by its address**, when exactly one account's default identity has it: its keys (account,
default identity, incoming server, and the outgoing server that identity names) become the module's, and
the account lists are left as they are;
5. **created** under the module's own keys when no account has it: its keys are added at the end of
`mail.accountmanager.accounts` and `mail.smtpservers` in `prefs.js`, every other key kept in its place,
only while Betterbird is not running.
**Refused, for that account alone and with the remedy** (§7): two accounts with the address (remove the
duplicate in Betterbird, or name the `key`), Owl's account at the address, a named key that does not
qualify, an account of another kind than declared (never converted), an identity that names no outgoing
server, and an outgoing server that another identity, neither adopted nor the module's, also uses
(Betterbird lets identities share one, and the declared values would change that other account's sending;
ADR 0317 §2 leaves the identity's server and the lists as they are, so the module refuses rather than give
it a server of its own). A recorded adoption follows the identity's current outgoing server at every apply,
under the same rule. The other accounts go ahead; the start and `betterbird_check` say it.
`betterbird_user_js` answers an account's preferences by name with their values withheld.
The declared fields are written in the user.js under the account's keys, so they win at every start; only
the fields the setting names, never the account's directory, folders, filters, caches, mail or kept
sign-in, so an adopted account keeps its sign-in (§5). The apply names, by preference name only, each one
whose value it changed (§4). What was adopted or created is recorded in
`~/.config/betterbird/accounts-record.json`. **Leaving the setting** releases an adopted account (its
lines and its record go, the account stays the person's) and takes a created account's keys out of the
lists (its mail stays in the profile).
**The value is derived, not retyped** (§9): `betterbird_accounts` with `propose` builds `accounts` from the
profile (every account but Owl's, no credential) and proposes it in the mesh-wide layer as it stands, through
the controller's settings verb; the operator approves on a channel that proves who answers. Its answer shows
the value it proposed (ADR 0329 §10).
**A node with no profile yet.** The preparation needs the profile Betterbird makes at its first start: on a
node set up from nothing, the declared accounts are written at the second start from the launcher (or at
`betterbird_restart`).
## Identities, calendars, address books, filters and tags (ADR 0329)
Five more settings, mesh-wide with a node's layer over each (an entry set to `null` in a node's layer is not on
that node). `identities`, `calendars`, `address-books` and `filters` reach the machine as
`~/.config/betterbird/configuration.json`, trusted as `accounts` is; `tags` as `tags.json`, not trusted (§5).
**No value holds a password, a token or a cookie**: a key shaped like one is refused, and so is an address whose
user part carries a password (§2). A setting holds no line break, so a signature is a list of its lines.
| setting | an entry | matched in the profile by |
|---|---|---|
| `identities` | by a name: `account` (its name in `accounts`), and for another identity than the account's default `address` and `name`; `reply-to`, `organization`, `signature` (lines), `signature-html`, `signature-above-quote`; `key` in a node's layer | the account's default (no `address`), or an identity of that account with the address |
| `calendars` | by a name: `name`, `url` (CalDAV), `color`, `read-only`, `reminders`, `user`, `invitations` (an identities entry); `key` in a node's layer | its address |
| `address-books` | by a name: `name`, `url` (CardDAV), `user`; `key` in a node's layer | its address |
| `tags` | by its key: `name`, `color`, `order` | its key |
| `filters` | by an account's name: the ordered list, each with `name`, `enabled`, `when` (new-mail, manual, after-junk, sending, archiving, periodic), `match` (all, any, every-message), `conditions` (`field`, `op`, `value`, in Betterbird's words) and `actions` (`action` in Betterbird's words; `folder` as a path within the account, or `value`) | owned whole per account |
**Adopted as accounts are** (§7): an entry recorded on this node is kept by its key; one under the module's own
key (`ident_mesh_<name>`, `mesh_<name>` for a calendar or an address book) is its creation; a `key` in the
node's layer is adopted when the profile holds it and it matches; else one match is adopted, none is created
under the module's key, and several are refused with the remedy. The declared fields go into the user.js under
that key. An entry leaving the setting releases what was adopted (its lines go) and removes the preferences of
what was created, never a calendar's events, an address book's contacts or a cache. An entry no setting declares
stays the person's. Owl's accounts and calendars, an ICS calendar (its address is often the secret), local
calendars and address books are never declared (§13).
**Filters are owned whole per declared account** (§8): the module writes that account's `msgFilterRules.dat`
from the list, each folder's path turned into its address on this node, so a filter added in Betterbird is gone
at the next apply (drift shows it first). Before its first write it keeps the replaced file in
`~/.config/betterbird/kept/<profile>/`; when the account leaves `filters`, the module stops writing and the file
stays the person's; `betterbird_filters` says where the kept copy is. A filter a setting cannot hold as it is (a
folder of another account, an add-on's action, a news filter) is named and not derived.
**Written only while Betterbird is closed** (§9): the lists in `prefs.js` and the filter files, because
Betterbird rewrites them as it runs and quits; the user.js is placed as before. `betterbird_check` counts per
kind the changes that wait.
**Derived, then proposed one layer at a time** (§10): `betterbird_derive` asks every desktop node holding
Betterbird for what its profile holds. An entry on every such node goes into the mesh-wide layer with the value of
the node the derive was called on, and the answer lists each field another node holds differently (which then
shows as drift there); one on some nodes only goes into those nodes' own layers. An account whose filter file
holds no filter is not put in `filters`. With `propose`, the next layer (the mesh-wide one, then each node's) is
proposed with the layer as it stands beside the derived kinds; never while a proposal of the module's waits, and
never while a desktop node does not answer. `prefs` on the call carries values of the setting `prefs` into the
mesh-wide layer. A `key` is never proposed for the mesh-wide layer.
## Tools
They are served by the node's runtime as the operator account (ADR 0175). **No answer carries mail, a
calendar entry, an account, an address or a credential.**
They are served by the node's runtime as the operator account (ADR 0175). **They show the operator's own
configuration with its values, and never a credential** (ADR 0329 §1, §2): every answer passes the module's
credential check, which withholds a field whose name says password, token, secret or cookie and the password
part of any address. No answer carries mail, a calendar entry or a contact.
| tool | does |
|---|---|
| `betterbird_status` (r) | whether it runs (pid, since, scope, children), the package and version and that it is from the AUR, where its output goes, the profile and how it was found, the user.js (declared, placed, in effect), the add-ons the operator installed (ids), and the preferences that decide how its windows and notifications look |
| `betterbird_prefs` (r) | named preferences or prefixes (ending in `.` or `*`, at most 200; an empty name, `*` or `.` is refused): value and source (user.js, prefs.js or the default). Withheld without its value: a name that can hold a credential (`password`, `token`, `oauth`, `signon`, …); one that names an account, server, calendar, recent folder or person (`mail.server.`, `mail.identity.`, `mail.last_msg…`, `messenger.account.`, `mail.cloud_files.`, `calendar.registry.`, …); and every `extensions.*` but the theme's id (`extensions.activeThemeID`). In every value answered, the home is `~`, and a mail address, a `user%40domain@host` URI and a host name are masked |
| `betterbird_user_js` (r; a with `apply`) | the declared user.js against the profile's; `apply` places it |
| `betterbird_log` (r) | the last lines of the journal of `mesh-betterbird` (or of the reminder test), addresses and credentials masked (the value after `password=`, `token:` and the like, and the token after `Bearer`/`Basic`). A Betterbird the module did not start prints elsewhere, and the answer says where |
| `betterbird_restart` (a) | ends Betterbird (asked to quit, forced after 8 s), places the user.js where allowed, starts it as `mesh-betterbird`. **Refused while a composer window is open** (X instance `Msgcompose`, whatever its class), because the unsent message would be lost, unless `discard_unsent` is true, which only the operator's yes justifies. It answers within the runtime's 30 s: a start not yet seen after 4 s is said in `note`, and `betterbird_status` shows it |
| `betterbird_check` (r) | installed; one running; profile found; the user.js on the machine, valid, placed, in effect; its output read |
| `betterbird_status` (r) | whether it runs (pid, since, scope, children); what is installed and from where, its version beside the newest published build; the policy; Owl's version against the pin and whether it is active; where its output goes; the profile and how it was found; the user.js (declared, placed, in effect, with every declared entry as it resolved); the declared accounts by count; the add-ons; the preferences that decide how its windows and notifications look |
| `betterbird_prefs` (r) | named preferences or prefixes: value and source, and the default with the file that gives it. Withheld without its value: a name that can hold a credential, one that names an account, server, calendar or person, and every `extensions.*` but the theme's id. Values masked (the home as `~`, addresses and hosts) |
| `betterbird_config` (r; a with `keep`) | each managed preference: its lock, the declared value, Betterbird's default where the module keeps that, the profile's saved value, the value in effect, and drift; with `keep`, this node's layer of `prefs` proposed with the profile's values |
| `betterbird_config_apply` (r; a with `apply`) | a kept preference's drifting values listed; with `apply`, while Betterbird is quit, taken out of `prefs.js` |
| `betterbird_user_js` (r; a with `apply`) | the user.js composed from the settings against the profile's; `apply` places it, writes what Betterbird rewrites while it is closed, and counts what waits |
| `betterbird_log` (r) | the last lines of the journal of `mesh-betterbird` (or of the reminder test), addresses and credentials masked |
| `betterbird_restart` (a) | ends Betterbird (asked to quit, forced after 8 s), places the user.js and writes what waited, starts it as `mesh-betterbird`. **Refused while a composer window is open**, unless `discard_unsent` is true, which only the operator's yes justifies |
| `betterbird_check` (r) | installed from the mesh's release; the policy and the module's defaults placed; no managed preference drifting; no ask to be the default mail client; Owl at its pin and active; one running; profile found; the user.js valid, placed, in effect; the declared accounts in the list, none refused, each signed in once; per kind, the declared entries drifting or refused and the changes waiting for Betterbird to close; its output read |
| `betterbird_accounts` (r; a with `propose`) | the profile's accounts with their values and the declared account each is, Owl's by name and address and marked; with `propose`, `accounts` built from them and proposed in the mesh-wide layer as it stands |
| `betterbird_identities`, `betterbird_calendars`, `betterbird_address_books`, `betterbird_filters`, `betterbird_tags` (r) | the profile's entries with their keys and values beside the declared ones and how each resolved (adopted, created or refused with why); an ICS calendar by its name only, Owl's marked; `betterbird_filters` also where the kept filter files are |
| `betterbird_derive` (r; a with `propose`) | the five kinds derived from the desktop nodes' profiles, per layer, with the fields another node holds differently and what was left out; with `propose`, the next layer proposed; `here` answers this node's profile, as another node asks it |
| `betterbird_drift` (r) | per kind and declared entry: holds, differs (each field with both values), lacks, or refused; and the profile's entries nothing declares |
| `betterbird_reminder_test` (a) | a reminder on demand without the operator's calendar (below) |
### A reminder on demand
**It opens windows on the operator's screen**, and its description says so first, so a caller tells the
operator before calling it. On 2026-10-12 at 12:05 a test Betterbird with an empty profile carrying the
operator's theme looked like the operator's configuration lost; so the test is **visibly a test**: its
profile carries a `userChrome.css` that frames every window of the test Betterbird in red and runs a red
band reading MESH TEST across its top, and its event is called "MESH TEST reminder (a throw-away
Betterbird of the mesh, not your calendar)". The reminder window itself is left unstyled, because how
Betterbird draws it is what the test looks at; its title is the event's. A window title cannot be set by a
preference, so the marking is the band, the frame and the event's name.
`betterbird_reminder_test` starts a **second** Betterbird with `--no-remote` in a throw-away profile
under `/run/user/<uid>/mesh-betterbird-reminder-test/`, as the user unit
`mesh-betterbird-reminder-test` (ended after 10 minutes at most). The profile holds the module's
user.js and one calendar: a local `.ics` file with one event, "Mesh reminder test", whose reminder is
user.js (the setting `prefs`; never a declared account) and one calendar: a local `.ics` file with one
event, "MESH TEST reminder", whose reminder is
due `after` seconds from the call (default 40). `carry` copies named preferences of the operator's
profile into it (a theme, a colour switch), under the same limits as `betterbird_prefs`. Then:
profile into it (a theme, a colour switch), under the same limits as `betterbird_prefs`. The test's own
preferences come last in its user.js, so nothing declared or carried overrides them; one of them turns
Betterbird's update check off (`betterbird.update.checkIntervalDays` 0), because a fresh profile checks
at its start and, when a newer build is published, opens the About window on the operator's screen
(seen 2026-10-12). Then:
- `xorg_windows` with class `Calendar` answers the reminder window: role `AlarmWindow`, type `Dialog`,
its depth (32: a visual with alpha, which the compositor blends), its map state;
@@ -106,13 +321,20 @@ A window of depth 32 that its program has not drawn is transparent under a compo
that is why the symptom looks the way it does. Why Betterbird did not draw it is hq issue 345's
question.
## What changes when it is assigned
## What changes when it is pushed
| | g14 | shanks |
|---|---|---|
| files | `~/.config/betterbird/` and its `user.js` (no preference) | the same |
| the profile | untouched until `betterbird_user_js` with `apply` or `betterbird_restart` | the same; the profile has no user.js today, so `apply` may place one |
| Betterbird | keeps running as started | keeps running as started (since 2026-10-04, its output on the session's pipe nobody reads: `betterbird_check` names it until `betterbird_restart`) |
The module is assigned to shanks today (look-only, from issue 345's first part); g14 runs Betterbird
outside the mesh. With this version, a push to a node (each waits for the operator: `upgrade: record`):
| | on a node with the community package (g14, shanks) |
|---|---|
| the package | `betterbird-bin` removed (quit Betterbird first) |
| `/opt/betterbird` | the mesh's 153.4.0esr-bb10, in the same place, so the same profile opens |
| the policy, Owl | placed; Owl installed into the profile by Betterbird at its next start where it is missing |
| `/usr/local` | the start, the launcher entry, the tools' program |
| `~/.config/betterbird/` | `prefs.json`, `accounts.json`, `configuration.json`, `tags.json` (each empty until its setting is approved) |
| the profile | untouched until the next start from the launcher, `betterbird_user_js` with apply, or `betterbird_restart` |
| the configuration | the policy's locks at Betterbird's next start; the settings in the user.js from the next start. Pushed in the order above, the operator's 21 preferences are in `prefs` before the build arrives; pushed before that layer is approved, Betterbird runs with its own defaults for them until it is |
## Relies on
@@ -120,3 +342,8 @@ question.
- **`xorg`** for `xorg_windows` and `xorg_screenshot`, which look at Betterbird's windows. This module
carries no window code of its own.
- `systemd-run` and `journalctl` of the account's user manager.
- The build seat's archive pinned by URL and sha256 (mesh-controller, ADR 0316 §2): a controller that does
not know it refuses this manifest.
- The controller's `settings` verb, for every proposal and to read a layer as it stands; its `nodes` and `node`
verbs, for which machines hold the module; and the module's own `betterbird_derive` on those machines
(`invokes`).
@@ -0,0 +1,842 @@
package main
// The operator's mail accounts, declared without credentials (novox/hq ADR 0316 §10–§14, to-be 56 §4).
//
// The setting `accounts` reaches the machine as ~/.config/betterbird/accounts.json, a file the node-engine
// writes from the module's settings layers. Per account it names the address, the incoming server and the
// outgoing server; never a password or a token, and the whole file is refused when any key in it is shaped
// like one. Betterbird's accounts are preferences, so a declared account becomes:
//
// - its declared fields as preferences in the user.js the module places, where they win at every start
// (§11): under the keys of the account the profile already holds at that address, which the module adopts
// (novox/hq ADR 0317), or, where the profile holds none, under keys of the module's own that end in
// mesh_<name> (account_mesh_<name>, id_mesh_<name>, server_mesh_<name>, smtp_mesh_<name>);
// - for an account the module created, its key in the account list (mail.accountmanager.accounts) and in the
// list of outgoing servers (mail.smtpservers) of the profile's prefs.js: written into, never owned, and
// only while Betterbird is not running, because it rewrites prefs.js when it quits (§12). Every other key
// keeps its place, and an adopted account's keys are in the lists already.
//
// Which account is adopted, created or refused is adoption.go's.
//
// Nobody's password passes through here: an account signs in at its first use, in Betterbird's own window,
// and Betterbird keeps the result in its login store (§14). Of that store the module reads only the server
// names each entry is kept for, which Betterbird writes in the clear, to count the declared accounts that
// have not signed in yet (§16); never a user name or a password, which are encrypted there anyway.
import (
"bufio"
"bytes"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
stdio "git.novox.be/novox/mesh-sdk/go"
)
// accountsFile is the setting accounts on the machine, written by the node-engine.
const accountsFile = ".config/betterbird/accounts.json"
// Account is one declared mail account, in the shape of the setting accounts: keyed by its name.
type Account struct {
Address string `json:"address"`
// FromName is the name the account's mail is sent under. Absent: Betterbird's, none.
FromName string `json:"from-name,omitempty"`
// Label is what Betterbird shows the account as. Absent: its address.
Label string `json:"label,omitempty"`
Incoming Server `json:"incoming"`
Outgoing Server `json:"outgoing"`
// Key is the profile's account key to adopt, given only in a node's own layer to settle an address the
// profile holds more than once (novox/hq ADR 0317 §8).
Key string `json:"key,omitempty"`
}
// Server is an account's incoming or outgoing server: never a password or a token.
type Server struct {
// Kind is imap or pop3, for the incoming server; the outgoing one is smtp and says nothing.
Kind string `json:"kind,omitempty"`
Host string `json:"host"`
Port int `json:"port,omitempty"`
Security string `json:"security"`
SignIn string `json:"sign-in"`
User string `json:"user,omitempty"`
}
// The words a server's connection security and sign-in method are said in, and Betterbird's numbers for
// them (socketType / try_ssl, authMethod).
var (
securities = map[string]int{"none": 0, "starttls": 2, "tls": 3}
signIns = map[string]int{"none": 1, "password": 3, "encrypted-password": 4, "kerberos": 5, "ntlm": 6,
"tls-certificate": 7, "oauth": 10}
)
func wordFor(words map[string]int, n int) string {
for w, v := range words {
if v == n {
return w
}
}
return ""
}
var (
accountName = regexp.MustCompile(`^[a-z][a-z0-9]{0,30}$`)
addressShape = regexp.MustCompile(`^[^\s@"\\]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}$`)
hostShape = regexp.MustCompile(`^[A-Za-z0-9](?:[A-Za-z0-9.\-]*[A-Za-z0-9])?$`)
profileKey = regexp.MustCompile(`^[A-Za-z0-9_]{1,64}$`)
// plainText is a value written into a preference: no quote, backslash or line break.
plainText = regexp.MustCompile(`^[^"\\\n\r]*$`)
// secretKey is a key the accounts file may never carry, at any depth: the setting holds no credential.
secretKey = regexp.MustCompile(`(?i)(pass|secret|token|oauth.?refresh|refresh|credential|api.?key|private.?key|cookie|bearer)`)
)
// keysOf are the module's own keys for a declared account: each ends in mesh_<name>.
func keysOf(name string) (account, identity, server, smtp string) {
return "account_mesh_" + name, "id_mesh_" + name, "server_mesh_" + name, "smtp_mesh_" + name
}
// ownKey is a key of an account list the module wrote, and the name it was declared under.
var ownKey = regexp.MustCompile(`^(?:account|smtp)_mesh_([a-z][a-z0-9]{0,30})$`)
// readAccounts reads the declared accounts. A missing file is no account (the module is older here); a file
// that carries a key shaped like a credential, an unknown field or a value that cannot be written is refused
// whole, naming the key and never a value.
func (m *Machine) readAccounts() (map[string]Account, bool, error) {
raw, err := readBounded(m.home(accountsFile))
if errors.Is(err, os.ErrNotExist) {
return map[string]Account{}, false, nil
}
if err != nil {
return nil, false, err
}
accounts, err := parseAccounts(raw)
return accounts, true, err
}
func parseAccounts(raw []byte) (map[string]Account, error) {
var tree any
if err := json.Unmarshal(raw, &tree); err != nil {
return nil, fmt.Errorf("~/%s is not JSON: %v", accountsFile, err)
}
if where := secretKeyIn(tree, ""); where != "" {
// An account's name is said by nothing: its segment of the path is withheld.
if parts := strings.SplitN(where, ".", 3); len(parts) == 3 && parts[0] == "accounts" {
where = "accounts.<an account>." + parts[2]
}
return nil, fmt.Errorf("the setting accounts carries %s, a key shaped like a credential: an account is declared "+
"without its password or token, which it signs in with at its first use (novox/hq ADR 0316 §10, §14). "+
"Take it out at the controller's terminal", where)
}
var top map[string]json.RawMessage
if err := json.Unmarshal(raw, &top); err != nil {
return nil, fmt.Errorf("~/%s is not an object: %v", accountsFile, err)
}
accounts := map[string]Account{}
if body, ok := top["accounts"]; ok && string(body) != "null" {
dec := json.NewDecoder(bytes.NewReader(body))
dec.DisallowUnknownFields()
if err := dec.Decode(&accounts); err != nil {
return nil, fmt.Errorf("the setting accounts is not a map of accounts by name: %v", err)
}
}
var problems []string
names := sortedNames(accounts)
for i, name := range names {
problems = append(problems, accountProblems(fmt.Sprintf("declared account %d of %d", i+1, len(names)),
name, accounts[name])...)
}
if len(problems) > 0 {
return nil, fmt.Errorf("the setting accounts is refused: %s", strings.Join(problems, "; "))
}
return accounts, nil
}
// secretKeyIn is the path of the first key shaped like a credential in a JSON tree, or "".
func secretKeyIn(node any, at string) string {
switch v := node.(type) {
case map[string]any:
keys := make([]string, 0, len(v))
for k := range v {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
here := k
if at != "" {
here = at + "." + k
}
if secretKey.MatchString(k) {
return here
}
if w := secretKeyIn(v[k], here); w != "" {
return w
}
}
case []any:
for i, x := range v {
if w := secretKeyIn(x, fmt.Sprintf("%s[%d]", at, i)); w != "" {
return w
}
}
}
return ""
}
func sortedNames(accounts map[string]Account) []string {
names := make([]string, 0, len(accounts))
for n := range accounts {
names = append(names, n)
}
sort.Strings(names)
return names
}
// accountProblems is what is wrong with one declared account, said of it by its place (label) and never by a
// value of it, its name included (novox/hq ADR 0316 §16).
func accountProblems(label, name string, a Account) []string {
var out []string
say := func(format string, args ...any) { out = append(out, label+": "+fmt.Sprintf(format, args...)) }
if !accountName.MatchString(name) {
say("an account's name is lower-case letters and digits, starting with a letter (it names the module's keys, mesh_<name>)")
}
if !addressShape.MatchString(a.Address) {
say("the address is not a mail address")
}
if a.Key != "" && !profileKey.MatchString(a.Key) {
say("key is an account key of the profile, such as account2")
}
for field, v := range map[string]string{"from-name": a.FromName, "label": a.Label} {
if !plainText.MatchString(v) {
say("%s holds a quote, a backslash or a line break", field)
}
}
for side, s := range map[string]Server{"incoming": a.Incoming, "outgoing": a.Outgoing} {
switch {
case side == "incoming" && s.Kind != "imap" && s.Kind != "pop3":
say("the incoming server's kind is imap or pop3")
case side == "outgoing" && s.Kind != "" && s.Kind != "smtp":
say("the outgoing server is smtp")
}
if !hostShape.MatchString(s.Host) {
say("the %s server's host is not a host name", side)
}
if s.Port < 0 || s.Port > 65535 {
say("the %s server's port is not a port", side)
}
if _, ok := securities[s.Security]; !ok {
say("the %s server's security is tls, starttls or none", side)
}
if _, ok := signIns[s.SignIn]; !ok || (side == "incoming" && s.SignIn == "none") {
say("the %s server's sign-in is password, encrypted-password, oauth, kerberos, ntlm or tls-certificate%s",
side, map[bool]string{true: ", or none", false: ""}[side == "outgoing"])
}
if !plainText.MatchString(s.User) {
say("the %s server's user holds a quote, a backslash or a line break", side)
}
}
return out
}
// quoted is a preference's string value as user.js writes it.
func quoted(s string) string { return strconv.Quote(s) }
// declaredPrefs are a declared account's fields as preferences under the keys it resolved to, name to value
// as user.js writes it, in a fixed order. Only the fields the setting names (novox/hq ADR 0317 §5): never
// the account's directory, folders, filters or caches. An adopted account keeps its kind, the outgoing server
// its identity names, and its own name and sender's name where the declaration says none; a created one
// gets every field.
func declaredPrefs(k keySet, a Account, adopted bool) [][2]string {
label := a.Label
if label == "" && !adopted {
label = a.Address
}
inPort := a.Incoming.Port
if inPort == 0 {
inPort = defaultPort(a.Incoming.Kind, a.Incoming.Security)
}
outPort := a.Outgoing.Port
if outPort == 0 {
outPort = defaultPort("smtp", a.Outgoing.Security)
}
var out [][2]string
add := func(key, value string) { out = append(out, [2]string{key, value}) }
if !adopted {
add("mail.account."+k.Account+".identities", quoted(k.Identity))
add("mail.account."+k.Account+".server", quoted(k.Server))
}
if a.FromName != "" || !adopted {
add("mail.identity."+k.Identity+".fullName", quoted(a.FromName))
}
add("mail.identity."+k.Identity+".useremail", quoted(a.Address))
if !adopted {
add("mail.identity."+k.Identity+".smtpServer", quoted(k.SMTP))
add("mail.identity."+k.Identity+".valid", "true")
add("mail.server."+k.Server+".type", quoted(a.Incoming.Kind))
}
add("mail.server."+k.Server+".hostname", quoted(a.Incoming.Host))
add("mail.server."+k.Server+".port", strconv.Itoa(inPort))
add("mail.server."+k.Server+".socketType", strconv.Itoa(securities[a.Incoming.Security]))
add("mail.server."+k.Server+".authMethod", strconv.Itoa(signIns[a.Incoming.SignIn]))
add("mail.server."+k.Server+".userName", quoted(a.Incoming.User))
if label != "" {
add("mail.server."+k.Server+".name", quoted(label))
}
add("mail.smtpserver."+k.SMTP+".hostname", quoted(a.Outgoing.Host))
add("mail.smtpserver."+k.SMTP+".port", strconv.Itoa(outPort))
add("mail.smtpserver."+k.SMTP+".try_ssl", strconv.Itoa(securities[a.Outgoing.Security]))
add("mail.smtpserver."+k.SMTP+".authMethod", strconv.Itoa(signIns[a.Outgoing.SignIn]))
add("mail.smtpserver."+k.SMTP+".username", quoted(a.Outgoing.User))
if label != "" {
add("mail.smtpserver."+k.SMTP+".description", quoted(label))
}
return out
}
// defaultPort is the port a server listens on when the declaration says none.
func defaultPort(kind, security string) int {
switch {
case kind == "imap" && security == "tls":
return 993
case kind == "imap":
return 143
case kind == "pop3" && security == "tls":
return 995
case kind == "pop3":
return 110
case security == "tls":
return 465
case security == "starttls":
return 587
}
return 25
}
// composeUserJS is the user.js the module places in the profile: the declared file (the setting prefs), then
// each adopted or created account's declared fields under its keys. A refused account has none.
func composeUserJS(declaredText string, accounts map[string]Account, resolved []resolvedAccount) string {
var lines []string
for _, r := range resolved {
if r.How == refused {
continue
}
lines = append(lines, fmt.Sprintf("// declared account %d: %s under %s", r.Place, r.How, r.Keys.Account))
for _, p := range declaredPrefs(r.Keys, accounts[r.name], r.How == adopted) {
lines = append(lines, fmt.Sprintf("user_pref(%s, %s);", quoted(p[0]), p[1]))
}
}
if len(lines) == 0 {
return declaredText
}
return strings.TrimRight(declaredText, "\n") + "\n\n" +
"// The accounts the setting accounts declares (novox/hq ADR 0316 §11, ADR 0317): the fields it names, under\n" +
"// the keys of the account adopted or created. No password or token: an account signs in at its first use.\n" +
strings.Join(lines, "\n") + "\n"
}
// profileAccount is one account the profile holds, as its preferences say it.
type profileAccount struct {
Key, Server, Kind string
// Identities are its identities, the default first, and Addresses their addresses in that order.
Identities []string
Addresses []string
Owl, Ours bool
}
// profileAccounts reads the accounts the profile's list names, from prefs.js and user.js (user.js wins).
func profileAccounts(prefs map[string]string) []profileAccount {
var out []profileAccount
for _, key := range listOf(prefs["mail.accountmanager.accounts"]) {
a := profileAccount{Key: key, Server: unquote(prefs["mail.account."+key+".server"])}
a.Kind = unquote(prefs["mail.server."+a.Server+".type"])
a.Owl = strings.HasPrefix(a.Kind, "owl")
a.Ours = ownKey.MatchString(key)
for _, id := range listOf(prefs["mail.account."+key+".identities"]) {
a.Identities = append(a.Identities, id)
a.Addresses = append(a.Addresses, unquote(prefs["mail.identity."+id+".useremail"]))
}
out = append(out, a)
}
return out
}
// mergedPrefs are a profile's preferences as Betterbird starts with them: prefs.js, then user.js over it.
func mergedPrefs(profile string) map[string]string {
prefsJS, _ := readBounded(filepath.Join(profile, "prefs.js"))
userJS, _ := readBounded(filepath.Join(profile, "user.js"))
out := readPrefs(prefsJS)
for k, v := range readPrefs(userJS) {
out[k] = v
}
return out
}
// listOf reads a comma-separated list preference's value.
func listOf(v string) []string {
var out []string
for _, k := range strings.Split(unquote(v), ",") {
if k = strings.TrimSpace(k); k != "" {
out = append(out, k)
}
}
return out
}
// unquote is a preference's string value without its quotes; any other value as written.
func unquote(v string) string {
if s, err := strconv.Unquote(v); err == nil {
return s
}
return v
}
// writeAccountLists writes the module's keys into the profile's account list and list of outgoing servers
// (§12): the declared accounts' keys added at the end, the module's keys of accounts no longer declared taken
// out, every other key and line kept as it was. Only while Betterbird is not running. Says what changed.
func (m *Machine) writeAccountLists(profile string, resolved []resolvedAccount) (string, error) {
path := filepath.Join(profile, "prefs.js")
raw, err := readBounded(path)
if err != nil && !errors.Is(err, os.ErrNotExist) {
return "", err
}
var wantAccounts, wantSMTP []string
for _, r := range resolved {
if r.How == created {
wantAccounts, wantSMTP = append(wantAccounts, r.Keys.Account), append(wantSMTP, r.Keys.SMTP)
}
}
out, changed := withListed(string(raw), map[string][]string{
"mail.accountmanager.accounts": wantAccounts, "mail.smtpservers": wantSMTP})
if !changed {
return "unchanged", nil
}
// Only while Betterbird is closed: no main process and no lock on the profile (novox/hq ADR 0316 §12, ADR 0329 §9).
if why := m.closedFor(profile); why != "" {
return "", errors.New(why + ": the account list is written at its next start from the launcher, or by betterbird_restart")
}
mode := os.FileMode(0o600)
if st, err := os.Stat(path); err == nil {
mode = st.Mode().Perm()
}
if err := writeAtomic(path, []byte(out), mode); err != nil {
return "", err
}
return fmt.Sprintf("written: %d account(s) the module created in the account list", len(wantAccounts)), nil
}
// withListed is prefs.js with each list preference holding the module's wanted keys: its own keys that are
// not wanted taken out, the wanted ones not there added at its end, everything else as it was. A list the
// file does not hold yet is added as a line, when anything is wanted in it.
func withListed(text string, want map[string][]string) (string, bool) {
changed := false
done := map[string]bool{}
var lines []string
s := bufio.NewScanner(strings.NewReader(text))
s.Buffer(make([]byte, 64<<10), 4<<20)
for s.Scan() {
line := s.Text()
if mm := prefLine.FindStringSubmatch(line); mm != nil {
if keys, ok := want[mm[1]]; ok && !done[mm[1]] {
done[mm[1]] = true
next := listedWith(listOf(mm[2]), keys)
if next != strings.Join(listOf(mm[2]), ",") {
line = fmt.Sprintf("user_pref(%s, %s);", quoted(mm[1]), quoted(next))
changed = true
}
}
}
lines = append(lines, line)
}
names := make([]string, 0, len(want))
for n := range want {
names = append(names, n)
}
sort.Strings(names)
for _, n := range names {
if !done[n] && len(want[n]) > 0 {
lines = append(lines, fmt.Sprintf("user_pref(%s, %s);", quoted(n), quoted(strings.Join(want[n], ","))))
changed = true
}
}
out := strings.Join(lines, "\n")
if text == "" || strings.HasSuffix(text, "\n") {
out += "\n"
}
return out, changed
}
// listedWith is a list with the module's own keys made the wanted ones, every other key in its place.
func listedWith(have, wanted []string) string {
want := map[string]bool{}
for _, k := range wanted {
want[k] = true
}
var out []string
there := map[string]bool{}
for _, k := range have {
if ownKey.MatchString(k) && !want[k] {
continue
}
out = append(out, k)
there[k] = true
}
for _, k := range wanted {
if !there[k] {
out = append(out, k)
}
}
return strings.Join(out, ",")
}
// AccountCount is what status and check say of the accounts: numbers, places and keys, never an address, a
// host or a name (novox/hq ADR 0316 §16, ADR 0317 §3).
type AccountCount struct {
Declared int `json:"declared"`
// InList are the declared accounts the profile's account list names, adopted or created.
InList int `json:"declared_in_the_list"`
// Others are the accounts the profile holds that no declaration names (the work account in Owl among them).
Others int `json:"other_accounts"`
// WithoutSignIn are the declared accounts with no sign-in kept in Betterbird's login store yet.
WithoutSignIn int `json:"declared_without_a_sign_in,omitempty"`
// Each is every declared account by its place in the setting: adopted, created or refused, and its keys.
Each []resolvedAccount `json:"each,omitempty"`
Problem string `json:"problem,omitempty"`
}
// countAccounts counts the declared accounts against the profile's.
func (m *Machine) countAccounts(profile, profileDir string) AccountCount {
var c AccountCount
accounts, _, err := m.readAccounts()
if err != nil {
c.Problem = err.Error()
return c
}
c.Declared = len(accounts)
prefs := mergedPrefs(profile)
have := profileAccounts(prefs)
c.Each = resolveAccounts(accounts, prefs, m.readRecord(profileDir))
mine := map[string]bool{}
refusedN := 0
for _, r := range c.Each {
if r.How == refused {
refusedN++
continue
}
mine[r.Keys.Account] = true
}
for _, a := range have {
switch {
case mine[a.Key]:
c.InList++
case a.Kind != "none":
c.Others++
}
}
if refusedN > 0 {
c.Problem = fmt.Sprintf("%d declared account(s) are refused: each says why and what to do", refusedN)
}
c.WithoutSignIn = len(withoutSignIn(accounts, keptSignIns(profile)))
return c
}
// keptSignIns are the server names Betterbird's login store keeps a sign-in for: the `hostname` of each
// entry of logins.json, which Betterbird writes in the clear. Nothing else of the file is read.
func keptSignIns(profile string) map[string]bool {
raw, err := readBounded(filepath.Join(profile, "logins.json"))
out := map[string]bool{}
if err != nil {
return out
}
var store struct {
Logins []struct {
Hostname string `json:"hostname"`
} `json:"logins"`
}
if json.Unmarshal(raw, &store) != nil {
return out
}
for _, l := range store.Logins {
out[strings.ToLower(l.Hostname)] = true
}
return out
}
// oauthIssuers are the sign-in services of the providers whose servers sign in through OAuth, by a suffix of
// the server's host: their kept sign-in is under the service's name, not the server's.
var oauthIssuers = map[string]string{"gmail.com": "oauth://accounts.google.com",
"googlemail.com": "oauth://accounts.google.com", "office365.com": "oauth://login.microsoftonline.com",
"outlook.com": "oauth://login.microsoftonline.com"}
// withoutSignIn are the declared accounts whose incoming server has no sign-in kept yet. A server signed in
// to without a password (none, kerberos, ntlm, a certificate) keeps nothing and is never counted.
func withoutSignIn(accounts map[string]Account, kept map[string]bool) []string {
var out []string
for _, name := range sortedNames(accounts) {
in := accounts[name].Incoming
switch in.SignIn {
case "password", "encrypted-password":
if !kept[strings.ToLower(in.Kind+"://"+in.Host)] {
out = append(out, name)
}
case "oauth":
issuer := ""
for suffix, i := range oauthIssuers {
if strings.HasSuffix(strings.ToLower(in.Host), suffix) {
issuer = i
}
}
found := false
for k := range kept {
if (issuer != "" && k == issuer) || (issuer == "" && strings.HasPrefix(k, "oauth://")) {
found = true
}
}
if !found {
out = append(out, name)
}
}
}
return out
}
// AccountsAnswer is what betterbird_accounts answers its caller: the profile's accounts with the operator's own
// values (novox/hq ADR 0329 §1, §12), Owl's marked and by name and address only (§13), and counts and kinds.
// Never a password, a token or anything of the login store (§2).
type AccountsAnswer struct {
Profile string `json:"profile"`
// Accounts are the profile's accounts, in its list's order.
Accounts []listedAccount `json:"accounts"`
// Derived is the value of the setting accounts the propose built, as it was proposed (ADR 0329 §10).
Derived map[string]Account `json:"derived,omitempty"`
// Declarable are the profile's mail accounts a declaration can hold (IMAP and POP3), by kind.
Declarable map[string]int `json:"declarable"`
// KeptByOwl are the accounts Owl keeps (the work account through Exchange): never declared (ADR 0316 §13).
KeptByOwl int `json:"kept_by_owl"`
// NotDeclarable are accounts of another kind (local folders, feeds, news, chat), by kind.
NotDeclarable map[string]int `json:"not_declarable,omitempty"`
// AlreadyDeclared are the declarable accounts under the module's own keys.
AlreadyDeclared int `json:"under_the_modules_keys"`
// Proposed is the controller's answer to the proposal, with every address and host masked.
Proposed string `json:"proposed,omitempty"`
Note string `json:"note"`
}
// listedAccount is one account of the profile as betterbird_accounts lists it.
type listedAccount struct {
Key string `json:"key"`
Kind string `json:"kind"`
// DeclaredAs is the name the setting accounts declares it under, or empty: the person's own.
DeclaredAs string `json:"declared_as,omitempty"`
Owl bool `json:"owl,omitempty"`
// Name is what Betterbird shows it as, and Address its default identity's.
Name string `json:"name,omitempty"`
Address string `json:"address,omitempty"`
Value *Account `json:"value,omitempty"`
Note string `json:"note,omitempty"`
}
// profileDeclarable reads the profile's accounts: the value a declaration of them would be, by name, and
// what is answered of them.
func (m *Machine) profileDeclarable() (map[string]Account, AccountsAnswer, error) {
p, err := m.profile()
if err != nil {
return nil, AccountsAnswer{}, err
}
prefs := mergedPrefs(p.path)
a := AccountsAnswer{Profile: p.Dir, Accounts: []listedAccount{}, Declarable: map[string]int{}, NotDeclarable: map[string]int{}}
value := map[string]Account{}
used := map[string]bool{}
declaredAs := map[string]string{}
if accounts, _, err := m.readAccounts(); err == nil {
for _, r := range resolveAccounts(accounts, prefs, m.readRecord(p.Dir)) {
if r.How != refused {
declaredAs[r.Keys.Account] = r.name
}
}
}
for _, pa := range profileAccounts(prefs) {
listed := listedAccount{Key: pa.Key, Kind: pa.Kind, DeclaredAs: declaredAs[pa.Key], Owl: pa.Owl,
Name: unquote(prefs["mail.server."+pa.Server+".name"])}
if len(pa.Addresses) > 0 {
listed.Address = pa.Addresses[0]
}
switch {
case pa.Owl:
a.KeptByOwl++
listed.Note = "Owl's: never declared (novox/hq ADR 0316 §13)"
a.Accounts = append(a.Accounts, listed)
continue
case pa.Kind != "imap" && pa.Kind != "pop3":
kind := pa.Kind
if kind == "none" {
kind = "local folders"
}
a.NotDeclarable[kind]++
listed.Note = "not an account a declaration holds"
a.Accounts = append(a.Accounts, listed)
continue
}
a.Declarable[pa.Kind]++
if pa.Ours {
a.AlreadyDeclared++
}
address := ""
if len(pa.Addresses) > 0 {
address = pa.Addresses[0]
}
name := nameFor(pa, address, used)
if declaredAs[pa.Key] != "" {
name = declaredAs[pa.Key]
}
used[name] = true
acc := readAccount(prefs, pa)
value[name] = acc
listed.Value = &acc
a.Accounts = append(a.Accounts, listed)
}
if len(a.NotDeclarable) == 0 {
a.NotDeclarable = nil
}
return value, a, nil
}
// asker calls a verb of the mesh as this module, through the runtime: a variable so a test answers.
var asker = stdio.Ask
// ProfileAccounts says, by count and kind, which accounts the profile in use holds; with propose it builds
// the setting accounts from them (every account but Owl's, without any credential) and proposes it through
// the controller's settings verb for the operator's Approve (novox/hq ADR 0317 §9, ADR 0277). The value
// never comes back to the caller: the operator reads it whole at their desk (`settings proposals <id>`) and
// checks the fingerprint the ask shows.
func (m *Machine) ProfileAccounts(propose bool) (AccountsAnswer, error) {
value, a, err := m.profileDeclarable()
if err != nil {
return a, err
}
a.Note = "the profile's accounts with their values, never a password or token (novox/hq ADR 0329 §1, §2). With " +
"propose, the setting accounts is built from them (never Owl's) and proposed to the operator in the mesh-wide " +
"layer as it stands, who approves it on a channel that proves who answers (novox/hq ADR 0317 §9)"
if !propose {
return a, nil
}
if len(value) == 0 {
return a, errors.New("the profile holds no IMAP or POP3 account to declare: nothing was proposed")
}
var problems []string
for i, name := range sortedNames(value) {
if p := accountProblems("", name, value[name]); len(p) > 0 {
problems = append(problems, fmt.Sprintf("account %d of %d: %d field(s) cannot be declared as the profile "+
"holds them", i+1, len(value), len(p)))
}
}
if len(problems) > 0 {
return a, fmt.Errorf("nothing was proposed: %s; betterbird_prefs shows the account's preferences by name",
strings.Join(problems, "; "))
}
// A layer is replaced whole: the proposal carries the mesh-wide layer as it stands, with this value of accounts.
layer, err := readLayer("")
if err != nil {
return a, fmt.Errorf("nothing was proposed: %v", err)
}
layer["accounts"] = value
a.Derived = value
said, err := proposeLayer("", layer, false)
a.Proposed = m.maskValue(said)
return a, err
}
// verbSaid is a verb's words and whether it went ahead, whatever wraps them: the controller's {ok, output},
// or the protocol's content list.
func verbSaid(raw json.RawMessage) (string, bool) {
var s string
if json.Unmarshal(raw, &s) == nil {
return verbSaid(json.RawMessage(s))
}
var m map[string]json.RawMessage
if json.Unmarshal(raw, &m) != nil {
return string(raw), true
}
if content, has := m["content"]; has {
var items []struct {
Text string `json:"text"`
}
var e struct {
IsError bool `json:"isError"`
}
_ = json.Unmarshal(raw, &e)
if json.Unmarshal(content, &items) == nil && len(items) > 0 {
out, ok := verbSaid(json.RawMessage(items[0].Text))
return out, ok && !e.IsError
}
}
if okRaw, has := m["ok"]; has {
var ok bool
_ = json.Unmarshal(okRaw, &ok)
var output string
_ = json.Unmarshal(m["output"], &output)
return output, ok
}
return string(raw), true
}
// readAccount reads one profile account's preferences into the shape of the setting.
func readAccount(prefs map[string]string, pa profileAccount) Account {
num := func(key string, def int) int {
if n, err := strconv.Atoi(prefs[key]); err == nil {
return n
}
return def
}
text := func(key string) string { return unquote(prefs[key]) }
srv := "mail.server." + pa.Server + "."
acc := Account{Incoming: Server{Kind: pa.Kind, Host: text(srv + "hostname"), User: text(srv + "userName"),
Security: wordFor(securities, num(srv+"socketType", 0)), SignIn: wordFor(signIns, num(srv+"authMethod", 3))}}
acc.Incoming.Port = num(srv+"port", defaultPort(pa.Kind, acc.Incoming.Security))
ids := listOf(prefs["mail.account."+pa.Key+".identities"])
if len(ids) > 0 {
id := "mail.identity." + ids[0] + "."
acc.Address, acc.FromName = text(id+"useremail"), text(id+"fullName")
smtp := text(id + "smtpServer")
if smtp == "" {
smtp = text("mail.smtp.defaultserver")
}
if smtp != "" {
s := "mail.smtpserver." + smtp + "."
acc.Outgoing = Server{Host: text(s + "hostname"), User: text(s + "username"),
Security: wordFor(securities, num(s+"try_ssl", 0)), SignIn: wordFor(signIns, num(s+"authMethod", 3))}
acc.Outgoing.Port = num(s+"port", defaultPort("smtp", acc.Outgoing.Security))
}
}
if label := text(srv + "name"); label != "" && label != acc.Address {
acc.Label = label
}
return acc
}
// nameFor is the name a profile account is answered under: its own name when the module declared it, else
// the first label of its address's domain, made unique.
func nameFor(pa profileAccount, address string, used map[string]bool) string {
if mm := ownKey.FindStringSubmatch(pa.Key); mm != nil {
return mm[1]
}
base := "account"
if _, domain, ok := strings.Cut(address, "@"); ok {
label, _, _ := strings.Cut(strings.ToLower(domain), ".")
label = regexp.MustCompile(`[^a-z0-9]`).ReplaceAllString(label, "")
if label != "" && label[0] >= 'a' && label[0] <= 'z' {
base = label
}
}
if len(base) > 28 {
base = base[:28]
}
name := base
for i := 2; used[name]; i++ {
name = base + strconv.Itoa(i)
}
return name
}
@@ -0,0 +1,567 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
// The setting accounts (novox/hq ADR 0316 §10–§16, ADR 0317 §9 for betterbird_accounts): declared without
// credentials, written under the module's own keys, the account list written into and never owned, refused
// rather than duplicated, and said by count.
const personal = `{"accounts": {
"personal": {"address": "me@mail.example.org", "from-name": "Me",
"incoming": {"kind": "imap", "host": "imap.example.org", "security": "tls", "sign-in": "password", "user": "me"},
"outgoing": {"host": "smtp.example.org", "port": 587, "security": "starttls", "sign-in": "password", "user": "me"}}
}}`
// profileWithAccounts is a profile holding an account of the person's at another address, Owl's work account,
// local folders, and an account list in that order.
func (f *fake) profileWithAccounts() {
f.betterbird()
f.write(profileDir+"/prefs.js", "// Mozilla User Preferences\n"+
`user_pref("mail.account.account1.server", "server1");`+"\n"+
`user_pref("mail.account.account1.identities", "id1");`+"\n"+
`user_pref("mail.account.account2.server", "server2");`+"\n"+
`user_pref("mail.account.account3.server", "server3");`+"\n"+
`user_pref("mail.account.account3.identities", "id3");`+"\n"+
`user_pref("mail.accountmanager.accounts", "account1,account3,account2");`+"\n"+
`user_pref("mail.identity.id1.useremail", "other@mail.example.org");`+"\n"+
`user_pref("mail.identity.id1.fullName", "Other");`+"\n"+
`user_pref("mail.identity.id1.smtpServer", "smtp1");`+"\n"+
`user_pref("mail.identity.id3.useremail", "work@corp.example.com");`+"\n"+
`user_pref("mail.server.server1.type", "imap");`+"\n"+
`user_pref("mail.server.server1.hostname", "imap.example.org");`+"\n"+
`user_pref("mail.server.server1.socketType", 3);`+"\n"+
`user_pref("mail.server.server1.authMethod", 10);`+"\n"+
`user_pref("mail.server.server1.userName", "other");`+"\n"+
`user_pref("mail.server.server1.directory", "/home/operator/.thunderbird/x/ImapMail/imap.example.org");`+"\n"+
`user_pref("mail.server.server2.type", "none");`+"\n"+
`user_pref("mail.server.server3.type", "owl-ews");`+"\n"+
`user_pref("mail.smtpserver.smtp1.hostname", "smtp.example.org");`+"\n"+
`user_pref("mail.smtpserver.smtp1.try_ssl", 3);`+"\n"+
`user_pref("mail.smtpserver.smtp1.authMethod", 10);`+"\n"+
`user_pref("mail.smtpserver.smtp1.username", "other");`+"\n"+
`user_pref("mail.smtpservers", "smtp1");`+"\n"+
`user_pref("mailnews.tags.version", 2);`+"\n")
}
func (f *fake) declareAccounts(body string) {
f.write(testHome+"/"+accountsFile, body)
}
func (f *fake) read(path string) string {
f.t.Helper()
raw, err := os.ReadFile(filepath.Join(f.Root, path))
if err != nil {
f.t.Fatal(err)
}
return string(raw)
}
func TestAnAccountsValueCarryingAPasswordLikeFieldIsRefusedWithoutItsValue(t *testing.T) {
for _, body := range []string{
`{"accounts": {"personal": {"address": "me@mail.example.org", "password": "hunter2"}}}`,
`{"accounts": {"personal": {"incoming": {"oauth_refresh": "1//hunter2"}}}}`,
`{"token": "hunter2", "accounts": {}}`,
} {
_, err := parseAccounts([]byte(body))
if err == nil || !strings.Contains(err.Error(), "shaped like a credential") || strings.Contains(err.Error(), "hunter2") {
t.Errorf("%s: %v", body, err)
}
}
// An unknown field is refused too, and a value that cannot be declared, said by the account's place.
if _, err := parseAccounts([]byte(`{"accounts": {"personal": {"address": "me@mail.example.org", "keychain": 1}}}`)); err == nil {
t.Error("an unknown field was taken")
}
_, err := parseAccounts([]byte(strings.Replace(personal, `"security": "tls"`, `"security": "maybe"`, 1)))
if err == nil || !strings.Contains(err.Error(), "declared account 1 of 1") || strings.Contains(err.Error(), "personal") ||
strings.Contains(err.Error(), "example.org") {
t.Errorf("a bad value is said by the account's place, never its name or a value: %v", err)
}
}
func TestTheSettingPrefsRefusesAnAccountsPreferences(t *testing.T) {
for _, name := range []string{"mail.account.account1.server", "mail.identity.id1.useremail", "mail.server.server1.hostname",
"mail.smtpserver.smtp1.hostname", "mail.accountmanager.accounts", "mail.smtpservers"} {
err := validUserJS(mark + ")\nuser_pref(\"" + name + "\", \"x\");\n")
if err == nil || !strings.Contains(err.Error(), "setting accounts") {
t.Errorf("%s was taken in prefs: %v", name, err)
}
}
if err := validUserJS(mark + ")\nuser_pref(\"calendar.alarms.show\", true);\n"); err != nil {
t.Fatal(err)
}
}
// noPackage answers pacman as a machine without the community package.
func (f *fake) noPackage() {
f.answer = func(name string, args []string) Output {
if name == "pacman" {
return Output{Code: 1}
}
return Output{}
}
}
// userPrefLines are the user_pref lines of a user.js, by preference name.
func userPrefLines(t *testing.T, text string) map[string]string {
t.Helper()
out := map[string]string{}
for _, line := range strings.Split(text, "\n") {
if !strings.HasPrefix(line, "user_pref(") {
continue
}
if !statement.MatchString(line) {
t.Errorf("not a statement Betterbird reads as one: %s", line)
}
out[line[len(`user_pref("`):strings.Index(line, `",`)]] = line
}
return out
}
func TestADeclaredAccountTheProfileDoesNotHoldIsCreatedUnderTheModulesKeysAndListed(t *testing.T) {
f := newFake(t)
f.profileWithAccounts()
f.noPackage()
links(t, nil)
f.declare("// none")
f.declareAccounts(personal)
// Betterbird is running: the user.js is placed, the list waits for its next start.
u, err := f.userJS(true)
if err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(u.Lists, "not written") || !strings.Contains(u.Lists, "running") {
t.Fatalf("the list is never written while Betterbird runs: %+v", u)
}
if len(u.Each) != 1 || u.Each[0].How != created || u.Each[0].Keys != (keySet{"account_mesh_personal", "id_mesh_personal",
"server_mesh_personal", "smtp_mesh_personal"}) {
t.Fatalf("%+v", u.Each)
}
placed := f.read(profileDir + "/user.js")
for name := range userPrefLines(t, placed) {
if key := strings.Split(name, ".")[2]; !strings.HasSuffix(key, "mesh_personal") {
t.Errorf("a created account's preference outside the module's keys: %s", name)
}
if strings.Contains(strings.ToLower(name), "password") || strings.Contains(name, "directory") {
t.Errorf("the module writes no credential and none of Betterbird's own preferences: %s", name)
}
}
for _, want := range []string{`user_pref("mail.server.server_mesh_personal.port", 993);`,
`user_pref("mail.server.server_mesh_personal.socketType", 3);`,
`user_pref("mail.smtpserver.smtp_mesh_personal.try_ssl", 2);`,
`user_pref("mail.identity.id_mesh_personal.useremail", "me@mail.example.org");`} {
if !strings.Contains(placed, want) {
t.Errorf("the user.js lacks %s:\n%s", want, placed)
}
}
// Quit: the list gains the module's keys at its end, every other line as it was.
before := f.read(profileDir + "/prefs.js")
os.RemoveAll(filepath.Join(f.Root, "proc", "4000"))
if err := f.Prepare(); err != nil {
t.Fatal(err)
}
after := f.read(profileDir + "/prefs.js")
if !strings.Contains(after, `user_pref("mail.accountmanager.accounts", "account1,account3,account2,account_mesh_personal");`) ||
!strings.Contains(after, `user_pref("mail.smtpservers", "smtp1,smtp_mesh_personal");`) {
t.Fatalf("the lists:\n%s", after)
}
bl, al := strings.Split(before, "\n"), strings.Split(after, "\n")
if len(bl) != len(al) {
t.Fatalf("lines were added or lost: %d then %d", len(bl), len(al))
}
for i := range bl {
if bl[i] != al[i] && !strings.Contains(bl[i], "mail.accountmanager.accounts") && !strings.Contains(bl[i], "mail.smtpservers") {
t.Errorf("line %d changed: %q to %q", i+1, bl[i], al[i])
}
}
// The record lost: an account under the module's own keys is still its creation, never adopted.
os.Remove(filepath.Join(f.Root, testHome, recordFile))
u, err = f.userJS(true)
if err != nil || u.Each[0].How != created {
t.Fatalf("without the record, the module's keys still say created: %+v %v", u.Each, err)
}
// The account leaves the setting: only the module's keys go, the mail stays in the profile.
f.declareAccounts(`{"accounts": {}}`)
if err := f.Prepare(); err != nil {
t.Fatal(err)
}
if got := f.read(profileDir + "/prefs.js"); got != before {
t.Fatalf("removing the module's keys left the file otherwise as it was:\n%s", got)
}
}
// adoptable is a declaration of the profile's account1 (other@mail.example.org): equal but for its outgoing
// port, said explicitly, and its incoming user.
const adoptable = `{"accounts": {
"mail": {"address": "Other@mail.example.org",
"incoming": {"kind": "imap", "host": "imap.example.org", "security": "tls", "sign-in": "oauth", "user": "other2"},
"outgoing": {"host": "smtp.example.org", "port": 465, "security": "tls", "sign-in": "oauth", "user": "other"}}}}`
func TestADeclaredAccountTheProfileHoldsIsAdoptedUnderItsOwnKeysAndTheListIsLeftAlone(t *testing.T) {
f := newFake(t)
f.profileWithAccounts()
f.noPackage()
links(t, nil)
f.declare("// none")
f.declareAccounts(adoptable)
os.RemoveAll(filepath.Join(f.Root, "proc", "4000"))
before := f.read(profileDir + "/prefs.js")
u, err := f.userJS(true)
if err != nil {
t.Fatal(err)
}
if len(u.Each) != 1 || u.Each[0].How != adopted || u.Each[0].Keys != (keySet{"account1", "id1", "server1", "smtp1"}) {
t.Fatalf("adopted under the profile's keys: %+v", u.Each)
}
// The address's case and the user name differ; the default port declared explicitly is no change. Names
// only, no value.
if strings.Join(u.Each[0].Changed, ",") != "mail.identity.id1.useremail,mail.server.server1.userName" {
t.Fatalf("the changed preferences, by name: %v", u.Each[0].Changed)
}
if f.read(profileDir+"/prefs.js") != before || u.Lists != "unchanged" {
t.Fatalf("an adopted account's keys are in the lists already: nothing written to prefs.js (%s)", u.Lists)
}
lines := userPrefLines(t, f.read(profileDir+"/user.js"))
for name := range lines {
key := strings.Split(name, ".")[2]
if key != "account1" && key != "id1" && key != "server1" && key != "smtp1" {
t.Errorf("an adopted account's preference outside its keys: %s", name)
}
for _, never := range []string{"directory", ".type", "smtpServer", "identities", ".server"} {
if strings.HasSuffix(name, never) {
t.Errorf("the module wrote %s, which is Betterbird's or the account's own", name)
}
}
}
if !strings.Contains(lines["mail.server.server1.userName"], `"other2"`) {
t.Fatalf("the declared value wins at every start: %v", lines)
}
// The status says it by place and keys, never by address, host or name.
s, err := f.Status()
if err != nil {
t.Fatal(err)
}
raw, _ := json.Marshal(s.Accounts)
if s.Accounts.InList != 1 || s.Accounts.Others != 1 || strings.Contains(string(raw), "example") ||
strings.Contains(string(raw), `"mail"`) || !strings.Contains(string(raw), `"account1"`) {
t.Fatalf("%s", raw)
}
// Leaving the setting releases it: its lines and its record go, the account and the lists stay.
f.declareAccounts(`{"accounts": {}}`)
if err := f.Prepare(); err != nil {
t.Fatal(err)
}
if f.read(profileDir+"/prefs.js") != before || strings.Contains(f.read(profileDir+"/user.js"), "server1") ||
strings.Contains(f.read(testHome+"/"+recordFile), "account1") {
t.Fatal("a released account stays the person's, with no line or record of the module's")
}
}
func TestAnAmbiguousOwlsOrOtherKindOfMatchIsRefusedForThatAccountAlone(t *testing.T) {
f := newFake(t)
f.profileWithAccounts()
f.noPackage()
links(t, nil)
f.declare("// none")
os.RemoveAll(filepath.Join(f.Root, "proc", "4000"))
// A second account with account1's address.
prefs := f.read(profileDir + "/prefs.js")
prefs = strings.Replace(prefs, `"account1,account3,account2"`, `"account1,account3,account2,account4"`, 1) +
`user_pref("mail.account.account4.server", "server4");` + "\n" +
`user_pref("mail.account.account4.identities", "id4");` + "\n" +
`user_pref("mail.identity.id4.useremail", "other@mail.example.org");` + "\n" +
`user_pref("mail.identity.id4.smtpServer", "smtp4");` + "\n" +
`user_pref("mail.smtpserver.smtp4.hostname", "smtp.example.org");` + "\n" +
`user_pref("mail.server.server4.type", "imap");` + "\n"
f.write(profileDir+"/prefs.js", prefs)
both := strings.Replace(adoptable, `{"accounts": {`, `{"accounts": {
"zz": {"address": "new@mail.example.org",
"incoming": {"kind": "imap", "host": "imap.example.org", "security": "tls", "sign-in": "password"},
"outgoing": {"host": "smtp.example.org", "security": "tls", "sign-in": "password"}},`, 1)
f.declareAccounts(both)
err := f.Prepare()
if err == nil || !strings.Contains(err.Error(), "declared account 1 of 2: 2 accounts in the profile have its address (account1, account4)") ||
!strings.Contains(err.Error(), "key") || strings.Contains(err.Error(), "example.org") {
t.Fatalf("refused by place, with the matches' keys and the remedy, never the address: %v", err)
}
u, _ := f.userJS(false)
if u.Each[0].How != refused || u.Each[1].How != created {
t.Fatalf("the other declared account goes ahead: %+v", u.Each)
}
if strings.Contains(f.read(profileDir+"/user.js"), "server1") || strings.Contains(f.read(profileDir+"/user.js"), "server4") {
t.Fatal("a refused account gets no line")
}
// The node's layer names the key: that one is adopted.
f.declareAccounts(strings.Replace(both, `"address": "Other@mail.example.org",`, `"address": "Other@mail.example.org", "key": "account4",`, 1))
if err := f.Prepare(); err != nil {
t.Fatalf("%v", err)
}
if u, _ := f.userJS(false); u.Each[0].How != adopted || u.Each[0].Keys.Account != "account4" {
t.Fatalf("%+v", u.Each)
}
os.Remove(filepath.Join(f.Root, testHome, recordFile))
for key, want := range map[string]string{"account9": "not in the profile's account list", "account3": "Owl's",
"account2": "another address"} {
f.declareAccounts(strings.Replace(adoptable, `"address": "Other@mail.example.org",`, `"address": "Other@mail.example.org", "key": "`+key+`",`, 1))
if err := f.Prepare(); err == nil || !strings.Contains(err.Error(), want) {
t.Errorf("key %s: %v", key, err)
}
}
// Owl's account at the address, and an account of another kind than declared.
f.declareAccounts(`{"accounts": {"work": {"address": "work@corp.example.com",
"incoming": {"kind": "imap", "host": "outlook.example.com", "security": "tls", "sign-in": "oauth"},
"outgoing": {"host": "smtp.example.com", "security": "tls", "sign-in": "oauth"}}}}`)
if err := f.Prepare(); err == nil || !strings.Contains(err.Error(), "Owl's") {
t.Errorf("Owl's account is never adopted: %v", err)
}
f.write(profileDir+"/prefs.js", strings.Replace(prefs, `"mail.server.server4.type", "imap"`, `"mail.server.server4.type", "pop3"`, 1))
f.declareAccounts(strings.Replace(adoptable, `"address": "Other@mail.example.org",`, `"address": "Other@mail.example.org", "key": "account4",`, 1))
if err := f.Prepare(); err == nil || !strings.Contains(err.Error(), "never converts") {
t.Errorf("an account of another kind is refused, not converted: %v", err)
}
}
func TestDeclaredAccountsWithoutASignInAreCountedFromTheServerNamesAlone(t *testing.T) {
f := newFake(t)
f.profileWithAccounts()
links(t, nil)
f.declareAccounts(`{"accounts": {
"personal": {"address": "me@mail.example.org", "incoming": {"kind": "imap", "host": "imap.example.org", "security": "tls", "sign-in": "password"},
"outgoing": {"host": "smtp.example.org", "security": "tls", "sign-in": "password"}},
"google": {"address": "me@gmail.com", "incoming": {"kind": "imap", "host": "imap.gmail.com", "security": "tls", "sign-in": "oauth"},
"outgoing": {"host": "smtp.gmail.com", "security": "tls", "sign-in": "oauth"}},
"lan": {"address": "me@lan.example.org", "incoming": {"kind": "pop3", "host": "pop.example.org", "security": "tls", "sign-in": "kerberos"},
"outgoing": {"host": "smtp.example.org", "security": "tls", "sign-in": "none"}}}}`)
// The username and password fields are encrypted and never read; a fixture holding junk there proves it.
f.write(profileDir+"/logins.json", `{"logins": [
{"hostname": "imap://imap.example.org", "httpRealm": "imap://imap.example.org", "encryptedUsername": "MEIE...", "encryptedPassword": "MDoE..."},
{"hostname": "smtp://smtp.example.org", "encryptedUsername": "x", "encryptedPassword": "y"}]}`)
c := f.countAccounts(filepath.Join(f.Root, profileDir), "~/.thunderbird/60016jsp.default-default")
if c.Declared != 3 || c.WithoutSignIn != 1 || c.Others != 2 || c.InList != 0 {
t.Fatalf("three declared, the OAuth one not signed in, the person's and Owl's other (local folders not counted): %+v", c)
}
raw, _ := json.Marshal(c)
if strings.Contains(string(raw), "example") || strings.Contains(string(raw), "gmail") {
t.Fatalf("a count names nothing: %s", raw)
}
}
// ADR 0329 §1, §10, §12, §13: betterbird_accounts lists the accounts with their values, Owl's by name and address
// and marked; its propose carries the mesh-wide layer as it stands with accounts in it, and answers the value.
func TestBetterbirdAccountsListsTheValuesAndProposesThemInTheLayerAsItStands(t *testing.T) {
f := newFake(t)
f.profileWithAccounts()
f.write(profileDir+"/logins.json", `{"logins":[{"hostname":"imap://imap.example.org","encryptedUsername":"MDoEEPgAAAAA","encryptedPassword":"hunter2secret"}]}`)
links(t, nil)
a, err := f.ProfileAccounts(false)
if err != nil {
t.Fatal(err)
}
if a.Declarable["imap"] != 1 || a.KeptByOwl != 1 || a.NotDeclarable["local folders"] != 1 || a.Proposed != "" {
t.Fatalf("%+v", a)
}
raw, _ := json.Marshal(credentialChecked(a))
for _, shown := range []string{"other@mail.example.org", "imap.example.org", "smtp.example.org", `"port":993`, `"work@corp.example.com"`} {
if !strings.Contains(string(raw), shown) {
t.Errorf("the answer shows the operator's own %s (ADR 0329 §1): %s", shown, raw)
}
}
for _, never := range []string{"hunter2", "MDoEEP"} {
if strings.Contains(string(raw), never) {
t.Errorf("the answer carries the login store's %q", never)
}
}
for _, l := range a.Accounts {
if l.Key == "account3" && (!l.Owl || l.Value != nil || l.Address != "work@corp.example.com") {
t.Errorf("Owl's account is listed by name and address, marked, never as a value to declare: %+v", l)
}
}
var asked []map[string]any
asker = func(key string, body any) (json.RawMessage, error) {
if key != "seat:mesh-controller.settings" {
t.Fatalf("asked %s", key)
}
args := body.(map[string]any)
asked = append(asked, args)
if args["propose"] == nil {
return json.RawMessage(`{"ok": true, "output": "{\n \"tags\": {\"$label1\": {\"name\": \"Important\"}}\n}\n approved by the operator\nbetterbird on the whole mesh, every value"}`), nil
}
return json.RawMessage(`{"ok": true, "output": "proposed p-17 for betterbird on the whole mesh: accounts (other@mail.example.org); fingerprint ab12"}`), nil
}
t.Cleanup(func() { asker = nil })
a, err = f.ProfileAccounts(true)
if err != nil {
t.Fatal(err)
}
if len(asked) != 2 || asked[1]["module"] != "betterbird" || asked[1]["propose"] != "true" || asked[1]["node"] != nil ||
asked[1]["replace"] != nil {
t.Fatalf("the layer read, then proposed for the whole mesh, never set, never removing: %v", asked)
}
var layer map[string]json.RawMessage
if err := json.Unmarshal([]byte(asked[1]["values"].(string)), &layer); err != nil || string(layer["tags"]) == "" {
t.Fatalf("the proposal carries the layer as it stands: %v %s", err, asked[1]["values"])
}
value, err := parseAccounts([]byte(asked[1]["values"].(string)))
if err != nil {
t.Fatalf("the proposed value is one the module takes: %v", err)
}
other, ok := value["mail"]
if len(value) != 1 || !ok || other.Address != "other@mail.example.org" || other.Incoming.SignIn != "oauth" ||
other.Incoming.Port != 993 || other.Outgoing.Security != "tls" || other.Outgoing.Port != 465 {
t.Fatalf("the value holds the person's account and not Owl's: %+v", value)
}
if a.Derived["mail"].Address != "other@mail.example.org" {
t.Fatalf("the answer shows the value it proposed (ADR 0329 §10): %+v", a.Derived)
}
if strings.Contains(a.Proposed, "other@") || !strings.Contains(a.Proposed, "p-17") {
t.Fatalf("the controller's words come back with every address masked: %s", a.Proposed)
}
}
func TestAnAdoptedAccountNeverWritesIntoAnOutgoingServerAnotherIdentityUses(t *testing.T) {
f := newFake(t)
f.profileWithAccounts()
f.noPackage()
links(t, nil)
f.declare("// none")
os.RemoveAll(filepath.Join(f.Root, "proc", "4000"))
// A second account of the person's, not declared, sends through smtp1 too.
prefs := strings.Replace(f.read(profileDir+"/prefs.js"), `"account1,account3,account2"`, `"account1,account3,account2,account5"`, 1) +
`user_pref("mail.account.account5.server", "server5");` + "\n" +
`user_pref("mail.account.account5.identities", "id5");` + "\n" +
`user_pref("mail.identity.id5.useremail", "fifth@mail.example.org");` + "\n" +
`user_pref("mail.identity.id5.smtpServer", "smtp1");` + "\n" +
`user_pref("mail.server.server5.type", "imap");` + "\n"
f.write(profileDir+"/prefs.js", prefs)
f.declareAccounts(adoptable)
err := f.Prepare()
if err == nil || !strings.Contains(err.Error(), "also used by identity id5") || strings.Contains(err.Error(), "example.org") {
t.Fatalf("refused with the remedy, by keys: %v", err)
}
if user, _ := os.ReadFile(filepath.Join(f.Root, profileDir, "user.js")); strings.Contains(string(user), "smtpserver.smtp1") {
t.Fatal("the shared outgoing server was written")
}
// Recorded adoption, then the person moves the identity to a server of its own: the module follows it.
f.write(profileDir+"/prefs.js", strings.Replace(prefs, `"mail.identity.id5.smtpServer", "smtp1"`, `"mail.identity.id5.smtpServer", "smtp5"`, 1))
if err := f.Prepare(); err != nil {
t.Fatal(err)
}
moved := strings.Replace(f.read(profileDir+"/prefs.js"), `"mail.identity.id1.smtpServer", "smtp1"`, `"mail.identity.id1.smtpServer", "smtp9"`, 1) +
`user_pref("mail.smtpserver.smtp9.hostname", "smtp.example.org");` + "\n"
f.write(profileDir+"/prefs.js", moved)
u, err := f.userJS(true)
if err != nil || u.Each[0].How != adopted || u.Each[0].Keys.SMTP != "smtp9" {
t.Fatalf("the identity's current outgoing server is followed: %+v %v", u.Each, err)
}
user := f.read(profileDir + "/user.js")
if strings.Contains(user, "smtpserver.smtp1.") || !strings.Contains(user, "smtpserver.smtp9.hostname") {
t.Fatalf("never the old one:\n%s", user)
}
// And the move back onto a shared server is refused again.
f.write(profileDir+"/prefs.js", strings.Replace(moved, `"mail.identity.id5.smtpServer", "smtp5"`, `"mail.identity.id5.smtpServer", "smtp9"`, 1))
if err := f.Prepare(); err == nil || !strings.Contains(err.Error(), "also used by identity id5") {
t.Fatalf("%v", err)
}
}
func TestTheUserJSAnswerWithholdsTheAccountsValues(t *testing.T) {
f := newFake(t)
f.profileWithAccounts()
f.noPackage()
links(t, nil)
f.declare(`user_pref("mail.compose.default_to", "x");`)
f.declareAccounts(personal)
u, err := f.userJS(false)
if err != nil {
t.Fatal(err)
}
for _, never := range []string{"example.org", `"me"`, `"Me"`} {
if strings.Contains(u.Content, never) {
t.Errorf("the answer carries %s:\n%s", never, u.Content)
}
}
if !strings.Contains(u.Content, `user_pref("mail.server.server_mesh_personal.hostname", "<withheld>");`) {
t.Fatalf("an account's preference is named and its value withheld:\n%s", u.Content)
}
// What is placed in the profile keeps the values: only the answer withholds them.
if f.read(testHome+"/"+accountsFile) == "" {
t.Fatal("no declaration")
}
}
func TestACredentialRefusalNamesNoAccount(t *testing.T) {
_, err := parseAccounts([]byte(`{"accounts": {"personal": {"incoming": {"password": "hunter2"}}}}`))
if err == nil || strings.Contains(err.Error(), "personal") || !strings.Contains(err.Error(), "accounts.<an account>.incoming.password") {
t.Fatalf("%v", err)
}
}
func TestAnAccountsOwnAliasesAndNonMailIdentitiesDoNotShareItsOutgoingServer(t *testing.T) {
f := newFake(t)
f.profileWithAccounts()
f.noPackage()
links(t, nil)
f.declare("// none")
os.RemoveAll(filepath.Join(f.Root, "proc", "4000"))
// account1 has an alias identity on smtp1; smtp1 is the default server; Owl's account and local folders
// have identities that name no outgoing server.
prefs := strings.Replace(f.read(profileDir+"/prefs.js"), `"mail.account.account1.identities", "id1"`, `"mail.account.account1.identities", "id1,id1b"`, 1)
prefs = strings.Replace(prefs, `"mail.account.account2.server", "server2");`, `"mail.account.account2.server", "server2");`+"\n"+
`user_pref("mail.account.account2.identities", "id2");`, 1) +
`user_pref("mail.identity.id1b.useremail", "alias@mail.example.org");` + "\n" +
`user_pref("mail.identity.id1b.smtpServer", "smtp1");` + "\n" +
`user_pref("mail.identity.id2.useremail", "local@mail.example.org");` + "\n" +
`user_pref("mail.smtp.defaultserver", "smtp1");` + "\n"
f.write(profileDir+"/prefs.js", prefs)
f.declareAccounts(adoptable)
if err := f.Prepare(); err != nil {
t.Fatalf("an account's own alias, and identities that do not send by SMTP, are no sharing: %v", err)
}
// An identity of another mail account naming no server does use the default one.
f.write(profileDir+"/prefs.js", strings.Replace(prefs, `"account1,account3,account2"`, `"account1,account3,account2,account6"`, 1)+
`user_pref("mail.account.account6.server", "server6");`+"\n"+
`user_pref("mail.account.account6.identities", "id6");`+"\n"+
`user_pref("mail.identity.id6.useremail", "six@mail.example.org");`+"\n"+
`user_pref("mail.server.server6.type", "pop3");`+"\n")
os.Remove(filepath.Join(f.Root, testHome, recordFile))
if err := f.Prepare(); err == nil || !strings.Contains(err.Error(), "also used by identity id6") {
t.Fatalf("%v", err)
}
}
// Review 121: the account lists wait for Betterbird to close by its profile's lock too, not only by its process.
func TestTheAccountListsWaitWhileTheProfileIsLocked(t *testing.T) {
f := newFake(t)
f.profileWithAccounts()
f.noPackage()
links(t, nil)
f.declare("// none")
f.declareAccounts(personal)
stopBetterbird(t, f)
f.write(profileDir+"/lock", "")
before := f.read(profileDir + "/prefs.js")
u, err := f.userJS(true)
if err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(u.Lists, "not written") || !strings.Contains(u.Lists, "locked") || f.read(profileDir+"/prefs.js") != before {
t.Fatalf("with the profile locked and no Betterbird running, prefs.js is unchanged: %q", u.Lists)
}
os.Remove(filepath.Join(f.Root, profileDir, "lock"))
if u, err = f.userJS(true); err != nil || !strings.HasPrefix(u.Lists, "written") {
t.Fatalf("once unlocked the list is written: %q %v", u.Lists, err)
}
}
@@ -0,0 +1,331 @@
package main
// Which account of the profile a declared account is (novox/hq ADR 0317): adopted, created or refused.
//
// At every apply, per declared account in its place in the setting (§1):
//
// 1. an account the module recorded on this node for this profile, adopted or created, is kept by its
// recorded keys without matching again, while its account key is still in the profile's account list;
// 2. otherwise an account under the module's own mesh_<name> keys is the module's creation;
// 3. otherwise a node's layer may name the key to adopt (§8): adopted only when it is in the account list,
// its default identity has the declared address and it is not Owl's;
// 4. otherwise the profile's accounts whose default identity has the declared address: one is adopted, none
// means the account is created under mesh_<name> keys (ADR 0316 §11).
//
// Refused, with the remedy, for that account alone (§7): two or more accounts with the address, the one that
// has it being Owl's, a named key that does not qualify, an account of another kind than the declaration says
// (the module never converts one: the review of ADR 0317), and an identity that names no outgoing server. The
// other declared accounts go ahead.
//
// Adopting takes the account's keys as the module's (§2): its account key, its default identity, its incoming
// server and the outgoing server that identity names. The account lists are left as they are, because the keys
// are in them already; the declared fields are written under those keys (§4, §5). What was adopted or created
// is recorded in the module's directory in the home, by the tools and never by a setting (§3); leaving the
// setting releases an adopted account (its lines and its record go, the account stays the person's, §6).
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
)
// recordFile is where the module records, per profile, what it adopted or created (ADR 0317 §3).
const recordFile = ".config/betterbird/accounts-record.json"
// How a declared account resolved.
const (
adopted = "adopted"
created = "created"
refused = "refused"
)
// keySet are an account's four keys.
type keySet struct {
Account string `json:"account"`
Identity string `json:"identity"`
Server string `json:"server"`
SMTP string `json:"smtp"`
}
// resolvedAccount is one declared account as it resolved, said by its place and keys, never its name, address
// or host (ADR 0316 §16, ADR 0317 §3).
type resolvedAccount struct {
Place int `json:"place"`
How string `json:"how"`
Keys keySet `json:"keys,omitempty"`
// Why is a refusal's reason and its remedy.
Why string `json:"why,omitempty"`
// Changed are the preferences an apply changed for it, by name only (ADR 0317 §4).
Changed []string `json:"changed,omitempty"`
name string
}
// record is what the module adopted or created, per profile (its directory as the tools say it), per
// declared account's name.
type record map[string]map[string]recorded
type recorded struct {
How string `json:"how"`
Keys keySet `json:"keys"`
}
func (m *Machine) readRecord(profileDir string) map[string]recorded {
raw, err := readBounded(m.home(recordFile))
if err != nil {
return map[string]recorded{}
}
var r record
if json.Unmarshal(raw, &r) != nil || r[profileDir] == nil {
return map[string]recorded{}
}
return r[profileDir]
}
// writeRecord keeps what this apply resolved for the profile: a refused account is not recorded, and an
// account no longer declared is dropped (§6).
func (m *Machine) writeRecord(profileDir string, resolved []resolvedAccount) error {
r := record{}
if raw, err := readBounded(m.home(recordFile)); err == nil {
_ = json.Unmarshal(raw, &r)
}
mine := map[string]recorded{}
for _, a := range resolved {
if a.How != refused {
mine[a.name] = recorded{How: a.How, Keys: a.Keys}
}
}
if len(mine) == 0 {
delete(r, profileDir)
} else {
r[profileDir] = mine
}
body, err := json.MarshalIndent(r, "", " ")
if err != nil {
return err
}
path := m.home(recordFile)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
tmp := path + ".mesh-new"
if err := os.WriteFile(tmp, append(body, '\n'), 0o600); err != nil {
return err
}
if err := os.Rename(tmp, path); err != nil {
os.Remove(tmp)
return err
}
return nil
}
// resolveAccounts resolves every declared account, in its place in the setting (the file's order, by name).
func resolveAccounts(accounts map[string]Account, prefs map[string]string, rec map[string]recorded) []resolvedAccount {
have := profileAccounts(prefs)
inList := map[string]profileAccount{}
for _, a := range have {
inList[a.Key] = a
}
names := sortedNames(accounts)
taken := map[string]bool{}
var out []resolvedAccount
for i, name := range names {
r := resolveOne(i+1, len(names), name, accounts[name], prefs, have, inList, rec, taken)
r.name = name
if r.How != refused {
taken[r.Keys.Account] = true
}
out = append(out, r)
}
return refuseSharedOutgoing(out, prefs, have)
}
// refuseSharedOutgoing refuses an adopted account whose outgoing server another identity uses that is neither
// adopted nor the module's (review 34 of mesh-catalog #232): Betterbird lets identities share an outgoing
// server, and the declared values written into it would change that other account's sending at every start.
// ADR 0317 §2 adopts the outgoing server the identity names and leaves the lists as they are, so the module
// does not give the identity a server of its own; it refuses, with the remedy.
func refuseSharedOutgoing(resolved []resolvedAccount, prefs map[string]string, have []profileAccount) []resolvedAccount {
// Every identity of an adopted or created account is its own, its aliases too (review 36).
accountOf := map[string]profileAccount{}
kindOf := map[string]string{}
for _, a := range have {
accountOf[a.Key] = a
for _, id := range a.Identities {
kindOf[id] = a.Kind
}
}
ours := map[string]bool{}
for _, r := range resolved {
if r.How == refused {
continue
}
ours[r.Keys.Identity] = true
for _, id := range accountOf[r.Keys.Account].Identities {
ours[id] = true
}
}
defaultServer := unquote(prefs["mail.smtp.defaultserver"])
users := map[string][]string{}
seen := map[string]bool{}
use := func(id string) {
// An identity the module created for a declared account sends as that account does (ADR 0329 §7).
if seen[id] || ours[id] || ownIdentity.MatchString(id) {
return
}
seen[id] = true
server := unquote(prefs["mail.identity."+id+".smtpServer"])
// Only a mail account's identity sends through the default server when it names none: Owl's, local
// folders and feeds do not send by SMTP (review 36).
if server == "" && (kindOf[id] == "imap" || kindOf[id] == "pop3") {
server = defaultServer
}
if server != "" {
users[server] = append(users[server], id)
}
}
for _, a := range have {
for _, id := range a.Identities {
use(id)
}
}
for k := range prefs {
if strings.HasPrefix(k, "mail.identity.") && strings.HasSuffix(k, ".smtpServer") {
use(strings.TrimSuffix(strings.TrimPrefix(k, "mail.identity."), ".smtpServer"))
}
}
for i, r := range resolved {
if r.How != adopted {
continue
}
if others := users[r.Keys.SMTP]; len(others) > 0 {
sort.Strings(others)
resolved[i] = resolvedAccount{Place: r.Place, How: refused, name: r.name, Why: fmt.Sprintf(
"declared account %d of %d: the outgoing server %s of the profile's account %s is also used by "+
"identity %s, whose sending the declared values would change: give the account an outgoing "+
"server of its own in Betterbird's account settings, then start Betterbird again",
r.Place, len(resolved), r.Keys.SMTP, r.Keys.Account, strings.Join(others, ", "))}
}
}
return resolved
}
func resolveOne(place, of int, name string, a Account, prefs map[string]string, have []profileAccount,
inList map[string]profileAccount, rec map[string]recorded, taken map[string]bool) resolvedAccount {
r := resolvedAccount{Place: place}
refuse := func(format string, args ...any) resolvedAccount {
r.How, r.Keys = refused, keySet{}
r.Why = fmt.Sprintf("declared account %d of %d: ", place, of) + fmt.Sprintf(format, args...)
return r
}
// 1. Recorded, while its account is still in the list.
if was, ok := rec[name]; ok {
if _, there := inList[was.Keys.Account]; there || was.How == created {
r.How, r.Keys = was.How, was.Keys
if was.How == adopted {
// The identity's outgoing server as it is now: a person may have moved it since.
smtpKey := unquote(prefs["mail.identity."+was.Keys.Identity+".smtpServer"])
if smtpKey == "" {
return refuse("the profile's account %s has an identity that names no outgoing server: choose one "+
"for it in Betterbird's account settings, then start Betterbird again", was.Keys.Account)
}
r.Keys.SMTP = smtpKey
}
return r
}
}
// 2. Under the module's own keys: its creation.
account, identity, server, smtp := keysOf(name)
own := keySet{account, identity, server, smtp}
if _, there := inList[account]; there {
r.How, r.Keys = created, own
return r
}
adopt := func(pa profileAccount) resolvedAccount {
if pa.Kind != a.Incoming.Kind {
return refuse("the profile's account %s is of kind %s and the declaration says %s: the module never "+
"converts an account. Declare the kind the account has, or remove the account in Betterbird",
pa.Key, pa.Kind, a.Incoming.Kind)
}
smtpKey := unquote(prefs["mail.identity."+pa.Identities[0]+".smtpServer"])
if smtpKey == "" {
return refuse("the profile's account %s has an identity that names no outgoing server: choose one for it "+
"in Betterbird's account settings, then start Betterbird again", pa.Key)
}
r.How, r.Keys = adopted, keySet{pa.Key, pa.Identities[0], pa.Server, smtpKey}
return r
}
matchesDefault := func(pa profileAccount) bool {
return len(pa.Addresses) > 0 && strings.EqualFold(pa.Addresses[0], a.Address)
}
// 3. The key a node's layer names.
if a.Key != "" {
pa, there := inList[a.Key]
switch {
case !there:
return refuse("the key %s this node's layer names is not in the profile's account list", a.Key)
case pa.Owl:
return refuse("the key %s this node's layer names is Owl's account, which is never declared "+
"(novox/hq ADR 0316 §13)", a.Key)
case !matchesDefault(pa):
return refuse("the key %s this node's layer names is an account whose default identity has another "+
"address", a.Key)
case taken[pa.Key]:
return refuse("the key %s is already another declared account's", a.Key)
}
return adopt(pa)
}
// 4. By the default identity's address.
var matches []profileAccount
for _, pa := range have {
if !pa.Ours && !taken[pa.Key] && matchesDefault(pa) {
matches = append(matches, pa)
}
}
switch {
case len(matches) == 0:
r.How, r.Keys = created, own
return r
case len(matches) > 1:
var keys []string
for _, pa := range matches {
keys = append(keys, pa.Key)
}
return refuse("%d accounts in the profile have its address (%s): remove the duplicate in Betterbird, or "+
"give the one to adopt as key in this node's layer of the setting accounts (novox/hq ADR 0317 §7, §8)",
len(matches), strings.Join(keys, ", "))
case matches[0].Owl:
return refuse("the profile's account with its address, %s, is Owl's, which is never declared (novox/hq "+
"ADR 0316 §13): take it out of the setting accounts", matches[0].Key)
}
return adopt(matches[0])
}
// changedPrefs are the preferences of a resolved account whose declared value differs from the profile's
// saved one (prefs.js), by name only (ADR 0317 §4). A preference prefs.js does not hold is changed unless the
// declared value is what Betterbird reads in its place.
func changedPrefs(r resolvedAccount, a Account, saved map[string]string) []string {
defaults := map[string]string{}
if r.How == adopted {
// What Betterbird reads when prefs.js holds no value: the same defaults betterbird_accounts reads with.
pa := profileAccount{Key: r.Keys.Account, Server: r.Keys.Server, Kind: a.Incoming.Kind,
Identities: []string{r.Keys.Identity}}
view := readAccount(saved, pa)
for _, p := range declaredPrefs(r.Keys, view, true) {
defaults[p[0]] = p[1]
}
}
var out []string
for _, p := range declaredPrefs(r.Keys, a, r.How == adopted) {
have, ok := saved[p[0]]
if !ok {
have, ok = defaults[p[0]]
}
if !ok || have != p[1] {
out = append(out, p[0])
}
}
return out
}
@@ -9,20 +9,24 @@ package main
// preferences asked for are answered and never one that can hold a credential or names an account
// (passwordSecret, accountPrefix);
// - the profile's extensions.json, for the add-ons' ids and whether each is on;
// - the module's declared user.js (~/.config/betterbird/user.js), which the node-engine writes from
// the module's setting `prefs`.
// - the module's settings as the node-engine writes them: prefs (~/.config/betterbird/prefs.json, prefs.go),
// accounts (~/.config/betterbird/accounts.json, accounts.go), and identities, calendars, address-books,
// filters and tags (configuration.json and tags.json, configuration.go);
// - of logins.json, only the server name each kept sign-in is for, which Betterbird keeps in the clear,
// to count the declared accounts that have not signed in yet (novox/hq ADR 0316 §16).
//
// Never read: the mail store, the address books, logins.json, key4.db, cert9.db, cookies, the
// calendar's caches. Nothing of the operator's mail, calendar or accounts is answered.
// Never read: the mail store, the address books' contacts, a user name or password in logins.json, key4.db,
// cert9.db, cookies, the calendar's events and caches. The operator's own configuration is answered with its
// values (novox/hq ADR 0329 §1), never a credential (§2).
//
// The one file a tool writes in the profile is user.js, and only the module's own: the declared file,
// copied whole (betterbird_user_js with apply, betterbird_restart). A user.js that is not the module's
// is never overwritten.
// The files a tool writes in the profile are user.js, and only the module's own (betterbird_user_js with apply,
// betterbird_restart, and the start from the launcher); and, only while Betterbird is not running, the module's
// own keys in the lists of prefs.js, the removals of what it created, and each declared account's
// msgFilterRules.dat (accounts.go, owned.go). A user.js that is not the module's is never overwritten.
import (
"bufio"
"bytes"
"encoding/json"
"errors"
"fmt"
"os"
@@ -35,16 +39,18 @@ import (
)
const (
mainComm = "betterbird-bin"
startBin = "/usr/bin/betterbird"
mainComm = "betterbird-bin"
// startBin is Betterbird itself, where the mesh's release and the community package both put it. The
// module's start (/usr/local/bin/betterbird) runs the preparation first; a tool that starts Betterbird
// prepares itself and starts this.
startBin = installDir + "/betterbird"
// packageFor is the community package the mesh's release replaces (novox/hq ADR 0316 §4).
packageFor = "betterbird-bin"
appIni = "/opt/betterbird/application.ini"
restartAs = "mesh-betterbird"
testUnit = "mesh-betterbird-reminder-test"
// profilesDir is where Betterbird keeps its profiles: Thunderbird's place, which Betterbird shares.
profilesDir = ".thunderbird"
// declared is the module's user.js, written by the node-engine from the setting prefs.
declared = ".config/betterbird/user.js"
// mark is the first line of the module's user.js: how a user.js is known to be the module's.
mark = "// Betterbird's user.js (module betterbird"
)
@@ -255,13 +261,22 @@ func readPrefs(raw []byte) map[string]string {
var (
passwordSecret = regexp.MustCompile(`(?i)(password|passwd|secret|token|oauth|auth|cookie|credential|signon|apikey|api_key|privatekey|private_key|\.key$)`)
accountPrefix = []string{"mail.server.", "mail.identity.", "mail.smtpserver.", "mail.smtp.", "mail.account.",
"mail.accountmanager.", "ldap_2.", "extensions.owl.", "calendar.registry.", "mail.root.", "mailnews.",
"mail.accountmanager.", "ldap_2.", "extensions.owl.", "calendar.registry.", "mail.root.",
"services.sync.", "mail.displayname", "mail.default_", "mail.last_msg", "messenger.account.", "mail.cloud_files."}
// allowedExtensions are the only extensions.* preferences answered: the add-ons' own preferences can
// hold anything, and a theme's id is what a window's look depends on.
allowedExtensions = map[string]bool{"extensions.activeThemeID": true}
// mailnewsAccount is a mailnews.* name that holds a server, an account, an identity or the operator's own
// tags: only those are withheld of mailnews.*, so a setting such as mailnews.start_page.enabled or
// mailnews.default_view_flags is answered (novox/hq issue 345).
mailnewsAccount = regexp.MustCompile(`(?i)^mailnews\..*(server|account|ident|smtp|ldap|imap|pop3|nntp|tags\.|email|addr)`)
)
// accountOwned are the preferences of an account and of the account lists: the setting accounts' alone, so
// the setting prefs refuses them and an account has one owner (novox/hq ADR 0316 §11).
var accountOwned = []string{"mail.account.", "mail.identity.", "mail.server.", "mail.smtpserver.",
"mail.accountmanager.", "mail.smtpservers", "mail.smtp.defaultserver"}
// withheld says why a preference's value is never answered, or "" when it may be.
func withheld(name string) string {
if strings.HasPrefix(name, "extensions.") && !allowedExtensions[name] {
@@ -275,16 +290,24 @@ func withheld(name string) string {
return "it names an account, a server, a calendar or a person"
}
}
if mailnewsAccount.MatchString(name) {
return "it names an account, a server, a calendar or a person"
}
return ""
}
// Pref is one preference as the profile holds it.
type Pref struct {
Name string `json:"name"`
// Value is what Betterbird uses: user.js's at the start, else prefs.js's. Absent: the default.
Value string `json:"value,omitempty"`
From string `json:"from,omitempty"`
Withheld string `json:"withheld,omitempty"`
// Value is what Betterbird uses: the policy's lock, else user.js's at the start, else prefs.js's, else the
// default, when a defaults file gives it.
Value string `json:"value,omitempty"`
From string `json:"from,omitempty"`
// Default is the default Betterbird starts with, and the file that gives it: Betterbird's own (omni.ja), or
// the module's (novox/hq issue 345). Absent for a preference compiled into Betterbird.
Default string `json:"default,omitempty"`
DefaultFrom string `json:"default_from,omitempty"`
Withheld string `json:"withheld,omitempty"`
}
// mostPrefs bounds how many preferences one answer carries.
@@ -306,7 +329,9 @@ func (m *Machine) Prefs(names []string) (map[string]any, error) {
}
prefsJS, _ := readBounded(filepath.Join(p.path, "prefs.js"))
userJS, _ := readBounded(filepath.Join(p.path, "user.js"))
fromPrefs, fromUser := readPrefs(prefsJS), readPrefs(userJS)
fromPrefs, fromUser := readPrefs(prefsJS), statementsOf(userJS)
own, ownErr := m.defaults(true)
locked := m.locks()
all := map[string]bool{}
for n := range fromPrefs {
all[n] = true
@@ -341,25 +366,40 @@ func (m *Machine) Prefs(names []string) (map[string]any, error) {
break
}
pr := Pref{Name: n}
d, hasDefault := own[n]
v, from := fromPrefs[n], "prefs.js"
if u, ok := fromUser[n]; ok {
if l, ok := locked[n]; ok {
v, from = l, "locked by the module's policy"
} else if u, ok := fromUser[n]; ok {
v, from = u, "user.js (wins at the start)"
} else if _, ok := fromPrefs[n]; !ok {
from = "not set: Betterbird's default"
v = ""
switch {
case hasDefault:
v, from = d.Value, "not set: "+d.From
default:
v, from = "", "not set: Betterbird's built-in default (in no defaults file)"
}
}
pr.From = from
if hasDefault {
pr.DefaultFrom = d.From
}
if why := withheld(n); why != "" {
pr.Withheld = why
} else if allowedExtensions[n] {
pr.Value = v
pr.Default = d.Value
} else {
pr.Value = m.maskValue(v)
pr.Default = m.maskValue(d.Value)
}
out = append(out, pr)
}
}
answer := map[string]any{"profile": p, "prefs": out}
if ownErr != nil {
answer["defaults_problem"] = ownErr.Error()
}
if cut {
answer["cut"] = fmt.Sprintf("the first %d; ask for a narrower prefix", mostPrefs)
}
@@ -392,6 +432,18 @@ func validUserJS(text string) error {
if passwordSecret.MatchString(name) {
return fmt.Errorf("line %d sets %s, a preference that can hold a credential: not through the mesh", i+1, name)
}
for _, p := range accountOwned {
if strings.HasPrefix(name, p) {
return fmt.Errorf("line %d sets %s, an account's preference: an account is declared in the setting "+
"accounts, never in prefs (novox/hq ADR 0316 §11)", i+1, name)
}
}
for _, p := range configurationOwned {
if strings.HasPrefix(name, p) {
return fmt.Errorf("line %d sets %s, which the setting calendars, address-books or tags owns, never "+
"prefs (novox/hq ADR 0329 §6)", i+1, name)
}
}
}
}
return nil
@@ -433,25 +485,78 @@ type UserJS struct {
Effective string `json:"effective,omitempty"`
Content string `json:"content,omitempty"`
Applied bool `json:"applied,omitempty"`
// Accounts are how many accounts the setting accounts declares, and what is wrong with them.
Accounts int `json:"declared_accounts"`
AccountsProblem string `json:"accounts_problem,omitempty"`
// Each is every declared account by its place: adopted, created or refused (with why), its keys, and the
// preferences an apply changed for it, by name (novox/hq ADR 0317 §3, §4).
Each []resolvedAccount `json:"accounts,omitempty"`
// Lists says whether the keys of the accounts the module created were written into the account lists.
Lists string `json:"account_lists,omitempty"`
// Configuration is every declared identity, calendar, address book and tag by its name: adopted, created or
// refused (with why), and its key (novox/hq ADR 0329 §7).
Configuration []resolvedEntry `json:"configuration,omitempty"`
ConfigurationProblem string `json:"configuration_problem,omitempty"`
// Filters are the declared accounts' filter files.
Filters []filterPlan `json:"filters,omitempty"`
// Closed says what was written of what Betterbird rewrites itself, or why it waits (§9).
Closed string `json:"written_while_closed,omitempty"`
// Waiting counts, per kind, the changes that wait for Betterbird to close.
Waiting map[string]int `json:"waiting_for_betterbird_to_close,omitempty"`
}
// userJS compares the declared user.js with the profile's; apply places the declared one when the
// profile has none or the module's.
// refusedAccounts are the reasons of the declared accounts that were refused.
func (u UserJS) refusedAccounts() []string {
var out []string
for _, r := range u.Each {
if r.How == refused {
out = append(out, r.Why)
}
}
return out
}
// userJS compares the user.js the module places — the declared file, then the declared accounts' fields under
// the keys each was adopted or created under (adoption.go) — with the profile's; apply places it when the
// profile has none or the module's, records what was adopted or created, and writes the keys of the accounts
// it created into the profile's account lists when Betterbird is not running. A refused account gets nothing;
// the others go ahead (novox/hq ADR 0317 §7).
func (m *Machine) userJS(apply bool) (UserJS, error) {
a := UserJS{Declared: "~/" + declared}
want, err := readBounded(m.home(declared))
a.Present = err == nil
a := UserJS{Declared: "~/" + prefsFile}
declaredText, present, derr := m.declaredText()
a.Present = present
if derr != nil {
a.Present, a.Valid = true, derr.Error()
}
p, perr := m.profile()
if perr != nil {
return a, perr
}
target := filepath.Join(p.path, "user.js")
a.Profile = m.tilde(strings.TrimPrefix(target, m.Root))
if a.Present {
if verr := validUserJS(string(want)); verr != nil {
accounts, _, aerr := m.readAccounts()
merged := mergedPrefs(p.path)
if aerr != nil {
a.AccountsProblem = aerr.Error()
accounts = nil
} else {
a.Accounts = len(accounts)
a.Each = resolveAccounts(accounts, merged, m.readRecord(p.Dir))
}
conf, cerr := m.readConfiguration()
if cerr != nil {
a.ConfigurationProblem = cerr.Error()
}
plan := m.planConfiguration(p, conf, accounts, a.Each, merged)
a.Configuration, a.Filters = plan.Entries, plan.filters
a.Waiting = m.waiting(p.path, plan, a.Each)
var want []byte
if a.Present && a.Valid == "" {
if verr := validUserJS(declaredText); verr != nil {
a.Valid = verr.Error()
}
a.Content = string(want)
want = []byte(composeUserJS(declaredText, accounts, a.Each) + plan.configBlock())
a.Content = m.answeredUserJS(string(want))
for _, line := range strings.Split(string(want), "\n") {
for _, st := range splitStatements(strings.TrimSpace(line)) {
if statement.MatchString(st) {
@@ -474,9 +579,22 @@ func (m *Machine) userJS(apply bool) (UserJS, error) {
if apply {
switch {
case !a.Present:
return a, fmt.Errorf("the module's user.js is not on this machine (~/%s): the module is not pushed here yet", declared)
return a, fmt.Errorf("the setting prefs is not on this machine (~/%s): the module is not pushed here yet", prefsFile)
case a.Valid != "":
return a, fmt.Errorf("the module's user.js is refused: %s; change the setting prefs", a.Valid)
case a.AccountsProblem != "":
return a, fmt.Errorf("nothing was placed: %s", a.AccountsProblem)
case a.ConfigurationProblem != "":
return a, fmt.Errorf("nothing was placed: %s", a.ConfigurationProblem)
}
saved, _ := readBounded(filepath.Join(p.path, "prefs.js"))
savedPrefs := readPrefs(saved)
for i, r := range a.Each {
if r.How != refused {
a.Each[i].Changed = changedPrefs(r, accounts[r.name], savedPrefs)
}
}
switch {
case herr == nil && !bytes.HasPrefix(have, []byte(mark)):
return a, fmt.Errorf("%s is not the module's: move it aside (its preferences belong in the setting prefs), then apply again", a.Profile)
}
@@ -491,6 +609,24 @@ func (m *Machine) userJS(apply bool) (UserJS, error) {
}
}
a.Applied, a.Placed = true, "the module's, as declared"
if err := m.writeRecord(p.Dir, a.Each); err != nil {
return a, fmt.Errorf("the user.js is placed, and what was adopted or created could not be recorded: %v", err)
}
if lists, err := m.writeAccountLists(p.path, a.Each); err != nil {
a.Lists = "not written: " + err.Error()
} else {
a.Lists = lists
}
if closed, err := m.writeClosed(p, &plan); err != nil {
a.Closed = "not written: " + err.Error()
} else {
a.Closed = closed
}
a.Filters = plan.filters
if err := m.writeConfigRecord(p.Dir, plan.record); err != nil {
return a, fmt.Errorf("the user.js is placed, and what was adopted or created could not be recorded: %v", err)
}
a.Waiting = m.waiting(p.path, plan, a.Each)
}
if ps := m.mainProcs(); len(ps) > 0 {
if st, err := os.Stat(target); err == nil {
@@ -504,27 +640,88 @@ func (m *Machine) userJS(apply bool) (UserJS, error) {
return a, nil
}
// answeredUserJS is the user.js as a tool answers it (novox/hq ADR 0316 §16, ADR 0317 §9): an account's
// preferences with their values withheld, every other value masked as betterbird_prefs masks it.
func (m *Machine) answeredUserJS(text string) string {
lines := strings.Split(text, "\n")
for i, line := range lines {
for _, st := range splitStatements(strings.TrimSpace(line)) {
mm := prefLine.FindStringSubmatch(st)
if mm == nil {
continue
}
value := m.maskValue(mm[2])
if why := withheld(mm[1]); why != "" {
value = `"<withheld>"`
}
lines[i] = fmt.Sprintf("user_pref(%s, %s);", quoted(mm[1]), value)
}
}
return strings.Join(lines, "\n")
}
// Prepare is what the module's start runs before Betterbird (`betterbird-tools prepare`): with no Betterbird
// of this account running, the user.js placed and the declared accounts written into the account lists. With
// one running it does nothing, since the start then only hands over to it. Its error is said by the start,
// which starts Betterbird either way.
func (m *Machine) Prepare() error {
if len(m.mainProcs()) > 0 {
return nil
}
u, err := m.userJS(true)
if err != nil {
return err
}
var problems []string
if strings.HasPrefix(u.Lists, "not written") {
problems = append(problems, "the account lists were "+u.Lists)
}
problems = append(problems, u.refusedAccounts()...)
if strings.HasPrefix(u.Closed, "not written") {
problems = append(problems, "what Betterbird rewrites itself was "+u.Closed)
}
// Said by kind, name and key only: the start's words reach the journal, which holds no address (ADR 0329 §1).
for _, e := range u.Configuration {
if e.How == refused {
problems = append(problems, fmt.Sprintf("%s %s is refused: %s", e.Kind, e.Name, e.Why))
}
}
for _, fp := range u.Filters {
if fp.Refused != "" {
problems = append(problems, fmt.Sprintf("the filters of %s are refused: %s", fp.Account, fp.Refused))
}
}
if len(problems) > 0 {
return fmt.Errorf("%s", strings.Join(problems, "; "))
}
return nil
}
// Status is what betterbird_status answers.
type Status struct {
Running []Proc `json:"running"`
Children int `json:"children"`
Installed string `json:"installed,omitempty"`
Running []Proc `json:"running"`
Children int `json:"children"`
// Installation is what is installed and from where, the policy and Owl (novox/hq ADR 0316 §16).
Installation Installation `json:"installation"`
// Installed is the community package's version, while it is still there.
Installed string `json:"community_package,omitempty"`
Foreign bool `json:"from_the_aur,omitempty"`
Version string `json:"version,omitempty"`
Build string `json:"build,omitempty"`
Profile *Profile `json:"profile,omitempty"`
Problem string `json:"profile_problem,omitempty"`
Output []Stream `json:"output,omitempty"`
UserJS *UserJS `json:"user_js,omitempty"`
AddOns []AddOn `json:"add_ons,omitempty"`
// Accounts are counts: the declared, those in the profile's list, the others, those without a sign-in.
Accounts *AccountCount `json:"accounts,omitempty"`
AddOns []AddOn `json:"add_ons,omitempty"`
// Display are the preferences that decide how its windows and notifications look.
Display []Pref `json:"display_prefs,omitempty"`
}
// AddOn is one add-on the operator installed: its id, and whether it is on.
type AddOn struct {
ID string `json:"id"`
Active bool `json:"active"`
ID string `json:"id"`
Version string `json:"version,omitempty"`
Active bool `json:"active"`
}
// displayPrefs are the preferences betterbird_status answers: the reminder dialog, the theme, the
@@ -550,39 +747,24 @@ func (m *Machine) Status() (Status, error) {
if v, err := m.installed(packageFor); err == nil && v != "" {
s.Installed, s.Foreign = v, m.foreign(packageFor)
}
if raw, err := readBounded(m.path(appIni)); err == nil {
_, sec := iniSections(raw)
s.Version, s.Build = sec["App"]["Version"], sec["App"]["BuildID"]
}
if len(s.Running) > 0 {
s.Output = m.streams(s.Running[0].PID)
}
p, err := m.profile()
if err != nil {
s.Installation = m.installation("")
s.Problem = err.Error()
return s, nil
}
s.Profile = &p
s.Installation = m.installation(p.path)
if u, err := m.userJS(false); err == nil {
u.Content = ""
s.UserJS = &u
}
if raw, err := readBounded(filepath.Join(p.path, "extensions.json")); err == nil {
var ext struct {
Addons []struct {
ID string `json:"id"`
Active bool `json:"active"`
Location string `json:"location"`
} `json:"addons"`
}
if json.Unmarshal(raw, &ext) == nil {
for _, a := range ext.Addons {
if a.Location == "app-profile" {
s.AddOns = append(s.AddOns, AddOn{a.ID, a.Active})
}
}
}
}
c := m.countAccounts(p.path, p.Dir)
s.Accounts = &c
s.AddOns = addOns(p.path)
if got, err := m.Prefs(displayPrefs); err == nil {
s.Display = got["prefs"].([]Pref)
}
@@ -711,7 +893,8 @@ func (m *Machine) Restart(discard bool) (RestartAnswer, error) {
if u, err := m.userJS(true); err != nil {
a.UserJS = "not placed: " + err.Error()
} else {
a.UserJS = fmt.Sprintf("placed: %d preference(s) from ~/%s", u.Statements, declared)
a.UserJS = fmt.Sprintf("placed: %d preference(s) from ~/%s, %d declared account(s); account lists %s",
u.Statements, prefsFile, u.Accounts, u.Lists)
}
// As detach does, with shorter bounds: the worst case of the whole call (3 s for the window tree,
// 8 s to quit, 3 + 5 s to start, 4 s to see it) stays under the runtime's 30 s.
@@ -746,16 +929,107 @@ type CheckAnswer struct {
}
// Check verifies what the module promises: Betterbird installed, one running in the session, its
// profile found, the module's user.js declared, valid, placed and in effect, and its output read.
// profile found, the module's user.js declared, valid, placed and in effect, its output read, and no ask at its
// start to be the default mail client (novox/hq issue 345).
func (m *Machine) Check() (CheckAnswer, error) {
a := CheckAnswer{Findings: []Finding{}}
add := func(what, do string) { a.Findings = append(a.Findings, Finding{what, do}) }
v, err := m.installed(packageFor)
if err != nil {
if _, err := m.installed(packageFor); err != nil {
return a, err
}
if v == "" {
add("Betterbird ("+packageFor+") is not installed", "install it from the AUR: the node-engine installs packages from the official repositories only, so the module does not")
p, perr := m.profile()
profilePath := ""
if perr == nil {
profilePath = p.path
}
in := m.installation(profilePath)
switch {
case in.From == "not installed":
add("Betterbird is not installed in "+installDir, "the module's push places the mesh's release; it waits for the operator's push (upgrade policy record)")
case in.From != "the mesh":
add("Betterbird is "+in.From, "quit Betterbird, then push the module to this node: the push removes the community package and places the mesh's release")
}
if in.Behind {
add("Betterbird "+in.Version+" (build "+in.Build+") is behind the newest published build "+in.Newest,
"move the module's pin in a reviewed change (novox/hq ADR 0316 §5)")
}
if in.From == "the mesh" && !strings.HasPrefix(in.Policy, "placed:") {
add("the enterprise policy is "+in.Policy, "push the module to this node")
}
if in.From == "the mesh" && !exists(m.path(meshDefaults)) {
add("the module's defaults are not placed ("+meshDefaults+")", "push the module to this node")
}
if c, _, err := m.config(); err == nil {
var cleared []string
for _, cp := range c.Prefs {
switch {
case cp.Drift == "":
case cp.Remedy != "":
add(cp.Name+" drifts from the module's: "+cp.Drift, cp.Remedy)
default:
cleared = append(cleared, cp.Name)
}
}
if len(cleared) > 0 {
add(fmt.Sprintf("%d managed preference(s) drift from the module's defaults: the profile saved its own value and no "+
"node layer says so (%s)", len(cleared), strings.Join(cleared, ", ")),
"betterbird_config shows each; to keep a value on this node, betterbird_config with keep proposes a node "+
"layer; otherwise quit Betterbird and call betterbird_config_apply with apply")
}
if len(c.Refused) > 0 {
add(fmt.Sprintf("the module's defaults list %d preference(s) the tools never manage (%s)", len(c.Refused), strings.Join(c.Refused, "; ")),
"take them out of install/defaults-pref/mesh-defaults.js in a reviewed catalogue change: an account is declared in the setting accounts")
}
}
if why := m.defaultClientAsk(profilePath); why != "" && in.From != "not installed" {
add("Betterbird asks at its start to be the default mail client: "+why,
"push the module to this node: its policy locks "+defaultClientPref+" off (novox/hq issue 345)")
}
switch {
case in.From != "the mesh":
case in.Owl == nil || in.Owl.Pinned == "":
add("Owl's xpi is not placed ("+owlFile+")", "push the module to this node")
case in.Owl.InProfile == "":
add("Owl is not installed in the profile yet", "Betterbird installs it from the policy at its next start")
case !in.Owl.AtThePin && !newerVersion(in.Owl.InProfile, in.Owl.Pinned):
add("Owl "+in.Owl.InProfile+" runs in the profile, older than the mesh's pin "+in.Owl.Pinned,
"Owl is installed by policy and cannot be removed in Betterbird: move the module's Owl pin in a reviewed change (novox/hq ADR 0316 §6)")
case !in.Owl.Active:
add("Owl is installed and not active", "turn it on in Betterbird's add-ons")
}
if perr == nil {
c := m.countAccounts(p.path, p.Dir)
for _, r := range c.Each {
if r.How == refused {
add("refused: "+r.Why, "as it says; the other declared accounts go ahead")
}
}
switch {
case c.InList < c.Declared:
add(fmt.Sprintf("%d of %d declared account(s) are not in the profile's account list yet", c.Declared-c.InList, c.Declared),
"quit Betterbird and start it from the launcher, or betterbird_restart")
}
if c.WithoutSignIn > 0 {
add(fmt.Sprintf("%d declared account(s) have not signed in yet", c.WithoutSignIn),
"a person signs in once, in Betterbird's own window, when it asks (novox/hq ADR 0316 §14)")
}
// The five kinds of ADR 0329: drift and what waits for Betterbird to close, per kind (§9, §11, §12).
if _, err := m.readConfiguration(); err != nil {
add(err.Error(), "change the setting at the controller's terminal, or derive and propose it (betterbird_derive)")
} else if v, err := m.view(); err == nil {
drift := m.driftCounts(v)
waiting := m.waiting(v.p.path, v.plan, v.ra)
for _, k := range allKinds {
if n := drift[k]; n > 0 {
add(fmt.Sprintf("%d declared %s differ from the profile, are not in it yet or are refused", n, k),
"betterbird_drift with kind "+k+" says which and how; the module's user.js holds them from Betterbird's next start (betterbird_restart)")
}
if n := waiting[k]; n > 0 {
add(fmt.Sprintf("%d change(s) to %s are not written yet: they are written only while Betterbird is closed", n, k),
"quit Betterbird and start it from the launcher, or betterbird_restart (novox/hq ADR 0329 §9)")
}
}
}
}
ps := m.mainProcs()
switch {
@@ -769,9 +1043,11 @@ func (m *Machine) Check() (CheckAnswer, error) {
case err != nil:
add("no profile: "+err.Error(), "start Betterbird once")
case !u.Present:
add("the module's user.js is not on this machine (~/"+declared+")", "the module is not pushed here: the controller's push")
add("the setting prefs is not on this machine (~/"+prefsFile+")", "the module is not pushed here: the controller's push")
case u.Valid != "":
add("the module's user.js is refused: "+u.Valid, "change the module's setting prefs")
case u.AccountsProblem != "":
add("the module's user.js is not placed: "+u.AccountsProblem, "change the module's setting accounts at the controller's terminal, or propose it (betterbird_accounts)")
case !strings.HasPrefix(u.Placed, "the module's, as declared"):
add("the profile's user.js is "+u.Placed, "betterbird_user_js with apply, then betterbird_restart")
case strings.HasPrefix(u.Effective, "not yet"):
@@ -1,6 +1,7 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"strings"
@@ -44,8 +45,20 @@ func (f *fake) betterbird() {
f.write(profileDir+"/extensions.json", `{"addons":[{"id":"owl@beonex.com","active":true,"location":"app-profile"},{"id":"x@mozilla.org","active":true,"location":"app-builtin"}]}`)
}
// declare writes the setting prefs as the node-engine does (prefs.json, novox/hq issue 586), from user_pref
// statements: each one entry of the map.
func (f *fake) declare(prefs string) {
f.write(testHome+"/.config/betterbird/user.js", mark+", novox/hq issue 345). Owned by the mesh.\n//\n"+prefs+"\n")
f.t.Helper()
values := map[string]any{}
for name, lit := range statementsOf([]byte(prefs)) {
v, ok := prefValue(lit)
if !ok {
f.t.Fatalf("%s: %s is not a value the setting holds", name, lit)
}
values[name] = v
}
body, _ := json.Marshal(map[string]any{"prefs": values})
f.write(testHome+"/"+prefsFile, string(body))
}
func TestTheProfileIsTheOneTheRunningBetterbirdHoldsElseTheInstallationsDefault(t *testing.T) {
@@ -106,9 +119,9 @@ func TestTheModulesUserJSIsPlacedOnlyWhereNoOperatorsFileIs(t *testing.T) {
t.Fatalf("%+v %v", u, err)
}
placed, _ := os.ReadFile(filepath.Join(f.Root, profileDir, "user.js"))
declared, _ := os.ReadFile(filepath.Join(f.Root, testHome, ".config/betterbird/user.js"))
if string(placed) != string(declared) {
t.Fatal("placed whole, as declared")
declared, _, _ := f.declaredText()
if string(placed) != declared || !strings.Contains(declared, `user_pref("ui.key.x", "a;b");`) {
t.Fatalf("placed whole, as declared:\n%s", placed)
}
if !strings.HasPrefix(u.Effective, "yes") {
t.Fatalf("the fake Betterbird started after the file's time: %q", u.Effective)
@@ -161,7 +174,7 @@ func TestStatusSaysWhatRunsTheProfileTheAddOnsAndTheDisplayPrefs(t *testing.T) {
t.Fatal(err)
}
if len(s.Running) != 1 || s.Running[0].PID != 4000 || s.Children != 1 || s.Installed != "153.1.0esr-1" || !s.Foreign ||
s.Version != "153.1.0" || s.Profile == nil || len(s.AddOns) != 1 || s.AddOns[0].ID != "owl@beonex.com" {
s.Installation.Version != "153.1.0" || s.Profile == nil || len(s.AddOns) != 1 || s.AddOns[0].ID != "owl@beonex.com" {
t.Fatalf("%+v", s)
}
if !s.Output[0].Dead {
@@ -238,6 +251,13 @@ func TestTheReminderTestTouchesNothingOfTheOperatorsProfile(t *testing.T) {
!strings.Contains(string(user), "file:///run/user/1000/"+testUnit+"/test.ics") || strings.Contains(string(user), "someone@") {
t.Fatalf("%s", user)
}
// Betterbird's update check stays off, so no About window opens on the operator's screen, and the
// test's own preferences come last, so nothing carried or declared overrides them.
if !strings.Contains(string(user), `"betterbird.update.checkIntervalDays", 0`) ||
strings.Index(string(user), "extensions.activeThemeID") > strings.Index(string(user), "calendar.registry.meshtest.uri") ||
strings.Index(string(user), `"calendar.alarms.show"`) > strings.Index(string(user), "calendar.registry.meshtest.uri") {
t.Fatalf("%s", user)
}
// The event starts 10 minutes after the reminder, which is due 40 s from now.
if !strings.Contains(string(cal), "DTSTART:20270115T081040Z") || !strings.Contains(string(cal), "TRIGGER:-PT10M") ||
a.Due != "2027-01-15T08:00:40Z" {
@@ -0,0 +1,666 @@
package main
// The configuration of Betterbird's preferences (novox/hq issue 345, issue 586), in three layers:
//
// - LOCKED: the enterprise policy (/opt/betterbird/distribution/policies.json). Its Preferences entries with
// Status locked win over everything, and Betterbird's settings show them greyed out.
// - DECLARED: the setting prefs, the operator's setup: mesh-wide, with a node's layer over it key by key. The
// node-engine writes it to ~/.config/betterbird/prefs.json and the tools place it in the profile's user.js,
// where it wins over prefs.js at every start.
// - BETTERBIRD'S DEFAULT: what Betterbird starts with. The module's defaults file
// (/opt/betterbird/defaults/pref/mesh-defaults.js) holds no value of the operator's any more (issue 586): it
// names the preferences the module keeps at Betterbird's own default, where a value the profile saved is drift.
//
// betterbird_config shows each managed preference through the layers and flags the drift; betterbird_config_apply
// clears a drifting value from prefs.js while Betterbird is not running; keep proposes a node layer of the setting
// prefs that holds the profile's value for this node instead, through the controller's settings verb, never by
// writing user.js.
import (
"archive/zip"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
)
const (
// prefDir is the installation's own defaults directory; the module places its files there.
prefDir = installDir + "/defaults/pref"
// meshDefaults is the module's DEFAULT layer.
meshDefaults = prefDir + "/mesh-defaults.js"
// omniJa is Betterbird's packed application, holding its own default preferences.
omniJa = installDir + "/omni.ja"
)
// moduleFiles are the module's files in the installation's defaults directory: their values are the module's
// defaults, not Betterbird's.
var moduleFiles = map[string]bool{"mesh-defaults.js": true, "vendor-prefs.js": true}
var (
// defaultLine is one pref(…) or sticky_pref(…) line of a defaults file; a comment may follow it.
defaultLine = regexp.MustCompile(`^\s*(?:sticky_)?pref\(\s*"((?:[^"\\]|\\.)*)"\s*,\s*(.*?)\s*\)\s*;`)
// keepsDefault is a line of the module's defaults naming a preference it manages without a value.
keepsDefault = regexp.MustCompile(`^//\s*keeps Betterbird's default:\s*([A-Za-z0-9_.@{}\-]+)\s*$`)
)
// builtIn are the defaults compiled into Betterbird of the preferences the module keeps at Betterbird's default,
// which no defaults file gives: so a value saved equal to them is not drift.
var builtIn = map[string]string{
themePref: `"` + defaultTheme + `"`,
"browser.display.use_system_colors": "false",
"layout.css.always_underline_links": "false",
}
const (
themePref = "extensions.activeThemeID"
defaultTheme = "default-theme@mozilla.org"
// themeRemedy is how the theme comes back: Betterbird keeps the active theme in its add-on database
// (extensions.json), and the preference only mirrors it (XPIDatabase addonChanged), so clearing it changes
// nothing and betterbird_config_apply never touches it.
themeRemedy = "enable the default theme in Betterbird's add-on manager (Add-ons and Themes, Themes): the active " +
"theme is kept in the add-on database, and its preference only mirrors it, so clearing prefs.js would not change it"
)
// activeTheme is the theme the profile's add-on database marks active, or "" when it cannot be read.
func activeTheme(profile string) string {
raw, err := readBounded(filepath.Join(profile, "extensions.json"))
if err != nil {
return ""
}
var ext struct {
Addons []struct {
ID string `json:"id"`
Type string `json:"type"`
Active bool `json:"active"`
} `json:"addons"`
}
if json.Unmarshal(raw, &ext) != nil {
return ""
}
for _, a := range ext.Addons {
if a.Type == "theme" && a.Active {
return a.ID
}
}
return ""
}
// unmanageable says why a name in the module's defaults is never managed by these tools, or "": a credential's,
// an account's or a server's preference, which betterbird_config never answers and betterbird_config_apply never
// clears, whatever the defaults file lists.
func unmanageable(name string) string {
if why := withheld(name); why != "" {
return why
}
for _, p := range accountOwned {
if strings.HasPrefix(name, p) {
return "an account's preference, the setting accounts' alone"
}
}
return ""
}
// answer is a value as these tools answer it: masked, but for the theme's id.
func (m *Machine) answer(name, v string) string {
if allowedExtensions[name] {
return v
}
return m.maskValue(v)
}
// nodeName is the machine this runtime serves, as the mesh names it: a variable so a test answers.
var nodeName = func() string { return os.Getenv("MESH_NODE") }
// shipped is the module's DEFAULT layer as placed on the machine.
type shipped struct {
values map[string]string
// keeps are the managed names whose module value is Betterbird's own default.
keeps map[string]bool
// order is every managed name, in the file's order.
order []string
}
// parseShipped reads the module's defaults file.
func parseShipped(raw []byte) shipped {
s := shipped{values: map[string]string{}, keeps: map[string]bool{}}
for _, line := range strings.Split(string(raw), "\n") {
if mm := keepsDefault.FindStringSubmatch(strings.TrimSpace(line)); mm != nil {
if !s.keeps[mm[1]] {
s.keeps[mm[1]] = true
s.order = append(s.order, mm[1])
}
continue
}
if mm := defaultLine.FindStringSubmatch(line); mm != nil {
if _, seen := s.values[mm[1]]; !seen {
s.order = append(s.order, mm[1])
}
s.values[mm[1]] = mm[2]
}
}
return s
}
// shippedDefaults reads the module's defaults file from the installation.
func (m *Machine) shippedDefaults() (shipped, error) {
raw, err := readBounded(m.path(meshDefaults))
if err != nil {
return shipped{values: map[string]string{}, keeps: map[string]bool{}}, err
}
return parseShipped(raw), nil
}
// DefaultValue is a default preference and the file that gives it.
type DefaultValue struct {
Value string
From string
// Module says the module's files give it, not Betterbird's.
Module bool
}
// readDefaultsInto reads the pref(…) lines of the files of one place, in Gecko's order: it reads a place's
// files last-first by name (Preferences.cpp, pref_ReadDefaultPrefs), so of a name in several files the value
// of the file first by name holds. A later place overrides an earlier one.
func readDefaultsInto(out map[string]DefaultValue, files map[string][]byte, label func(string) (string, bool)) {
names := make([]string, 0, len(files))
for n := range files {
names = append(names, n)
}
sort.Strings(names)
place := map[string]DefaultValue{}
for i := len(names) - 1; i >= 0; i-- {
from, module := label(names[i])
for _, line := range strings.Split(string(files[names[i]]), "\n") {
if mm := defaultLine.FindStringSubmatch(line); mm != nil {
place[mm[1]] = DefaultValue{Value: mm[2], From: from, Module: module}
}
}
}
for k, v := range place {
out[k] = v
}
}
// mostDefaultsFile bounds one defaults file read from omni.ja.
const mostDefaultsFile = 4 << 20
// defaults reads every default preference Betterbird starts with: greprefs.js and defaults/pref/*.js in
// omni.ja, then the installation's defaults/pref/*.js, where the module's files are. A preference compiled into
// Betterbird (a static preference) is in no file, and is absent here. withModule false leaves the module's own
// files out, so what remains is Betterbird's. The error says what could not be read.
func (m *Machine) defaults(withModule bool) (map[string]DefaultValue, error) {
out := map[string]DefaultValue{}
var problems []string
if r, err := zip.OpenReader(m.path(omniJa)); err != nil {
problems = append(problems, omniJa+" is unreadable: "+err.Error())
} else {
gre, app := map[string][]byte{}, map[string][]byte{}
for _, f := range r.File {
switch {
case f.Name == "greprefs.js":
case strings.HasPrefix(f.Name, "defaults/pref/") && strings.HasSuffix(f.Name, ".js") && !strings.Contains(f.Name[len("defaults/pref/"):], "/"):
default:
continue
}
rc, err := f.Open()
if err != nil {
continue
}
b, _ := io.ReadAll(io.LimitReader(rc, mostDefaultsFile))
rc.Close()
if f.Name == "greprefs.js" {
gre[f.Name] = b
} else {
app[filepath.Base(f.Name)] = b
}
}
r.Close()
readDefaultsInto(out, gre, func(n string) (string, bool) { return "Betterbird's default (omni.ja " + n + ")", false })
readDefaultsInto(out, app, func(n string) (string, bool) {
return "Betterbird's default (omni.ja defaults/pref/" + n + ")", false
})
}
installed := map[string][]byte{}
entries, _ := os.ReadDir(m.path(prefDir))
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".js") || (moduleFiles[e.Name()] && !withModule) {
continue
}
if b, err := readBounded(m.path(filepath.Join(prefDir, e.Name()))); err == nil {
installed[e.Name()] = b
}
}
readDefaultsInto(out, installed, func(n string) (string, bool) {
if moduleFiles[n] {
return "the module's default (" + prefDir + "/" + n + ")", true
}
return "Betterbird's default (" + prefDir + "/" + n + ")", false
})
if len(problems) > 0 {
return out, errors.New(strings.Join(problems, "; "))
}
return out, nil
}
// policyPrefs are the Preferences entries of the placed policy: name to its value as a preference file writes
// it, and whether it is locked. An unreadable policy has none.
type policyPref struct {
Value string
Locked bool
}
func policyPrefsOf(raw []byte) map[string]policyPref {
var p struct {
Policies struct {
Preferences map[string]struct {
Value any `json:"Value"`
Status string `json:"Status"`
} `json:"Preferences"`
} `json:"policies"`
}
out := map[string]policyPref{}
if json.Unmarshal(raw, &p) != nil {
return out
}
for name, e := range p.Policies.Preferences {
out[name] = policyPref{Value: literal(e.Value), Locked: e.Status == "locked"}
}
return out
}
// locks are the preferences the placed policy locks: name to value.
func (m *Machine) locks() map[string]string {
raw, err := readBounded(m.path(policyFile))
if err != nil {
return map[string]string{}
}
out := map[string]string{}
for name, p := range policyPrefsOf(raw) {
if p.Locked {
out[name] = p.Value
}
}
return out
}
// literal is a JSON value as a preference file writes it.
func literal(v any) string {
switch x := v.(type) {
case bool:
return strconv.FormatBool(x)
case float64:
return strconv.FormatFloat(x, 'f', -1, 64)
case string:
return quoted(x)
case nil:
return ""
}
b, _ := json.Marshal(v)
return string(b)
}
// statementsOf are the user_pref(…) statements of a user.js, several to a line allowed: name to value.
func statementsOf(raw []byte) map[string]string {
out := map[string]string{}
for _, line := range strings.Split(string(raw), "\n") {
for _, st := range splitStatements(strings.TrimSpace(line)) {
if mm := prefLine.FindStringSubmatch(st); mm != nil {
out[mm[1]] = mm[2]
}
}
}
return out
}
// ConfigPref is one managed preference through the layers.
type ConfigPref struct {
Name string `json:"name"`
// Layer is locked (the policy), declared (the setting prefs), default (a value of the module's defaults file) or
// "Betterbird's default" (a managed preference whose module value is Betterbird's own).
Layer string `json:"layer"`
Locked string `json:"locked,omitempty"`
ModuleDefault string `json:"module_default,omitempty"`
BetterbirdDefault string `json:"betterbird_default,omitempty"`
// Declared is what the setting prefs says on this machine: mesh-wide, with the node's layer over it.
Declared string `json:"declared,omitempty"`
// Profile is what the profile saved in its prefs.js.
Profile string `json:"profile,omitempty"`
Effective string `json:"effective"`
From string `json:"effective_from"`
// Drift says how the profile's own value beats the module's without a node layer saying so.
Drift string `json:"drift,omitempty"`
// Remedy is how drift that clearing prefs.js cannot fix is fixed (the theme).
Remedy string `json:"remedy,omitempty"`
// module is the value the module means: its default, or Betterbird's.
module string
}
// ConfigAnswer is what betterbird_config answers.
type ConfigAnswer struct {
Profile string `json:"profile"`
Defaults string `json:"module_defaults"`
Prefs []ConfigPref `json:"prefs"`
// Drifting are the names that drift.
Drifting []string `json:"drifting"`
// Refused are names the module's defaults list that the tools never manage: an account's or a credential's.
Refused []string `json:"refused,omitempty"`
// PrefsProblem says why the setting prefs on this machine is refused.
PrefsProblem string `json:"prefs_problem,omitempty"`
// Proposed is the controller's answer to a node layer proposed with keep.
Proposed string `json:"proposed,omitempty"`
Note string `json:"note,omitempty"`
}
// config reads every managed preference through the layers: the policy's locks, the module's defaults, the
// node layer (the declared user.js), the profile's prefs.js and user.js.
func (m *Machine) config() (ConfigAnswer, Profile, error) {
a := ConfigAnswer{Prefs: []ConfigPref{}, Drifting: []string{}}
p, err := m.profile()
if err != nil {
return a, p, err
}
a.Profile = p.Dir
sh, serr := m.shippedDefaults()
if serr != nil {
a.Defaults = "not placed: " + meshDefaults + " is missing; the module's push places it"
} else {
a.Defaults = fmt.Sprintf("placed: %s, %d preference(s) with a value and %d kept at Betterbird's default",
meshDefaults, len(sh.values), len(sh.keeps))
}
locked := m.locks()
own, _ := m.defaults(false)
node, _, nerr := m.readDeclaredPrefs()
if nerr != nil {
a.PrefsProblem = nerr.Error()
}
prefsRaw, _ := readBounded(filepath.Join(p.path, "prefs.js"))
saved := readPrefs(prefsRaw)
userRaw, _ := readBounded(filepath.Join(p.path, "user.js"))
placed := statementsOf(userRaw)
var names []string
for n := range locked {
names = append(names, n)
}
sort.Strings(names)
listed := map[string]bool{}
for _, n := range names {
listed[n] = true
}
for _, n := range append(append([]string{}, sh.order...), sortedKeys(node)...) {
if !listed[n] {
listed[n] = true
names = append(names, n)
}
}
theme := activeTheme(p.path)
for _, n := range names {
if why := unmanageable(n); why != "" {
a.Refused = append(a.Refused, n+": "+why)
continue
}
c := ConfigPref{Name: n, Declared: node[n], Profile: saved[n]}
if n == themePref && theme != "" {
// The add-on database says which theme is active; the preference only mirrors it.
c.Profile = quoted(theme)
}
bb, hasBB := own[n]
if hasBB {
c.BetterbirdDefault = bb.Value
} else if v, ok := builtIn[n]; ok {
c.BetterbirdDefault = v
bb.From = "Betterbird's built-in default"
}
switch v, isLocked := locked[n]; {
case isLocked:
c.Layer, c.Locked, c.module = "locked", v, v
case c.Declared != "":
c.Layer, c.module = "declared", c.Declared
case sh.keeps[n]:
c.Layer = "Betterbird's default"
c.module = c.BetterbirdDefault
default:
c.Layer, c.ModuleDefault, c.module = "default", sh.values[n], sh.values[n]
}
switch {
case c.Locked != "":
c.Effective, c.From = c.Locked, "locked by the module's policy"
case placed[n] != "":
c.Effective, c.From = placed[n], "the profile's user.js (wins at the start)"
case c.Profile != "":
c.Effective, c.From = c.Profile, "the profile's prefs.js"
case c.ModuleDefault != "":
c.Effective, c.From = c.ModuleDefault, "the module's default"
case c.BetterbirdDefault != "":
c.Effective, c.From = c.BetterbirdDefault, bb.From
default:
c.From = "Betterbird's built-in default (in no defaults file)"
}
if why := drift(c, placed[n]); why != "" {
c.Drift = why
switch {
case n == themePref:
c.Remedy = themeRemedy
case c.Layer == "declared":
c.Remedy = "betterbird_user_js with apply places the setting in the profile's user.js; Betterbird reads it " +
"at its next start (betterbird_restart)"
}
a.Drifting = append(a.Drifting, n)
}
c.Declared, c.Profile, c.Effective = m.answer(n, c.Declared), m.answer(n, c.Profile), m.answer(n, c.Effective)
a.Prefs = append(a.Prefs, c)
}
return a, p, nil
}
// drift says how the profile differs from the module's configuration, or "": for a declared preference, the
// profile's user.js not holding the declared value (so Betterbird does not start with it); for a preference kept at
// Betterbird's default, a value in prefs.js that is not it. A lock holds whatever the profile says.
func drift(c ConfigPref, placed string) string {
if c.Locked != "" {
return ""
}
if c.Declared != "" {
if placed == c.Declared {
return ""
}
return fmt.Sprintf("the setting prefs says %s, and the profile's user.js does not hold it yet", c.Declared)
}
if c.Profile == "" || c.Profile == c.module {
return ""
}
want := c.module
if want == "" {
want = "Betterbird's built-in default"
}
where := "the profile's prefs.js holds"
if c.Name == themePref {
where = "the profile's active theme is"
}
return fmt.Sprintf("%s %s, which beats the module's %s, and the setting prefs does not say so", where, c.Profile, want)
}
// Config is what betterbird_config answers; with keep it proposes a node layer of the setting prefs that holds
// the profile's own values of the named managed preferences on this machine (novox/hq issue 345). The module
// never writes user.js for it: the operator approves the layer, and the next push writes it.
func (m *Machine) Config(keep []string) (ConfigAnswer, error) {
a, p, err := m.config()
if err != nil {
return a, err
}
a.Note = "drift is a declared value the profile's user.js does not hold yet (betterbird_user_js with apply), or a " +
"value the profile saved where the module keeps Betterbird's default: betterbird_config_apply clears that one " +
"(Betterbird quit), or keep proposes a node layer of the setting prefs that holds it for this machine"
if len(keep) == 0 {
return a, nil
}
by := map[string]ConfigPref{}
for _, c := range a.Prefs {
by[c.Name] = c
}
prefsRaw, _ := readBounded(filepath.Join(p.path, "prefs.js"))
saved := readPrefs(prefsRaw)
kept := map[string]string{}
for _, n := range keep {
c, managed := by[n]
switch {
case !managed:
return a, fmt.Errorf("nothing was proposed: %s is not a preference the module manages (betterbird_config lists them)", n)
case c.Locked != "":
return a, fmt.Errorf("nothing was proposed: %s is locked by the module's policy; a lock changes only in the catalogue", n)
case n == themePref:
return a, fmt.Errorf("nothing was proposed: a node layer cannot choose the theme; the add-on manager does (%s)", themeRemedy)
case saved[n] == "":
return a, fmt.Errorf("nothing was proposed: the profile saved no value of its own for %s", n)
}
kept[n] = saved[n]
}
node := nodeName()
if node == "" {
return a, errors.New("nothing was proposed: this runtime does not say which machine it serves (MESH_NODE)")
}
// A layer is replaced whole, so the proposal carries this node's layer as it stands, with the kept values in
// the setting prefs beside what it held (novox/hq issue 586: one entry per preference).
layer, err := readLayer(node)
if err != nil {
return a, fmt.Errorf("nothing was proposed: %v", err)
}
prefs, _ := layer["prefs"].(map[string]any)
if prefs == nil {
prefs = map[string]any{}
}
for _, n := range keep {
v, ok := prefValue(kept[n])
if !ok {
return a, fmt.Errorf("nothing was proposed: the profile's value of %s is not one the setting can hold", n)
}
prefs[n] = v
}
layer["prefs"] = prefs
said, err := proposeLayer(node, layer, false)
a.Proposed = m.maskValue(said)
if err != nil {
return a, err
}
a.Note = "proposed for " + node + ": the operator approves it on a channel that proves who answers; the next " +
"push writes it, and it is placed at Betterbird's next start from the launcher (or betterbird_restart). " +
"The layer carries this node's layer as it stands, with the kept preferences in the setting prefs"
return a, nil
}
// Cleared is one value betterbird_config_apply took out of prefs.js.
type Cleared struct {
Name string `json:"name"`
Was string `json:"was"`
// Now is the value that holds once Betterbird starts again.
Now string `json:"now"`
}
// ConfigApplyAnswer is what betterbird_config_apply answers.
type ConfigApplyAnswer struct {
Profile string `json:"profile"`
Applied bool `json:"applied"`
Clears []Cleared `json:"clears"`
// NotCleared is drift that clearing prefs.js cannot fix, each with how it is fixed.
NotCleared []string `json:"not_cleared,omitempty"`
Note string `json:"note"`
}
// ConfigApply takes the drifting values out of the profile's prefs.js, so the module's value holds at the next
// start. Without apply it lists them. It acts only while Betterbird is not running, because Betterbird
// rewrites prefs.js when it quits. Every other line of prefs.js is kept as it was.
func (m *Machine) ConfigApply(apply bool) (ConfigApplyAnswer, error) {
c, p, err := m.config()
a := ConfigApplyAnswer{Profile: c.Profile, Clears: []Cleared{}}
if err != nil {
return a, err
}
if strings.HasPrefix(c.Defaults, "not placed") {
return a, fmt.Errorf("the module's defaults are %s: nothing to bring the profile back to", c.Defaults)
}
drifting := map[string]bool{}
for _, cp := range c.Prefs {
if cp.Drift != "" && cp.Remedy != "" {
a.NotCleared = append(a.NotCleared, cp.Name+": "+cp.Remedy)
continue
}
if cp.Drift != "" {
drifting[cp.Name] = true
now := m.answer(cp.Name, cp.module)
if now == "" {
now = "Betterbird's built-in default"
}
a.Clears = append(a.Clears, Cleared{Name: cp.Name, Was: cp.Profile, Now: now})
}
}
if !apply {
a.Note = "a dry run: these values would be taken out of prefs.js. Quit Betterbird, then call with apply"
return a, nil
}
if ps := m.mainProcs(); len(ps) > 0 {
return a, fmt.Errorf("Betterbird is running (pid %d): it rewrites prefs.js when it quits, so nothing was cleared; quit it, then apply again", ps[0].PID)
}
// The profile's own lock is Gecko's sign that a Betterbird holds it, whichever installation started it.
if _, err := os.Lstat(filepath.Join(p.path, "lock")); err == nil {
return a, errors.New("the profile is locked (its lock file is there): a Betterbird holds it, or one ended " +
"without quitting; nothing was cleared. Quit Betterbird (or start and quit it once), then apply again")
}
if len(drifting) == 0 {
a.Applied, a.Note = true, "nothing in prefs.js drifts: it was left as it was"
return a, nil
}
target := filepath.Join(p.path, "prefs.js")
raw, err := readBounded(target)
if err != nil {
return a, err
}
st, err := os.Stat(target)
if err != nil {
return a, err
}
lines := strings.SplitAfter(string(raw), "\n")
var kept strings.Builder
for _, l := range lines {
if mm := prefLine.FindStringSubmatch(strings.TrimRight(l, "\n")); mm != nil && drifting[mm[1]] {
continue
}
kept.WriteString(l)
}
tmp := target + ".mesh-new"
if err := writeSynced(tmp, []byte(kept.String()), st.Mode().Perm()); err != nil {
os.Remove(tmp)
return a, err
}
if err := os.Rename(tmp, target); err != nil {
os.Remove(tmp)
return a, err
}
a.Applied = true
a.Note = fmt.Sprintf("cleared %d value(s) from prefs.js; the module's values hold from Betterbird's next start", len(a.Clears))
return a, nil
}
// writeSynced writes a file and syncs it to disk before it is renamed into place, so a crash never leaves an
// empty prefs.js.
func writeSynced(path string, data []byte, mode os.FileMode) error {
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode)
if err != nil {
return err
}
if _, err := f.Write(data); err != nil {
f.Close()
return err
}
if err := f.Sync(); err != nil {
f.Close()
return err
}
return f.Close()
}
@@ -0,0 +1,541 @@
package main
import (
"archive/zip"
"encoding/json"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
)
// The configuration the module ships (novox/hq issue 345): the policy's locks, the module's defaults file, and the
// tools that show the layers, flag drift, clear it and propose a node layer.
// shippedDefaultsWant is the setup the operator approved on 2026-10-12 (the laptop's, with reminders missed while
// asleep shown at the next start and chat off), which the module shipped as its defaults file until issue 586 moved
// it into the mesh-wide layer of the setting prefs: the catalogue holds none of it now.
var shippedDefaultsWant = map[string]string{
"mail.uidensity": "0",
"mail.uifontsize": "14",
"mail.dark-reader.enabled": "false",
"mail.pane_config.dynamic": "2",
"mail.pane_config.multiline_all": "true",
"mail.threadpane.listview": "0",
"mailnews.default_view_flags": "1",
"mail.warn_on_shift_delete": "false",
"mail.biff.show_alert": "false",
"mail.biff.play_sound": "false",
"mail.biff.show_tray_icon": "false",
"mail.biff.use_system_alert": "true",
"calendar.view.daystarthour": "9",
"calendar.view.dayendhour": "20",
"calendar.item.promptDelete": "false",
"calendar.timezone.useSystemTimezone": "true",
"calendar.alarms.show": "true",
"calendar.alarms.showmissed": "true",
"mail.chat.enabled": "false",
"mailnews.message_display.disable_remote_image": "true",
"privacy.donottrackheader.enabled": "true",
}
var shippedKeepsWant = []string{"extensions.activeThemeID", "browser.display.use_system_colors",
"layout.css.always_underline_links", "mail.spam.manualMark", "mail.compose.show_attachment_pane"}
func repoFile(t *testing.T, rel string) []byte {
t.Helper()
raw, err := os.ReadFile(filepath.Join("..", "..", rel))
if err != nil {
t.Fatal(err)
}
return raw
}
// withDefaults places the module's defaults file as the push does.
func (f *fake) withDefaults() {
f.t.Helper()
f.write(meshDefaults, string(repoFile(f.t, "install/defaults-pref/mesh-defaults.js")))
}
// omni places a packed application holding the given files.
func (f *fake) omni(files map[string]string) {
f.t.Helper()
full := filepath.Join(f.Root, omniJa)
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
f.t.Fatal(err)
}
out, err := os.Create(full)
if err != nil {
f.t.Fatal(err)
}
w := zip.NewWriter(out)
for name, body := range files {
e, _ := w.Create(name)
e.Write([]byte(body))
}
w.Close()
out.Close()
}
// betterbirdsOwn is a small omni.ja: two files that contradict each other, as all-thunderbird.js and
// mailnews.js do in the pinned release.
func (f *fake) betterbirdsOwn() {
f.omni(map[string]string{
"greprefs.js": `pref("gfx.webrender.software", false);` + "\n",
"defaults/pref/all-thunderbird.js": `pref("offline.autoDetect", true);` + "\n" +
`pref("mail.threadpane.listview", 1);` + "\n" + `pref("mail.uidensity", 1);` + "\n" +
`pref("mail.shell.checkDefaultClient", true);` + "\n",
"defaults/pref/mailnews.js": `pref("offline.autoDetect", false);` + "\n" +
`pref("mail.biff.show_alert", true); // a comment after it` + "\n" + `pref("mail.spam.manualMark", false);` + "\n" +
`pref("mailnews.start_page.enabled", true);` + "\n",
"defaults/pref/calendar.js": `pref("calendar.alarms.showmissed", false);` + "\n",
"chrome/other.js": `pref("not.a.default", 1);` + "\n",
})
}
func stopBetterbird(t *testing.T, f *fake) {
t.Helper()
for _, pid := range []string{"4000", "4001"} {
if err := os.RemoveAll(filepath.Join(f.Root, "proc", pid)); err != nil {
t.Fatal(err)
}
}
}
func TestTheModuleShipsTheApprovedLocksAndDefaults(t *testing.T) {
m, _ := readManifest(t)
if a := artifact(t, m, "defaults"); a["from"] != "install/defaults-pref" {
t.Fatalf("the defaults file ships through the defaults artifact: %v", a)
}
if r := resource(t, m, "spelling"); r["path"] != prefDir || r["artifact"] != "defaults" {
t.Fatalf("placed in Betterbird's own defaults directory: %v", r)
}
raw := repoFile(t, "install/distribution/policies.json")
if why := policyProblem(raw); why != "" {
t.Fatal(why)
}
var p struct {
Policies map[string]json.RawMessage `json:"policies"`
}
if err := json.Unmarshal(raw, &p); err != nil {
t.Fatal(err)
}
for k, want := range map[string]string{"DisableAppUpdate": "true", "DisableTelemetry": "true", "ExtensionUpdate": "false"} {
if string(p.Policies[k]) != want {
t.Errorf("%s is %s, not %s", k, p.Policies[k], want)
}
}
prefs := policyPrefsOf(raw)
for _, n := range []string{"mail.shell.checkDefaultClient", "mail.inappnotifications.enabled", "mailnews.start_page.enabled"} {
if prefs[n] != (policyPref{Value: "false", Locked: true}) {
t.Errorf("%s is locked off: %+v", n, prefs[n])
}
}
// Betterbird's Preferences policy accepts only these prefixes (Policies.sys.mjs); privacy. and toolkit. are not.
allowed := []string{"accessibility.", "app.update.", "browser.", "calendar.", "chat.", "datareporting.policy.", "dom.",
"extensions.", "general.autoScroll", "general.smoothScroll", "geo.", "gfx.", "intl.", "layers.", "layout.", "mail.",
"mailnews.", "media.", "network.", "pdfjs.", "places.", "print.", "signon.", "spellchecker.", "ui.", "widget."}
for n := range prefs {
ok := false
for _, a := range allowed {
ok = ok || strings.HasPrefix(n, a)
}
if !ok {
t.Errorf("the policy cannot lock %s: its prefix is not one Betterbird accepts", n)
}
}
text := string(repoFile(t, "install/defaults-pref/mesh-defaults.js"))
sh := parseShipped([]byte(text))
if len(sh.values) != 0 {
t.Errorf("the module's defaults hold no value of the operator's: they are the setting prefs (issue 586): %v", sh.values)
}
for n := range shippedDefaultsWant {
if strings.Contains(text, n) {
t.Errorf("%s is the operator's choice and lives in the setting prefs, never in the catalogue", n)
}
}
var keeps []string
for _, n := range sh.order {
if sh.keeps[n] {
keeps = append(keeps, n)
}
}
if !reflect.DeepEqual(keeps, shippedKeepsWant) {
t.Errorf("the preferences kept at Betterbird's default: %v", keeps)
}
lines := strings.Split(text, "\n")
for i, l := range lines {
if defaultLine.MatchString(l) && (i == 0 || !strings.HasPrefix(lines[i-1], "// ")) {
t.Errorf("line %d has no comment saying why: %s", i+1, l)
}
if t2 := strings.TrimSpace(l); t2 != "" && !strings.HasPrefix(t2, "//") && !defaultLine.MatchString(l) {
t.Errorf("line %d is neither a comment nor a pref(…) line: %s", i+1, l)
}
}
for n := range sh.values {
if withheld(n) != "" || passwordSecret.MatchString(n) {
t.Errorf("%s is an account's or a credential's preference, never a default of the module", n)
}
if _, locked := prefs[n]; locked {
t.Errorf("%s is both locked and a default", n)
}
}
}
// T2: Betterbird's own defaults are values read from omni.ja, in Gecko's order, and the module's beside them.
func TestPrefsSayTheDefaultAndTheFileThatGivesIt(t *testing.T) {
f := newFake(t)
f.betterbird()
f.placedByTheMesh()
f.withDefaults()
f.betterbirdsOwn()
links(t, nil)
got, err := f.Prefs([]string{"offline.autoDetect", "mail.threadpane.listview", "mail.uidensity", "gfx.webrender.software",
"mailnews.start_page.enabled", "layout.css.always_underline_links", "not.a.default"})
if err != nil {
t.Fatal(err)
}
by := map[string]Pref{}
for _, p := range got["prefs"].([]Pref) {
by[p.Name] = p
}
if p := by["offline.autoDetect"]; p.Value != "true" || p.Default != "true" || !strings.Contains(p.DefaultFrom, "all-thunderbird.js") {
t.Errorf("of two files, the one first by name holds, as Gecko reads them: %+v", p)
}
if p := by["mail.threadpane.listview"]; p.Value != "1" || strings.Contains(p.From, "module") || strings.Contains(p.DefaultFrom, "module") {
t.Errorf("the module ships no value of the operator's, so Betterbird's own default holds: %+v", p)
}
if p := by["gfx.webrender.software"]; p.Value != "false" || !strings.Contains(p.From, "greprefs.js") {
t.Errorf("%+v", p)
}
if p := by["mailnews.start_page.enabled"]; p.Value != "false" || p.From != "locked by the module's policy" || p.Default != "true" {
t.Errorf("a locked preference says its lock and Betterbird's default under it: %+v", p)
}
if p := by["layout.css.always_underline_links"]; p.Value != "" || !strings.Contains(p.From, "built-in") {
t.Errorf("a preference in no file is said as such: %+v", p)
}
if p := by["not.a.default"]; p.Default != "" {
t.Errorf("only defaults/pref and greprefs.js are defaults: %+v", p)
}
}
// T4: of mailnews.* only what names an account, a server or the operator's tags is withheld.
func TestMailnewsIsWithheldOnlyWhereItNamesAnAccount(t *testing.T) {
for name, held := range map[string]bool{
"mailnews.start_page.enabled": false,
"mailnews.default_view_flags": false,
"mailnews.message_display.disable_remote_image": false,
"mailnews.tags.$label1.tag": true,
"mailnews.smtp.loglevel": true,
"mailnews.reply_to_self_check_all_ident": true,
"mailnews.auto_config.fetchFromISP.sendEmailAddress": true,
"mailnews.oauth.useSchemeRedirect": true,
"mail.server.server1.hostname": true,
} {
if got := withheld(name) != ""; got != held {
t.Errorf("%s: withheld %v, want %v", name, got, held)
}
}
}
// T1: every managed preference through the layers, and drift: a declared value the profile's user.js does not hold
// yet, or a value the profile saved where the module keeps Betterbird's default.
func TestTheConfigShowsTheLayersAndTheDrift(t *testing.T) {
f := newFake(t)
f.betterbird()
f.placedByTheMesh()
f.withDefaults()
f.betterbirdsOwn()
links(t, nil)
f.declare(`user_pref("mail.uifontsize", 16); user_pref("mail.biff.show_alert", false); user_pref("calendar.alarms.showmissed", true);`)
f.write(profileDir+"/user.js", mark+")\n"+`user_pref("mail.uifontsize", 16);`+"\n"+`user_pref("calendar.alarms.showmissed", true);`+"\n")
f.write(profileDir+"/prefs.js", "// Mozilla User Preferences\n"+
`user_pref("extensions.activeThemeID", "thunderbird-compact-dark@mozilla.org");`+"\n"+
`user_pref("mail.uifontsize", 12);`+"\n"+
`user_pref("mail.biff.show_alert", true);`+"\n"+
`user_pref("mail.spam.manualMark", true);`+"\n"+
`user_pref("browser.display.use_system_colors", false);`+"\n"+
`user_pref("mailnews.start_page.enabled", true);`+"\n")
c, err := f.Config(nil)
if err != nil {
t.Fatal(err)
}
by := map[string]ConfigPref{}
for _, p := range c.Prefs {
by[p.Name] = p
}
if len(c.Prefs) != 3+3+len(shippedKeepsWant) || !strings.HasPrefix(c.Defaults, "placed") {
t.Fatalf("every lock, declared preference and kept default: %d, %s", len(c.Prefs), c.Defaults)
}
if !reflect.DeepEqual(c.Drifting, []string{"extensions.activeThemeID", "mail.spam.manualMark", "mail.biff.show_alert"}) {
t.Fatalf("drift: %v", c.Drifting)
}
if p := by["mail.biff.show_alert"]; p.Layer != "declared" || p.Declared != "false" || p.Profile != "true" ||
p.Effective != "true" || !strings.Contains(p.Drift, "the setting prefs says false") || !strings.Contains(p.Remedy, "betterbird_user_js") {
t.Errorf("a declared value the profile's user.js does not hold yet: %+v", p)
}
if p := by["mail.spam.manualMark"]; p.Layer != "Betterbird's default" || p.BetterbirdDefault != "false" ||
!strings.Contains(p.Drift, "beats the module's false") || p.Remedy != "" {
t.Errorf("a saved value where the module keeps Betterbird's default: %+v", p)
}
if p := by["extensions.activeThemeID"]; p.Layer != "Betterbird's default" || !strings.Contains(p.Drift, `"default-theme@mozilla.org"`) ||
!strings.Contains(p.Remedy, "add-on manager") || p.Profile != `"thunderbird-compact-dark@mozilla.org"` {
t.Errorf("another theme is drift whose remedy is the add-on manager, its id answered whole: %+v", p)
}
if p := by["browser.display.use_system_colors"]; p.Drift != "" {
t.Errorf("a saved value equal to Betterbird's default is not drift: %+v", p)
}
if p := by["mail.uifontsize"]; p.Drift != "" || p.Declared != "16" || p.Effective != "16" || !strings.Contains(p.From, "user.js") {
t.Errorf("a declared value the user.js holds wins at the start, whatever prefs.js saved: %+v", p)
}
if p := by["mailnews.start_page.enabled"]; p.Layer != "locked" || p.Effective != "false" || p.Drift != "" {
t.Errorf("a lock wins over the profile: %+v", p)
}
if p := by["calendar.alarms.showmissed"]; p.Effective != "true" || p.Layer != "declared" {
t.Errorf("%+v", p)
}
// T5: the check says the drift, with both remedies.
ch, err := f.Check()
if err != nil {
t.Fatal(err)
}
found := false
for _, x := range ch.Findings {
found = found || (strings.Contains(x.What, "1 managed preference(s) drift") && strings.Contains(x.What, "mail.spam.manualMark") &&
strings.Contains(x.Do, "betterbird_config_apply") && strings.Contains(x.Do, "keep"))
}
theme := false
for _, x := range ch.Findings {
theme = theme || (strings.HasPrefix(x.What, "extensions.activeThemeID drifts") && strings.Contains(x.Do, "add-on manager") &&
!strings.Contains(x.Do, "betterbird_config_apply"))
}
declared := false
for _, x := range ch.Findings {
declared = declared || (strings.HasPrefix(x.What, "mail.biff.show_alert drifts") && strings.Contains(x.Do, "betterbird_user_js"))
}
if !found || !theme || !declared {
t.Fatalf("the check reports the drift: %+v", ch.Findings)
}
os.Remove(filepath.Join(f.Root, meshDefaults))
ch, _ = f.Check()
found = false
for _, x := range ch.Findings {
found = found || strings.Contains(x.What, "the module's defaults are not placed")
}
if !found {
t.Fatalf("defaults missing on a node of the mesh's release is a finding: %+v", ch.Findings)
}
}
// T3: the drifting values are cleared from prefs.js only while Betterbird is not running, and nothing else.
func TestConfigApplyClearsTheDriftOnlyWhileBetterbirdIsQuit(t *testing.T) {
f := newFake(t)
f.betterbird()
f.placedByTheMesh()
f.withDefaults()
f.betterbirdsOwn()
links(t, nil)
f.declare("// none")
prefs := "// Mozilla User Preferences\n" +
`user_pref("extensions.activeThemeID", "thunderbird-compact-dark@mozilla.org");` + "\n" +
`user_pref("mail.identity.id1.useremail", "someone@example.org");` + "\n" +
`user_pref("mail.spam.manualMark", true);` + "\n" +
`user_pref("mail.uidensity", 0);` + "\n"
f.write(profileDir+"/prefs.js", prefs)
dry, err := f.ConfigApply(false)
if err != nil || dry.Applied || len(dry.Clears) != 1 || len(dry.NotCleared) != 1 {
t.Fatalf("a dry run lists and changes nothing: %+v %v", dry, err)
}
if f.read(profileDir+"/prefs.js") != prefs {
t.Fatal("a dry run wrote prefs.js")
}
if _, err := f.ConfigApply(true); err == nil || !strings.Contains(err.Error(), "running") {
t.Fatalf("refused while Betterbird runs: %v", err)
}
if f.read(profileDir+"/prefs.js") != prefs {
t.Fatal("prefs.js changed while Betterbird runs")
}
stopBetterbird(t, f)
f.write(profileDir+"/lock", "")
if _, err := f.ConfigApply(true); err == nil || !strings.Contains(err.Error(), "locked") {
t.Fatalf("refused while the profile's own lock is there: %v", err)
}
if err := os.Remove(filepath.Join(f.Root, profileDir, "lock")); err != nil {
t.Fatal(err)
}
a, err := f.ConfigApply(true)
if err != nil || !a.Applied {
t.Fatalf("%+v %v", a, err)
}
want := "// Mozilla User Preferences\n" +
`user_pref("extensions.activeThemeID", "thunderbird-compact-dark@mozilla.org");` + "\n" +
`user_pref("mail.identity.id1.useremail", "someone@example.org");` + "\n" +
`user_pref("mail.uidensity", 0);` + "\n"
if got := f.read(profileDir + "/prefs.js"); got != want {
t.Fatalf("only the drifting lines go:\n%s", got)
}
said := map[string]Cleared{}
for _, c := range a.Clears {
said[c.Name] = c
}
if c := said["mail.spam.manualMark"]; c.Was != "true" || c.Now != "false" {
t.Errorf("it says what it cleared and what holds now: %+v", c)
}
if _, cleared := said["extensions.activeThemeID"]; cleared || len(a.NotCleared) != 1 || !strings.Contains(a.NotCleared[0], "add-on manager") {
t.Errorf("the theme is never cleared, and said with its remedy: %+v", a)
}
raw, _ := json.Marshal(a)
if strings.Contains(string(raw), "example.org") {
t.Fatalf("no account value leaves: %s", raw)
}
if c, _ := f.Config(nil); !reflect.DeepEqual(c.Drifting, []string{"extensions.activeThemeID"}) {
t.Fatalf("once applied, only the theme drifts, until the add-on manager changes it: %v", c.Drifting)
}
os.Remove(filepath.Join(f.Root, meshDefaults))
if _, err := f.ConfigApply(true); err == nil || !strings.Contains(err.Error(), "not placed") {
t.Fatalf("without the module's defaults there is nothing to come back to: %v", err)
}
}
// T6: keeping a value on one node is that node's layer of the setting prefs proposed through the controller, one
// entry per preference beside what the layer holds (issue 586), never a write to user.js.
func TestKeepProposesANodeLayerThroughTheController(t *testing.T) {
f := newFake(t)
f.betterbird()
f.placedByTheMesh()
f.withDefaults()
f.betterbirdsOwn()
links(t, nil)
f.declare(`user_pref("calendar.alarms.playsound", false); user_pref("mail.uifontsize", 12);`)
f.write(profileDir+"/prefs.js", `user_pref("mail.uifontsize", 16);`+"\n"+`user_pref("mail.spam.manualMark", true);`+"\n")
oldNode, oldAsk := nodeName, asker
t.Cleanup(func() { nodeName, asker = oldNode, oldAsk })
nodeName = func() string { return "shanks" }
var asked []map[string]any
asker = func(v string, a any) (json.RawMessage, error) {
args := a.(map[string]any)
asked = append(asked, args)
if args["propose"] == nil {
return json.RawMessage(`{"ok": true, "output": "{\"prefs\": {\"calendar.alarms.playsound\": false}, \"identities\": {\"mail\": {\"account\": \"mail\"}}}\n set at the terminal\nbetterbird on shanks, every value"}`), nil
}
return json.RawMessage(`{"ok": true, "output": "proposed: p-1"}`), nil
}
before := f.read(testHome + "/" + prefsFile)
c, err := f.Config([]string{"mail.uifontsize", "mail.spam.manualMark"})
if err != nil {
t.Fatal(err)
}
if len(asked) != 2 || asked[0]["node"] != "shanks" || asked[1]["node"] != "shanks" || asked[1]["propose"] != "true" ||
asked[1]["module"] != "betterbird" {
t.Fatalf("the node's layer read, then proposed: %v", asked)
}
var values map[string]any
if err := json.Unmarshal([]byte(asked[1]["values"].(string)), &values); err != nil {
t.Fatal(err)
}
want := map[string]any{"calendar.alarms.playsound": false, "mail.uifontsize": float64(16), "mail.spam.manualMark": true}
if !reflect.DeepEqual(values["prefs"], want) || values["identities"] == nil {
t.Fatalf("the layer as it stands with the kept values, one entry each:\n got %v\nwant %v", values, want)
}
if c.Proposed != "proposed: p-1" || f.read(testHome+"/"+prefsFile) != before {
t.Fatalf("proposed, and nothing written: %+v", c)
}
for _, bad := range [][]string{{"mailnews.start_page.enabled"}, {"some.other.pref"}, {"calendar.alarms.playsound"}} {
asked = nil
if _, err := f.Config(bad); err == nil || len(asked) != 0 {
t.Errorf("%v: a lock, an unmanaged name or a value the profile did not save is refused: %v", bad, err)
}
}
nodeName = func() string { return "" }
if _, err := f.Config([]string{"mail.uifontsize"}); err == nil || !strings.Contains(err.Error(), "MESH_NODE") {
t.Fatalf("%v", err)
}
}
// The review of pull request 253: whether Betterbird asks to be the default client is decided from the policy's
// own Preferences entry, never from what else the policy lacks.
func TestTheDefaultClientAskIsDecidedFromThePreferencesEntry(t *testing.T) {
f := newFake(t)
f.betterbird()
f.placedByTheMesh()
links(t, nil)
f.write(policyFile, `{"policies": {"ExtensionSettings": {}}}`)
if why := f.defaultClientAsk(filepath.Join(f.Root, profileDir)); why == "" {
t.Fatal("a policy lacking the updater and the lock still asks")
}
f.write(policyFile, `{"policies": {"Preferences": {"mail.shell.checkDefaultClient": {"Value": false, "Status": "locked"}}}}`)
if why := f.defaultClientAsk(filepath.Join(f.Root, profileDir)); why != "" {
t.Fatalf("the lock holds whatever else the policy lacks: %s", why)
}
}
// The review of pull request 256: the theme is the add-on database's, and a saved default is not drift.
func TestTheThemeIsTheAddOnDatabasesAndASavedDefaultIsNotDrift(t *testing.T) {
f := newFake(t)
f.betterbird()
f.placedByTheMesh()
f.withDefaults()
links(t, nil)
f.write(profileDir+"/prefs.js", `user_pref("browser.display.use_system_colors", false);`+"\n"+
`user_pref("layout.css.always_underline_links", false);`+"\n")
f.write(profileDir+"/extensions.json", `{"addons":[{"id":"thunderbird-compact-dark@mozilla.org","type":"theme","active":true,"location":"app-builtin"},`+
`{"id":"default-theme@mozilla.org","type":"theme","active":false,"location":"app-builtin"}]}`)
c, err := f.Config(nil)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(c.Drifting, []string{"extensions.activeThemeID"}) {
t.Fatalf("the active theme from the add-on database, with no preference saved, is drift; a saved default is not: %v", c.Drifting)
}
f.write(profileDir+"/prefs.js", `user_pref("extensions.activeThemeID", "default-theme@mozilla.org");`+"\n")
f.write(profileDir+"/extensions.json", `{"addons":[{"id":"default-theme@mozilla.org","type":"theme","active":true,"location":"app-builtin"}]}`)
if c, _ := f.Config(nil); len(c.Drifting) != 0 {
t.Fatalf("a profile back on the default theme does not drift: %v", c.Drifting)
}
if _, err := f.Config([]string{"extensions.activeThemeID"}); err == nil || !strings.Contains(err.Error(), "add-on manager") {
t.Fatalf("a node layer cannot choose the theme: %v", err)
}
}
// The review of pull request 256: a credential's or an account's name in the defaults file is never managed.
func TestAnAccountsNameInTheDefaultsIsNeverManaged(t *testing.T) {
f := newFake(t)
f.betterbird()
f.placedByTheMesh()
links(t, nil)
f.write(meshDefaults, string(repoFile(t, "install/defaults-pref/mesh-defaults.js"))+
`pref("mail.identity.id1.useremail", "x");`+"\n"+"// keeps Betterbird's default: mail.server.server1.hostname\n"+
`pref("mail.smtpservers", "");`+"\n")
prefs := `user_pref("mail.identity.id1.useremail", "someone@example.org");` + "\n" +
`user_pref("mail.server.server1.hostname", "imap.example.org");` + "\n" +
`user_pref("mail.smtpservers", "smtp1");` + "\n"
f.write(profileDir+"/prefs.js", prefs)
c, err := f.Config(nil)
if err != nil {
t.Fatal(err)
}
if len(c.Refused) != 3 || len(c.Drifting) != 0 {
t.Fatalf("refused, never managed: %+v", c)
}
raw, _ := json.Marshal(c)
if strings.Contains(string(raw), "example.org") {
t.Fatalf("no account value is answered: %s", raw)
}
stopBetterbird(t, f)
if _, err := f.ConfigApply(true); err != nil {
t.Fatal(err)
}
if f.read(profileDir+"/prefs.js") != prefs {
t.Fatal("prefs.js keeps every account's value")
}
ch, _ := f.Check()
found := false
for _, x := range ch.Findings {
found = found || (strings.Contains(x.What, "3 preference(s) the tools never manage") && strings.Contains(x.Do, "mesh-defaults.js"))
}
if !found {
t.Fatalf("the check says so: %+v", ch.Findings)
}
}
@@ -0,0 +1,471 @@
package main
// Betterbird's whole configuration, beside the accounts (novox/hq ADR 0329 §4–§6, issue 586).
//
// Five settings, each mesh-wide with a node's layer over it, reach the machine as two files the node-engine
// merges from the module's layers key by key:
//
// - ~/.config/betterbird/configuration.json: identities, calendars, address-books and filters, trusted as
// accounts is (§5), because each names where a password, a reply or forwarded mail goes;
// - ~/.config/betterbird/tags.json: tags, which names no server and sends no mail, and is not trusted.
//
// An entry is keyed by a name of the operator's (lower-case letters and digits, as an account's), a tag by its
// own key. A node's layer may set an entry to null to take it out on that node, and may give an identity, a
// calendar or an address book its `key`: the profile's key to adopt there (§4, §7). No value holds a password,
// a token or a cookie: a key shaped like one is refused whole, and so is an address whose user part carries a
// password (§2). A setting holds no line break (novox/hq issue 339), so a signature is a list of its lines.
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/url"
"os"
"regexp"
"sort"
"strings"
)
const (
// configurationFile holds identities, calendars, address-books and filters; tagsFile holds tags.
configurationFile = ".config/betterbird/configuration.json"
tagsFile = ".config/betterbird/tags.json"
)
// The five kinds, in the order the tools answer them.
const (
kindIdentities = "identities"
kindCalendars = "calendars"
kindAddressBooks = "address-books"
kindFilters = "filters"
kindTags = "tags"
)
var allKinds = []string{kindIdentities, kindCalendars, kindAddressBooks, kindFilters, kindTags}
// Identity is one identity of a declared account. The account's default identity is the entry that gives no
// address: its name and address are the ones the setting accounts gives (§4). Any other entry is another
// identity of that account, matched by its address.
type Identity struct {
Account string `json:"account"`
Address string `json:"address,omitempty"`
Name string `json:"name,omitempty"`
ReplyTo string `json:"reply-to,omitempty"`
Organization string `json:"organization,omitempty"`
// Signature is the signature's text, a line to an element.
Signature []string `json:"signature,omitempty"`
SignatureHTML bool `json:"signature-html,omitempty"`
// SignatureAboveQuote puts the reply and its signature above the quoted message (Betterbird's "start my
// reply above the quote", signature below the reply); false is below the quote.
SignatureAboveQuote bool `json:"signature-above-quote,omitempty"`
Key string `json:"key,omitempty"`
}
// Calendar is one CalDAV calendar.
type Calendar struct {
Name string `json:"name"`
URL string `json:"url"`
Color string `json:"color,omitempty"`
ReadOnly bool `json:"read-only,omitempty"`
// Reminders absent is on.
Reminders *bool `json:"reminders,omitempty"`
User string `json:"user,omitempty"`
// Invitations is the identities entry whose address answers invitations; absent, none.
Invitations string `json:"invitations,omitempty"`
Key string `json:"key,omitempty"`
}
// AddressBook is one CardDAV address book.
type AddressBook struct {
Name string `json:"name"`
URL string `json:"url"`
User string `json:"user,omitempty"`
Key string `json:"key,omitempty"`
}
// Tag is one tag, keyed in the setting by its own key.
type Tag struct {
Name string `json:"name"`
Color string `json:"color,omitempty"`
// Order is its place among the tags, from 1.
Order int `json:"order,omitempty"`
}
// Filter is one message filter of an account, in its place in the account's list.
type Filter struct {
Name string `json:"name"`
Enabled bool `json:"enabled"`
// When it runs: new-mail, manual, after-junk, sending, archiving, periodic.
When []string `json:"when"`
// Match is all (every condition holds), any (one does) or every-message (no condition).
Match string `json:"match"`
Conditions []Condition `json:"conditions,omitempty"`
Actions []Action `json:"actions"`
}
// Condition is one of a filter's conditions, in Betterbird's own words: a field, an operator, a value.
type Condition struct {
Field string `json:"field"`
Op string `json:"op"`
Value string `json:"value,omitempty"`
}
// Action is one of a filter's actions, in Betterbird's own words. A folder is named by its path within the
// account (INBOX/Receipts), and becomes that folder's address on each node (§8).
type Action struct {
Action string `json:"action"`
Folder string `json:"folder,omitempty"`
Value string `json:"value,omitempty"`
}
// Configuration is the five settings as the machine has them.
type Configuration struct {
Identities map[string]*Identity `json:"identities,omitempty"`
Calendars map[string]*Calendar `json:"calendars,omitempty"`
AddressBooks map[string]*AddressBook `json:"address-books,omitempty"`
Filters map[string][]Filter `json:"filters,omitempty"`
Tags map[string]*Tag `json:"tags,omitempty"`
}
var (
entryName = regexp.MustCompile(`^[a-z][a-z0-9]{0,30}$`)
tagKey = regexp.MustCompile(`^[$A-Za-z0-9_.\-]{1,64}$`)
colorWord = regexp.MustCompile(`^#[0-9A-Fa-f]{6}$`)
// secretName is a field's name that says it holds a credential: such a field is withheld from every answer (§2).
secretName = regexp.MustCompile(`(?i)(password|passwd|token|secret|cookie)`)
// userPassword is an address whose user part carries a password: scheme://user:password@host.
userPassword = regexp.MustCompile(`(?i)\b([a-z][a-z0-9+.\-]*://[^/:@\s]*):[^@/\s]+@`)
)
// The words a filter is written in.
var (
filterWhen = map[string]int{"new-mail": 0x1, "manual": 0x10, "after-junk": 0x20, "sending": 0x40,
"archiving": 0x80, "periodic": 0x100}
filterFields = map[string]bool{"subject": true, "from": true, "to": true, "cc": true, "to or cc": true,
"all addresses": true, "body": true, "date": true, "priority": true, "status": true, "age in days": true,
"size": true, "tag": true, "junk status": true, "junk percent": true, "junk score origin": true,
"has attachment status": true, "date received": true}
filterOps = map[string]bool{"contains": true, "doesn't contain": true, "is": true, "isn't": true,
"begins with": true, "ends with": true, "is empty": true, "isn't empty": true, "is before": true,
"is after": true, "is greater than": true, "is less than": true, "is higher than": true,
"is lower than": true, "is in ab": true, "isn't in ab": true, "matches": true, "doesn't match": true}
// filterActions are the actions a declared filter may take, and whether each names a folder or a value.
filterActions = map[string]string{"Move to folder": "folder", "Copy to folder": "folder",
"Change priority": "value", "Delete": "", "Mark read": "", "Mark unread": "", "Mark flagged": "",
"Ignore thread": "", "Ignore subthread": "", "Watch thread": "", "AddTag": "value", "JunkScore": "value",
"Forward": "value", "Stop execution": "", "Delete from Pop3 server": "", "Leave on Pop3 server": "",
"Fetch body from Pop3Server": ""}
)
// readConfiguration reads the five settings. A missing file holds none of its kinds (the module is older
// here); a file that carries a key shaped like a credential, an address with a password, an unknown field or a
// value Betterbird cannot be given is refused whole, naming the entry and never a value.
func (m *Machine) readConfiguration() (Configuration, error) {
var c Configuration
var problems []string
for _, file := range []string{configurationFile, tagsFile} {
raw, err := readBounded(m.home(file))
if errors.Is(err, os.ErrNotExist) {
continue
}
if err != nil {
return Configuration{}, err
}
if err := decodeInto(raw, file, &c); err != nil {
problems = append(problems, err.Error())
}
}
if len(problems) > 0 {
return Configuration{}, errors.New(strings.Join(problems, "; "))
}
if p := configurationProblems(c); len(p) > 0 {
return Configuration{}, fmt.Errorf("the configuration is refused: %s", strings.Join(p, "; "))
}
c.dropNulls()
for _, cal := range c.Calendars {
cal.Color = strings.ToLower(cal.Color)
}
for _, t := range c.Tags {
t.Color = strings.ToUpper(t.Color)
}
return c, nil
}
// decodeInto reads one settings file into c, refusing a credential-shaped key or an unknown field.
func decodeInto(raw []byte, file string, c *Configuration) error {
var tree any
if err := json.Unmarshal(raw, &tree); err != nil {
return fmt.Errorf("~/%s is not JSON: %v", file, err)
}
if where := secretKeyIn(tree, ""); where != "" {
return fmt.Errorf("~/%s carries %s, a key shaped like a credential: the configuration is declared without "+
"any password, token or cookie (novox/hq ADR 0329 §2); take it out at the controller's terminal", file, where)
}
dec := json.NewDecoder(bytes.NewReader(raw))
dec.DisallowUnknownFields()
var into Configuration
if err := dec.Decode(&into); err != nil {
return fmt.Errorf("~/%s does not hold the shape of the settings it takes: %v", file, err)
}
if file == tagsFile {
if into.Identities != nil || into.Calendars != nil || into.AddressBooks != nil || into.Filters != nil {
return fmt.Errorf("~/%s holds only tags", file)
}
c.Tags = into.Tags
return nil
}
if into.Tags != nil {
return fmt.Errorf("~/%s holds no tags: tags are their own setting", file)
}
c.Identities, c.Calendars, c.AddressBooks, c.Filters = into.Identities, into.Calendars, into.AddressBooks, into.Filters
return nil
}
// dropNulls takes out the entries a node's layer set to null: not on this node.
func (c *Configuration) dropNulls() {
for k, v := range c.Identities {
if v == nil {
delete(c.Identities, k)
}
}
for k, v := range c.Calendars {
if v == nil {
delete(c.Calendars, k)
}
}
for k, v := range c.AddressBooks {
if v == nil {
delete(c.AddressBooks, k)
}
}
for k, v := range c.Filters {
if v == nil {
delete(c.Filters, k)
}
}
for k, v := range c.Tags {
if v == nil {
delete(c.Tags, k)
}
}
}
// addressPassword says whether an address carries a password in its user part.
func addressPassword(s string) bool {
if userPassword.MatchString(s) {
return true
}
if u, err := url.Parse(s); err == nil && u.User != nil {
_, has := u.User.Password()
return has
}
return false
}
// withoutPassword is an address with the password of its user part as ‹withheld› (§2).
func withoutPassword(s string) string {
return userPassword.ReplaceAllString(s, "${1}:‹withheld›@")
}
// davURL says what is wrong with a calendar's or an address book's address, or "".
func davURL(s string) string {
if addressPassword(s) {
return "its address carries a password in its user part, which no setting holds (novox/hq ADR 0329 §2): " +
"Betterbird asks for the password at the first sign-in"
}
u, err := url.Parse(s)
if err != nil || (u.Scheme != "https" && u.Scheme != "http") || u.Host == "" {
return "its address is an https:// (or http://) address of the server"
}
if strings.ContainsAny(s, lineEnds+`"\`) {
return "its address holds a quote, a backslash or a line break"
}
return ""
}
// configurationProblems is everything wrong with the five settings, each said by its kind and entry name.
func configurationProblems(c Configuration) []string {
var out []string
say := func(kind, name, format string, args ...any) {
out = append(out, fmt.Sprintf("%s %s: ", kind, name)+fmt.Sprintf(format, args...))
}
text := func(kind, name, field, v string) {
if !plainText.MatchString(v) {
say(kind, name, "%s holds a quote, a backslash or a line break", field)
}
}
for _, name := range sortedKeys(c.Identities) {
id := c.Identities[name]
if id == nil {
continue
}
if !entryName.MatchString(name) {
say(kindIdentities, name, "a name is lower-case letters and digits, starting with a letter")
}
if !accountName.MatchString(id.Account) {
say(kindIdentities, name, "account names an entry of the setting accounts")
}
if id.Address == "" && id.Name != "" {
say(kindIdentities, name, "the account's default identity (no address) takes its name from the setting accounts")
}
if id.Address != "" && !addressShape.MatchString(id.Address) {
say(kindIdentities, name, "the address is not a mail address")
}
if id.ReplyTo != "" && !addressShape.MatchString(id.ReplyTo) {
say(kindIdentities, name, "reply-to is not a mail address")
}
for field, v := range map[string]string{"name": id.Name, "organization": id.Organization} {
text(kindIdentities, name, field, v)
}
for _, line := range id.Signature {
if strings.ContainsAny(line, lineEnds) {
say(kindIdentities, name, "a signature's line holds a line break: each line is its own element")
break
}
}
if id.Key != "" && !profileKey.MatchString(id.Key) {
say(kindIdentities, name, "key is an identity key of the profile, such as id3")
}
}
for _, name := range sortedKeys(c.Calendars) {
cal := c.Calendars[name]
if cal == nil {
continue
}
if !entryName.MatchString(name) {
say(kindCalendars, name, "a name is lower-case letters and digits, starting with a letter")
}
if why := davURL(cal.URL); why != "" {
say(kindCalendars, name, "%s", why)
}
if cal.Color != "" && !colorWord.MatchString(cal.Color) {
say(kindCalendars, name, "a colour is #rrggbb")
}
if cal.Invitations != "" && c.Identities[cal.Invitations] == nil {
say(kindCalendars, name, "invitations names %s, which the setting identities does not hold", cal.Invitations)
}
text(kindCalendars, name, "name", cal.Name)
text(kindCalendars, name, "user", cal.User)
if cal.Key != "" && !tagKey.MatchString(cal.Key) {
say(kindCalendars, name, "key is a calendar key of the profile")
}
}
for _, name := range sortedKeys(c.AddressBooks) {
ab := c.AddressBooks[name]
if ab == nil {
continue
}
if !entryName.MatchString(name) {
say(kindAddressBooks, name, "a name is lower-case letters and digits, starting with a letter")
}
if why := davURL(ab.URL); why != "" {
say(kindAddressBooks, name, "%s", why)
}
text(kindAddressBooks, name, "name", ab.Name)
text(kindAddressBooks, name, "user", ab.User)
if ab.Key != "" && !profileKey.MatchString(ab.Key) {
say(kindAddressBooks, name, "key is an address book key of the profile")
}
}
for _, key := range sortedKeys(c.Tags) {
t := c.Tags[key]
if t == nil {
continue
}
if !tagKey.MatchString(key) {
say(kindTags, key, "a tag's key is letters, digits, $, _, . or -")
}
if t.Color != "" && !colorWord.MatchString(t.Color) {
say(kindTags, key, "a colour is #rrggbb")
}
text(kindTags, key, "name", t.Name)
}
for _, account := range sortedKeys(c.Filters) {
if !accountName.MatchString(account) {
say(kindFilters, account, "filters are keyed by an account's name in the setting accounts")
}
for i, f := range c.Filters[account] {
for _, p := range filterProblems(f) {
say(kindFilters, account, "filter %d: %s", i+1, p)
}
}
}
return out
}
// filterValue is what a filter's name, value or folder may hold: what its file can carry without quoting.
var filterValue = regexp.MustCompile(`^[^"\\()\n\r]*$`)
func filterProblems(f Filter) []string {
var out []string
if f.Name == "" || !filterValue.MatchString(f.Name) {
out = append(out, "a name, without a quote, a backslash or a parenthesis")
}
if len(f.When) == 0 {
out = append(out, "when says when it runs: new-mail, manual, after-junk, sending, archiving or periodic")
}
for _, w := range f.When {
if _, ok := filterWhen[w]; !ok {
out = append(out, fmt.Sprintf("when %q is new-mail, manual, after-junk, sending, archiving or periodic", w))
}
}
switch f.Match {
case "all", "any":
if len(f.Conditions) == 0 {
out = append(out, "match all or any needs a condition")
}
case "every-message":
if len(f.Conditions) > 0 {
out = append(out, "match every-message takes no condition")
}
default:
out = append(out, "match is all, any or every-message")
}
for i, c := range f.Conditions {
if !filterFields[c.Field] && !(strings.HasPrefix(c.Field, `"`) && strings.HasSuffix(c.Field, `"`) && len(c.Field) > 2) {
out = append(out, fmt.Sprintf("condition %d: the field %q is not one Betterbird filters on", i+1, c.Field))
}
if !filterOps[c.Op] {
out = append(out, fmt.Sprintf("condition %d: the operator %q is not one of Betterbird's", i+1, c.Op))
}
if !filterValue.MatchString(c.Value) {
out = append(out, fmt.Sprintf("condition %d: the value holds a quote, a backslash, a parenthesis or a line break", i+1))
}
}
if len(f.Actions) == 0 {
out = append(out, "a filter takes an action")
}
for i, a := range f.Actions {
takes, ok := filterActions[a.Action]
switch {
case !ok:
out = append(out, fmt.Sprintf("action %d: %q is not an action a declared filter takes", i+1, a.Action))
case takes == "folder" && (a.Folder == "" || a.Value != ""):
out = append(out, fmt.Sprintf("action %d: %s names a folder, by its path within the account", i+1, a.Action))
case takes == "value" && (a.Value == "" || a.Folder != ""):
out = append(out, fmt.Sprintf("action %d: %s takes a value", i+1, a.Action))
case takes == "" && (a.Value != "" || a.Folder != ""):
out = append(out, fmt.Sprintf("action %d: %s takes no value", i+1, a.Action))
}
if !filterValue.MatchString(a.Value) || !filterValue.MatchString(a.Folder) || strings.HasPrefix(a.Folder, "/") ||
strings.Contains(a.Folder, "://") {
out = append(out, fmt.Sprintf("action %d: a folder is a path within the account, and a value holds no quote, "+
"backslash, parenthesis or line break", i+1))
}
if a.Action == "Forward" && !addressShape.MatchString(a.Value) {
out = append(out, fmt.Sprintf("action %d: Forward names a mail address", i+1))
}
}
return out
}
// sortedFilterAccounts are the accounts filters are declared for.
func (c Configuration) sortedFilterAccounts() []string {
out := make([]string, 0, len(c.Filters))
for a := range c.Filters {
out = append(out, a)
}
sort.Strings(out)
return out
}
@@ -0,0 +1,518 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
)
// Betterbird's whole configuration (novox/hq ADR 0329, issue 586): identities, calendars, address books, filters
// and tags declared mesh-wide, adopted or created as accounts are, filters owned whole per declared account, what
// Betterbird rewrites written only while it is closed, derived from the nodes' profiles one layer at a time, and
// shown with the operator's own values but never a credential.
// profileWithConfiguration adds to profileWithAccounts' profile: account1's directory and signature, CalDAV
// calendars (one address twice), an ICS and an Owl calendar, a local and a CardDAV address book, two tags, and
// account1's filter file holding one filter of the person's.
func (f *fake) profileWithConfiguration() {
f.t.Helper()
prefs := f.read(profileDir+"/prefs.js") +
`user_pref("mail.server.server1.directory-rel", "[ProfD]ImapMail/imap.example.org");` + "\n" +
`user_pref("mail.server.server2.directory-rel", "[ProfD]Mail/Local Folders");` + "\n" +
`user_pref("mail.identity.id1.htmlSigText", "Regards");` + "\n" +
`user_pref("calendar.registry.c1.type", "caldav");` + "\n" +
`user_pref("calendar.registry.c1.uri", "https://dav.example.org/cal/home/");` + "\n" +
`user_pref("calendar.registry.c1.name", "Home");` + "\n" +
`user_pref("calendar.registry.c1.color", "#3366ff");` + "\n" +
`user_pref("calendar.registry.c2.type", "caldav");` + "\n" +
`user_pref("calendar.registry.c2.uri", "https://dav.example.org/cal/shared/");` + "\n" +
`user_pref("calendar.registry.c2.name", "Shared A");` + "\n" +
`user_pref("calendar.registry.c3.type", "caldav");` + "\n" +
`user_pref("calendar.registry.c3.uri", "https://dav.example.org/cal/shared/");` + "\n" +
`user_pref("calendar.registry.c3.name", "Shared B");` + "\n" +
`user_pref("calendar.registry.c4.type", "ics");` + "\n" +
`user_pref("calendar.registry.c4.uri", "https://feeds.example.org/secret-token-abc.ics");` + "\n" +
`user_pref("calendar.registry.c4.name", "Holidays");` + "\n" +
`user_pref("calendar.registry.c5.type", "owl");` + "\n" +
`user_pref("calendar.registry.c5.uri", "owl://work@corp.example.com/calendar");` + "\n" +
`user_pref("calendar.registry.c5.name", "Work");` + "\n" +
`user_pref("calendar.list.sortOrder", "c1 c2 c3 c4 c5");` + "\n" +
`user_pref("ldap_2.servers.pab.description", "Personal Address Book");` + "\n" +
`user_pref("ldap_2.servers.pab.dirType", 101);` + "\n" +
`user_pref("ldap_2.servers.ab1.description", "Contacts");` + "\n" +
`user_pref("ldap_2.servers.ab1.dirType", 102);` + "\n" +
`user_pref("ldap_2.servers.ab1.carddav.url", "https://dav.example.org/card/me/");` + "\n" +
`user_pref("ldap_2.servers.ab1.carddav.username", "me");` + "\n" +
`user_pref("mailnews.tags.$label1.tag", "Important");` + "\n" +
`user_pref("mailnews.tags.$label1.color", "#FF0000");` + "\n" +
`user_pref("mailnews.tags.personal.tag", "Personal");` + "\n" +
`user_pref("mailnews.tags.personal.color", "#00AA00");` + "\n"
f.write(profileDir+"/prefs.js", prefs)
f.write(profileDir+"/ImapMail/imap.example.org/"+filterFileName, "version=\"9\"\nlogging=\"no\"\nname=\"Old\"\n"+
"enabled=\"yes\"\ntype=\"17\"\naction=\"Mark read\"\ncondition=\"AND (from,contains,news)\"\n")
f.write(profileDir+"/Mail/Local Folders/"+filterFileName, "version=\"9\"\nlogging=\"no\"\n")
f.write(profileDir+"/calendar-data/cache.sqlite", "events")
}
const declaredConfiguration = `{
"identities": {
"mail": {"account": "mail", "signature": ["-- ", "Other"], "signature-above-quote": true},
"mailalias": {"account": "mail", "address": "alias@mail.example.org", "name": "Alias", "reply-to": "other@mail.example.org"}
},
"calendars": {
"home": {"name": "Home", "url": "https://dav.example.org/cal/home", "color": "#3366CC", "user": "other", "invitations": "mail"},
"work": {"name": "Team", "url": "https://dav.example.org/cal/team/"},
"shared": {"name": "Shared", "url": "https://dav.example.org/cal/shared/"}
},
"address-books": {
"contacts": {"name": "Contacts", "url": "https://dav.example.org/card/me/", "user": "other"},
"team": {"name": "Team", "url": "https://dav.example.org/card/team/"}
},
"filters": {}
}`
const declaredTags = `{"tags": {"$label1": {"name": "Important", "color": "#ff0000", "order": 1}, "todo": {"name": "To do", "color": "#0000ff", "order": 2}}}`
func (f *fake) declareConfiguration(body, tags string) {
f.write(testHome+"/"+configurationFile, body)
f.write(testHome+"/"+tagsFile, tags)
}
func entriesBy(u UserJS) map[string]resolvedEntry {
out := map[string]resolvedEntry{}
for _, e := range u.Configuration {
out[e.Kind+"/"+e.Name] = e
}
return out
}
// §2, §4, §6: the settings' shapes; a credential, an address with a password, a key on a tag and the four new
// prefixes in prefs are refused, said without the value.
func TestTheSettingsRefuseACredentialAPasswordedAddressATagsKeyAndTheirPrefixesInPrefs(t *testing.T) {
f := newFake(t)
f.declareConfiguration(declaredConfiguration, declaredTags)
c, err := f.readConfiguration()
if err != nil || len(c.Identities) != 2 || len(c.Calendars) != 3 || len(c.AddressBooks) != 2 || len(c.Tags) != 2 {
t.Fatalf("the declared shapes are taken: %+v %v", c, err)
}
if c.Calendars["home"].Color != "#3366cc" || c.Tags["todo"].Color != "#0000FF" {
t.Errorf("colours are said as Betterbird keeps them: %+v %+v", c.Calendars["home"], c.Tags["todo"])
}
for _, bad := range []struct{ conf, tags, says string }{
{`{"calendars": {"home": {"name": "H", "url": "https://dav.example.org/c/", "password": "hunter2"}}}`, `{}`, "shaped like a credential"},
{`{"calendars": {"home": {"name": "H", "url": "https://me:hunter2@dav.example.org/c/"}}}`, `{}`, "carries a password"},
{`{"address-books": {"b": {"name": "B", "url": "https://me:hunter2@dav.example.org/b/"}}}`, `{}`, "carries a password"},
{`{}`, `{"tags": {"todo": {"name": "To do", "key": "x"}}}`, "does not hold the shape"},
{`{"tags": {"todo": {"name": "To do"}}}`, `{}`, "holds no tags"},
{`{"identities": {"x": {"account": "mail", "name": "Me"}}}`, `{}`, "default identity"},
{`{"filters": {"mail": [{"name": "F", "enabled": true, "when": ["manual"], "match": "all", "actions": [{"action": "Delete"}]}]}}`, `{}`, "needs a condition"},
{`{"filters": {"mail": [{"name": "F", "enabled": true, "when": ["manual"], "match": "every-message", "actions": [{"action": "Move to folder", "folder": "imap://x@y/INBOX"}]}]}}`, `{}`, "path within the account"},
} {
f.declareConfiguration(bad.conf, bad.tags)
_, err := f.readConfiguration()
if err == nil || !strings.Contains(err.Error(), bad.says) || strings.Contains(err.Error(), "hunter2") {
t.Errorf("%s %s: %v", bad.conf, bad.tags, err)
}
}
// A key is an identity's, a calendar's or an address book's.
f.declareConfiguration(`{"identities": {"x": {"account": "mail", "address": "x@mail.example.org", "key": "id9"}},
"calendars": {"c": {"name": "C", "url": "https://dav.example.org/c/", "key": "c9"}},
"address-books": {"b": {"name": "B", "url": "https://dav.example.org/b/", "key": "ab9"}}}`, `{}`)
if _, err := f.readConfiguration(); err != nil {
t.Fatal(err)
}
// prefs refuses the four prefixes the new settings own, and the account's and a credential's as before.
for _, name := range []string{"calendar.registry.c1.color", "calendar.list.sortOrder", "ldap_2.servers.ab1.description",
"mailnews.tags.$label1.color", "mail.identity.id1.htmlSigText", "signon.rememberSignons"} {
body, _ := json.Marshal(map[string]any{"prefs": map[string]any{name: "x"}})
if _, err := parseDeclaredPrefs(body); err == nil || !strings.Contains(err.Error(), name) {
t.Errorf("%s: %v", name, err)
}
}
got, err := parseDeclaredPrefs([]byte(`{"prefs": {"mail.uifontsize": 14, "mail.chat.enabled": false, "calendar.alarms.show": null,
"intl.date_time.pattern_override.date_short": "yyyy-MM-dd"}}`))
if err != nil || !reflect.DeepEqual(got, map[string]string{"mail.uifontsize": "14", "mail.chat.enabled": "false",
"intl.date_time.pattern_override.date_short": `"yyyy-MM-dd"`}) {
t.Fatalf("one entry per preference, null taking one out: %v %v", got, err)
}
if _, err := parseDeclaredPrefs([]byte(`{"prefs": {"mail.uifontsize": 14.5}}`)); err == nil {
t.Fatal("a number Betterbird does not keep is refused")
}
}
// §7, §9: each kind is adopted, created or refused as accounts are; the declared fields go into the user.js, and
// what Betterbird rewrites waits for it to close; leaving the setting releases or removes, never events.
func TestEachKindIsAdoptedOrCreatedAndWhatBetterbirdRewritesWaitsForItToClose(t *testing.T) {
f := newFake(t)
f.profileWithAccounts()
f.profileWithConfiguration()
f.noPackage()
links(t, nil)
f.declare("// none")
f.declareAccounts(adoptable)
f.declareConfiguration(declaredConfiguration, declaredTags)
before := f.read(profileDir + "/prefs.js")
// Betterbird runs: the user.js is placed, prefs.js and the filter files wait (§9).
u, err := f.userJS(true)
if err != nil {
t.Fatal(err)
}
by := entriesBy(u)
for want, how := range map[string][2]string{
"identities/mail": {adopted, "id1"}, "identities/mailalias": {created, "ident_mesh_mailalias"},
"calendars/home": {adopted, "c1"}, "calendars/work": {created, "mesh_work"}, "calendars/shared": {refused, ""},
"address-books/contacts": {adopted, "ab1"}, "address-books/team": {created, "mesh_team"},
"tags/$label1": {adopted, "$label1"}, "tags/todo": {created, "todo"},
} {
if e := by[want]; e.How != how[0] || e.Key != how[1] {
t.Errorf("%s: %+v, want %v", want, e, how)
}
}
if why := by["calendars/shared"].Why; !strings.Contains(why, "2 entries") || !strings.Contains(why, "c2, c3") ||
!strings.Contains(why, "key in this node's layer") {
t.Errorf("an ambiguous match is refused with the keys and the remedy: %s", why)
}
placed := f.read(profileDir + "/user.js")
for _, want := range []string{
`user_pref("mail.identity.id1.htmlSigText", "-- \nOther");`,
`user_pref("mail.identity.id1.reply_on_top", 1);`,
`user_pref("mail.identity.id1.sig_bottom", false);`,
`user_pref("mail.identity.ident_mesh_mailalias.useremail", "alias@mail.example.org");`,
`user_pref("mail.identity.ident_mesh_mailalias.smtpServer", "smtp1");`,
`user_pref("calendar.registry.c1.color", "#3366cc");`,
`user_pref("calendar.registry.c1.imip.identity.key", "id1");`,
`user_pref("calendar.registry.mesh_work.type", "caldav");`,
`user_pref("ldap_2.servers.ab1.carddav.username", "other");`,
`user_pref("ldap_2.servers.mesh_team.dirType", 102);`,
`user_pref("mailnews.tags.todo.color", "#0000FF");`,
`user_pref("mailnews.tags.todo.ordinal", "1002");`,
} {
if !strings.Contains(placed, want) {
t.Errorf("the user.js lacks %s", want)
}
}
for _, never := range []string{"calendar.registry.c2.", "calendar.registry.c3.", "calendar.registry.c4.", "calendar.registry.c5.",
"ldap_2.servers.pab.", "mailnews.tags.personal.", "calendar.list.sortOrder"} {
if strings.Contains(placed, never) {
t.Errorf("the user.js writes %s, which nothing declares or which is refused", never)
}
}
if !strings.HasPrefix(u.Closed, "not written") || !strings.Contains(u.Closed, "running") ||
u.Waiting[kindIdentities] != 1 || u.Waiting[kindCalendars] != 1 {
t.Fatalf("while Betterbird runs prefs.js is not written, and the waiting changes are counted: %q %v", u.Closed, u.Waiting)
}
if f.read(profileDir+"/prefs.js") != before {
t.Fatal("prefs.js changed while Betterbird runs")
}
ch, _ := f.Check()
waits := false
for _, x := range ch.Findings {
waits = waits || (strings.Contains(x.What, "change(s) to calendars are not written yet") && strings.Contains(x.Do, "betterbird_restart"))
}
if !waits {
t.Fatalf("the check counts the waiting changes per kind: %+v", ch.Findings)
}
// A key in the node's layer settles the ambiguous calendar; one the profile does not hold is refused.
f.declareConfiguration(strings.Replace(declaredConfiguration, `"url": "https://dav.example.org/cal/shared/"}`,
`"url": "https://dav.example.org/cal/shared/", "key": "c3"}`, 1), declaredTags)
if u, _ = f.userJS(false); entriesBy(u)["calendars/shared"].Key != "c3" || entriesBy(u)["calendars/shared"].How != adopted {
t.Fatalf("the named key is adopted: %+v", entriesBy(u)["calendars/shared"])
}
f.declareConfiguration(strings.Replace(declaredConfiguration, `"url": "https://dav.example.org/cal/shared/"}`,
`"url": "https://dav.example.org/cal/shared/", "key": "c9"}`, 1), declaredTags)
if u, _ = f.userJS(false); entriesBy(u)["calendars/shared"].How != refused || !strings.Contains(entriesBy(u)["calendars/shared"].Why, "c9") {
t.Fatalf("a key the profile does not hold is refused: %+v", entriesBy(u)["calendars/shared"])
}
f.declareConfiguration(strings.Replace(declaredConfiguration, `"url": "https://dav.example.org/cal/shared/"}`,
`"url": "https://dav.example.org/cal/shared/", "key": "c1"}`, 1), declaredTags)
if u, _ = f.userJS(false); entriesBy(u)["calendars/shared"].How != refused {
t.Fatalf("a key whose entry does not match is refused: %+v", entriesBy(u)["calendars/shared"])
}
f.declareConfiguration(declaredConfiguration, declaredTags)
// Closed: the lists gain the module's own keys at their end, every other line as it was.
stopBetterbird(t, f)
f.write(profileDir+"/lock", "")
if err := f.Prepare(); err == nil || !strings.Contains(err.Error(), "locked") {
t.Fatalf("the profile's lock holds the writes back: %v", err)
}
os.Remove(filepath.Join(f.Root, profileDir, "lock"))
if err := f.Prepare(); err != nil && !strings.Contains(err.Error(), "calendars shared is refused") {
t.Fatal(err)
}
after := f.read(profileDir + "/prefs.js")
for _, want := range []string{`user_pref("mail.account.account1.identities", "id1,ident_mesh_mailalias");`,
`user_pref("calendar.list.sortOrder", "c1 c2 c3 c4 c5 mesh_work");`} {
if !strings.Contains(after, want) {
t.Errorf("prefs.js lacks %s:\n%s", want, after)
}
}
bl, al := strings.Split(before, "\n"), strings.Split(after, "\n")
for i := range bl {
if bl[i] != al[i] && !strings.Contains(bl[i], ".identities") && !strings.Contains(bl[i], "sortOrder") {
t.Errorf("line %d changed: %q to %q", i+1, bl[i], al[i])
}
}
// Leaving: an adopted entry is released (its lines go, the profile keeps it), a created one's preferences are
// removed; the calendar's events stay.
f.write(profileDir+"/prefs.js", f.read(profileDir+"/prefs.js")+
`user_pref("calendar.registry.mesh_work.calendarSyncState", "x");`+"\n"+`user_pref("mailnews.tags.todo.tag", "To do");`+"\n")
f.declareConfiguration(`{"identities": {"mail": {"account": "mail"}}}`, `{"tags": {}}`)
if err := f.Prepare(); err != nil {
t.Fatal(err)
}
final := f.read(profileDir + "/prefs.js")
for _, gone := range []string{"mesh_work", "ident_mesh_mailalias", "mailnews.tags.todo."} {
if strings.Contains(final, gone) {
t.Errorf("what the module created and the setting left is removed: %s is still there", gone)
}
}
for _, kept := range []string{`calendar.registry.c1.uri`, `ldap_2.servers.ab1.carddav.url`, `mailnews.tags.$label1.tag`,
`calendar.registry.c4.uri`} {
if !strings.Contains(final, kept) {
t.Errorf("released or undeclared, %s stays the person's", kept)
}
}
if f.read(profileDir+"/calendar-data/cache.sqlite") != "events" {
t.Error("a calendar's events are never touched")
}
if placed := f.read(profileDir + "/user.js"); strings.Contains(placed, "calendar.registry.c1.") || strings.Contains(placed, "ab1") {
t.Errorf("a released entry's lines leave the user.js:\n%s", placed)
}
}
const declaredFilters = `{"filters": {"mail": [
{"name": "Receipts", "enabled": true, "when": ["new-mail", "manual"], "match": "all",
"conditions": [{"field": "subject", "op": "contains", "value": "receipt"}],
"actions": [{"action": "Move to folder", "folder": "INBOX/Receipts"}, {"action": "Forward", "value": "archive@mail.example.org"}]},
{"name": "Everything", "enabled": false, "when": ["manual"], "match": "every-message", "actions": [{"action": "Mark read"}]}
]}}`
const renderedFilters = "version=\"9\"\nlogging=\"no\"\n" +
"name=\"Receipts\"\nenabled=\"yes\"\ntype=\"17\"\naction=\"Move to folder\"\n" +
"actionValue=\"imap://other2@imap.example.org/INBOX/Receipts\"\naction=\"Forward\"\nactionValue=\"archive@mail.example.org\"\n" +
"condition=\"AND (subject,contains,receipt)\"\n" +
"name=\"Everything\"\nenabled=\"no\"\ntype=\"16\"\naction=\"Mark read\"\ncondition=\"ALL\"\n"
// §8: an account named in filters has its msgFilterRules.dat written whole from the list, the replaced file kept;
// an account never named keeps its file byte for byte; one taken out keeps the file it last had.
func TestFiltersAreOwnedWholePerDeclaredAccount(t *testing.T) {
f := newFake(t)
f.profileWithAccounts()
f.profileWithConfiguration()
f.noPackage()
links(t, nil)
f.declare("// none")
f.declareAccounts(adoptable)
f.declareConfiguration(declaredFilters, `{"tags": {}}`)
filters := profileDir + "/ImapMail/imap.example.org/" + filterFileName
local := profileDir + "/Mail/Local Folders/" + filterFileName
old, oldLocal := f.read(filters), f.read(local)
u, err := f.userJS(true)
if err != nil {
t.Fatal(err)
}
if u.Waiting[kindFilters] != 1 || f.read(filters) != old {
t.Fatalf("while Betterbird runs the filter file waits: %v", u.Waiting)
}
stopBetterbird(t, f)
if err := f.Prepare(); err != nil {
t.Fatal(err)
}
if got := f.read(filters); got != renderedFilters {
t.Fatalf("the file is the declared list, its folder this node's address:\n%s", got)
}
keptPath := testHome + "/.config/betterbird/kept/60016jsp.default-default/mail." + filterFileName
if f.read(keptPath) != old {
t.Fatal("the replaced file is kept in the module's own directory")
}
if f.read(local) != oldLocal {
t.Fatal("an account never named keeps its file byte for byte")
}
if parsed := parseFilters(renderedFilters, "imap://other2@imap.example.org"); len(parsed.NotDeclarable) != 0 ||
mustJSON(parsed.Filters) != mustJSON(mustConfig(t, declaredFilters).Filters["mail"]) {
t.Fatalf("the file reads back as the declared list: %+v", parsed)
}
// A filter the person adds in Betterbird is drift first, and gone at the next apply; the kept file stays the first.
f.write(filters, renderedFilters+"name=\"Mine\"\nenabled=\"yes\"\ntype=\"16\"\naction=\"Delete\"\ncondition=\"ALL\"\n")
d, _ := f.Drift(kindFilters)
if len(d.Kinds) != 1 || len(d.Kinds[0].Declared) != 1 || d.Kinds[0].Declared[0].State != "differs" ||
!strings.Contains(mustJSON(d.Kinds[0].Declared[0].Differs), "Mine") {
t.Fatalf("drift shows the added filter: %+v", d)
}
if err := f.Prepare(); err != nil {
t.Fatal(err)
}
if f.read(filters) != renderedFilters || f.read(keptPath) != old {
t.Fatal("the added filter is gone, and the kept file is still the one first replaced")
}
// Taken out of filters: the module stops writing, the last file stays the person's, the kept copy is named.
f.declareConfiguration(`{"filters": {}}`, `{"tags": {}}`)
f.write(filters, renderedFilters+"name=\"Later\"\nenabled=\"yes\"\ntype=\"16\"\naction=\"Delete\"\ncondition=\"ALL\"\n")
lastHad := f.read(filters)
if err := f.Prepare(); err != nil {
t.Fatal(err)
}
if f.read(filters) != lastHad || f.read(keptPath) != old {
t.Fatal("an account taken out keeps its file byte for byte, and the kept copy stays")
}
l, _ := f.List(kindFilters)
if l.Kept["mail"] != "~/.config/betterbird/kept/60016jsp.default-default/mail."+filterFileName {
t.Fatalf("betterbird_filters says where the kept copy is: %+v", l.Kept)
}
d, _ = f.Drift(kindFilters)
if len(d.Kinds[0].Declared) != 0 || len(d.Kinds[0].Undeclared) != 0 {
t.Fatalf("drift no longer counts that account's filters: %+v", d.Kinds[0])
}
}
func mustConfig(t *testing.T, body string) Configuration {
t.Helper()
var c Configuration
if err := decodeInto([]byte(body), configurationFile, &c); err != nil {
t.Fatal(err)
}
return c
}
// §11: per kind and entry, the field that differs with both values; an undeclared entry is listed; once the module's
// user.js is placed, what it declares holds.
func TestDriftNamesEachDifferingFieldAndTheUndeclaredEntries(t *testing.T) {
f := newFake(t)
f.profileWithAccounts()
f.profileWithConfiguration()
f.noPackage()
links(t, nil)
f.declare("// none")
f.declareAccounts(adoptable)
f.declareConfiguration(strings.Replace(declaredConfiguration, `"work": {"name": "Team", "url": "https://dav.example.org/cal/team/"},`, "", 1), declaredTags)
d, err := f.Drift("")
if err != nil {
t.Fatal(err)
}
by := map[string]KindDrift{}
for _, k := range d.Kinds {
by[k.Kind] = k
}
field := func(kind, name, f string) *fieldDiff {
for _, e := range by[kind].Declared {
if e.Name == name {
for _, x := range e.Differs {
if x.Field == f {
return &x
}
}
}
}
return nil
}
if x := field(kindIdentities, "mail", "signature"); x == nil || mustJSON(x.Profile) != `["Regards"]` {
t.Errorf("the signature differs, with both values: %+v", by[kindIdentities])
}
if x := field(kindCalendars, "home", "color"); x == nil || x.Declared != "#3366cc" || x.Profile != "#3366ff" {
t.Errorf("the colour differs: %+v", by[kindCalendars])
}
if x := field(kindAddressBooks, "contacts", "user"); x == nil || x.Profile != "me" {
t.Errorf("the user differs: %+v", by[kindAddressBooks])
}
states := map[string]string{}
for _, k := range d.Kinds {
for _, e := range k.Declared {
states[k.Kind+"/"+e.Name] = e.State
}
}
if states["tags/todo"] != "lacks" || states["calendars/shared"] != refused || states["tags/$label1"] != "holds" ||
states["address-books/team"] != "lacks" {
t.Errorf("lacks, refused and holds: %v", states)
}
undeclared := mustJSON(d)
for _, want := range []string{`"key":"personal"`, `"key":"c2"`, `"key":"c3"`} {
if !strings.Contains(undeclared, want) {
t.Errorf("the undeclared entries are listed: lacks %s", want)
}
}
if _, err := f.userJS(true); err != nil {
t.Fatal(err)
}
d, _ = f.Drift("")
for _, k := range d.Kinds {
for _, e := range k.Declared {
if e.State == "differs" {
t.Errorf("once the user.js is placed, %s %s holds: %+v", k.Kind, e.Name, e)
}
}
}
}
// §1, §2, §13: the listing tools answer the operator's own values, Owl's marked, an ICS calendar by its name only,
// and never a credential: through the tools, a password in an address and a field named for one are withheld.
func TestTheListingsShowTheValuesOwlsMarkedAnICSByNameAndNoCredential(t *testing.T) {
f := newFake(t)
f.profileWithAccounts()
f.profileWithConfiguration()
f.write(profileDir+"/prefs.js", f.read(profileDir+"/prefs.js")+
`user_pref("calendar.registry.c6.type", "caldav");`+"\n"+
`user_pref("calendar.registry.c6.uri", "https://other:hunter2@dav.example.org/cal/x/");`+"\n"+
`user_pref("calendar.registry.c6.name", "Old");`+"\n")
f.write(profileDir+"/logins.json", `{"logins":[{"hostname":"https://dav.example.org","encryptedPassword":"hunter3"}]}`)
links(t, nil)
f.declare("// none")
f.declareAccounts(adoptable)
f.declareConfiguration(declaredConfiguration, declaredTags)
old := machine
machine = f.Machine
t.Cleanup(func() { machine = old })
answers := map[string]string{}
for _, tool := range tools() {
if strings.HasPrefix(tool.Name, "betterbird_") && contains([]string{"betterbird_identities", "betterbird_calendars",
"betterbird_address_books", "betterbird_filters", "betterbird_tags", "betterbird_accounts", "betterbird_drift"}, tool.Name) {
out, err := tool.Run(map[string]any{})
if err != nil {
t.Fatalf("%s: %v", tool.Name, err)
}
answers[tool.Name] = mustJSON(out)
}
}
cals := answers["betterbird_calendars"]
for _, shown := range []string{"https://dav.example.org/cal/home/", `"name":"Work","url":"owl://work@corp.example.com/calendar"`,
`"owl":true`, `"name":"Holidays"`} {
if !strings.Contains(cals, shown) {
t.Errorf("betterbird_calendars shows %s: %s", shown, cals)
}
}
if strings.Contains(cals, "secret-token") {
t.Error("an ICS calendar is listed by its name only")
}
for name, a := range answers {
if strings.Contains(a, "hunter2") || strings.Contains(a, "hunter3") {
t.Errorf("%s carries a credential: %s", name, a)
}
}
if !strings.Contains(cals, "https://other:‹withheld›@dav.example.org/cal/x/") {
t.Errorf("an address's password is shown as ‹withheld›: %s", cals)
}
for _, shown := range []string{`"signature":["Regards"]`, "other@mail.example.org"} {
if !strings.Contains(answers["betterbird_identities"], shown) {
t.Errorf("betterbird_identities shows %s", shown)
}
}
if got := mustJSON(credentialChecked(map[string]any{"name": "x", "password": "p", "nested": []any{map[string]any{"oauthToken": "t"}}})); got !=
`{"name":"x","nested":[{"oauthToken":"‹withheld›"}],"password":"‹withheld›"}` {
t.Errorf("a field named for a credential is withheld: %s", got)
}
// The start's words reach the journal: a refusal names kinds, names and keys, never an address.
stopBetterbird(t, f)
err := f.Prepare()
if err == nil || !strings.Contains(err.Error(), "calendars shared is refused") || strings.Contains(err.Error(), "example.org") {
t.Fatalf("%v", err)
}
}
@@ -0,0 +1,700 @@
package main
// The five settings derived from the desktop nodes' profiles and proposed for the operator's Approve, one layer
// at a time (novox/hq ADR 0329 §10, §12).
//
// betterbird_derive asks every desktop node that holds Betterbird for what its profile holds (its own derive with
// here), and lays the answers out per layer: an entry found on every such node goes into the mesh-wide layer, with
// the value of the node the derive was called on, and the answer lists every field another node holds differently,
// which then shows as drift there; an entry found on some nodes only goes into each of those nodes' layers, so
// nothing is added to another node until the operator moves it. With propose it proposes the next layer through
// the controller's settings verb: first the mesh-wide one, then each node's that needs one, each only once no
// proposal of the module's is open, against the layer as it then stands, which it carries whole beside the derived
// kinds (a layer is replaced whole). The ask shows the values in their shape (ADR 0277 §6); the operator reads them
// whole with `settings proposals <id>` at their desk.
import (
"encoding/json"
"errors"
"fmt"
"regexp"
"sort"
"strings"
)
// derivedEntry is one entry a node's profile holds, in the setting's shape. Match is what makes two nodes' entries
// the same one: an identity's account and address, a calendar's or an address book's address, a tag's key, an
// account's name for its filters.
type derivedEntry struct {
Match string `json:"match"`
Name string `json:"name"`
Value json.RawMessage `json:"value"`
}
// nodeDerivation is what one node's profile holds of the five kinds.
type nodeDerivation struct {
Node string `json:"node"`
Kinds map[string][]derivedEntry `json:"kinds"`
// NotDerived names what a setting does not hold, by name and why: Owl's, an ICS calendar, a local address
// book, an address carrying a password, a filter a setting cannot say.
NotDerived []string `json:"not_derived,omitempty"`
}
var nameChars = regexp.MustCompile(`[^a-z0-9]`)
// entryNameFor is a name of the operator's kind for an entry: lower-case letters and digits from its words,
// unique among those used.
func entryNameFor(words string, fallback string, used map[string]bool) string {
base := nameChars.ReplaceAllString(strings.ToLower(words), "")
if base == "" || base[0] < 'a' || base[0] > 'z' {
base = fallback + base
}
if len(base) > 28 {
base = base[:28]
}
name := base
for i := 2; used[name]; i++ {
name = fmt.Sprintf("%s%d", base, i)
}
used[name] = true
return name
}
func raw(v any) json.RawMessage {
b, _ := json.Marshal(v)
return b
}
// deriveHere reads this node's profile into the five kinds.
func (m *Machine) deriveHere(kinds []string) (nodeDerivation, error) {
d := nodeDerivation{Node: nodeName(), Kinds: map[string][]derivedEntry{}}
p, err := m.profile()
if err != nil {
return d, err
}
prefs := mergedPrefs(p.path)
accounts, _, err := m.readAccounts()
if err != nil {
return d, err
}
nameOf := map[string]string{}
serverOf := map[string]string{}
for _, r := range resolveAccounts(accounts, prefs, m.readRecord(p.Dir)) {
if r.How != refused {
nameOf[r.Keys.Account], serverOf[r.name] = r.name, r.Keys.Server
}
}
want := map[string]bool{}
for _, k := range kinds {
want[k] = true
}
// Identities, by their account's name; the default carries no name or address (the setting accounts' own).
identityName := map[string]string{}
used := map[string]bool{}
byAccount := map[string]bool{}
for _, pi := range m.profileIdentities(prefs) {
account, declared := nameOf[pi.AccountKey]
switch {
case pi.Owl:
if pi.Default {
d.NotDerived = append(d.NotDerived, fmt.Sprintf("identities: %s <%s> is Owl's (novox/hq ADR 0316 §13)",
pi.Value.Name, pi.Value.Address))
}
continue
case !declared:
if !byAccount[pi.AccountKey] {
byAccount[pi.AccountKey] = true
d.NotDerived = append(d.NotDerived, fmt.Sprintf("identities: those of the profile's account %s, which the "+
"setting accounts does not hold", pi.AccountKey))
}
continue
}
v := pi.Value
v.Account = account
e := derivedEntry{}
if pi.Default {
v.Address, v.Name = "", ""
e.Match, e.Name = account+"|", entryNameFor(account, "id", used)
} else {
local, _, _ := strings.Cut(v.Address, "@")
e.Match, e.Name = account+"|"+strings.ToLower(v.Address), entryNameFor(account+local, "id", used)
}
identityName[pi.Key] = e.Name
e.Value = raw(v)
if want[kindIdentities] {
d.Kinds[kindIdentities] = append(d.Kinds[kindIdentities], e)
}
}
// Calendars: CalDAV only; Owl's are listed, an ICS one by its name only, a local one by its name (§13).
used = map[string]bool{}
for _, pc := range profileCalendars(prefs) {
if !want[kindCalendars] {
break
}
switch {
case pc.Kind == "owl" || strings.HasPrefix(pc.Kind, "owl"):
d.NotDerived = append(d.NotDerived, fmt.Sprintf("calendars: %s (%s) is Owl's", pc.Value.Name, pc.Value.URL))
continue
case pc.Kind == "ics":
d.NotDerived = append(d.NotDerived, fmt.Sprintf("calendars: %s is subscribed by an ICS address, which is "+
"often itself the secret: listed by its name only, never declared (novox/hq ADR 0329 §13)", pc.Value.Name))
continue
case pc.Kind != "caldav":
d.NotDerived = append(d.NotDerived, fmt.Sprintf("calendars: %s is %s, kept in the profile: its events are never declared",
pc.Value.Name, pc.Kind))
continue
case addressPassword(pc.Value.URL):
d.NotDerived = append(d.NotDerived, fmt.Sprintf("calendars: %s at %s carries a password in its address: refused "+
"(novox/hq ADR 0329 §2)", pc.Value.Name, withoutPassword(pc.Value.URL)))
continue
}
v := pc.Value
v.Invitations = identityName[pc.IdentityKey]
d.Kinds[kindCalendars] = append(d.Kinds[kindCalendars], derivedEntry{Match: strings.ToLower(strings.TrimRight(v.URL, "/")),
Name: entryNameFor(v.Name, "calendar", used), Value: raw(v)})
}
// Address books: CardDAV only.
used = map[string]bool{}
for _, b := range profileAddressBooks(prefs) {
if !want[kindAddressBooks] {
break
}
switch {
case !b.CardDAV:
d.NotDerived = append(d.NotDerived, fmt.Sprintf("address-books: %s is %s: its contacts are never declared", b.Value.Name, b.Kind))
continue
case addressPassword(b.Value.URL):
d.NotDerived = append(d.NotDerived, fmt.Sprintf("address-books: %s at %s carries a password in its address: "+
"refused (novox/hq ADR 0329 §2)", b.Value.Name, withoutPassword(b.Value.URL)))
continue
}
d.Kinds[kindAddressBooks] = append(d.Kinds[kindAddressBooks], derivedEntry{
Match: strings.ToLower(strings.TrimRight(b.Value.URL, "/")), Name: entryNameFor(b.Value.Name, "book", used), Value: raw(b.Value)})
}
// Tags, by their own key.
if want[kindTags] {
for _, t := range profileTags(prefs) {
d.Kinds[kindTags] = append(d.Kinds[kindTags], derivedEntry{Match: t.Key, Name: t.Key, Value: raw(t.Value)})
}
}
// Filters of each declared account whose file holds one or more: an account with none keeps its file as the
// person's until a setting names it.
if want[kindFilters] {
for _, name := range sortedKeys(serverOf) {
dir := m.accountDirectory(p.path, prefs, serverOf[name])
if dir == "" {
continue
}
text, err := readBounded(dir + "/" + filterFileName)
if err != nil {
continue
}
parsed := parseFilters(string(text), folderBase(prefs, serverOf[name]))
for _, why := range parsed.NotDeclarable {
d.NotDerived = append(d.NotDerived, "filters of "+name+": "+why)
}
if len(parsed.Filters) > 0 {
d.Kinds[kindFilters] = append(d.Kinds[kindFilters], derivedEntry{Match: name, Name: name, Value: raw(parsed.Filters)})
}
}
}
return d, nil
}
// difference is one field another node holds differently from the value the mesh-wide layer gets.
type difference struct {
Kind string `json:"kind"`
Entry string `json:"entry"`
Node string `json:"node"`
Field string `json:"field"`
MeshWide any `json:"mesh_wide"`
There any `json:"there"`
}
// DeriveAnswer is what betterbird_derive answers its caller: the values it derived, which ADR 0329 §10 lets it show.
type DeriveAnswer struct {
// Here is this node's own derivation, when asked with here.
Here *nodeDerivation `json:"here,omitempty"`
// From are the desktop nodes derived from, and NotHeard those holding Betterbird that did not answer.
From []string `json:"derived_from,omitempty"`
NotHeard []string `json:"not_heard,omitempty"`
// Layers are the derived values per layer: "mesh-wide", then each node's own.
Layers map[string]map[string]any `json:"layers,omitempty"`
Differences []difference `json:"differences,omitempty"`
NotDerived map[string][]string `json:"not_derived,omitempty"`
// Next is the layer a propose proposes now, or why none.
Next string `json:"next,omitempty"`
Proposed string `json:"proposed,omitempty"`
Note string `json:"note,omitempty"`
}
const meshWide = "mesh-wide"
// Derive derives the kinds asked for (all five when none) from the desktop nodes, and with propose proposes the
// next layer. prefs are values for the setting prefs to carry in the mesh-wide layer (issue 586: the operator's
// setup moved out of the catalogue); layer names the layer to propose instead of the next in order.
func (m *Machine) Derive(kinds []string, here, propose bool, prefs map[string]any, layer string) (DeriveAnswer, error) {
var a DeriveAnswer
if len(kinds) == 0 {
kinds = allKinds
}
for _, k := range kinds {
if !contains(allKinds, k) {
return a, fmt.Errorf("kinds are %s; %q is not one", strings.Join(allKinds, ", "), k)
}
}
if here {
d, err := m.deriveHere(kinds)
if err != nil {
return a, err
}
a.Here = &d
return a, nil
}
self := nodeName()
if self == "" {
return a, errors.New("this runtime does not say which machine it serves (MESH_NODE), so the node derived on is unknown")
}
holders, err := holdersOfBetterbird()
if err != nil {
return a, err
}
derived := map[string]nodeDerivation{}
for _, n := range holders {
if n == self {
d, err := m.deriveHere(kinds)
if err != nil {
return a, fmt.Errorf("this node's profile: %v", err)
}
derived[n] = d
continue
}
d, err := deriveOn(n, kinds)
if err != nil {
a.NotHeard = append(a.NotHeard, n+": "+err.Error())
continue
}
derived[n] = d
}
if _, ok := derived[self]; !ok {
return a, fmt.Errorf("%s does not hold Betterbird as the controller says: derive on a desktop node", self)
}
a.From = sortedKeys(derived)
a.Layers, a.Differences = layOut(self, derived, kinds)
a.NotDerived = map[string][]string{}
for n, d := range derived {
if len(d.NotDerived) > 0 {
a.NotDerived[n] = d.NotDerived
}
}
if len(a.NotDerived) == 0 {
a.NotDerived = nil
}
if len(prefs) > 0 {
a.Layers[meshWide]["prefs"] = prefs
}
a.Note = "the values each layer would get; an entry on every desktop node is mesh-wide with this node's value, one " +
"on some nodes only is in those nodes' own layers. With propose, the next layer is proposed for the operator's " +
"Approve, carrying the layer as it stands beside these kinds; nothing changes until then (novox/hq ADR 0329 §10)"
if !propose {
return a, nil
}
if len(a.NotHeard) > 0 {
return a, fmt.Errorf("nothing was proposed: %d desktop node(s) holding Betterbird did not answer (%s), and a "+
"layer derived without them would put on every node what only some hold; derive again when they are awake",
len(a.NotHeard), strings.Join(a.NotHeard, "; "))
}
if open, err := openProposals(); err != nil {
return a, fmt.Errorf("nothing was proposed: whether a proposal of the module's is open cannot be read: %v", err)
} else if len(open) > 0 {
return a, fmt.Errorf("nothing was proposed: the module's proposal %s waits for the operator; the next layer is "+
"proposed once it is set or declined (novox/hq ADR 0329 §10, ADR 0277 §10)", strings.Join(open, ", "))
}
order := []string{meshWide}
for _, n := range sortedKeys(a.Layers) {
if n != meshWide {
order = append(order, n)
}
}
if layer != "" {
if _, ok := a.Layers[layer]; !ok {
return a, fmt.Errorf("nothing was proposed: layer is %s", strings.Join(order, " or "))
}
order = []string{layer}
}
for _, l := range order {
node := l
if l == meshWide {
node = ""
}
current, err := readLayer(node)
if err != nil {
return a, fmt.Errorf("nothing was proposed: %v", err)
}
next := withKinds(current, a.Layers[l])
if l == meshWide {
if prefs, ok := next["prefs"]; ok {
body, _ := json.Marshal(map[string]any{"prefs": prefs})
if _, err := parseDeclaredPrefs(body); err != nil {
return a, fmt.Errorf("nothing was proposed: %v", err)
}
}
}
if err := proposable(next, l == meshWide); err != nil {
return a, fmt.Errorf("nothing was proposed for %s: %v", l, err)
}
if mustJSON(next) == mustJSON(current) {
continue
}
a.Next = l
said, err := proposeLayer(node, next, true)
a.Proposed = m.maskValue(said)
if err != nil {
return a, err
}
a.Note = "proposed the " + l + " layer for the operator's Approve; the next layer is proposed by calling again " +
"once this one is set or declined"
return a, nil
}
a.Next = "none: every layer already holds what was derived"
return a, nil
}
// proposable checks a layer's derived kinds as the module would read them, so the operator is never asked to set
// a value the module then refuses. A key to adopt names one node's profile, so the mesh-wide layer holds none (§4).
func proposable(layer map[string]any, mesh bool) error {
conf := map[string]any{}
for _, k := range []string{kindIdentities, kindCalendars, kindAddressBooks, kindFilters} {
if v, ok := layer[k]; ok {
conf[k] = v
}
}
var c Configuration
if err := decodeInto([]byte(mustJSON(conf)), configurationFile, &c); err != nil {
return err
}
if v, ok := layer[kindTags]; ok {
if err := decodeInto([]byte(mustJSON(map[string]any{kindTags: v})), tagsFile, &c); err != nil {
return err
}
}
// A node's layer may name an identity the mesh-wide layer holds for a calendar's invitations: that is judged
// where the layers meet, on the node.
var problems []string
if mesh {
for name, id := range c.Identities {
if id != nil && id.Key != "" {
problems = append(problems, "identities "+name+": a key is given in a node's own layer only")
}
}
for name, cal := range c.Calendars {
if cal != nil && cal.Key != "" {
problems = append(problems, "calendars "+name+": a key is given in a node's own layer only")
}
}
for name, ab := range c.AddressBooks {
if ab != nil && ab.Key != "" {
problems = append(problems, "address-books "+name+": a key is given in a node's own layer only")
}
}
}
for _, p := range configurationProblems(c) {
if !strings.Contains(p, "invitations names") {
problems = append(problems, p)
}
}
if len(problems) > 0 {
return errors.New(strings.Join(problems, "; "))
}
return nil
}
// layOut puts each kind's entries into the layers (§10).
func layOut(self string, derived map[string]nodeDerivation, kinds []string) (map[string]map[string]any, []difference) {
layers := map[string]map[string]any{meshWide: {}}
var diffs []difference
nodes := sortedKeys(derived)
for _, kind := range kinds {
type seen struct {
by map[string]derivedEntry
}
groups := map[string]*seen{}
var order []string
for _, n := range append([]string{self}, nodes...) {
for _, e := range derived[n].Kinds[kind] {
g := groups[e.Match]
if g == nil {
g = &seen{by: map[string]derivedEntry{}}
groups[e.Match] = g
order = append(order, e.Match)
}
if _, already := g.by[n]; !already {
g.by[n] = e
}
}
}
mesh := map[string]any{}
used := map[string]bool{}
for _, match := range order {
g := groups[match]
if len(g.by) == len(nodes) {
mine := g.by[self]
name := uniqueName(mine.Name, used)
var v any
_ = json.Unmarshal(mine.Value, &v)
mesh[name] = v
for _, n := range nodes {
if n == self {
continue
}
diffs = append(diffs, fieldDifferences(kind, name, n, mine.Value, g.by[n].Value)...)
}
}
}
for _, match := range order {
g := groups[match]
if len(g.by) == len(nodes) {
continue
}
for _, n := range sortedKeys(g.by) {
e := g.by[n]
if layers[n] == nil {
layers[n] = map[string]any{}
}
nodeKind, _ := layers[n][kind].(map[string]any)
if nodeKind == nil {
nodeKind = map[string]any{}
layers[n][kind] = nodeKind
}
var v any
_ = json.Unmarshal(e.Value, &v)
nodeKind[uniqueName(e.Name, used)] = v
}
}
if len(mesh) > 0 {
layers[meshWide][kind] = mesh
}
}
return layers, diffs
}
func uniqueName(name string, used map[string]bool) string {
n := name
for i := 2; used[n]; i++ {
n = fmt.Sprintf("%s%d", name, i)
}
used[n] = true
return n
}
// fieldDifferences are the fields a node holds differently from the mesh-wide value of one entry.
func fieldDifferences(kind, entry, node string, mesh, there json.RawMessage) []difference {
var a, b map[string]any
if json.Unmarshal(mesh, &a) != nil || json.Unmarshal(there, &b) != nil {
if mustJSON(json.RawMessage(mesh)) != mustJSON(json.RawMessage(there)) {
var x, y any
_ = json.Unmarshal(mesh, &x)
_ = json.Unmarshal(there, &y)
return []difference{{Kind: kind, Entry: entry, Node: node, Field: "the whole list", MeshWide: x, There: y}}
}
return nil
}
var out []difference
for _, f := range sortedKeys(mergeKeys(a, b)) {
if mustJSON(a[f]) != mustJSON(b[f]) {
out = append(out, difference{Kind: kind, Entry: entry, Node: node, Field: f, MeshWide: a[f], There: b[f]})
}
}
return out
}
func mergeKeys(a, b map[string]any) map[string]bool {
out := map[string]bool{}
for k := range a {
out[k] = true
}
for k := range b {
out[k] = true
}
return out
}
// withKinds is a layer with the derived kinds in place of its own, every other key kept.
func withKinds(layer map[string]any, kinds map[string]any) map[string]any {
out := map[string]any{}
for k, v := range layer {
out[k] = v
}
for k, v := range kinds {
if k == "prefs" {
merged := map[string]any{}
if was, ok := out["prefs"].(map[string]any); ok {
for n, x := range was {
merged[n] = x
}
}
for n, x := range v.(map[string]any) {
merged[n] = x
}
out[k] = merged
continue
}
out[k] = v
}
return out
}
func contains(list []string, s string) bool {
for _, x := range list {
if x == s {
return true
}
}
return false
}
// readLayer is the module's layer as the controller holds it: the mesh-wide one for node "", else that node's.
func readLayer(node string) (map[string]any, error) {
args := map[string]any{"module": "betterbird"}
if node != "" {
args["node"] = node
}
raw, err := asker("seat:mesh-controller.settings", args)
if err != nil {
return nil, fmt.Errorf("the controller's settings verb did not answer: %v", err)
}
said, ok := verbSaid(raw)
if !ok {
return nil, fmt.Errorf("the controller did not answer the layer: %s", said)
}
// A missing layer is said in exactly these words; anything else must read as the layer's JSON. A proposal
// replaces the layer whole, so an answer read wrongly as empty would drop what the layer holds: refused, loudly.
text := strings.TrimSpace(said)
where := "the whole mesh"
if node != "" {
where = node
}
if strings.HasPrefix(text, "betterbird on "+where+": no layer") {
return map[string]any{}, nil
}
out := map[string]any{}
if err := json.NewDecoder(strings.NewReader(text)).Decode(&out); err != nil || !strings.HasPrefix(text, "{") {
first, _, _ := strings.Cut(text, "\n")
return nil, fmt.Errorf("the controller's answer for the layer of betterbird on %s cannot be read as the layer "+
"(it begins %q), so nothing is proposed: a layer is replaced whole and would lose what it holds", where, first)
}
return out, nil
}
// proposeLayer proposes a layer whole for the operator's Approve (ADR 0277); replace says a key the layer had
// and the proposal does not hold is meant to go, which a re-derive needs when an entry left every profile.
func proposeLayer(node string, values map[string]any, replace bool) (string, error) {
body, err := json.Marshal(values)
if err != nil {
return "", err
}
args := map[string]any{"module": "betterbird", "values": string(body), "propose": "true"}
if replace {
args["replace"] = "true"
}
if node != "" {
args["node"] = node
}
raw, err := asker("seat:mesh-controller.settings", args)
if err != nil {
return "", fmt.Errorf("the controller's settings verb did not answer: %v", err)
}
said, ok := verbSaid(raw)
if !ok {
return said, fmt.Errorf("the controller did not take the proposal: %s", said)
}
return said, nil
}
var (
proposalHead = regexp.MustCompile(`^(\S+)\s+(?:set|clear) betterbird on (.+?)\s+\(`)
waitingLine = regexp.MustCompile(`^\s+waiting for the operator until`)
)
// openProposals are the module's proposals that wait for the operator, by id and layer.
func openProposals() ([]string, error) {
raw, err := asker("seat:mesh-controller.settings", map[string]any{"proposals": "true"})
if err != nil {
return nil, err
}
said, ok := verbSaid(raw)
if !ok {
return nil, errors.New(said)
}
var out []string
current := ""
for _, line := range strings.Split(said, "\n") {
if mm := proposalHead.FindStringSubmatch(line); mm != nil {
current = mm[1] + " (" + mm[2] + ")"
continue
}
if !strings.HasPrefix(line, " ") {
current = ""
}
if current != "" && waitingLine.MatchString(line) {
out = append(out, current)
current = ""
}
}
return out, nil
}
// holdersOfBetterbird are the nodes the controller has assigned the module to: the desktop nodes.
func holdersOfBetterbird() ([]string, error) {
raw, err := asker("seat:mesh-controller.nodes", map[string]any{})
if err != nil {
return nil, fmt.Errorf("the controller's nodes verb did not answer: %v", err)
}
said, _ := verbSaid(raw)
var nodes []struct {
Name string `json:"name"`
}
if err := json.Unmarshal([]byte(said), &nodes); err != nil {
return nil, fmt.Errorf("the controller's nodes cannot be read: %v", err)
}
var out []string
for _, n := range nodes {
raw, err := asker("seat:mesh-controller.node", map[string]any{"node": n.Name})
if err != nil {
return nil, fmt.Errorf("the controller's node verb did not answer for %s: %v", n.Name, err)
}
said, _ := verbSaid(raw)
for _, line := range strings.Split(said, "\n") {
if rest, ok := strings.CutPrefix(strings.TrimSpace(line), "assigned:"); ok {
for _, mod := range strings.Split(rest, ",") {
if strings.TrimSpace(mod) == "betterbird" {
out = append(out, n.Name)
}
}
}
}
}
sort.Strings(out)
if len(out) == 0 {
return nil, errors.New("the controller names no node the module is assigned to")
}
return out, nil
}
// deriveOn asks another node's betterbird for what its profile holds.
func deriveOn(node string, kinds []string) (nodeDerivation, error) {
raw, err := asker("betterbird.betterbird_derive@"+node, map[string]any{"here": true, "kinds": kinds})
if err != nil {
return nodeDerivation{}, err
}
said, ok := verbSaid(raw)
if !ok {
return nodeDerivation{}, errors.New(said)
}
var a DeriveAnswer
if err := json.Unmarshal([]byte(said), &a); err != nil || a.Here == nil {
return nodeDerivation{}, fmt.Errorf("its answer cannot be read")
}
a.Here.Node = node
return *a.Here, nil
}
@@ -0,0 +1,263 @@
package main
import (
"encoding/json"
"errors"
"fmt"
"reflect"
"strings"
"testing"
)
// ADR 0329 §10, §12: derived from every desktop node's profile, laid out per layer, and proposed one layer at a
// time, carrying the layer as it stands.
// derivingMesh is two desktop nodes, g14 (this one, f) and shanks (f2), and a server that holds no Betterbird, as the
// controller answers them; layers are the controller's layers, open the proposals that wait.
type derivingMesh struct {
t *testing.T
shanks *fake
layers map[string]map[string]any
open bool
proposed []map[string]any
asked []string
asleep bool
// garbled answers the layer in words that are not its JSON.
garbled bool
}
func (d *derivingMesh) ask(key string, body any) (json.RawMessage, error) {
d.asked = append(d.asked, key)
args, _ := body.(map[string]any)
answer := func(ok bool, text string) (json.RawMessage, error) {
b, _ := json.Marshal(map[string]any{"ok": ok, "output": text})
return b, nil
}
switch {
case key == "seat:mesh-controller.nodes":
return answer(true, `[{"name": "g14"}, {"name": "novox"}, {"name": "shanks"}]`)
case key == "seat:mesh-controller.node":
if args["node"] == "novox" {
return answer(true, "novox\n assigned: postgres, nats\n")
}
return answer(true, fmt.Sprintf("%s\n last heard from here\n assigned: adwaita, betterbird, dunst\n", args["node"]))
case key == "betterbird.betterbird_derive@shanks":
if d.asleep {
return nil, errors.New("nats: no responders available for request")
}
here, err := d.shanks.deriveHere(stringsOf(args["kinds"]))
if err != nil {
d.t.Fatal(err)
}
text, _ := json.Marshal(credentialChecked(DeriveAnswer{Here: &here}))
return json.Marshal(map[string]any{"content": []any{map[string]any{"type": "text", "text": string(text)}}})
case key == "seat:mesh-controller.settings" && args["proposals"] == "true":
if d.open {
return answer(true, "sab12 set betterbird on the whole mesh (accounts, identities)\n by g14.node-tools at 2026-10-13 01:00; fingerprint ab12\n waiting for the operator until 2026-10-14 01:00\n"+
"scd34 set mounts on shanks (sources)\n by g14.node-tools at 2026-10-11 00:05; fingerprint dd0b\n chosen: done\n")
}
return answer(true, "scd34 set mounts on shanks (sources)\n by g14.node-tools; fingerprint dd0b\n chosen: done\n")
case key == "seat:mesh-controller.settings" && args["propose"] == "true":
d.proposed = append(d.proposed, args)
return answer(true, "proposal sab12: Set the settings of betterbird?\n fingerprint ab12")
case key == "seat:mesh-controller.settings":
node, _ := args["node"].(string)
if d.garbled {
return answer(true, "Settings of betterbird on "+node+":\n accounts = {…}\n")
}
layer := d.layers[node]
if layer == nil {
where := node
if where == "" {
where = "the whole mesh"
}
return answer(true, "betterbird on "+where+": no layer — the module's definition says\nbetterbird on "+where+", every value")
}
b, _ := json.MarshalIndent(layer, "", " ")
return answer(true, string(b)+"\n approved by the operator\nbetterbird on the whole mesh, every value and where it comes from:\n")
}
d.t.Fatalf("asked %s %v", key, body)
return nil, nil
}
func stringsOf(v any) []string {
switch x := v.(type) {
case []string:
return x
case []any:
var out []string
for _, s := range x {
out = append(out, s.(string))
}
return out
}
return nil
}
func TestDeriveLaysEachEntryOutPerLayerAndProposesOneLayerAtATime(t *testing.T) {
f := newFake(t)
f.profileWithAccounts()
f.profileWithConfiguration()
f.write(profileDir+"/prefs.js", f.read(profileDir+"/prefs.js")+
`user_pref("calendar.registry.c6.type", "caldav");`+"\n"+
`user_pref("calendar.registry.c6.uri", "https://other:hunter2@dav.example.org/cal/x/");`+"\n"+
`user_pref("calendar.registry.c6.name", "Old");`+"\n")
f.declareAccounts(adoptable)
links(t, nil)
f2 := newFake(t)
f2.profileWithAccounts()
f2.profileWithConfiguration()
// shanks holds the same accounts and calendars, another signature, another tag colour and a calendar of its own.
f2.write(profileDir+"/prefs.js", strings.NewReplacer(`"Regards"`, `"Cheers"`, `"#FF0000"`, `"#CC0000"`).Replace(f2.read(profileDir+"/prefs.js"))+
`user_pref("calendar.registry.s1.type", "caldav");`+"\n"+
`user_pref("calendar.registry.s1.uri", "https://dav.example.org/cal/standup/");`+"\n"+
`user_pref("calendar.registry.s1.name", "Standup");`+"\n")
f2.declareAccounts(adoptable)
mesh := &derivingMesh{t: t, shanks: f2, layers: map[string]map[string]any{"": {"accounts": map[string]any{"mail": "as approved"}}}}
oldNode, oldAsk := nodeName, asker
t.Cleanup(func() { nodeName, asker = oldNode, oldAsk })
nodeName = func() string { return "g14" }
asker = mesh.ask
a, err := f.Derive(nil, false, false, nil, "")
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(a.From, []string{"g14", "shanks"}) || len(a.NotHeard) != 0 {
t.Fatalf("derived from the nodes holding Betterbird: %v %v", a.From, a.NotHeard)
}
whole := mustJSON(a)
mw := a.Layers[meshWide]
ids, _ := mw[kindIdentities].(map[string]any)
if mail, _ := ids["mail"].(map[string]any); mail == nil || mustJSON(mail["signature"]) != `["Regards"]` || mail["address"] != nil {
t.Errorf("an identity on both nodes is mesh-wide with this node's value, the default without its address: %v", ids)
}
cals, _ := mw[kindCalendars].(map[string]any)
if cals["home"] == nil || cals["standup"] != nil {
t.Errorf("a calendar both hold is mesh-wide, one only shanks holds is not: %v", cals)
}
if sh := mustJSON(a.Layers["shanks"]); !strings.Contains(sh, "https://dav.example.org/cal/standup/") {
t.Errorf("an entry on one node only goes into that node's layer: %s", sh)
}
tags, _ := mw[kindTags].(map[string]any)
if l1, _ := tags["$label1"].(map[string]any); l1 == nil || l1["color"] != "#FF0000" {
t.Errorf("the tag is mesh-wide with this node's colour: %v", tags)
}
diffs := map[string]difference{}
for _, d := range a.Differences {
diffs[d.Kind+"/"+d.Entry+"/"+d.Field] = d
}
if d := diffs["identities/mail/signature"]; d.Node != "shanks" || mustJSON(d.There) != `["Cheers"]` {
t.Errorf("the answer lists the field shanks holds differently: %+v", a.Differences)
}
if d := diffs["tags/$label1/color"]; d.There != "#CC0000" || d.MeshWide != "#FF0000" {
t.Errorf("%+v", a.Differences)
}
for _, never := range []string{"secret-token", "hunter2", "owl://", `"Work"`} {
if strings.Contains(mustJSON(a.Layers), never) {
t.Errorf("no ICS address, credential or Owl calendar is derived: %s in %s", never, mustJSON(a.Layers))
}
}
if !strings.Contains(whole, "Holidays") || !strings.Contains(whole, "‹withheld›") || strings.Contains(mustJSON(credentialChecked(a)), "hunter2") {
t.Errorf("what is left out is named, an ICS calendar by its name and a password as ‹withheld›: %v", a.NotDerived)
}
if len(mesh.proposed) != 0 {
t.Fatal("without propose nothing is proposed")
}
// Propose: the mesh-wide layer first, as it stands with the derived kinds, and the operator's setup of prefs.
prefs := map[string]any{}
for n, v := range shippedDefaultsWant {
prefs[n], _ = prefValue(v)
}
a, err = f.Derive(nil, false, true, prefs, "")
if err != nil {
t.Fatal(err)
}
if len(mesh.proposed) != 1 || mesh.proposed[0]["node"] != nil || mesh.proposed[0]["replace"] != "true" || a.Next != meshWide {
t.Fatalf("the mesh-wide layer is proposed first: %v %s", mesh.proposed, a.Next)
}
var values map[string]any
_ = json.Unmarshal([]byte(mesh.proposed[0]["values"].(string)), &values)
if values["accounts"] == nil || values[kindIdentities] == nil || values[kindTags] == nil || values["prefs"] == nil {
t.Fatalf("the layer as it stands, with every derived kind and prefs: %v", values)
}
body, _ := json.Marshal(map[string]any{"prefs": values["prefs"]})
if got, err := parseDeclaredPrefs(body); err != nil || !reflect.DeepEqual(got, shippedDefaultsWant) {
t.Fatalf("the operator's 21 preferences, one entry each: %v %v", got, err)
}
if !strings.Contains(a.Proposed, "sab12") {
t.Fatalf("%s", a.Proposed)
}
// While it waits nothing more is proposed.
mesh.open = true
if _, err := f.Derive(nil, false, true, nil, ""); err == nil || !strings.Contains(err.Error(), "sab12 (the whole mesh)") || len(mesh.proposed) != 1 {
t.Fatalf("one open proposal of the module's at a time: %v", err)
}
// Approved: the mesh-wide layer holds it, so the next layer is shanks's own.
mesh.open = false
mesh.layers[""] = values
a, err = f.Derive(nil, false, true, prefs, "")
if err != nil {
t.Fatal(err)
}
if len(mesh.proposed) != 2 || mesh.proposed[1]["node"] != "shanks" || a.Next != "shanks" ||
!strings.Contains(mesh.proposed[1]["values"].(string), "standup") {
t.Fatalf("then the node's own layer: %v", mesh.proposed)
}
var shanks map[string]any
if err := json.Unmarshal([]byte(mesh.proposed[1]["values"].(string)), &shanks); err != nil {
t.Fatal(err)
}
mesh.layers["shanks"] = shanks
if a, err = f.Derive(nil, false, true, prefs, ""); err != nil || len(mesh.proposed) != 2 || !strings.HasPrefix(a.Next, "none") {
t.Fatalf("nothing left to propose: %v %s", err, a.Next)
}
// A desktop node that does not answer: derived without it, never proposed.
mesh.asleep = true
a, err = f.Derive(nil, false, true, nil, meshWide)
if err == nil || len(a.NotHeard) != 1 || !strings.Contains(err.Error(), "did not answer") || len(mesh.proposed) != 2 {
t.Fatalf("a layer is never proposed without a desktop node: %v %v", err, a.NotHeard)
}
// A key to adopt is a node's: the mesh-wide layer's proposal refuses one.
if err := proposable(map[string]any{kindCalendars: map[string]any{"c": map[string]any{"name": "C",
"url": "https://dav.example.org/c/", "key": "c3"}}}, true); err == nil || !strings.Contains(err.Error(), "node's own layer") {
t.Fatalf("%v", err)
}
}
// Review 121: a layer the controller answers in any other words than its JSON or "no layer" refuses the proposal,
// because a proposal replaces the layer whole and would drop accounts.
func TestALayerThatCannotBeReadRefusesTheProposal(t *testing.T) {
f := newFake(t)
f.profileWithAccounts()
f.profileWithConfiguration()
f.declareAccounts(adoptable)
links(t, nil)
f2 := newFake(t)
f2.profileWithAccounts()
f2.declareAccounts(adoptable)
mesh := &derivingMesh{t: t, shanks: f2, garbled: true}
oldNode, oldAsk := nodeName, asker
t.Cleanup(func() { nodeName, asker = oldNode, oldAsk })
nodeName = func() string { return "g14" }
asker = mesh.ask
if _, err := f.Derive(nil, false, true, nil, ""); err == nil || !strings.Contains(err.Error(), "cannot be read as the layer") ||
len(mesh.proposed) != 0 {
t.Fatalf("the derive refuses: %v %v", err, mesh.proposed)
}
if _, err := f.ProfileAccounts(true); err == nil || !strings.Contains(err.Error(), "cannot be read as the layer") ||
len(mesh.proposed) != 0 {
t.Fatalf("betterbird_accounts refuses: %v %v", err, mesh.proposed)
}
if _, err := readLayer(""); err == nil {
t.Fatal("an unreadable layer is an error, never an empty layer")
}
mesh.garbled = false
if got, err := readLayer("shanks"); err != nil || len(got) != 0 {
t.Fatalf("no layer, in the controller's words, is an empty layer: %v %v", got, err)
}
}
@@ -0,0 +1,271 @@
package main
// An account's filters, owned whole per declared account (novox/hq ADR 0329 §8).
//
// Betterbird keeps an account's filters in msgFilterRules.dat in the account's own directory: a header, then per
// filter its name, whether it is on, when it runs (a bit mask), its actions and one condition line. For an account
// the setting filters names, the module writes that file from the declared list, naming each folder by its path
// within the account and turning it into the folder's address on this node; a filter in the file the setting does
// not hold is gone after the apply. Before its first write on a node the module keeps the file it replaces, in its
// own directory in the home (ADR 0182). It writes only while Betterbird does not run, because Betterbird rewrites
// the file itself (§9).
import (
"bufio"
"fmt"
"net/url"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
)
// filterFileName is the file an account's filters are in.
const filterFileName = "msgFilterRules.dat"
// accountDirectory is an account's directory in the profile, as its server's preferences name it, or "".
func accountDirectory(profile string, prefs map[string]string, server string) string {
p := "mail.server." + server + "."
if rel := unquote(prefs[p+"directory-rel"]); strings.HasPrefix(rel, "[ProfD]") {
dir := filepath.Join(profile, filepath.FromSlash(strings.TrimPrefix(rel, "[ProfD]")))
if strings.HasPrefix(filepath.Clean(dir), filepath.Clean(profile)+string(filepath.Separator)) {
return dir
}
return ""
}
return ""
}
// folderBase is the address of an account's root folder on this node: imap://user@host for IMAP, mailbox://
// for POP3 and local folders, the user's @ written %40 as Betterbird writes it.
func folderBase(prefs map[string]string, server string) string {
p := "mail.server." + server + "."
kind := unquote(prefs[p+"type"])
host := unquote(prefs[p+"hostname"])
user := unquote(prefs[p+"userName"])
scheme := "mailbox"
switch kind {
case "imap":
scheme = "imap"
case "none":
host, user = "Local Folders", "nobody"
}
return scheme + "://" + strings.ReplaceAll(url.PathEscape(user), "@", "%40") + "@" + url.PathEscape(host)
}
// folderAddress is a folder's address on this node from its path within the account.
func folderAddress(base, path string) string {
parts := strings.Split(strings.Trim(path, "/"), "/")
for i, p := range parts {
parts[i] = url.PathEscape(p)
}
return base + "/" + strings.Join(parts, "/")
}
// folderPath is a folder's path within the account from its address, when the address is the account's.
func folderPath(base, address string) (string, bool) {
rest, ok := strings.CutPrefix(address, base+"/")
if !ok {
return "", false
}
parts := strings.Split(rest, "/")
for i, p := range parts {
u, err := url.PathUnescape(p)
if err != nil {
return "", false
}
parts[i] = u
}
return strings.Join(parts, "/"), true
}
// whenOf is a filter's type mask from its words; whenWords the reverse, or false when the mask holds a bit no
// word says (a news filter's).
func whenOf(words []string) int {
n := 0
for _, w := range words {
n |= filterWhen[w]
}
return n
}
func whenWords(mask int) ([]string, bool) {
var out []string
left := mask
for _, w := range []string{"new-mail", "manual", "after-junk", "sending", "archiving", "periodic"} {
if mask&filterWhen[w] != 0 {
out = append(out, w)
left &^= filterWhen[w]
}
}
return out, left == 0
}
// renderFilters is the file Betterbird reads for a declared list of filters on this node.
func renderFilters(filters []Filter, base string) string {
var b strings.Builder
b.WriteString("version=\"9\"\nlogging=\"no\"\n")
for _, f := range filters {
fmt.Fprintf(&b, "name=\"%s\"\n", f.Name)
fmt.Fprintf(&b, "enabled=\"%s\"\n", map[bool]string{true: "yes", false: "no"}[f.Enabled])
fmt.Fprintf(&b, "type=\"%d\"\n", whenOf(f.When))
for _, a := range f.Actions {
fmt.Fprintf(&b, "action=\"%s\"\n", a.Action)
switch {
case a.Folder != "":
fmt.Fprintf(&b, "actionValue=\"%s\"\n", folderAddress(base, a.Folder))
case a.Value != "":
fmt.Fprintf(&b, "actionValue=\"%s\"\n", a.Value)
}
}
fmt.Fprintf(&b, "condition=\"%s\"\n", conditionText(f))
}
return b.String()
}
func conditionText(f Filter) string {
if f.Match == "every-message" {
return "ALL"
}
join := "AND"
if f.Match == "any" {
join = "OR"
}
var parts []string
for _, c := range f.Conditions {
parts = append(parts, fmt.Sprintf("%s (%s,%s,%s)", join, c.Field, c.Op, c.Value))
}
return strings.Join(parts, " ")
}
var (
filterLine = regexp.MustCompile(`^([A-Za-z]+)="((?:[^"\\]|\\.)*)"$`)
clause = regexp.MustCompile(`(AND|OR) \(([^,()]+),([^,()]+),([^()]*)\)`)
)
// parsedFilters is a filter file read into the setting's shape. NotDeclarable names, by the filter's name, the
// filters a setting cannot hold as they are (a folder of another account, an action of an add-on, a news filter).
type parsedFilters struct {
Filters []Filter
NotDeclarable []string
}
// parseFilters reads a filter file for an account whose folders are under base.
func parseFilters(text, base string) parsedFilters {
var out parsedFilters
var cur *Filter
bad := ""
finish := func() {
if cur == nil {
return
}
if bad != "" {
out.NotDeclarable = append(out.NotDeclarable, cur.Name+": "+bad)
} else if p := filterProblems(*cur); len(p) > 0 {
out.NotDeclarable = append(out.NotDeclarable, cur.Name+": "+p[0])
} else {
out.Filters = append(out.Filters, *cur)
}
cur, bad = nil, ""
}
s := bufio.NewScanner(strings.NewReader(text))
s.Buffer(make([]byte, 64<<10), 4<<20)
for s.Scan() {
mm := filterLine.FindStringSubmatch(strings.TrimSpace(s.Text()))
if mm == nil {
continue
}
field, value := mm[1], strings.NewReplacer(`\"`, `"`, `\\`, `\`).Replace(mm[2])
if field == "name" {
finish()
cur = &Filter{Name: value}
continue
}
if cur == nil {
continue
}
switch field {
case "enabled":
cur.Enabled = value == "yes"
case "type":
n, _ := strconv.Atoi(value)
words, ok := whenWords(n)
if !ok && bad == "" {
bad = "it runs on news or at a time no setting word says"
}
cur.When = words
case "action":
cur.Actions = append(cur.Actions, Action{Action: value})
case "actionValue":
if len(cur.Actions) == 0 {
continue
}
a := &cur.Actions[len(cur.Actions)-1]
if filterActions[a.Action] == "folder" {
path, ok := folderPath(base, value)
if !ok && bad == "" {
bad = "it moves or copies to a folder of another account"
}
a.Folder = path
} else {
a.Value = value
}
case "condition":
switch {
case value == "ALL":
cur.Match = "every-message"
default:
all := clause.FindAllStringSubmatch(value, -1)
if len(all) == 0 && bad == "" {
bad = "its condition cannot be read"
}
for _, c := range all {
if c[1] == "OR" {
cur.Match = "any"
} else if cur.Match == "" {
cur.Match = "all"
}
cur.Conditions = append(cur.Conditions, Condition{Field: c[2], Op: c[3], Value: c[4]})
}
}
}
}
finish()
return out
}
// filterDifferences says how a file's filters differ from the declared list, by place and name: missing,
// not declared, or differing.
func filterDifferences(declared, have []Filter) []string {
var out []string
for i := 0; i < len(declared) || i < len(have); i++ {
switch {
case i >= len(have):
out = append(out, fmt.Sprintf("filter %d (%s) is declared and not in the file", i+1, declared[i].Name))
case i >= len(declared):
out = append(out, fmt.Sprintf("filter %d (%s) is in the file and not declared: removed at the next apply", i+1, have[i].Name))
default:
d, h := mustJSON(declared[i]), mustJSON(have[i])
if d != h {
out = append(out, fmt.Sprintf("filter %d: declared %s, the file holds %s", i+1, d, h))
}
}
}
return out
}
// keptFilterFile is where the module keeps the file it replaced at its first write of an account's filters.
func (m *Machine) keptFilterFile(profileDir, account string) string {
base := filepath.Base(profileDir)
return m.home(".config/betterbird/kept", base, account+"."+filterFileName)
}
func sortedAccountsOf(fs map[string][]Filter) []string {
out := make([]string, 0, len(fs))
for k := range fs {
out = append(out, k)
}
sort.Strings(out)
return out
}
@@ -0,0 +1,307 @@
package main
// What the mesh installs (novox/hq ADR 0316 §1–§9, to-be 56 §1–§3): Betterbird from its pinned upstream release
// in /opt/betterbird, where the community package betterbird-bin put it, so Betterbird keeps choosing the same
// profile; the enterprise policy in its distribution directory, which turns Betterbird's own updater off and
// installs Owl into every profile from the xpi the mesh placed beside it; and a defaults file naming the
// system's spelling dictionaries. The tools read these files and say what they find: the version against the
// pin and the newest published build, whether the policy is placed, Owl's version against its pin.
//
// **The pin on the machine is what the mesh placed.** The manifest pins the release and the xpi by sha256; the
// node-engine places exactly those bytes, so the version in /opt/betterbird/application.ini (when the mesh
// placed it) and the version in the placed xpi are the pins. The community package's own copy is said as
// such, and the switch from it waits for the operator (ADR 0316 §4).
import (
"archive/zip"
"encoding/json"
"fmt"
"io"
"net/http"
"path/filepath"
"strings"
"time"
)
const (
installDir = "/opt/betterbird"
// policyFile is the enterprise policy the module places (ADR 0316 §7).
policyFile = installDir + "/distribution/policies.json"
// owlFile is Owl's xpi as the mesh placed it, and the URL the policy installs it from.
owlFile = installDir + "/owl/owl.xpi"
owlID = "owl@beonex.com"
// defaultClientPref is Betterbird's ask at its start to be the default mail, news, feed and calendar
// client (Thunderbird's all-thunderbird.js: pref("mail.shell.checkDefaultClient", true); messenger.js opens
// systemIntegrationDialog.xhtml while it is true). The module's policy locks it off (novox/hq issue 345).
defaultClientPref = "mail.shell.checkDefaultClient"
// preparation is the tools' program as the module places it for its start (betterbird-tools prepare).
preparation = "/usr/local/lib/mesh-betterbird/betterbird-tools"
// versionsURL is where Betterbird publishes its newest build per major version: what its own updater
// reads (MailGlue's BetterbirdUpdateCheck), and what the status compares the installed build with.
versionsURL = "https://www.betterbird.eu/start/versions.txt"
)
// publishedBuilds fetches Betterbird's list of newest builds: a variable so a test answers.
var publishedBuilds = func() (string, error) {
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Get(versionsURL)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("%s answered %s", versionsURL, resp.Status)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
return string(body), err
}
// Installation is what is installed, and from where.
type Installation struct {
// From is the mesh, the community package (betterbird-bin, before the switch), or nothing.
From string `json:"from"`
Version string `json:"version,omitempty"`
Build string `json:"build,omitempty"`
// Newest is the newest build Betterbird published for this major version, and whether this one is behind.
Newest string `json:"newest_published_build,omitempty"`
Behind bool `json:"behind,omitempty"`
// Policy says whether the module's enterprise policy is placed, and what it does.
Policy string `json:"policy"`
// Owl is the add-on's version as the mesh placed it (the pin), and as the profile runs it.
Owl *OwlState `json:"owl,omitempty"`
}
// OwlState is Owl against its pin.
type OwlState struct {
Pinned string `json:"pinned,omitempty"`
InProfile string `json:"in_profile,omitempty"`
Active bool `json:"active"`
AtThePin bool `json:"at_the_pin"`
// Note says, as a fact, a profile running an Owl newer than the pin: the policy's install keeps a newer
// version, and Owl installed by policy cannot be removed in Betterbird.
Note string `json:"note,omitempty"`
}
// newerVersion says whether version a is newer than b, comparing dotted numbers.
func newerVersion(a, b string) bool {
if a == "" || b == "" {
return false
}
pa, pb := strings.Split(a, "."), strings.Split(b, ".")
for i := 0; i < len(pa) || i < len(pb); i++ {
var x, y int
if i < len(pa) {
fmt.Sscanf(pa[i], "%d", &x)
}
if i < len(pb) {
fmt.Sscanf(pb[i], "%d", &y)
}
if x != y {
return x > y
}
}
return false
}
// installation reads what is installed. profile may be empty when none is found yet.
func (m *Machine) installation(profile string) Installation {
var in Installation
community, _ := m.installed(packageFor)
raw, err := readBounded(m.path(appIni))
switch {
case community != "":
in.From = "the community package " + packageFor + " " + community + ": the switch to the mesh's release waits for the operator's push, with Betterbird quit (novox/hq ADR 0316 §4)"
case err == nil:
in.From = "the mesh"
default:
in.From = "not installed"
}
if err == nil {
_, sec := iniSections(raw)
in.Version, in.Build = sec["App"]["Version"], sec["App"]["BuildID"]
in.Newest, in.Behind = newestBuild(in.Version, in.Build)
}
in.Policy = m.policyState()
pinned := xpiVersion(m.path(owlFile))
owl := &OwlState{Pinned: pinned}
if profile != "" {
for _, a := range addOns(profile) {
if a.ID == owlID {
owl.InProfile, owl.Active = a.Version, a.Active
}
}
}
owl.AtThePin = owl.Pinned != "" && owl.Pinned == owl.InProfile
if newerVersion(owl.InProfile, owl.Pinned) {
owl.Note = "the profile runs Owl " + owl.InProfile + ", newer than the mesh's pin " + owl.Pinned + ": the policy " +
"keeps a newer version and installs the pin only where Owl is missing. To run the same Owl everywhere, " +
"move the module's pin in a reviewed change"
}
if owl.Pinned != "" || owl.InProfile != "" {
in.Owl = owl
}
return in
}
// newestBuild is the newest build Betterbird published for a version's major, and whether build is older.
func newestBuild(version, build string) (string, bool) {
major, _, _ := strings.Cut(version, ".")
if major == "" || build == "" {
return "", false
}
list, err := publishedBuilds()
if err != nil {
return "unknown: " + err.Error(), false
}
for _, l := range strings.Split(list, "\n") {
f := strings.Split(strings.TrimSpace(l), ",")
if len(f) == 3 && f[0] == "Linux" && f[1] == major {
return f[2], f[2] > build
}
}
return "unknown: " + versionsURL + " names no build for " + major, false
}
// policyState says whether the module's policy is placed and does what ADR 0316 §5 and §7 and issue 345 ask.
func (m *Machine) policyState() string {
raw, err := readBounded(m.path(policyFile))
if err != nil {
return "not placed: " + policyFile + " is missing"
}
if why := policyProblem(raw); why != "" {
return "placed, and " + why
}
return "placed: Betterbird's updater, its telemetry and add-on updates off, Owl installed into every profile from " +
"the mesh's xpi with its updates off, and the ask to be the default mail client, the vendor's in-app notices " +
"and the start page locked off"
}
// lockedOff are the preferences the module's policy locks to false (novox/hq issue 345), each with what it is.
var lockedOff = []struct{ Name, What string }{
{defaultClientPref, "the ask to be the default mail client"},
{"mail.inappnotifications.enabled", "the vendor's in-app notices"},
{"mailnews.start_page.enabled", "the start page, loaded from the vendor at every start"},
}
// policyProblem is everything the policy fails to say of what ADR 0316 and issue 345 ask, or "".
func policyProblem(raw []byte) string {
var p struct {
Policies struct {
DisableAppUpdate bool `json:"DisableAppUpdate"`
DisableTelemetry bool `json:"DisableTelemetry"`
ExtensionUpdate *bool `json:"ExtensionUpdate"`
ExtensionSettings map[string]struct {
Mode string `json:"installation_mode"`
URL string `json:"install_url"`
Disabled bool `json:"updates_disabled"`
} `json:"ExtensionSettings"`
} `json:"policies"`
}
if err := json.Unmarshal(raw, &p); err != nil {
return "it is not JSON: " + err.Error()
}
var problems []string
if !p.Policies.DisableAppUpdate {
problems = append(problems, "it does not turn Betterbird's updater off (DisableAppUpdate)")
}
if !p.Policies.DisableTelemetry {
problems = append(problems, "it does not turn telemetry off (DisableTelemetry)")
}
if p.Policies.ExtensionUpdate == nil || *p.Policies.ExtensionUpdate {
problems = append(problems, "it leaves add-ons updating themselves (ExtensionUpdate)")
}
owl, ok := p.Policies.ExtensionSettings[owlID]
switch {
case !ok || owl.Mode != "force_installed":
problems = append(problems, "it does not install Owl into every profile (force_installed)")
case owl.URL != "file://"+owlFile:
problems = append(problems, "it installs Owl from "+owl.URL+", not from the mesh's xpi")
case !owl.Disabled:
problems = append(problems, "it leaves Owl's updates on")
}
prefs := policyPrefsOf(raw)
for _, l := range lockedOff {
if pr, ok := prefs[l.Name]; !ok || pr.Value != "false" || !pr.Locked {
problems = append(problems, "it does not lock "+l.What+" off (Preferences "+l.Name+": false, locked)")
}
}
return strings.Join(problems, "; ")
}
// defaultClientAsk says why Betterbird asks at its start to be the default mail client, or "" when it does
// not: the placed policy's own Preferences entry locks the ask off, else the profile's user.js (which wins at
// the start) or prefs.js turns it off. Betterbird's own default is to ask. Decided from the entry itself, never
// from what else the policy lacks.
func (m *Machine) defaultClientAsk(profile string) string {
if v, ok := m.locks()[defaultClientPref]; ok && v == "false" {
return ""
}
if profile != "" {
for _, f := range []string{"user.js", "prefs.js"} {
b, err := readBounded(filepath.Join(profile, f))
if err != nil {
continue
}
if v, ok := statementsOf(b)[defaultClientPref]; ok {
if v == "false" {
return ""
}
return "the profile's " + f + " sets " + defaultClientPref + " to " + v + " and no policy locks it off"
}
}
}
return "no policy locks " + defaultClientPref + " off and Betterbird's default is to ask"
}
// xpiVersion is the version an add-on's manifest.json says, or "" when it cannot be read.
func xpiVersion(path string) string {
r, err := zip.OpenReader(path)
if err != nil {
return ""
}
defer r.Close()
for _, f := range r.File {
if f.Name != "manifest.json" {
continue
}
rc, err := f.Open()
if err != nil {
return ""
}
defer rc.Close()
var mf struct {
Version string `json:"version"`
}
if json.NewDecoder(io.LimitReader(rc, 1<<20)).Decode(&mf) != nil {
return ""
}
return mf.Version
}
return ""
}
// addOns reads a profile's extensions.json: each add-on's id, version, whether it is on, and where from.
func addOns(profile string) []AddOn {
raw, err := readBounded(filepath.Join(profile, "extensions.json"))
if err != nil {
return nil
}
var ext struct {
Addons []struct {
ID string `json:"id"`
Version string `json:"version"`
Active bool `json:"active"`
Location string `json:"location"`
} `json:"addons"`
}
if json.Unmarshal(raw, &ext) != nil {
return nil
}
var out []AddOn
for _, a := range ext.Addons {
if a.Location == "app-profile" {
out = append(out, AddOn{ID: a.ID, Version: a.Version, Active: a.Active})
}
}
return out
}
@@ -0,0 +1,231 @@
package main
import (
"archive/zip"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// What the mesh installs, as the tools say it (novox/hq ADR 0316 §16): the version against the pin and the
// newest published build, the policy, Owl against its pin; and the reminder test, visibly a test.
func (f *fake) xpi(path, version string) {
f.t.Helper()
full := filepath.Join(f.Root, path)
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
f.t.Fatal(err)
}
out, err := os.Create(full)
if err != nil {
f.t.Fatal(err)
}
w := zip.NewWriter(out)
m, _ := w.Create("manifest.json")
m.Write([]byte(`{"version": "` + version + `", "browser_specific_settings": {"gecko": {"id": "owl@beonex.com"}}}`))
w.Close()
out.Close()
}
// placedByTheMesh is a node after the switch: the release, the policy and Owl's xpi in /opt/betterbird, and
// the profile running Owl at the pin.
func (f *fake) placedByTheMesh() {
f.noPackage()
f.write(appIni, "[App]\nVendor=Betterbird Project\nVersion=153.4.0\nBuildID=20260929222610\n")
raw, err := os.ReadFile(filepath.Join("..", "..", "install", "distribution", "policies.json"))
if err != nil {
f.t.Fatal(err)
}
f.write(policyFile, string(raw))
f.xpi(owlFile, "1.5.6.1")
f.write(profileDir+"/extensions.json", `{"addons":[{"id":"owl@beonex.com","version":"1.5.6.1","active":true,"location":"app-profile"}]}`)
}
func TestTheStatusSaysTheInstallationAgainstThePinTheNewestBuildThePolicyAndOwl(t *testing.T) {
f := newFake(t)
f.betterbird()
f.placedByTheMesh()
links(t, nil)
publishedBuilds = func() (string, error) {
return "blocklist,{}\nLinux,153,20261101000000\nLinux,140,20260829111240\n", nil
}
t.Cleanup(func() { publishedBuilds = func() (string, error) { return "", os.ErrDeadlineExceeded } })
s, err := f.Status()
if err != nil {
t.Fatal(err)
}
in := s.Installation
if in.From != "the mesh" || in.Version != "153.4.0" || in.Newest != "20261101000000" || !in.Behind ||
!strings.HasPrefix(in.Policy, "placed:") || in.Owl == nil || in.Owl.Pinned != "1.5.6.1" || !in.Owl.AtThePin || !in.Owl.Active {
t.Fatalf("%+v %+v", in, in.Owl)
}
c, err := f.Check()
if err != nil {
t.Fatal(err)
}
found := false
for _, x := range c.Findings {
found = found || strings.Contains(x.What, "behind the newest published build 20261101000000")
}
if !found {
t.Fatalf("a release left behind is said: %+v", c.Findings)
}
}
func TestTheCommunityPackageAPolicyThatIsNotTheModulesAndAnOwlOffItsPinAreSaid(t *testing.T) {
f := newFake(t)
f.betterbird()
f.placedByTheMesh()
links(t, nil)
f.write(policyFile, `{"policies": {"ExtensionSettings": {"owl@beonex.com": {"installation_mode": "force_installed",
"install_url": "https://vendor.example/owl.xpi", "updates_disabled": true}}}}`)
f.write(profileDir+"/extensions.json", `{"addons":[{"id":"owl@beonex.com","version":"1.5.7","active":true,"location":"app-profile"}]}`)
in := f.installation(filepath.Join(f.Root, profileDir))
if !strings.Contains(in.Policy, "DisableAppUpdate") || in.Owl.AtThePin || in.Owl.InProfile != "1.5.7" {
t.Fatalf("%+v %+v", in, in.Owl)
}
// A newer Owl than the pin is a fact with the real remedy, never a finding to remove it.
if !strings.Contains(in.Owl.Note, "move the module's pin") {
t.Fatalf("%+v", in.Owl)
}
f.write(policyFile, `{"policies": {"DisableAppUpdate": true, "ExtensionSettings": {"owl@beonex.com": {"installation_mode": "force_installed",
"install_url": "file:///opt/betterbird/owl/owl.xpi", "updates_disabled": true}}}}`)
c, _ := f.Check()
for _, x := range c.Findings {
if strings.Contains(x.What, "Owl") {
t.Errorf("a newer Owl is not a finding: %+v", x)
}
}
f.write(profileDir+"/extensions.json", `{"addons":[{"id":"owl@beonex.com","version":"1.5.5","active":true,"location":"app-profile"}]}`)
c, _ = f.Check()
found := false
for _, x := range c.Findings {
found = found || (strings.Contains(x.What, "older than the mesh's pin") && strings.Contains(x.Do, "move the module's Owl pin"))
}
if !found {
t.Fatalf("an older Owl is a finding whose remedy is the pin: %+v", c.Findings)
}
f.write(policyFile, `{"policies": {"DisableAppUpdate": true, "ExtensionSettings": {"owl@beonex.com": {"installation_mode": "force_installed",
"install_url": "https://vendor.example/owl.xpi", "updates_disabled": true}}}}`)
if in := f.installation(""); !strings.Contains(in.Policy, "not from the mesh's xpi") {
t.Fatalf("%s", in.Policy)
}
f.answer = func(name string, args []string) Output {
if name == "pacman" && args[0] == "-Q" {
return Output{Stdout: "betterbird-bin 153.1.0esr-1\n"}
}
return Output{}
}
in = f.installation("")
if !strings.HasPrefix(in.From, "the community package betterbird-bin 153.1.0esr-1") || !strings.Contains(in.From, "operator") {
t.Fatalf("%s", in.From)
}
raw, _ := json.Marshal(in)
if strings.Contains(string(raw), "example.org") {
t.Fatalf("%s", raw)
}
}
func TestTheReminderTestIsVisiblyATestAndSaysItOpensAWindow(t *testing.T) {
f := newFake(t)
f.desktopSession()
f.betterbird()
links(t, nil)
f.declare("// none")
f.declareAccounts(personal)
a, err := f.ReminderTest(40*time.Second, nil, false)
if err != nil {
t.Fatal(err)
}
dir := filepath.Join("/run/user/1000", testUnit)
css := f.read(dir + "/profile/chrome/userChrome.css")
user := f.read(dir + "/profile/user.js")
cal := f.read(dir + "/test.ics")
if !strings.Contains(css, "MESH TEST") || !strings.Contains(css, `:root:not([windowtype="Calendar:AlarmWindow"])`) {
t.Fatalf("the test's windows but the reminder carry a MESH TEST band: %s", css)
}
if !strings.Contains(user, `user_pref("toolkit.legacyUserProfileCustomizations.stylesheets", true);`) ||
strings.Contains(user, "mesh_personal") || strings.Contains(user, "example.org") {
t.Fatalf("the test profile reads its userChrome.css and holds no declared account: %s", user)
}
if !strings.Contains(cal, "SUMMARY:MESH TEST reminder") || !strings.Contains(a.OnScreen, "operator's screen") {
t.Fatalf("the event and the answer say it is a test on the operator's screen: %s %+v", cal, a)
}
if !f.called("systemd-run --user --collect --quiet --unit="+testUnit) || !strings.Contains(strings.Join(f.calls, "\n"), startBin+" --no-remote") {
t.Fatalf("the test starts Betterbird itself, never the module's start: %v", f.calls)
}
for _, tool := range tools() {
if tool.Name == "betterbird_reminder_test" && !strings.HasPrefix(tool.Description, "OPENS WINDOWS ON THE OPERATOR'S SCREEN") {
t.Fatalf("the description says first that a window opens on the operator's screen: %s", tool.Description)
}
}
}
func TestTheUserJSDescriptionSaysAdoption(t *testing.T) {
for _, tool := range tools() {
if tool.Name == "betterbird_user_js" && (!strings.Contains(tool.Description, "adopted under its own keys") ||
strings.Contains(tool.Description, "refuses the apply")) {
t.Fatalf("%s", tool.Description)
}
}
}
// Betterbird never asks at its start to be the default mail client: the module's policy locks
// mail.shell.checkDefaultClient off, and the check says so when it is on (novox/hq issue 345).
func TestTheDefaultClientAskIsLockedOffAndTheCheckSaysWhenItIsOn(t *testing.T) {
f := newFake(t)
f.betterbird()
f.placedByTheMesh()
links(t, nil)
asks := func() string {
t.Helper()
c, err := f.Check()
if err != nil {
t.Fatal(err)
}
for _, x := range c.Findings {
if strings.Contains(x.What, "asks at its start") {
return x.What + " | " + x.Do
}
}
return ""
}
if got := asks(); got != "" {
t.Fatalf("the module's policy locks the ask off, yet: %s", got)
}
unlocked := `{"policies": {"DisableAppUpdate": true, "ExtensionSettings": {"owl@beonex.com": {"installation_mode": "force_installed",
"install_url": "file:///opt/betterbird/owl/owl.xpi", "updates_disabled": true}}}}`
if why := policyProblem([]byte(unlocked)); !strings.Contains(why, defaultClientPref) {
t.Fatalf("a policy that leaves the ask on is said: %q", why)
}
f.write(policyFile, unlocked)
if got := asks(); !strings.Contains(got, "default mail client") || !strings.Contains(got, "push the module") {
t.Fatalf("the ask on is a finding with its remedy: %q", got)
}
f.write(policyFile, `{"policies": {"DisableAppUpdate": true, "Preferences": {"mail.shell.checkDefaultClient": {"Value": false, "Status": "default"}},
"ExtensionSettings": {"owl@beonex.com": {"installation_mode": "force_installed", "install_url": "file:///opt/betterbird/owl/owl.xpi", "updates_disabled": true}}}}`)
if why := policyProblem(mustRead(t, f, policyFile)); !strings.Contains(why, "locked") {
t.Fatalf("a default the profile can override is not a lock: %q", why)
}
f.write(profileDir+"/prefs.js", `user_pref("mail.shell.checkDefaultClient", true);`+"\n")
if got := asks(); got == "" {
t.Fatal("a profile that turned the ask back on over an unlocked default is a finding")
}
f.write(policyFile, unlocked)
f.write(profileDir+"/prefs.js", `user_pref("mail.shell.checkDefaultClient", false);`+"\n")
if got := asks(); got != "" {
t.Fatalf("a profile with the ask off does not ask: %s", got)
}
}
func mustRead(t *testing.T, f *fake, p string) []byte {
t.Helper()
raw, err := os.ReadFile(filepath.Join(f.Root, p))
if err != nil {
t.Fatal(err)
}
return raw
}
@@ -0,0 +1,480 @@
package main
// What the tools show of the five kinds (novox/hq ADR 0329 §1, §11–§13): the profile's entries beside the declared
// ones, with the operator's own values, and how a node differs from what is declared. Never a credential (§2):
// every answer passes credentialChecked, which withholds a field whose name says password, token, secret or cookie
// and the password part of any address.
import (
"encoding/json"
"fmt"
"strings"
)
// configView is the profile and the settings, read once for an answer.
type configView struct {
p Profile
prefs map[string]string
accounts map[string]Account
ra []resolvedAccount
conf Configuration
problem string
plan configPlan
rec configRecord
}
func (m *Machine) view() (configView, error) {
var v configView
p, err := m.profile()
if err != nil {
return v, err
}
v.p, v.prefs = p, mergedPrefs(p.path)
accounts, _, aerr := m.readAccounts()
if aerr != nil {
v.problem = aerr.Error()
}
v.accounts = accounts
v.ra = resolveAccounts(accounts, v.prefs, m.readRecord(p.Dir))
conf, cerr := m.readConfiguration()
if cerr != nil {
v.problem = strings.TrimPrefix(v.problem+"; "+cerr.Error(), "; ")
}
v.conf = conf
v.rec = m.readConfigRecord(p.Dir)
v.plan = m.planConfiguration(p, conf, accounts, v.ra, v.prefs)
return v, nil
}
// listedEntry is one entry of the profile, with its values: Owl's marked, an ICS calendar by its name only (§13).
type listedEntry struct {
Key string `json:"key"`
Kind string `json:"kind,omitempty"`
// Account is an identity's account key in the profile, or the account name filters are listed under.
Account string `json:"account,omitempty"`
Owl bool `json:"owl,omitempty"`
// DeclaredAs is the declared entry this one is, or empty: the person's own.
DeclaredAs string `json:"declared_as,omitempty"`
Value any `json:"value,omitempty"`
Note string `json:"note,omitempty"`
}
// declaredEntry is one declared entry, its value and how it resolved on this node.
type declaredEntry struct {
Name string `json:"name"`
Value any `json:"value"`
How string `json:"how,omitempty"`
Key string `json:"key,omitempty"`
Why string `json:"why,omitempty"`
}
// KindListing is what a listing tool answers.
type KindListing struct {
Profile string `json:"profile"`
Kind string `json:"kind"`
InProfile []listedEntry `json:"in_the_profile"`
Declared []declaredEntry `json:"declared"`
// Kept says where the module keeps the filter files it replaced, per account (§8).
Kept map[string]string `json:"kept_filter_files,omitempty"`
Problem string `json:"problem,omitempty"`
}
func (v configView) resolved(kind string) map[string]resolvedEntry {
out := map[string]resolvedEntry{}
for _, e := range v.plan.Entries {
if e.Kind == kind {
out[e.Name] = e
}
}
return out
}
// declaredBy is, per profile key, the declared entry of a kind adopted or created under it.
func (v configView) declaredBy(kind string) map[string]string {
out := map[string]string{}
for _, e := range v.plan.Entries {
if e.Kind == kind && e.How != refused {
out[e.Key] = e.Name
}
}
return out
}
// List answers one kind: the profile's entries beside the declared ones.
func (m *Machine) List(kind string) (KindListing, error) {
v, err := m.view()
a := KindListing{Kind: kind, InProfile: []listedEntry{}, Declared: []declaredEntry{}}
if err != nil {
return a, err
}
a.Profile, a.Problem = v.p.Dir, v.problem
res := v.resolved(kind)
by := v.declaredBy(kind)
declare := func(name string, value any) {
r := res[name]
a.Declared = append(a.Declared, declaredEntry{Name: name, Value: value, How: r.How, Key: r.Key, Why: r.Why})
}
switch kind {
case kindIdentities:
for _, pi := range m.profileIdentities(v.prefs) {
a.InProfile = append(a.InProfile, listedEntry{Key: pi.Key, Account: pi.AccountKey, Owl: pi.Owl,
DeclaredAs: by[pi.Key], Value: pi.Value})
}
for _, name := range sortedKeys(v.conf.Identities) {
declare(name, v.conf.Identities[name])
}
case kindCalendars:
for _, pc := range profileCalendars(v.prefs) {
e := listedEntry{Key: pc.Key, Kind: pc.Kind, DeclaredAs: by[pc.Key], Value: pc.Value}
switch {
case strings.HasPrefix(pc.Kind, "owl"):
e.Owl, e.Value = true, map[string]string{"name": pc.Value.Name, "url": pc.Value.URL}
e.Note = "Owl's: never declared (novox/hq ADR 0316 §13)"
case pc.Kind == "ics":
e.Value = map[string]string{"name": pc.Value.Name}
e.Note = "subscribed by an ICS address, which is often itself the secret: its name only, never declared (novox/hq ADR 0329 §13)"
case pc.Kind != "caldav":
e.Note = "kept in the profile: its events are never declared"
}
a.InProfile = append(a.InProfile, e)
}
for _, name := range sortedKeys(v.conf.Calendars) {
declare(name, v.conf.Calendars[name])
}
case kindAddressBooks:
for _, b := range profileAddressBooks(v.prefs) {
e := listedEntry{Key: b.Key, Kind: b.Kind, DeclaredAs: by[b.Key], Value: b.Value}
if !b.CardDAV {
e.Value, e.Note = map[string]string{"name": b.Value.Name}, "its contacts are kept in the profile and never declared"
}
a.InProfile = append(a.InProfile, e)
}
for _, name := range sortedKeys(v.conf.AddressBooks) {
declare(name, v.conf.AddressBooks[name])
}
case kindTags:
for _, t := range profileTags(v.prefs) {
a.InProfile = append(a.InProfile, listedEntry{Key: t.Key, DeclaredAs: by[t.Key], Value: t.Value})
}
for _, key := range sortedKeys(v.conf.Tags) {
declare(key, v.conf.Tags[key])
}
case kindFilters:
for _, f := range m.profileFilters(v) {
a.InProfile = append(a.InProfile, f)
}
for _, account := range v.conf.sortedFilterAccounts() {
d := declaredEntry{Name: account, Value: v.conf.Filters[account], How: "owned whole"}
for _, fp := range v.plan.filters {
if fp.Account == account {
d.Key, d.Why = fp.File, strings.TrimSpace(fp.Refused+" "+fp.Waiting)
if fp.Refused != "" {
d.How = refused
}
}
}
a.Declared = append(a.Declared, d)
}
a.Kept = map[string]string{}
for _, account := range sortedKeys(v.rec.Filters) {
if r := v.rec.Filters[account]; r.Kept != "" {
a.Kept[account] = r.Kept
}
}
if len(a.Kept) == 0 {
a.Kept = nil
}
default:
return a, fmt.Errorf("a kind is one of %s", strings.Join(allKinds, ", "))
}
return a, nil
}
// profileFilters are the filters of each declared account in the profile, by the account's name.
func (m *Machine) profileFilters(v configView) []listedEntry {
var out []listedEntry
for _, r := range v.ra {
if r.How == refused {
continue
}
dir := m.accountDirectory(v.p.path, v.prefs, r.Keys.Server)
if dir == "" {
continue
}
text, err := readBounded(dir + "/" + filterFileName)
if err != nil {
continue
}
parsed := parseFilters(string(text), folderBase(v.prefs, r.Keys.Server))
e := listedEntry{Key: m.tilde(strings.TrimPrefix(dir+"/"+filterFileName, m.Root)), Account: r.name, Value: parsed.Filters}
if _, declared := v.conf.Filters[r.name]; declared {
e.DeclaredAs = r.name
}
if len(parsed.NotDeclarable) > 0 {
e.Note = "not declarable as they are: " + strings.Join(parsed.NotDeclarable, "; ")
}
out = append(out, e)
}
return out
}
// fieldDiff is one field a profile holds differently from the declared value.
type fieldDiff struct {
Field string `json:"field"`
Declared any `json:"declared"`
Profile any `json:"profile"`
}
// DriftEntry is one declared entry against the profile: holds, differs, lacks or refused (§11).
type DriftEntry struct {
Name string `json:"name"`
State string `json:"state"`
Key string `json:"key,omitempty"`
Differs []fieldDiff `json:"differs,omitempty"`
Why string `json:"why,omitempty"`
}
// KindDrift is one kind's drift, and the profile's entries nothing declares.
type KindDrift struct {
Kind string `json:"kind"`
Declared []DriftEntry `json:"declared"`
Undeclared []listedEntry `json:"undeclared,omitempty"`
}
// DriftAnswer is what betterbird_drift answers.
type DriftAnswer struct {
Profile string `json:"profile"`
Kinds []KindDrift `json:"kinds"`
Note string `json:"note"`
Problem string `json:"problem,omitempty"`
}
// Drift answers, per kind, how the profile differs from what is declared: the profile as Betterbird reads it at
// its next start (prefs.js with user.js over it), so a declared value the module placed and Betterbird has not
// started with yet holds; the drift then is only what the module did not place.
func (m *Machine) Drift(kind string) (DriftAnswer, error) {
v, err := m.view()
a := DriftAnswer{Kinds: []KindDrift{}}
if err != nil {
return a, err
}
a.Profile, a.Problem = v.p.Dir, v.problem
a.Note = "the profile as Betterbird reads it at its next start (prefs.js, the module's user.js over it): differs " +
"names each field with the declared value and the profile's; lacks is an entry the profile does not hold yet " +
"(betterbird_user_js with apply, betterbird_restart); undeclared entries stay the person's"
kinds := allKinds
if kind != "" {
if !contains(allKinds, kind) {
return a, fmt.Errorf("a kind is one of %s", strings.Join(allKinds, ", "))
}
kinds = []string{kind}
}
for _, k := range kinds {
a.Kinds = append(a.Kinds, m.kindDrift(v, k))
}
return a, nil
}
func (m *Machine) kindDrift(v configView, kind string) KindDrift {
d := KindDrift{Kind: kind, Declared: []DriftEntry{}}
res := v.resolved(kind)
by := v.declaredBy(kind)
entry := func(name string, declared any, profile func(key string) (any, bool)) {
r := res[name]
e := DriftEntry{Name: name, Key: r.Key}
switch {
case r.How == refused:
e.State, e.Why = refused, r.Why
default:
have, ok := profile(r.Key)
if !ok {
e.State = "lacks"
break
}
e.Differs = differing(declared, have)
e.State = map[bool]string{true: "holds", false: "differs"}[len(e.Differs) == 0]
}
d.Declared = append(d.Declared, e)
}
switch kind {
case kindIdentities:
ids := map[string]profileIdentity{}
for _, pi := range m.profileIdentities(v.prefs) {
ids[pi.Key] = pi
if by[pi.Key] == "" && !pi.Owl {
d.Undeclared = append(d.Undeclared, listedEntry{Key: pi.Key, Account: pi.AccountKey, Value: pi.Value})
}
}
for _, name := range sortedKeys(v.conf.Identities) {
want := *v.conf.Identities[name]
want.Key = ""
entry(name, want, func(key string) (any, bool) {
pi, ok := ids[key]
if !ok {
return nil, false
}
have := pi.Value
have.Account = want.Account
if want.Address == "" {
have.Address, have.Name = "", ""
}
return have, true
})
}
case kindCalendars:
cals := map[string]profileCalendar{}
for _, pc := range profileCalendars(v.prefs) {
cals[pc.Key] = pc
if by[pc.Key] == "" && pc.Kind == "caldav" {
d.Undeclared = append(d.Undeclared, listedEntry{Key: pc.Key, Kind: pc.Kind, Value: pc.Value})
}
}
idName := v.declaredBy(kindIdentities)
for _, name := range sortedKeys(v.conf.Calendars) {
want := *v.conf.Calendars[name]
want.Key = ""
entry(name, want, func(key string) (any, bool) {
pc, ok := cals[key]
if !ok {
return nil, false
}
have := pc.Value
have.Invitations = idName[pc.IdentityKey]
if have.Reminders == nil && want.Reminders != nil && *want.Reminders {
on := true
have.Reminders = &on
}
return have, true
})
}
case kindAddressBooks:
books := map[string]profileAddressBook{}
for _, b := range profileAddressBooks(v.prefs) {
books[b.Key] = b
if by[b.Key] == "" && b.CardDAV {
d.Undeclared = append(d.Undeclared, listedEntry{Key: b.Key, Kind: b.Kind, Value: b.Value})
}
}
for _, name := range sortedKeys(v.conf.AddressBooks) {
want := *v.conf.AddressBooks[name]
want.Key = ""
entry(name, want, func(key string) (any, bool) {
b, ok := books[key]
return b.Value, ok
})
}
case kindTags:
tags := map[string]profileTag{}
for _, t := range profileTags(v.prefs) {
tags[t.Key] = t
if by[t.Key] == "" {
d.Undeclared = append(d.Undeclared, listedEntry{Key: t.Key, Value: t.Value})
}
}
for _, key := range sortedKeys(v.conf.Tags) {
want := *v.conf.Tags[key]
entry(key, want, func(k string) (any, bool) {
t, ok := tags[k]
have := t.Value
if want.Order == 0 {
have.Order = 0
}
return have, ok
})
}
case kindFilters:
files := map[string]listedEntry{}
for _, f := range m.profileFilters(v) {
files[f.Account] = f
released := v.rec.Filters[f.Account].Released
if f.DeclaredAs == "" && !released {
if list, _ := f.Value.([]Filter); len(list) > 0 {
d.Undeclared = append(d.Undeclared, f)
}
}
}
for _, fp := range v.plan.filters {
e := DriftEntry{Name: fp.Account, Key: fp.File}
switch {
case fp.Refused != "":
e.State, e.Why = refused, fp.Refused
case fp.path == "" || !fp.exists:
e.State, e.Why = "lacks", strings.TrimSpace(fp.Waiting)
default:
have, _ := files[fp.Account].Value.([]Filter)
for _, line := range filterDifferences(v.conf.Filters[fp.Account], have) {
e.Differs = append(e.Differs, fieldDiff{Field: line})
}
if fp.have != fp.want && len(e.Differs) == 0 {
e.Differs = append(e.Differs, fieldDiff{Field: "the file's text, beyond the filters' own words"})
}
e.State = map[bool]string{true: "holds", false: "differs"}[len(e.Differs) == 0]
}
d.Declared = append(d.Declared, e)
}
}
return d
}
// differing are the fields of two values of the same shape that differ.
func differing(declared, have any) []fieldDiff {
var a, b map[string]any
_ = json.Unmarshal([]byte(mustJSON(declared)), &a)
_ = json.Unmarshal([]byte(mustJSON(have)), &b)
var out []fieldDiff
for _, f := range sortedKeys(mergeKeys(a, b)) {
if mustJSON(a[f]) != mustJSON(b[f]) {
out = append(out, fieldDiff{Field: f, Declared: a[f], Profile: b[f]})
}
}
return out
}
// driftCounts are, per kind, how many declared entries do not hold.
func (m *Machine) driftCounts(v configView) map[string]int {
out := map[string]int{}
for _, k := range allKinds {
for _, e := range m.kindDrift(v, k).Declared {
if e.State != "holds" {
out[k]++
}
}
}
return out
}
// credentialChecked is an answer as it may leave the tool (§2): a field whose name says password, token, secret
// or cookie withheld, and the password part of any address as ‹withheld›.
func credentialChecked(v any) any {
b, err := json.Marshal(v)
if err != nil {
return v
}
var tree any
if json.Unmarshal(b, &tree) != nil {
return v
}
return scrubTree(tree)
}
func scrubTree(n any) any {
switch x := n.(type) {
case map[string]any:
for k, v := range x {
if secretName.MatchString(k) {
x[k] = "‹withheld›"
continue
}
x[k] = scrubTree(v)
}
return x
case []any:
for i, v := range x {
x[i] = scrubTree(v)
}
return x
case string:
return withoutPassword(x)
}
return n
}
+207 -25
View File
@@ -2,20 +2,32 @@
// mail and calendar client, 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 operator's mail, calendars, accounts and credentials are never read: a tool reads processes,
// profiles.ini, and of the profile only prefs.js, user.js and extensions.json, and answers no
// preference that can hold a credential or names an account.
// The operator's mail, calendar events, contacts and credentials are never read: a tool reads processes,
// profiles.ini, and of the profile its prefs.js, user.js, extensions.json, each account's msgFilterRules.dat and
// of logins.json only the server each kept sign-in is for. The tools show the operator's own configuration, an
// account's, an identity's, a calendar's, an address book's, a filter's and a tag's values, and never a credential
// (novox/hq ADR 0329 §1, §2): every answer passes credentialChecked.
package main
import (
"fmt"
"os"
"strings"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
)
func main() {
// The module's start runs this before Betterbird (novox/hq ADR 0316 §11, §12): the user.js placed and the
// declared accounts written into the account lists, while no Betterbird of this account runs.
if len(os.Args) == 2 && os.Args[1] == "prepare" {
if err := machine.Prepare(); err != nil {
fmt.Fprintln(os.Stderr, "betterbird: "+err.Error())
os.Exit(1)
}
return
}
if err := stdio.Serve("", tools()); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
@@ -25,22 +37,52 @@ func main() {
var machine = NewMachine()
func tools() []stdio.Tool {
list := toolList()
for i := range list {
run := list[i].Run
list[i].Run = func(args map[string]any) (any, error) {
out, err := run(args)
return credentialChecked(out), err
}
}
return list
}
// listTool is the listing tool of one of the five kinds (novox/hq ADR 0329 §12).
func listTool(kind, what string) stdio.Tool {
return stdio.Tool{
Name: "betterbird_" + strings.ReplaceAll(kind, "-", "_"),
Description: "The " + what + " of the profile Betterbird uses beside the ones the module's setting " + kind +
" declares (novox/hq ADR 0329): each of the profile's with its key, its values and the declared entry it is " +
"(none: the person's own), Owl's marked; each declared one with its value and how it resolved on this node " +
"(adopted, created or refused with why, and its key). The operator's own values, never a password, token " +
"or cookie. betterbird_drift says how they differ. (r)",
Run: func(map[string]any) (any, error) { return machine.List(kind) },
}
}
func toolList() []stdio.Tool {
return []stdio.Tool{
{
Name: "betterbird_status",
Description: "Betterbird: whether it runs (the main process's pid, since, and the scope it runs in; how " +
"many child processes), the installed package and version and whether it is from the AUR, where its " +
"output goes, the profile it uses and how that was found, the module's user.js (declared, placed, in " +
"effect), the add-ons the operator installed, and the preferences that decide how its windows and " +
"notifications look (reminder dialog, theme, renderer, system notifications). (r)",
"many child processes); what is installed in /opt/betterbird and from where (the mesh's pinned release, " +
"or the community package before the switch), its version and build beside the newest published build; " +
"whether the enterprise policy is placed; Owl's version against the mesh's pin and whether it is active; " +
"where its output goes; the profile it uses and how that was found; the module's user.js (declared, " +
"placed, in effect, with every declared identity, calendar, address book and tag as it resolved); the " +
"declared accounts by count (in the profile's list, other accounts, not signed in yet); the add-ons in the " +
"profile; and the preferences that decide how its windows and notifications look. (r)",
Run: func(map[string]any) (any, error) { return machine.Status() },
},
{
Name: "betterbird_prefs",
Description: "Named preferences of Betterbird's profile: each one's value and where it comes from (user.js, " +
"which wins at the start; prefs.js; or Betterbird's default). A name ending in . or * is a prefix " +
"(at most 200 answered). A preference whose name can hold a credential, or that names an account, a " +
"server, a calendar or a person, is answered without its value. (r)",
Description: "Named preferences of Betterbird's profile: each one's value and where it comes from (the " +
"module's policy, which locks it; user.js, which wins at the start; prefs.js; or the default), and the " +
"default itself with the file that gives it: Betterbird's own, read from omni.ja and the installation's " +
"defaults/pref, or the module's defaults (mesh-defaults.js). A name ending in . or * is a prefix (at most " +
"200 answered). A preference whose name can hold a credential, or that names an account, a server, a " +
"calendar or a person, is answered without its value. (r)",
Input: map[string]any{
"names": map[string]any{"type": "array", "items": map[string]any{"type": "string"},
"description": "preference names or prefixes, e.g. [\"calendar.alarms.\", \"extensions.activeThemeID\"]"},
@@ -53,13 +95,57 @@ func tools() []stdio.Tool {
return machine.Prefs(names)
},
},
{
Name: "betterbird_config",
Description: "Betterbird's preferences through the module's layers, preference by preference: the value its " +
"policy locks, the setting prefs (the operator's setup, mesh-wide with this node's layer over it, one entry " +
"per preference), Betterbird's own default where the module keeps that, the profile's own saved value and " +
"the value in effect. It flags drift: a declared value the profile's user.js does not hold yet, or a value " +
"the profile saved where the module keeps Betterbird's default. With keep (names), it proposes this node's " +
"layer of the setting prefs holding the profile's values of those preferences beside what the layer holds, " +
"through the controller's settings verb, for the operator's Approve; it never writes user.js. (r; a with keep)",
Input: map[string]any{
"keep": map[string]any{"type": "array", "items": map[string]any{"type": "string"},
"description": "managed preferences whose profile value this machine keeps: proposed as a node layer, e.g. [\"mail.uifontsize\"]"},
},
Run: func(args map[string]any) (any, error) {
keep, err := texts(args, "keep")
if err != nil {
return nil, err
}
return machine.Config(keep)
},
},
{
Name: "betterbird_config_apply",
Description: "Bring the profile back to the module's configuration: takes the drifting values " +
"(betterbird_config) out of the profile's prefs.js, so the module's defaults hold at Betterbird's next " +
"start, and says what it cleared, each with the value it had and the one that now holds. Without apply " +
"it lists them only. With apply it acts only while Betterbird is not running, because Betterbird " +
"rewrites prefs.js when it quits; every other line of prefs.js is kept. (r; a with apply)",
Input: map[string]any{
"apply": map[string]any{"type": "boolean", "description": "clear the drifting values (default false: a dry run)"},
},
Run: func(args map[string]any) (any, error) {
apply, err := flag(args, "apply", false)
if err != nil {
return nil, err
}
return machine.ConfigApply(apply)
},
},
{
Name: "betterbird_user_js",
Description: "The module's user.js — the preferences the module declares, from its setting prefs, written by " +
"the mesh to ~/.config/betterbird/user.js — against the profile's user.js: whether it is placed, and " +
"whether the running Betterbird started after it was. With apply, place it in the profile (only where " +
"the profile has no user.js or the module's own; an operator's file is never overwritten). Betterbird " +
"reads it at its next start: betterbird_restart. (r; a with apply)",
Description: "The module's user.js — the setting prefs, then the declared accounts' preferences (setting " +
"accounts), then the declared identities, calendars, address books and tags — against the profile's " +
"user.js: whether it is placed, and whether the running Betterbird started after it was. With apply, place " +
"it in the profile (only where the profile has no user.js or the module's own; an operator's file is never " +
"overwritten). A declared entry the profile already holds is adopted under its own keys; one it does not " +
"hold is created under the module's keys; an ambiguous one is refused alone, with the remedy (novox/hq ADR " +
"0317, ADR 0329 §7). What Betterbird rewrites itself — the account and identity lists, the calendars' " +
"order, the removals and each declared account's msgFilterRules.dat — is written only while Betterbird is " +
"closed, and the answer counts what waits (§8, §9). Accounts' values are withheld in the user.js shown. " +
"Betterbird reads it at its next start: betterbird_restart. (r; a with apply)",
Input: map[string]any{
"apply": map[string]any{"type": "boolean", "description": "place the declared user.js in the profile (default false)"},
},
@@ -118,19 +204,115 @@ func tools() []stdio.Tool {
},
{
Name: "betterbird_check",
Description: "Check what the module promises: Betterbird is installed; one runs; its profile is found; the " +
"module's user.js is on the machine, valid, placed in the profile and in effect; its output is read " +
"(a pipe nobody reads loses what it prints). Answers ok and each finding with what to do. (r)",
Description: "Check what the module promises: Betterbird installed from the mesh's pinned release and not " +
"behind the newest published build; the enterprise policy placed and the module's defaults with it; no " +
"managed preference drifting from them; Owl at its pin and active; one " +
"Betterbird runs; its profile is found; the module's user.js on the machine, valid, placed and in " +
"effect; the declared accounts in the profile's list, none refused, each signed in once; per kind, no " +
"declared identity, calendar, address book, filter or tag drifting or refused, and no change waiting for " +
"Betterbird to close; its output read. Answers ok and each finding with what to do. (r)",
Run: func(map[string]any) (any, error) { return machine.Check() },
},
{
Name: "betterbird_accounts",
Description: "The accounts of the profile Betterbird uses with their values (name, address, user, servers' " +
"hosts and ports) and the declared account each is, Owl's marked by name and address (never declared), " +
"with counts by kind. Never a password, a token or anything of the login store (novox/hq ADR 0329 §1, §2). " +
"With propose, it builds the module's setting accounts from them (every account but Owl's) and proposes " +
"it in the mesh-wide layer as it stands, through the controller's settings verb, for the operator's " +
"Approve on a channel that proves who answers (novox/hq ADR 0317, ADR 0277); the answer shows the value it " +
"proposed. (r; a with propose)",
Input: map[string]any{
"propose": map[string]any{"type": "boolean", "description": "build the setting accounts from the profile and propose it to the operator (default false)"},
},
Run: func(args map[string]any) (any, error) {
propose, err := flag(args, "propose", false)
if err != nil {
return nil, err
}
return machine.ProfileAccounts(propose)
},
},
listTool(kindIdentities, "identities (each account's default and its other identities, with name, address, reply-to, organisation and signature)"),
listTool(kindCalendars, "calendars (CalDAV with name, address, colour and options; an ICS one by its name only)"),
listTool(kindAddressBooks, "address books (CardDAV with name, address and user; a local one by its name)"),
listTool(kindFilters, "filters (per declared account: its msgFilterRules.dat as filters, folders by their path within the account; where the module keeps a file it replaced)"),
listTool(kindTags, "tags (key, name, colour, place)"),
{
Name: "betterbird_derive",
Description: "Derive the module's settings identities, calendars, address-books, filters and tags (or the kinds " +
"given) from the profiles of every desktop node holding Betterbird, laid out per layer: an entry on every " +
"such node goes into the mesh-wide layer with this node's value, and the answer lists each field another " +
"node holds differently; one on some nodes only goes into those nodes' own layers. Owl's, ICS calendars, " +
"local address books and any address carrying a password are left out and named. prefs puts values of the " +
"setting prefs into the mesh-wide layer. With propose, the next layer (the mesh-wide first, then each " +
"node's) is proposed for the operator's Approve through the controller's settings verb, carrying the layer " +
"as it stands beside the derived kinds; refused while a proposal of the module's waits, or while a desktop " +
"node does not answer (novox/hq ADR 0329 §10, ADR 0277). here answers this node's profile only, as the " +
"other nodes are asked. The values are the operator's own; never a credential. (r; a with propose)",
Input: map[string]any{
"kinds": map[string]any{"type": "array", "items": map[string]any{"type": "string", "enum": allKinds},
"description": "the kinds to derive (default all five)"},
"here": map[string]any{"type": "boolean", "description": "this node's profile only (default false)"},
"propose": map[string]any{"type": "boolean", "description": "propose the next layer to the operator (default false)"},
"prefs": map[string]any{"type": "object", "description": "values of the setting prefs for the mesh-wide " +
"layer, by preference name, e.g. {\"mail.uifontsize\": 14}"},
"layer": map[string]any{"type": "string", "description": "the layer to propose instead of the next: mesh-wide or a node's name"},
},
Run: func(args map[string]any) (any, error) {
kinds, err := texts(args, "kinds")
if err != nil {
return nil, err
}
here, err := flag(args, "here", false)
if err != nil {
return nil, err
}
propose, err := flag(args, "propose", false)
if err != nil {
return nil, err
}
layer, err := text(args, "layer", false)
if err != nil {
return nil, err
}
var prefs map[string]any
if v, ok := args["prefs"]; ok && v != nil {
if prefs, ok = v.(map[string]any); !ok {
return nil, fmt.Errorf("prefs is an object of preference names and values, not %T", v)
}
}
return machine.Derive(kinds, here, propose, prefs, layer)
},
},
{
Name: "betterbird_drift",
Description: "How this node's profile differs from the declared identities, calendars, address books, " +
"filters and tags (novox/hq ADR 0329 §11): per declared entry whether the profile holds it, holds another " +
"value (each differing field with the declared value and the profile's), lacks it, or it is refused; and " +
"the profile's entries nothing declares, which stay the person's. The profile as Betterbird reads it at " +
"its next start. (r)",
Input: map[string]any{
"kind": map[string]any{"type": "string", "enum": allKinds, "description": "one kind (default every kind)"},
},
Run: func(args map[string]any) (any, error) {
kind, err := text(args, "kind", false)
if err != nil {
return nil, err
}
return machine.Drift(kind)
},
},
{
Name: "betterbird_reminder_test",
Description: "Show a calendar reminder without touching the operator's calendar: a second Betterbird in a " +
"throw-away profile under the runtime directory (--no-remote, unit mesh-betterbird-reminder-test, at most " +
"10 minutes), with the module's user.js and one local calendar file holding one event whose reminder is " +
"due after `after` seconds. carry copies named preferences of the operator's profile (a theme, a " +
"colour switch) into it, never a credential or an account. Then xorg_windows (class Calendar) and " +
"xorg_screenshot show the reminder window. stop ends it and removes the profile. (a)",
Description: "OPENS WINDOWS ON THE OPERATOR'S SCREEN: tell the operator before calling it. Shows a calendar " +
"reminder without touching the operator's calendar: a second Betterbird in a throw-away profile under " +
"the runtime directory (--no-remote, unit mesh-betterbird-reminder-test, at most 10 minutes), its main " +
"window marked MESH TEST in red, with the module's user.js (no accounts) and one local calendar file " +
"holding one event, \"MESH TEST reminder\", whose reminder is due after `after` seconds. carry copies " +
"named preferences of the operator's profile (a theme, a colour switch) into it, never a credential or " +
"an account. Then xorg_windows (class Calendar) and xorg_screenshot show the reminder window. stop ends " +
"it and removes the profile. (a)",
Input: map[string]any{
"after": map[string]any{"type": "integer", "description": "seconds until the reminder is due (default 40, 15 to 300)"},
"carry": map[string]any{"type": "array", "items": map[string]any{"type": "string"},
@@ -2,25 +2,32 @@ package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"reflect"
"regexp"
"strconv"
"strings"
"testing"
)
// betterbird's shape (novox/hq issue 345, ADR 0208): no package (Betterbird comes from the AUR, and the
// node-engine installs from the official repositories only), no seat, no start of its own (the operator
// starts it from the launcher); the X display on its own machine; one directory and one file of its own,
// the user.js the setting prefs fills; and the Go bundle serving exactly the listed betterbird_ tools.
// betterbird's shape (novox/hq issue 345, ADR 0208, ADR 0316): the X display on its own machine; its directory
// in the home with the mergeable files the settings fill, each naming the keys it takes (ADR 0326): prefs, accounts
// and configuration (identities, calendars, address-books, filters) trusted, tags not (ADR 0329 §5); the community package declared absent before anything is placed in /opt/betterbird; Betterbird from
// its pinned upstream release, Owl pinned by its vendor's digest, the policy and the dictionaries' default
// unpacked into the installation; the tools' program for the start's preparation, the start and the launcher;
// every new build waiting for the operator's push; and the Go bundle serving exactly the listed tools.
type manifest struct {
Module string `json:"module"`
Version string `json:"version"`
Requires []string `json:"requires"`
Settings map[string]any `json:"settings"`
Tools []string `json:"tools"`
Resources []map[string]any `json:"resources"`
Module string `json:"module"`
Version string `json:"version"`
Upgrade map[string]string `json:"upgrade"`
Requires []string `json:"requires"`
Invokes []string `json:"invokes"`
Settings map[string]any `json:"settings"`
Tools []string `json:"tools"`
Resources []map[string]any `json:"resources"`
Build struct {
Artifacts []map[string]any `json:"artifacts"`
} `json:"build"`
@@ -41,43 +48,245 @@ func readManifest(t *testing.T) (manifest, string) {
return m, string(raw)
}
func TestItOwnsTheUserJSAndNothingOfTheProfile(t *testing.T) {
func resource(t *testing.T, m manifest, id string) map[string]any {
t.Helper()
for _, r := range m.Resources {
if r["id"] == id {
return r
}
}
t.Fatalf("module.json has no resource %s", id)
return nil
}
func artifact(t *testing.T, m manifest, name string) map[string]any {
t.Helper()
for _, a := range m.Build.Artifacts {
if a["name"] == name {
return a
}
}
t.Fatalf("module.json builds no artifact %s", name)
return nil
}
func TestItPlacesBetterbirdWhereTheCommunityPackageDidAndOnlyAfterItIsGone(t *testing.T) {
m, raw := readManifest(t)
if m.Module != "betterbird" || !reflect.DeepEqual(m.Requires, []string{"x11-display"}) {
t.Fatalf("%+v", m)
}
// Every new build waits for a person's push: the switch removes a package and replaces what a running
// Betterbird has open (ADR 0316 §4).
if m.Upgrade["policy"] != "record" || strings.TrimSpace(m.Upgrade["why"]) == "" {
t.Fatalf("the module is marked for a person's approval, with why: %v", m.Upgrade)
}
var ids []string
for _, r := range m.Resources {
ids = append(ids, r["id"].(string))
if r["type"] == "package" {
t.Errorf("betterbird-bin is the AUR's: a package resource asks the node-engine for what it cannot install")
}
if p, _ := r["path"].(string); !strings.HasPrefix(p, "${machine:account-home}/.config/betterbird") {
t.Errorf("a path outside the module's directory: %s", p)
}
}
if strings.Join(ids, ",") != "configuration-dir,user-js" {
t.Fatalf("%v", ids)
want := "configuration-dir,prefs,accounts,configuration,tags,community-package,installation,owl,policies,spelling,preparation,start,launcher"
if strings.Join(ids, ",") != want {
t.Fatalf("the resources, in the order the node-engine applies them: %v, want %s", ids, want)
}
pkg := resource(t, m, "community-package")
if pkg["type"] != "package" || pkg["package"] != packageFor || pkg["absent"] != true {
t.Fatalf("the community package is declared absent (ADR 0180): %v", pkg)
}
places := map[string]string{"installation": installDir, "owl": filepath.Dir(owlFile),
"policies": filepath.Dir(policyFile), "spelling": installDir + "/defaults/pref", "preparation": filepath.Dir(preparation)}
arts := map[string]string{"installation": "archive", "owl": "owl", "policies": "distribution", "spelling": "defaults", "preparation": "tools"}
for id, path := range places {
r := resource(t, m, id)
if r["type"] != "archive" || r["path"] != path || r["artifact"] != arts[id] || r["owner"] != nil {
t.Errorf("%s is the %s archive unpacked at %s, root's: %v", id, arts[id], path, r)
}
}
for _, never := range []string{".thunderbird", "prefs.js", "logins.json", "key4.db"} {
if strings.Contains(raw, never) {
if regexp.MustCompile(regexp.QuoteMeta(never) + `\b`).MatchString(raw) {
t.Errorf("module.json names %q: the profile is Betterbird's", never)
}
}
s := strings.ToLower(raw)
for _, never := range []string{"/home/", "jochen", "g14", "shanks", "novox.be", "http://", "mediahuis"} {
if strings.Contains(s, never) {
t.Errorf("module.json names %q", never)
}
}
file := m.Resources[1]
content := file["content"].(string)
if !strings.HasPrefix(content, mark) || !strings.Contains(content, "${setting:prefs}") || file["trusted"] != nil {
t.Fatalf("the user.js begins with the mark, holds the setting, and stays trusted (only the terminal sets it): %v", file)
}
// ADR 0329 §4–§6, ADR 0326, issue 586: every setting reaches the machine as a mergeable file of the account's that
// names the keys it takes; four kinds and accounts and prefs are trusted, tags is not; no setting has a default in the
// definition, since each is the operator's own value; and each file's default declares nothing.
func TestTheSettingsAreMergeableFilesThatNameTheirKeysAndTagsAloneIsUntrusted(t *testing.T) {
m, _ := readManifest(t)
if len(m.Settings) != 0 {
t.Fatalf("no setting has a default in the definition: the operator's setup is the settings' mesh-wide layer (issue 586, ADR 0262): %v", m.Settings)
}
// The default is a comment, so a machine without the setting places a valid, empty user.js.
def := m.Settings["prefs"].(map[string]any)["default"].(string)
if err := validUserJS(strings.ReplaceAll(content, "${setting:prefs}", def)); err != nil {
files := map[string]struct {
path, mode string
takes []any
trusted bool
}{
"prefs": {prefsFile, "0644", []any{"prefs"}, true},
"accounts": {accountsFile, "0600", []any{"accounts"}, true},
"configuration": {configurationFile, "0600", []any{"identities", "calendars", "address-books", "filters"}, true},
"tags": {tagsFile, "0644", []any{"tags"}, false},
}
merging := 0
for _, r := range m.Resources {
if r["merge"] != nil {
merging++
}
}
if merging != len(files) {
t.Fatalf("%d mergeable files, want %d", merging, len(files))
}
for id, want := range files {
r := resource(t, m, id)
if r["path"] != "${machine:account-home}/"+want.path || r["merge"] != "json" || r["trusted"] != want.trusted ||
r["mode"] != want.mode || r["owner"] != "${machine:account}" || !reflect.DeepEqual(r["takes-settings"], want.takes) {
t.Errorf("%s: %v", id, r)
}
var content map[string]any
if err := json.Unmarshal([]byte(r["content"].(string)), &content); err != nil || len(content) != len(want.takes) {
t.Errorf("%s's default holds each key it takes, empty: %v %v", id, content, err)
}
}
got, err := parseAccounts([]byte(resource(t, m, "accounts")["content"].(string)))
if err != nil || len(got) != 0 {
t.Fatalf("its default declares no account: %v %v", got, err)
}
prefs, err := parseDeclaredPrefs([]byte(resource(t, m, "prefs")["content"].(string)))
if err != nil || len(prefs) != 0 {
t.Fatalf("its default declares no preference: %v %v", prefs, err)
}
var c Configuration
if err := decodeInto([]byte(resource(t, m, "configuration")["content"].(string)), configurationFile, &c); err != nil {
t.Fatal(err)
}
if err := decodeInto([]byte(resource(t, m, "tags")["content"].(string)), tagsFile, &c); err != nil {
t.Fatal(err)
}
}
func TestBothPinsAreAURLAVersionAndASha256AndOwlIsNeverInTheRepository(t *testing.T) {
m, _ := readManifest(t)
sha := regexp.MustCompile(`^[0-9a-f]{64}$`)
for _, name := range []string{"archive", "owl"} {
a := artifact(t, m, name)
u, _ := a["url"].(string)
if a["kind"] != "archive" || !strings.HasPrefix(u, "https://") || !sha.MatchString(fmt.Sprint(a["sha256"])) ||
strings.TrimSpace(fmt.Sprint(a["version"])) == "" || a["from"] != nil {
t.Errorf("%s is pinned by URL, version and sha256, and copied from nowhere in the repository: %v", name, a)
}
}
if a := artifact(t, m, "archive"); a["within"] != "betterbird" {
t.Errorf("the release unpacks into betterbird/, whose content is the installation: %v", a)
}
if a := artifact(t, m, "owl"); a["file"] != filepath.Base(owlFile) {
t.Errorf("Owl's xpi is kept whole as %s: %v", filepath.Base(owlFile), a)
}
// Owl's licence forbids copying it to third parties: no xpi anywhere in the module's tree.
filepath.Walk(filepath.Join("..", ".."), func(p string, info os.FileInfo, err error) error {
if err == nil && strings.HasSuffix(strings.ToLower(p), ".xpi") {
t.Errorf("%s is in the repository", p)
}
return nil
})
}
// owlSupports is the range of Betterbird versions each pinned Owl declares in its manifest.json
// (browser_specific_settings.gecko strict_min_version, strict_max_version), read from the vendor's xpi
// when its pin was taken. Moving Owl's pin means reading the new xpi's range into here.
var owlSupports = map[string][2]string{
"1.5.6.1": {"115.0", "156.*"},
}
// owlAccepts says why a Betterbird version is outside the range an Owl version declares, or nil.
func owlAccepts(owl, betterbird string) error {
r, known := owlSupports[owl]
if !known {
return fmt.Errorf("Owl %s's supported range is not recorded in owlSupports: read it from the xpi's manifest.json", owl)
}
major := func(v string) int {
head, _, _ := strings.Cut(v, ".")
n, err := strconv.Atoi(head)
if err != nil {
return -1
}
return n
}
b, lo, hi := major(betterbird), major(r[0]), major(r[1])
if b < 0 || b < lo || b > hi {
return fmt.Errorf("Betterbird %s is outside the range Owl %s supports (%s to %s): a Betterbird Owl does not support "+
"waits for Owl's pin to move (novox/hq ADR 0316 §8)", betterbird, owl, r[0], r[1])
}
return nil
}
func TestABetterbirdOwlDoesNotSupportIsRefused(t *testing.T) {
m, _ := readManifest(t)
owl, bb := fmt.Sprint(artifact(t, m, "owl")["version"]), fmt.Sprint(artifact(t, m, "archive")["version"])
if err := owlAccepts(owl, bb); err != nil {
t.Fatal(err)
}
for _, outside := range []string{"157.0esr-bb1", "102.15.1esr-bb4"} {
if err := owlAccepts(owl, outside); err == nil {
t.Errorf("Betterbird %s was accepted with Owl %s", outside, owl)
}
}
if err := owlAccepts("9.9", bb); err == nil {
t.Error("an Owl whose range nobody read was accepted")
}
}
func TestThePolicyTurnsTheUpdaterOffAndInstallsTheMeshsOwlEverywhere(t *testing.T) {
m, _ := readManifest(t)
if a := artifact(t, m, "distribution"); a["kind"] != "archive" || a["from"] != "install/distribution" {
t.Fatalf("%v", a)
}
raw, err := os.ReadFile(filepath.Join("..", "..", "install", "distribution", "policies.json"))
if err != nil {
t.Fatal(err)
}
if why := policyProblem(raw); why != "" {
t.Fatalf("the module's policies.json: %s", why)
}
entries, _ := os.ReadDir(filepath.Join("..", "..", "install", "distribution"))
if len(entries) != 1 {
t.Fatalf("the distribution directory holds the policy alone: %v", entries)
}
if a := artifact(t, m, "defaults"); a["kind"] != "archive" || a["from"] != "install/defaults-pref" {
t.Fatalf("%v", a)
}
prefs, err := os.ReadFile(filepath.Join("..", "..", "install", "defaults-pref", "vendor-prefs.js"))
if err != nil || !strings.Contains(string(prefs), `pref("spellchecker.dictionary_path", "/usr/share/hunspell");`) {
t.Fatalf("the dictionaries are named as a default, in place of a link: %s %v", prefs, err)
}
}
func TestTheStartPreparesThenStartsBetterbirdAndTheLauncherUsesIt(t *testing.T) {
m, _ := readManifest(t)
start := resource(t, m, "start")
content := start["content"].(string)
if start["path"] != "/usr/local/bin/betterbird" || start["mode"] != "0755" ||
!strings.Contains(content, "\n"+preparation+" prepare ||") || !strings.HasSuffix(content, "\nexec "+startBin+" \"$@\"\n") {
t.Fatalf("the start runs the preparation, says when it did not complete, and starts Betterbird either way: %v", start)
}
launcher := resource(t, m, "launcher")
lc := launcher["content"].(string)
if launcher["path"] != "/usr/local/share/applications/eu.betterbird.Betterbird.desktop" ||
!strings.Contains(lc, "\nExec=/usr/local/bin/betterbird %u\n") || !strings.Contains(lc, "\nIcon="+installDir+"/") ||
!strings.Contains(lc, "\nStartupWMClass=eu.betterbird.Betterbird\n") {
t.Fatalf("the launcher starts the module's start and names the icon in the installation: %v", launcher)
}
if b := artifact(t, m, "tools"); filepath.Base(preparation) != b["binary"] {
t.Fatalf("the preparation runs the tools' binary: %v", b)
}
}
func TestTheToolsAgreeWithTheManifest(t *testing.T) {
m, raw := readManifest(t)
m, _ := readManifest(t)
served := map[string]bool{}
for _, tool := range tools() {
served[tool.Name] = true
@@ -94,17 +303,14 @@ func TestTheToolsAgreeWithTheManifest(t *testing.T) {
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]
b := artifact(t, m, "tools")
if b["kind"] != "bundle" || b["language"] != "go" || b["from"] != "cmd/betterbird-tools" || b["binary"] != "betterbird-tools" {
t.Errorf("the Go tools bundle: %v", b)
}
s := strings.ToLower(raw)
for _, never := range []string{"/home/", "jochen", "g14", "shanks", "novox.be", "http"} {
if strings.Contains(s, never) {
t.Errorf("module.json names %q", never)
}
// The tools propose through the controller's settings verb (ADR 0317 §9), and betterbird_derive asks the controller
// which nodes hold the module and each of them for its profile (ADR 0329 §10).
if !reflect.DeepEqual(m.Invokes, []string{"seat:mesh-controller.settings", "seat:mesh-controller.nodes",
"seat:mesh-controller.node", "betterbird.betterbird_derive"}) {
t.Errorf("the module invokes the controller's settings, nodes and node verbs and its own derive: %v", m.Invokes)
}
}
@@ -0,0 +1,749 @@
package main
// How the five settings of ADR 0329 reach the profile (§7–§9).
//
// Identities, calendars, address books and tags are matched as accounts are (ADR 0317): an entry the module
// recorded on this node is kept by its recorded key; else an entry under the module's own key is its creation;
// else a key a node's layer names is adopted when the profile holds it and it matches; else the profile's
// entries that match (an identity by its address within its account, a calendar and an address book by their
// address, a tag by its key): one is adopted, none means the entry is created under the module's own key, more
// than one is refused with the remedy. Their declared fields are written into the user.js the module places,
// where they win at every start. An entry leaving the setting releases what was adopted and removes the
// preferences of what was created; never a calendar's events, an address book's contacts or a cache.
//
// What Betterbird rewrites itself is written only while it does not run (§9): the lists in prefs.js (an adopted
// account's identities, the calendars' order) and each account's msgFilterRules.dat. Until then the module's
// check counts, per kind, the changes that wait.
import (
"bufio"
"bytes"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
)
// configRecordFile is where the module records, per profile, what it adopted or created of the five kinds and
// which filter files it wrote (ADR 0329 §7, §8, ADR 0317 §3).
const configRecordFile = ".config/betterbird/configuration-record.json"
// resolvedEntry is one declared entry as it resolved on this node.
type resolvedEntry struct {
Kind string `json:"kind"`
Name string `json:"name"`
How string `json:"how"`
Key string `json:"key,omitempty"`
Why string `json:"why,omitempty"`
}
type recordedEntry struct {
How string `json:"how"`
Key string `json:"key"`
}
// filterRecord is what the module did with one account's filter file on a profile.
type filterRecord struct {
// File is the account's filter file as the module last wrote it, and Kept the file it replaced at its first write.
File string `json:"file"`
Kept string `json:"kept,omitempty"`
// Released says the account left the setting filters: the module no longer writes its file (§8).
Released bool `json:"released,omitempty"`
}
type configRecord struct {
Entries map[string]map[string]recordedEntry `json:"entries,omitempty"`
Filters map[string]filterRecord `json:"filters,omitempty"`
}
func (m *Machine) readConfigRecord(profileDir string) configRecord {
all := map[string]configRecord{}
if raw, err := readBounded(m.home(configRecordFile)); err == nil {
_ = json.Unmarshal(raw, &all)
}
r := all[profileDir]
if r.Entries == nil {
r.Entries = map[string]map[string]recordedEntry{}
}
if r.Filters == nil {
r.Filters = map[string]filterRecord{}
}
return r
}
func (m *Machine) writeConfigRecord(profileDir string, r configRecord) error {
all := map[string]configRecord{}
if raw, err := readBounded(m.home(configRecordFile)); err == nil {
_ = json.Unmarshal(raw, &all)
}
all[profileDir] = r
body, err := json.MarshalIndent(all, "", " ")
if err != nil {
return err
}
return writeAtomic(m.home(configRecordFile), append(body, '\n'), 0o600)
}
// writeAtomic writes a file beside itself and renames it into place.
func writeAtomic(path string, data []byte, mode os.FileMode) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
tmp := path + ".mesh-new"
if err := writeSynced(tmp, data, mode); err != nil {
os.Remove(tmp)
return err
}
if err := os.Rename(tmp, path); err != nil {
os.Remove(tmp)
return err
}
return nil
}
// The module's own keys for what it creates: each names the entry.
func ownIdentityKey(name string) string { return "ident_mesh_" + name }
func ownCalendarKey(name string) string { return "mesh_" + name }
func ownBookKey(name string) string { return "mesh_" + name }
var (
ownIdentity = regexp.MustCompile(`^ident_mesh_[a-z][a-z0-9]{0,30}$`)
ownMeshKey = regexp.MustCompile(`^mesh_[a-z][a-z0-9]{0,30}$`)
)
// configPlan is what an apply does for the five kinds on this profile.
type configPlan struct {
Entries []resolvedEntry
// lines are the user.js statements, a comment before each entry's.
lines []string
// identityLists are, per adopted account key, the identity keys of the module's own it wants in its list.
identityLists map[string][]string
// calendarOrder are the calendar keys the module created, for calendar.list.sortOrder.
calendarOrder []string
// removals are the preference prefixes of what the module created and the setting no longer holds.
removals []string
filters []filterPlan
// released are the accounts that left the setting filters since the module last wrote their file.
released []string
record configRecord
problem string
}
// filterPlan is one declared account's filter file on this node.
type filterPlan struct {
Account string `json:"account"`
File string `json:"file,omitempty"`
// Waiting says why the file cannot be written yet, when it cannot.
Waiting string `json:"waiting,omitempty"`
Refused string `json:"refused,omitempty"`
want string
have string
exists bool
path string
}
func (p filterPlan) changes() bool { return p.Refused == "" && p.path != "" && p.want != p.have }
// planConfiguration resolves every declared entry against the profile and says what an apply writes.
func (m *Machine) planConfiguration(profile Profile, c Configuration, accounts map[string]Account,
ra []resolvedAccount, prefs map[string]string) configPlan {
rec := m.readConfigRecord(profile.Dir)
plan := configPlan{identityLists: map[string][]string{},
record: configRecord{Entries: map[string]map[string]recordedEntry{}, Filters: map[string]filterRecord{}}}
byName := map[string]resolvedAccount{}
for _, r := range ra {
byName[r.name] = r
}
have := profileAccounts(prefs)
listOfAccount := map[string][]string{}
for _, a := range have {
listOfAccount[a.Key] = a.Identities
}
identityKeyOf := map[string]string{}
// Identities.
taken := map[string]bool{}
for _, name := range sortedKeys(c.Identities) {
id := c.Identities[name]
e := resolvedEntry{Kind: kindIdentities, Name: name}
acct, declared := byName[id.Account]
switch {
case !declared:
e.How, e.Why = refused, fmt.Sprintf("its account %s is not in the setting accounts", id.Account)
case acct.How == refused:
e.How, e.Why = refused, fmt.Sprintf("its account %s is refused: betterbird_check says why", id.Account)
case id.Address == "":
e.How, e.Key = acct.How, acct.Keys.Identity
if taken[e.Key] {
e.How, e.Key, e.Why = refused, "", fmt.Sprintf("account %s has its default identity declared twice", id.Account)
}
default:
list := listOfAccount[acct.Keys.Account]
var others []string
if len(list) > 1 {
others = list[1:]
}
present := func(k string) bool {
for _, x := range others {
if x == k {
return true
}
}
return false
}
matches := func(k string) bool {
return strings.EqualFold(unquote(prefs["mail.identity."+k+".useremail"]), id.Address)
}
e = resolveEntry(kindIdentities, name, id.Key, ownIdentityKey(name), rec.Entries[kindIdentities][name],
others, present, matches, taken, "an identity of its account with its address",
"give the one to adopt as key in this node's layer of the setting identities")
if e.How == created {
plan.identityLists[acct.Keys.Account] = append(plan.identityLists[acct.Keys.Account], e.Key)
}
}
if e.How != refused {
taken[e.Key] = true
identityKeyOf[name] = e.Key
plan.lines = append(plan.lines, fmt.Sprintf("// identity %s: %s under %s", name, e.How, e.Key))
plan.lines = append(plan.lines, identityLines(e, id, acct)...)
}
plan.Entries = append(plan.Entries, e)
}
// A created account's identity list is the module's whole: its default and the identities it created.
for _, name := range sortedKeys(byName) {
r := byName[name]
if r.How == created && len(plan.identityLists[r.Keys.Account]) > 0 {
list := append([]string{r.Keys.Identity}, plan.identityLists[r.Keys.Account]...)
plan.lines = append(plan.lines, fmt.Sprintf("user_pref(%s, %s);", quoted("mail.account."+r.Keys.Account+".identities"),
quoted(strings.Join(list, ","))))
delete(plan.identityLists, r.Keys.Account)
}
}
// Adopted accounts keep their list in prefs.js: the module's own keys are written into it while Betterbird is
// closed. An adopted account whose list holds none of them and wants none is left out.
for _, a := range have {
if _, wanted := plan.identityLists[a.Key]; wanted {
continue
}
for _, k := range a.Identities {
if ownIdentity.MatchString(k) {
plan.identityLists[a.Key] = []string{}
break
}
}
}
// Calendars.
cals := profileCalendars(prefs)
calKeys := map[string]profileCalendar{}
var caldav []string
for _, pc := range cals {
calKeys[pc.Key] = pc
if pc.Kind == "caldav" {
caldav = append(caldav, pc.Key)
}
}
taken = map[string]bool{}
for _, name := range sortedKeys(c.Calendars) {
cal := c.Calendars[name]
present := func(k string) bool { pc, ok := calKeys[k]; return ok && pc.Kind == "caldav" }
matches := func(k string) bool { return sameURL(calKeys[k].Value.URL, cal.URL) }
e := resolveEntry(kindCalendars, name, cal.Key, ownCalendarKey(name), rec.Entries[kindCalendars][name],
caldav, present, matches, taken, "a CalDAV calendar at its address",
"remove the duplicate in Betterbird, or give the one to adopt as key in this node's layer of the setting calendars")
if e.How != refused {
taken[e.Key] = true
if e.How == created {
plan.calendarOrder = append(plan.calendarOrder, e.Key)
}
plan.lines = append(plan.lines, fmt.Sprintf("// calendar %s: %s under %s", name, e.How, e.Key))
plan.lines = append(plan.lines, calendarLines(e, cal, identityKeyOf[cal.Invitations])...)
}
plan.Entries = append(plan.Entries, e)
}
// Address books.
books := profileAddressBooks(prefs)
bookKeys := map[string]profileAddressBook{}
var carddav []string
for _, b := range books {
bookKeys[b.Key] = b
if b.CardDAV {
carddav = append(carddav, b.Key)
}
}
taken = map[string]bool{}
for _, name := range sortedKeys(c.AddressBooks) {
ab := c.AddressBooks[name]
present := func(k string) bool { b, ok := bookKeys[k]; return ok && b.CardDAV }
matches := func(k string) bool { return sameURL(bookKeys[k].Value.URL, ab.URL) }
e := resolveEntry(kindAddressBooks, name, ab.Key, ownBookKey(name), rec.Entries[kindAddressBooks][name],
carddav, present, matches, taken, "a CardDAV address book at its address",
"remove the duplicate in Betterbird, or give the one to adopt as key in this node's layer of the setting address-books")
if e.How != refused {
taken[e.Key] = true
plan.lines = append(plan.lines, fmt.Sprintf("// address book %s: %s under %s", name, e.How, e.Key))
plan.lines = append(plan.lines, addressBookLines(e, ab, name)...)
}
plan.Entries = append(plan.Entries, e)
}
// Tags: a tag's key is its own, unique in a profile, so a match is never ambiguous.
tagKeys := map[string]bool{}
for _, t := range profileTags(prefs) {
tagKeys[t.Key] = true
}
for _, key := range sortedKeys(c.Tags) {
t := c.Tags[key]
e := resolvedEntry{Kind: kindTags, Name: key, Key: key, How: adopted}
if was, ok := rec.Entries[kindTags][key]; ok && was.How == created {
e.How = created
} else if !tagKeys[key] {
e.How = created
}
plan.lines = append(plan.lines, fmt.Sprintf("// tag %s: %s", key, e.How))
plan.lines = append(plan.lines, tagLines(key, t)...)
plan.Entries = append(plan.Entries, e)
}
// What the setting no longer holds: an adopted entry is released (its lines go), a created one's preferences
// are removed from prefs.js while Betterbird is closed.
declaredNow := map[string]map[string]bool{}
for _, e := range plan.Entries {
if declaredNow[e.Kind] == nil {
declaredNow[e.Kind] = map[string]bool{}
}
declaredNow[e.Kind][e.Name] = true
if e.How != refused {
if plan.record.Entries[e.Kind] == nil {
plan.record.Entries[e.Kind] = map[string]recordedEntry{}
}
plan.record.Entries[e.Kind][e.Name] = recordedEntry{How: e.How, Key: e.Key}
}
}
for _, kind := range []string{kindIdentities, kindCalendars, kindAddressBooks, kindTags} {
for _, name := range sortedKeys(rec.Entries[kind]) {
was := rec.Entries[kind][name]
if declaredNow[kind][name] || was.How != created {
continue
}
switch kind {
case kindIdentities:
plan.removals = append(plan.removals, "mail.identity."+was.Key+".")
case kindCalendars:
plan.removals = append(plan.removals, "calendar.registry."+was.Key+".")
case kindAddressBooks:
plan.removals = append(plan.removals, "ldap_2.servers."+was.Key+".")
case kindTags:
plan.removals = append(plan.removals, "mailnews.tags."+was.Key+".")
}
}
}
// Filters, owned whole per declared account (§8).
for _, account := range sortedAccountsOf(c.Filters) {
fp := filterPlan{Account: account}
r, ok := byName[account]
switch {
case !ok:
fp.Refused = fmt.Sprintf("its account %s is not in the setting accounts", account)
case r.How == refused:
fp.Refused = fmt.Sprintf("its account %s is refused: betterbird_check says why", account)
default:
dir := m.accountDirectory(profile.path, prefs, r.Keys.Server)
if dir == "" {
fp.Waiting = "Betterbird has not made the account's directory yet: it does at its first start with the account"
break
}
fp.path = filepath.Join(dir, filterFileName)
fp.File = m.tilde(strings.TrimPrefix(fp.path, m.Root))
fp.want = renderFilters(c.Filters[account], folderBase(prefs, r.Keys.Server))
raw, err := readBounded(fp.path)
fp.exists = err == nil
fp.have = string(raw)
}
was := rec.Filters[account]
was.Released = false
if fp.path != "" {
was.File = fp.File
}
if was.File != "" || was.Kept != "" {
plan.record.Filters[account] = was
}
plan.filters = append(plan.filters, fp)
}
for _, account := range sortedKeys(rec.Filters) {
if _, still := c.Filters[account]; still {
continue
}
was := rec.Filters[account]
if !was.Released {
plan.released = append(plan.released, account)
}
was.Released = true
plan.record.Filters[account] = was
}
return plan
}
// resolveEntry resolves an identity, a calendar or an address book as ADR 0317 resolves an account (§7).
func resolveEntry(kind, name, layerKey, own string, was recordedEntry, candidates []string, present, matches func(string) bool,
taken map[string]bool, what, remedy string) resolvedEntry {
e := resolvedEntry{Kind: kind, Name: name}
refuse := func(format string, args ...any) resolvedEntry {
e.How, e.Key, e.Why = refused, "", fmt.Sprintf(format, args...)
return e
}
// 1. Recorded, while the profile still holds it.
if was.Key != "" && !taken[was.Key] && (present(was.Key) || was.How == created) && (layerKey == "" || layerKey == was.Key) {
e.How, e.Key = was.How, was.Key
return e
}
// 2. Under the module's own key: its creation.
if present(own) {
e.How, e.Key = created, own
return e
}
// 3. The key a node's layer names.
if layerKey != "" {
switch {
case !present(layerKey):
return refuse("the key %s this node's layer names is not in the profile", layerKey)
case taken[layerKey]:
return refuse("the key %s is already another declared entry's", layerKey)
case !matches(layerKey):
return refuse("the key %s this node's layer names is not %s", layerKey, what)
}
e.How, e.Key = adopted, layerKey
return e
}
// 4. By the match.
var found []string
for _, k := range candidates {
if !taken[k] && !ownMeshKey.MatchString(k) && !ownIdentity.MatchString(k) && matches(k) {
found = append(found, k)
}
}
switch len(found) {
case 0:
e.How, e.Key = created, own
case 1:
e.How, e.Key = adopted, found[0]
default:
return refuse("%d entries in the profile are %s (%s): %s (novox/hq ADR 0329 §7)", len(found), what,
strings.Join(found, ", "), remedy)
}
return e
}
// sameURL compares two addresses without a trailing slash's difference.
func sameURL(a, b string) bool {
return a != "" && strings.EqualFold(strings.TrimRight(a, "/"), strings.TrimRight(b, "/"))
}
func pref(name, value string) string { return fmt.Sprintf("user_pref(%s, %s);", quoted(name), value) }
func boolWord(b bool) string { return strconv.FormatBool(b) }
// identityLines are an identity's declared fields under its key. The default identity's name and address are
// the setting accounts' (§4), so only another identity carries them here.
func identityLines(e resolvedEntry, id *Identity, acct resolvedAccount) []string {
p := "mail.identity." + e.Key + "."
var out []string
if id.Address != "" {
out = append(out, pref(p+"fullName", jsQuoted(id.Name)), pref(p+"useremail", jsQuoted(id.Address)))
if e.How == created {
out = append(out, pref(p+"valid", "true"), pref(p+"smtpServer", quoted(acct.Keys.SMTP)))
}
}
above := 0
if id.SignatureAboveQuote {
above = 1
}
out = append(out,
pref(p+"reply_to", jsQuoted(id.ReplyTo)),
pref(p+"organization", jsQuoted(id.Organization)),
pref(p+"htmlSigText", jsQuoted(strings.Join(id.Signature, "\n"))),
pref(p+"htmlSigFormat", boolWord(id.SignatureHTML)),
pref(p+"attach_signature", "false"),
pref(p+"reply_on_top", strconv.Itoa(above)),
pref(p+"sig_bottom", boolWord(!id.SignatureAboveQuote)))
return out
}
func calendarLines(e resolvedEntry, cal *Calendar, identityKey string) []string {
p := "calendar.registry." + e.Key + "."
var out []string
if e.How == created {
out = append(out, pref(p+"type", quoted("caldav")), pref(p+"cache.enabled", "true"),
pref(p+"calendar-main-in-composite", "true"))
}
reminders := cal.Reminders == nil || *cal.Reminders
out = append(out, pref(p+"uri", jsQuoted(cal.URL)), pref(p+"name", jsQuoted(cal.Name)))
if cal.Color != "" {
out = append(out, pref(p+"color", jsQuoted(strings.ToLower(cal.Color))))
}
out = append(out, pref(p+"readOnly", boolWord(cal.ReadOnly)), pref(p+"suppressAlarms", boolWord(!reminders)),
pref(p+"username", jsQuoted(cal.User)), pref(p+"imip.identity.key", jsQuoted(identityKey)))
return out
}
func addressBookLines(e resolvedEntry, ab *AddressBook, name string) []string {
p := "ldap_2.servers." + e.Key + "."
var out []string
if e.How == created {
out = append(out, pref(p+"dirType", "102"), pref(p+"filename", quoted("abook-mesh_"+name+".sqlite")))
}
return append(out, pref(p+"description", jsQuoted(ab.Name)), pref(p+"carddav.url", jsQuoted(ab.URL)),
pref(p+"carddav.username", jsQuoted(ab.User)))
}
func tagLines(key string, t *Tag) []string {
p := "mailnews.tags." + key + "."
out := []string{pref(p+"tag", jsQuoted(t.Name))}
if t.Color != "" {
out = append(out, pref(p+"color", jsQuoted(strings.ToUpper(t.Color))))
}
if t.Order > 0 {
out = append(out, pref(p+"ordinal", quoted(tagOrdinal(t.Order))))
}
return out
}
// configBlock is the user.js block of the five kinds.
func (p configPlan) configBlock() string {
if len(p.lines) == 0 {
return ""
}
return "\n// The identities, calendars, address books and tags the settings declare (novox/hq ADR 0329), under the\n" +
"// keys each was adopted or created under. No password, token or cookie.\n" + strings.Join(p.lines, "\n") + "\n"
}
// closedFor says why the profile's own files may not be written now, or "": Betterbird runs, or the profile's
// lock says one holds it (§9).
func (m *Machine) closedFor(profile string) string {
if ps := m.mainProcs(); len(ps) > 0 {
return fmt.Sprintf("Betterbird is running (pid %d), and it rewrites these files when it quits", ps[0].PID)
}
if _, err := os.Lstat(filepath.Join(profile, "lock")); err == nil {
return "the profile is locked (its lock file is there): a Betterbird holds it, or one ended without quitting"
}
return ""
}
// prefsChanges is prefs.js as the plan wants it, and how many changes each kind has in it.
func (p configPlan) prefsChanges(text string) (string, map[string]int) {
counts := map[string]int{}
var lines []string
done := map[string]bool{}
s := bufio.NewScanner(strings.NewReader(text))
s.Buffer(make([]byte, 64<<10), 4<<20)
for s.Scan() {
line := s.Text()
mm := prefLine.FindStringSubmatch(line)
if mm == nil {
lines = append(lines, line)
continue
}
name := mm[1]
removed := false
for _, prefix := range p.removals {
if strings.HasPrefix(name, prefix) {
removed = true
counts[kindOfPrefix(prefix)]++
break
}
}
if removed {
continue
}
if acct, ok := strings.CutPrefix(name, "mail.account."); ok && strings.HasSuffix(acct, ".identities") {
key := strings.TrimSuffix(acct, ".identities")
if want, ok := p.identityLists[key]; ok {
done[name] = true
next := listedWithOwn(listOf(mm[2]), want, ownIdentity.MatchString, ",")
if next != strings.Join(listOf(mm[2]), ",") {
line = pref(name, quoted(next))
counts[kindIdentities]++
}
}
}
if name == "calendar.list.sortOrder" {
done[name] = true
have := strings.Fields(unquote(mm[2]))
next := listedWithOwn(have, p.calendarOrder, ownMeshKey.MatchString, " ")
if next != strings.Join(have, " ") {
line = pref(name, quoted(next))
counts[kindCalendars]++
}
}
lines = append(lines, line)
}
if !done["calendar.list.sortOrder"] && len(p.calendarOrder) > 0 {
lines = append(lines, pref("calendar.list.sortOrder", quoted(strings.Join(p.calendarOrder, " "))))
counts[kindCalendars]++
}
out := strings.Join(lines, "\n")
if text == "" || strings.HasSuffix(text, "\n") {
out += "\n"
}
return out, counts
}
func kindOfPrefix(prefix string) string {
switch {
case strings.HasPrefix(prefix, "mail.identity."):
return kindIdentities
case strings.HasPrefix(prefix, "calendar."):
return kindCalendars
case strings.HasPrefix(prefix, "ldap_2."):
return kindAddressBooks
}
return kindTags
}
// listedWithOwn is a list with the module's own keys made the wanted ones, every other key in its place.
func listedWithOwn(have, wanted []string, own func(string) bool, sep string) string {
want := map[string]bool{}
for _, k := range wanted {
want[k] = true
}
var out []string
there := map[string]bool{}
for _, k := range have {
if own(k) && !want[k] {
continue
}
out = append(out, k)
there[k] = true
}
for _, k := range wanted {
if !there[k] {
out = append(out, k)
}
}
return strings.Join(out, sep)
}
// Waiting counts, per kind, the changes of what Betterbird rewrites that wait for it to close (§9).
func (m *Machine) waiting(profile string, p configPlan, ra []resolvedAccount) map[string]int {
raw, _ := readBounded(filepath.Join(profile, "prefs.js"))
_, counts := p.prefsChanges(string(raw))
var wantAccounts, wantSMTP []string
for _, r := range ra {
if r.How == created {
wantAccounts, wantSMTP = append(wantAccounts, r.Keys.Account), append(wantSMTP, r.Keys.SMTP)
}
}
if _, changed := withListed(string(raw), map[string][]string{
"mail.accountmanager.accounts": wantAccounts, "mail.smtpservers": wantSMTP}); changed {
counts["accounts"]++
}
for _, f := range p.filters {
if f.changes() {
counts[kindFilters]++
}
}
for k, v := range counts {
if v == 0 {
delete(counts, k)
}
}
return counts
}
// writeClosed writes what Betterbird rewrites itself, only while it is closed: prefs.js's lists and the
// removals, and each declared account's filter file, keeping the file it replaces at its first write. It
// answers what it wrote, or why it waits.
func (m *Machine) writeClosed(profile Profile, p *configPlan) (string, error) {
var said []string
path := filepath.Join(profile.path, "prefs.js")
raw, err := readBounded(path)
if err != nil && !errors.Is(err, os.ErrNotExist) {
return "", err
}
next, counts := p.prefsChanges(string(raw))
pending := next != string(raw)
for _, f := range p.filters {
pending = pending || f.changes()
}
if !pending {
return "unchanged", nil
}
if why := m.closedFor(profile.path); why != "" {
return "", errors.New(why + ": the identities' and calendars' lists, the removals and the filter files are " +
"written at its next start from the launcher, or by betterbird_restart")
}
if next != string(raw) {
mode := os.FileMode(0o600)
if st, err := os.Stat(path); err == nil {
mode = st.Mode().Perm()
}
if err := writeAtomic(path, []byte(next), mode); err != nil {
return "", err
}
said = append(said, fmt.Sprintf("prefs.js: %d change(s) to the lists and removals", sum(counts)))
}
for i, f := range p.filters {
if !f.changes() {
continue
}
was := p.record.Filters[f.Account]
if was.Kept == "" && f.exists {
kept := m.keptFilterFile(profile.Dir, f.Account)
if err := writeAtomic(kept, []byte(f.have), 0o600); err != nil {
return strings.Join(said, "; "), fmt.Errorf("the filter file of %s was not written: the file it replaces could not be kept: %v", f.Account, err)
}
was.Kept = m.tilde(strings.TrimPrefix(kept, m.Root))
}
mode := os.FileMode(0o644)
if st, err := os.Stat(f.path); err == nil {
mode = st.Mode().Perm()
}
if err := writeAtomic(f.path, []byte(f.want), mode); err != nil {
return strings.Join(said, "; "), err
}
was.File = f.File
p.record.Filters[f.Account] = was
p.filters[i].have, p.filters[i].exists = f.want, true
said = append(said, "the filters of "+f.Account)
}
if len(said) == 0 {
return "unchanged", nil
}
return "written: " + strings.Join(said, "; "), nil
}
func sum(m map[string]int) int {
n := 0
for _, v := range m {
n += v
}
return n
}
// accountDirectory is an account's directory in the profile, as its server's preferences name it, or "" when
// they name none within the profile.
func (m *Machine) accountDirectory(profile string, prefs map[string]string, server string) string {
if dir := accountDirectory(profile, prefs, server); dir != "" {
return dir
}
abs := unquote(prefs["mail.server."+server+".directory"])
if abs == "" {
return ""
}
dir := m.path(abs)
if strings.HasPrefix(filepath.Clean(dir), filepath.Clean(profile)+string(filepath.Separator)) {
return dir
}
return ""
}
func mustJSON(v any) string {
b, _ := json.Marshal(v)
return string(bytes.TrimSpace(b))
}
@@ -0,0 +1,228 @@
package main
// The setting prefs, one entry per preference (novox/hq issue 586, ADR 0329; ADR 0316 §11 for the user.js).
//
// Until issue 586 the setting was one line of user_pref(…) statements, so a node's layer replaced it whole and
// the operator's own choices were shipped in the catalogue as the module's defaults file. Now the setting is a
// map from a preference's name to its value, and reaches the machine as ~/.config/betterbird/prefs.json, a
// file the node-engine merges from the module's layers key by key: the mesh-wide layer holds the operator's
// setup, and a node's layer overrides one preference, or takes one out with null. The module itself ships only
// its policy's locks and Betterbird's own defaults (mesh-defaults.js names the preferences it manages there).
//
// The tools turn the setting into the head of the user.js they place in the profile, where Betterbird reads it
// at its start and lets it win over what it saved itself. A preference that can hold a credential, or that
// another setting owns (an account's, an identity's, a calendar's, an address book's, a tag's), is refused, so
// each value has one owner (ADR 0316 §11, ADR 0329 §6).
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"math"
"os"
"regexp"
"sort"
"strconv"
"strings"
)
// prefsFile is the setting prefs on the machine, written by the node-engine.
const prefsFile = ".config/betterbird/prefs.json"
// configurationOwned are the preferences the settings of ADR 0329 own: the setting prefs refuses them (§6).
var configurationOwned = []string{"calendar.registry.", "calendar.list.", "ldap_2.servers.", "mailnews.tags."}
// prefName is a preference's name as the setting may give it.
var prefName = regexp.MustCompile(`^[A-Za-z0-9_][A-Za-z0-9_.\-\[\]@{}]{0,199}$`)
// prefOwner says why the setting prefs may not set a preference, or "" when it may.
func prefOwner(name string) string {
if passwordSecret.MatchString(name) {
return "a preference that can hold a credential: not through the mesh"
}
for _, p := range accountOwned {
if strings.HasPrefix(name, p) {
return "an account's preference: an account is declared in the setting accounts, never in prefs (novox/hq ADR 0316 §11)"
}
}
for _, p := range configurationOwned {
if strings.HasPrefix(name, p) {
return "owned by the setting calendars, address-books or tags, never by prefs (novox/hq ADR 0329 §6)"
}
}
return ""
}
// readDeclaredPrefs reads the setting prefs on the machine: each preference's value as user.js writes it. A
// missing file says the module is not pushed here in this form; a file that names a preference another setting
// owns, or holds a value user.js cannot carry, is refused whole, naming the preference.
func (m *Machine) readDeclaredPrefs() (map[string]string, bool, error) {
raw, err := readBounded(m.home(prefsFile))
if errors.Is(err, os.ErrNotExist) {
return map[string]string{}, false, nil
}
if err != nil {
return nil, false, err
}
prefs, err := parseDeclaredPrefs(raw)
return prefs, true, err
}
func parseDeclaredPrefs(raw []byte) (map[string]string, error) {
var top map[string]json.RawMessage
if err := json.Unmarshal(raw, &top); err != nil {
return nil, fmt.Errorf("~/%s is not a JSON object: %v", prefsFile, err)
}
out := map[string]string{}
body, ok := top["prefs"]
if !ok || string(body) == "null" {
return out, nil
}
var values map[string]any
dec := json.NewDecoder(bytes.NewReader(body))
dec.UseNumber()
if err := dec.Decode(&values); err != nil {
return nil, fmt.Errorf("the setting prefs is a map from a preference's name to its value: %v", err)
}
var problems []string
for _, name := range sortedKeys(values) {
if !prefName.MatchString(name) {
problems = append(problems, fmt.Sprintf("%q is not a preference's name", name))
continue
}
if why := prefOwner(name); why != "" {
problems = append(problems, name+": "+why)
continue
}
lit, err := prefLiteral(values[name])
if err != nil {
problems = append(problems, name+": "+err.Error())
continue
}
if lit != "" {
out[name] = lit
}
}
if len(problems) > 0 {
return nil, fmt.Errorf("the setting prefs is refused: %s", strings.Join(problems, "; "))
}
return out, nil
}
// prefLiteral is a setting's value as user.js writes it: true or false, a whole number, or a string. null is
// no value: a node's layer takes the mesh-wide one out with it, and Betterbird's own default holds there.
func prefLiteral(v any) (string, error) {
switch x := v.(type) {
case nil:
return "", nil
case bool:
return strconv.FormatBool(x), nil
case json.Number:
n, err := x.Int64()
if err != nil || n > math.MaxInt32 || n < math.MinInt32 {
return "", fmt.Errorf("%s is not a whole number Betterbird keeps", x)
}
return strconv.FormatInt(n, 10), nil
case float64:
if x != math.Trunc(x) || x > math.MaxInt32 || x < math.MinInt32 {
return "", fmt.Errorf("%v is not a whole number Betterbird keeps", x)
}
return strconv.FormatInt(int64(x), 10), nil
case string:
if strings.ContainsAny(x, lineEnds) {
return "", errors.New("a string preference is one line")
}
return jsQuoted(x), nil
}
return "", fmt.Errorf("a value is true, false, a whole number or a string, not %T", v)
}
// prefValue is a preference's literal as the setting holds it: the reverse of prefLiteral.
func prefValue(lit string) (any, bool) {
switch {
case lit == "true":
return true, true
case lit == "false":
return false, true
}
if n, err := strconv.ParseInt(lit, 10, 32); err == nil {
return n, true
}
if strings.HasPrefix(lit, `"`) {
if s, err := strconv.Unquote(lit); err == nil {
return s, true
}
}
return nil, false
}
// lineEnds are the characters a reader may take as a line's end: a setting holds none (novox/hq issue 339).
const lineEnds = "\n\r\v\f\x00\u0085

"
// jsQuoted is a string as a preference file writes it: quote, backslash and the control characters escaped the
// way Gecko's preference parser reads them.
func jsQuoted(s string) string {
var b strings.Builder
b.WriteByte('"')
for _, r := range s {
switch {
case r == '"':
b.WriteString(`\"`)
case r == '\\':
b.WriteString(`\\`)
case r == '\n':
b.WriteString(`\n`)
case r == '\r':
b.WriteString(`\r`)
case r == '\t':
b.WriteString(`\t`)
case r < 0x20 || r == 0x7f || r == 0x2028 || r == 0x2029 || r == 0x85:
fmt.Fprintf(&b, `\u%04x`, r)
default:
b.WriteRune(r)
}
}
b.WriteByte('"')
return b.String()
}
// declaredText is the head of the user.js the module places: its mark and the setting prefs, one statement a
// line, by name. present says the setting is on this machine; err says why it is refused.
func (m *Machine) declaredText() (string, bool, error) {
prefs, present, err := m.readDeclaredPrefs()
if err != nil || !present {
return "", present, err
}
return composeDeclared(prefs), true, nil
}
// composeDeclared is the user.js head for a set of declared preferences.
func composeDeclared(prefs map[string]string) string {
var b strings.Builder
b.WriteString(mark + ", novox/hq issue 345). Owned by the mesh: the tools write it into the profile from the\n" +
"// module's settings at Betterbird's start (betterbird_user_js with apply, betterbird_restart, the launcher), and\n" +
"// Betterbird reads it at its start, where it wins over what Betterbird saved. Change the settings, never this file.\n" +
"//\n// The setting prefs (novox/hq issue 586): the operator's setup, mesh-wide with a node's own layer over it.\n")
names := make([]string, 0, len(prefs))
for n := range prefs {
names = append(names, n)
}
sort.Strings(names)
for _, n := range names {
fmt.Fprintf(&b, "user_pref(%s, %s);\n", quoted(n), prefs[n])
}
if len(names) == 0 {
b.WriteString("// none\n")
}
return b.String()
}
func sortedKeys[V any](m map[string]V) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
@@ -0,0 +1,178 @@
package main
// The profile's identities, calendars, address books and tags, read from its preferences into the setting's
// shape (novox/hq ADR 0329). The profile is read as Betterbird starts with it: prefs.js, with user.js over it.
// Nothing here reads the login store, a calendar's events or an address book's contacts (§13).
import (
"path/filepath"
"sort"
"strconv"
"strings"
)
// profileIdentity is one identity the profile holds.
type profileIdentity struct {
Key string
AccountKey string
// Default says it is its account's first identity.
Default bool
Owl bool
Value Identity
}
// profileIdentities reads every identity of every account in the profile's list, the default first.
func (m *Machine) profileIdentities(prefs map[string]string) []profileIdentity {
var out []profileIdentity
for _, a := range profileAccounts(prefs) {
for i, id := range a.Identities {
out = append(out, profileIdentity{Key: id, AccountKey: a.Key, Default: i == 0, Owl: a.Owl,
Value: m.readIdentity(prefs, id)})
}
}
return out
}
// readIdentity reads one identity's fields. A signature the profile reads from a file is read as its text, since
// a file's path differs per node (§10).
func (m *Machine) readIdentity(prefs map[string]string, key string) Identity {
p := "mail.identity." + key + "."
text := func(name string) string { return unquote(prefs[p+name]) }
id := Identity{Address: text("useremail"), Name: text("fullName"), ReplyTo: text("reply_to"),
Organization: text("organization"), SignatureHTML: prefs[p+"htmlSigFormat"] == "true",
SignatureAboveQuote: prefs[p+"reply_on_top"] == "1" && prefs[p+"sig_bottom"] == "false"}
sig := text("htmlSigText")
if prefs[p+"attach_signature"] == "true" {
if file := text("sig_file"); file != "" {
sig = m.signatureFile(file)
}
}
if sig = strings.TrimRight(strings.ReplaceAll(sig, "\r\n", "\n"), "\n"); sig != "" {
id.Signature = strings.Split(sig, "\n")
}
return id
}
// signatureFile is a signature file's text, read only within the home and bounded.
func (m *Machine) signatureFile(file string) string {
clean := filepath.Clean(file)
if !strings.HasPrefix(clean, strings.TrimSuffix(m.Home, "/")+"/") {
return ""
}
raw, err := readBounded(m.path(clean))
if err != nil || len(raw) > 64<<10 {
return ""
}
return string(raw)
}
// keysUnder are the distinct second-level keys of the preferences under a prefix: calendar.registry.<key>.….
func keysUnder(prefs map[string]string, prefix string) []string {
seen := map[string]bool{}
for name := range prefs {
if rest, ok := strings.CutPrefix(name, prefix); ok {
if key, _, found := strings.Cut(rest, "."); found && key != "" {
seen[key] = true
}
}
}
return sortedKeys(seen)
}
// profileCalendar is one calendar of the profile: Kind is Betterbird's (caldav, ics, storage, owl …).
type profileCalendar struct {
Key string
Kind string
// IdentityKey is the identity that answers its invitations.
IdentityKey string
Value Calendar
}
func profileCalendars(prefs map[string]string) []profileCalendar {
var out []profileCalendar
for _, key := range keysUnder(prefs, "calendar.registry.") {
p := "calendar.registry." + key + "."
text := func(name string) string { return unquote(prefs[p+name]) }
c := profileCalendar{Key: key, Kind: text("type"), IdentityKey: text("imip.identity.key"),
Value: Calendar{Name: text("name"), URL: text("uri"), Color: strings.ToLower(text("color")),
ReadOnly: prefs[p+"readOnly"] == "true", User: text("username")}}
if prefs[p+"suppressAlarms"] == "true" {
off := false
c.Value.Reminders = &off
}
if c.Kind == "" && c.Value.URL == "" {
continue
}
out = append(out, c)
}
return out
}
// profileAddressBook is one address book of the profile: CardDAV is the kind a setting holds.
type profileAddressBook struct {
Key string
CardDAV bool
Kind string
Value AddressBook
}
// Betterbird's kinds of address book (dirType).
var addressBookKinds = map[string]string{"0": "LDAP", "101": "local", "102": "CardDAV"}
func profileAddressBooks(prefs map[string]string) []profileAddressBook {
var out []profileAddressBook
for _, key := range keysUnder(prefs, "ldap_2.servers.") {
p := "ldap_2.servers." + key + "."
if _, has := prefs[p+"dirType"]; !has && prefs[p+"description"] == "" {
continue
}
kind := addressBookKinds[prefs[p+"dirType"]]
if kind == "" {
kind = "other"
}
out = append(out, profileAddressBook{Key: key, CardDAV: kind == "CardDAV", Kind: kind,
Value: AddressBook{Name: unquote(prefs[p+"description"]), URL: unquote(prefs[p+"carddav.url"]),
User: unquote(prefs[p+"carddav.username"])}})
}
return out
}
// profileTag is one tag of the profile.
type profileTag struct {
Key string
ordinal string
Value Tag
}
// profileTags reads the tags, each with its place as Betterbird orders them.
func profileTags(prefs map[string]string) []profileTag {
var out []profileTag
for _, key := range keysUnder(prefs, "mailnews.tags.") {
p := "mailnews.tags." + key + "."
if _, has := prefs[p+"tag"]; !has {
continue
}
out = append(out, profileTag{Key: key, ordinal: unquote(prefs[p+"ordinal"]),
Value: Tag{Name: unquote(prefs[p+"tag"]), Color: strings.ToUpper(unquote(prefs[p+"color"]))}})
}
// Betterbird orders the tags by their ordinal, a tag without one by its key in its place.
at := func(t profileTag) string {
if t.ordinal != "" {
return t.ordinal
}
return t.Key
}
sort.SliceStable(out, func(i, j int) bool {
if at(out[i]) != at(out[j]) {
return at(out[i]) < at(out[j])
}
return out[i].Key < out[j].Key
})
for i := range out {
out[i].Value.Order = i + 1
}
return out
}
// tagOrdinal is the ordinal a tag's place is written as, so Betterbird's order by text is the declared one.
func tagOrdinal(order int) string { return strconv.Itoa(1000 + order) }
@@ -10,6 +10,13 @@ package main
// module declares. A theme or another preference of the operator's profile can be carried over by
// name (carry), within the same limits as betterbird_prefs: never a credential, never an account.
//
// **It is visibly a test** (the operator, 2026-10-12): on 2026-10-12 at 12:05 a test Betterbird with an empty
// profile carrying the operator's theme looked like the operator's configuration lost. So the test profile
// carries a userChrome.css that marks its main window with a red band reading MESH TEST, and the event is
// called "MESH TEST reminder". The reminder window itself is left as Betterbird draws it, because how it is
// drawn is what the test looks at. The tool's description says a window opens on the operator's screen, so
// a caller tells the operator first.
//
// It lives for at most ten minutes (the unit's RuntimeMaxSec), and stop ends it early and removes the
// profile. The reminder window is then found with xorg_windows (class Calendar, role AlarmWindow) and
// looked at with xorg_screenshot.
@@ -41,6 +48,7 @@ type ReminderAnswer struct {
Carried []string `json:"carried,omitempty"`
Withheld []string `json:"withheld,omitempty"`
Look string `json:"look"`
OnScreen string `json:"on_screen,omitempty"`
Stopped bool `json:"stopped,omitempty"`
}
@@ -50,11 +58,38 @@ func ics(now time.Time, after time.Duration, stamp string) string {
start := now.Add(after + 10*time.Minute)
return strings.Join([]string{"BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//novox mesh//betterbird reminder test//EN",
"BEGIN:VEVENT", "UID:mesh-reminder-test-" + stamp + "@mesh", "DTSTAMP:" + f(now), "DTSTART:" + f(start),
"DTEND:" + f(start.Add(30*time.Minute)), "SUMMARY:Mesh reminder test", "BEGIN:VALARM", "ACTION:DISPLAY",
"DESCRIPTION:Mesh reminder test", "TRIGGER:-PT10M", "END:VALARM", "END:VEVENT", "END:VCALENDAR", ""}, "\r\n")
"DTEND:" + f(start.Add(30*time.Minute)), "SUMMARY:" + testTitle, "BEGIN:VALARM", "ACTION:DISPLAY",
"DESCRIPTION:" + testTitle, "TRIGGER:-PT10M", "END:VALARM", "END:VEVENT", "END:VCALENDAR", ""}, "\r\n")
}
// testTitle is the test event's title: what the reminder window shows.
const testTitle = "MESH TEST reminder (a throw-away Betterbird of the mesh, not your calendar)"
// testChrome is the test profile's userChrome.css: a red band reading MESH TEST across the top of every
// Betterbird window but the reminder (windowtype Calendar:AlarmWindow), and a red frame around it, so the test
// Betterbird is never taken for the operator's.
const testChrome = `/* The mesh's reminder test (module betterbird, novox/hq issue 345): a throw-away Betterbird, not yours. */
:root:not([windowtype="Calendar:AlarmWindow"]) {
outline: 6px solid #d00000 !important;
outline-offset: -6px !important;
}
:root:not([windowtype="Calendar:AlarmWindow"]) > body::before {
content: "MESH TEST \2014 a throw-away Betterbird started by the mesh for a reminder test; your mail and calendar are not here. It ends by itself within 10 minutes.";
display: block !important;
background: #d00000 !important;
color: #ffffff !important;
font: bold 15px sans-serif !important;
padding: 6px 12px !important;
text-align: center !important;
}
`
// testPrefs are the test profile's own preferences: its one calendar, and no first-run questions.
//
// betterbird.update.checkIntervalDays 0 is Betterbird's own switch for its update check (MailGlue): a
// fresh profile has never checked, so Betterbird checks at its start and, when betterbird.eu publishes a
// newer build than the installed one, opens the About window on the operator's screen and gives it the
// focus (seen on the workstation 2026-10-12, novox/hq issue 345). The test is about the reminder only.
func testPrefs(calendar string) string {
return strings.Join([]string{
`user_pref("calendar.registry.meshtest.type", "ics");`,
@@ -68,7 +103,9 @@ func testPrefs(calendar string) string {
`user_pref("datareporting.policy.dataSubmissionEnabled", false);`,
`user_pref("toolkit.telemetry.reportingpolicy.firstRun", false);`,
`user_pref("app.update.auto", false);`,
`user_pref("betterbird.update.checkIntervalDays", 0);`,
`user_pref("mail.rights.version", 1);`,
`user_pref("toolkit.legacyUserProfileCustomizations.stylesheets", true);`,
"",
}, "\n")
}
@@ -125,14 +162,22 @@ func (m *Machine) ReminderTest(after time.Duration, carry []string, stop bool) (
if err := os.WriteFile(calendar, []byte(ics(now, after, strconv.FormatInt(now.Unix(), 10))), 0o600); err != nil {
return a, err
}
user := testPrefs(strings.TrimPrefix(calendar, m.Root))
if mine, err := readBounded(m.home(declared)); err == nil && validUserJS(string(mine)) == nil {
user = string(mine) + "\n" + user
// The module's user.js first, then what is carried, then the test's own preferences: the last
// statement wins, so neither can take the test's calendar or its quiet start away.
user := ""
if mine, present, err := m.declaredText(); err == nil && present && validUserJS(mine) == nil {
user = mine + "\n"
}
user += strings.Join(carried, "\n") + "\n"
user += strings.Join(carried, "\n") + "\n" + testPrefs(strings.TrimPrefix(calendar, m.Root))
if err := os.WriteFile(filepath.Join(dir, "profile", "user.js"), []byte(user), 0o600); err != nil {
return a, err
}
if err := os.MkdirAll(filepath.Join(dir, "profile", "chrome"), 0o700); err != nil {
return a, err
}
if err := os.WriteFile(filepath.Join(dir, "profile", "chrome", "userChrome.css"), []byte(testChrome), 0o600); err != nil {
return a, err
}
// The unit removes the profile when it ends, however it ends: at its 10 minutes, on stop, or when the
// test Betterbird is closed.
call := []string{"--user", "--collect", "--quiet", "--unit=" + testUnit, "-p", "RuntimeMaxSec=600",
@@ -146,7 +191,9 @@ func (m *Machine) ReminderTest(after time.Duration, carry []string, stop bool) (
if err := failed(m.cmd(8*time.Second, s.Env(), "systemd-run", call...), "systemd-run", call...); err != nil {
return a, err
}
a.Event = "Mesh reminder test, in a calendar file of the test profile only"
a.Event = testTitle + ", in a calendar file of the test profile only"
a.OnScreen = "a second Betterbird's window is now on the operator's screen, marked MESH TEST in red; its reminder " +
"window follows when the reminder is due. Both go when the test ends (stop, or 10 minutes)"
a.Due = now.Add(after).UTC().Format(time.RFC3339)
return a, nil
}
@@ -0,0 +1,16 @@
package main
import (
"encoding/json"
"errors"
"os"
"testing"
)
// No test reaches the network or the mesh: Betterbird's list of published builds and the controller's
// settings verb answer from here, and a test that needs them says what they answer.
func TestMain(m *testing.M) {
publishedBuilds = func() (string, error) { return "", errors.New("no network in tests") }
asker = func(string, any) (json.RawMessage, error) { return nil, errors.New("no mesh in tests") }
os.Exit(m.Run())
}
@@ -0,0 +1,20 @@
// Written by the mesh (module betterbird, novox/hq issue 345, issue 586): the preferences the module manages at
// Betterbird's own default. It holds no value of the operator's: the operator's setup is the module's setting
// prefs, mesh-wide with a node's own layer over it, which the tools place in the profile's user.js, where it wins
// at every start; the policy's locks (distribution/policies.json) win over everything. betterbird_config shows the
// layers and the drift. Change it in the catalogue, never here.
//
// A line "keeps Betterbird's default" names a preference the module manages without writing a value: Betterbird's
// own default holds there, and a value the profile saved is drift, which betterbird_config_apply clears.
// keeps Betterbird's default: extensions.activeThemeID
// The default theme (default-theme@mozilla.org) follows the desktop's dark mode. Betterbird keeps the active theme
// in its add-on database, so another theme is changed back in the add-on manager, never by clearing prefs.js.
// keeps Betterbird's default: browser.display.use_system_colors
// Betterbird's own colours, which the default theme is drawn for.
// keeps Betterbird's default: layout.css.always_underline_links
// Links are underlined only where the message's own style says so.
// keeps Betterbird's default: mail.spam.manualMark
// Marking a message as junk by hand does nothing more than mark it.
// keeps Betterbird's default: mail.compose.show_attachment_pane
// The composer shows its attachment pane only when a message has attachments.
@@ -0,0 +1,3 @@
// Written by the mesh (module betterbird, novox/hq ADR 0316 §3): the system's spelling dictionaries, named
// as a default preference in place of the link the community package made.
pref("spellchecker.dictionary_path", "/usr/share/hunspell");
@@ -0,0 +1,28 @@
{
"policies": {
"DisableAppUpdate": true,
"DisableTelemetry": true,
"ExtensionUpdate": false,
"ExtensionSettings": {
"owl@beonex.com": {
"installation_mode": "force_installed",
"install_url": "file:///opt/betterbird/owl/owl.xpi",
"updates_disabled": true
}
},
"Preferences": {
"mail.shell.checkDefaultClient": {
"Value": false,
"Status": "locked"
},
"mail.inappnotifications.enabled": {
"Value": false,
"Status": "locked"
},
"mailnews.start_page.enabled": {
"Value": false,
"Status": "locked"
}
}
}
}
+152 -10
View File
@@ -1,23 +1,36 @@
{
"module": "betterbird",
"version": "1",
"upgrade": {
"policy": "record",
"why": "a new build may remove the community package betterbird-bin and replaces the installation in /opt/betterbird, which a running Betterbird has open: the switch on each node and every upgrade wait for the operator, who quits Betterbird first and then pushes (novox/hq ADR 0316 §4, §5)"
},
"requires": [
"x11-display"
],
"settings": {
"prefs": {
"kind": "preference",
"default": "// none",
"why": "Betterbird runs with its own defaults until a machine's assignment names a preference: one line of user_pref(\"name\", value); statements, which the module's user.js holds and Betterbird reads at its start (novox/hq issue 345)"
}
},
"invokes": [
"seat:mesh-controller.settings",
"seat:mesh-controller.nodes",
"seat:mesh-controller.node",
"betterbird.betterbird_derive"
],
"tools": [
"betterbird_status",
"betterbird_prefs",
"betterbird_config",
"betterbird_config_apply",
"betterbird_user_js",
"betterbird_log",
"betterbird_restart",
"betterbird_check",
"betterbird_accounts",
"betterbird_identities",
"betterbird_calendars",
"betterbird_address_books",
"betterbird_filters",
"betterbird_tags",
"betterbird_derive",
"betterbird_drift",
"betterbird_reminder_test"
],
"resources": [
@@ -29,12 +42,109 @@
"mode": "0755"
},
{
"id": "user-js",
"id": "prefs",
"type": "file",
"path": "${machine:account-home}/.config/betterbird/user.js",
"path": "${machine:account-home}/.config/betterbird/prefs.json",
"owner": "${machine:account}",
"mode": "0644",
"content": "// Betterbird's user.js (module betterbird, novox/hq issue 345). Owned by the mesh: the node-engine\n// writes it here at every push, and the module's tools copy it whole into Betterbird's profile\n// (betterbird_user_js with apply, betterbird_restart), where Betterbird reads it at its start and lets it\n// win over the preferences Betterbird saves itself. Change the module's setting prefs, never this file or the profile's copy.\n//\n// Only comments and user_pref(\"name\", value); statements; the tools refuse anything else, and any\n// preference whose name can hold a credential.\n${setting:prefs}\n"
"merge": "json",
"takes-settings": [
"prefs"
],
"content": "{\n \"prefs\": {}\n}\n",
"trusted": true
},
{
"id": "accounts",
"type": "file",
"path": "${machine:account-home}/.config/betterbird/accounts.json",
"owner": "${machine:account}",
"mode": "0600",
"merge": "json",
"takes-settings": [
"accounts"
],
"content": "{\n \"accounts\": {}\n}\n",
"trusted": true
},
{
"id": "configuration",
"type": "file",
"path": "${machine:account-home}/.config/betterbird/configuration.json",
"owner": "${machine:account}",
"mode": "0600",
"merge": "json",
"takes-settings": [
"identities",
"calendars",
"address-books",
"filters"
],
"content": "{\n \"identities\": {},\n \"calendars\": {},\n \"address-books\": {},\n \"filters\": {}\n}\n",
"trusted": true
},
{
"id": "tags",
"type": "file",
"path": "${machine:account-home}/.config/betterbird/tags.json",
"owner": "${machine:account}",
"mode": "0644",
"merge": "json",
"takes-settings": [
"tags"
],
"content": "{\n \"tags\": {}\n}\n",
"trusted": false
},
{
"id": "community-package",
"type": "package",
"package": "betterbird-bin",
"absent": true
},
{
"id": "installation",
"type": "archive",
"path": "/opt/betterbird",
"artifact": "archive"
},
{
"id": "owl",
"type": "archive",
"path": "/opt/betterbird/owl",
"artifact": "owl"
},
{
"id": "policies",
"type": "archive",
"path": "/opt/betterbird/distribution",
"artifact": "distribution"
},
{
"id": "spelling",
"type": "archive",
"path": "/opt/betterbird/defaults/pref",
"artifact": "defaults"
},
{
"id": "preparation",
"type": "archive",
"path": "/usr/local/lib/mesh-betterbird",
"artifact": "tools"
},
{
"id": "start",
"type": "file",
"path": "/usr/local/bin/betterbird",
"mode": "0755",
"content": "#!/bin/sh\n# Betterbird's start, written by the mesh (module betterbird, novox/hq ADR 0316). Replaced at every push.\n#\n# Before Betterbird starts, the module's tools place its user.js in the profile (the settings prefs, accounts, identities, calendars,\n# address-books and tags) and write what Betterbird rewrites itself: the account lists and each declared account's filters. They do\n# it only while no Betterbird of this account runs, because Betterbird rewrites its saved preferences when it quits; a\n# second start while one runs only hands over to it. What the preparation could not do it says here, and\n# betterbird_check says it too. Betterbird starts either way: its configuration never holds the mail back.\n/usr/local/lib/mesh-betterbird/betterbird-tools prepare || echo \"betterbird: the mesh's preparation did not complete; betterbird_check says why\" >&2\nexec /opt/betterbird/betterbird \"$@\"\n"
},
{
"id": "launcher",
"type": "file",
"path": "/usr/local/share/applications/eu.betterbird.Betterbird.desktop",
"mode": "0644",
"content": "[Desktop Entry]\nVersion=1.0\nType=Application\nName=Betterbird\nGenericName=Mail Client\nComment=Send and receive mail with Betterbird\nExec=/usr/local/bin/betterbird %u\nIcon=/opt/betterbird/chrome/icons/default/default128.png\nTerminal=false\nCategories=Network;Email;\nMimeType=message/rfc822;x-scheme-handler/mailto;application/x-xpinstall;application/x-extension-ics;text/calendar;text/vcard;text/x-vcard;x-scheme-handler/webcal;x-scheme-handler/webcals;x-scheme-handler/mid;\nStartupNotify=true\nStartupWMClass=eu.betterbird.Betterbird\nKeywords=Email;E-mail;Calendar;Thunderbird;\nActions=ComposeMessage;OpenAddressBook;\n# Written by the mesh (module betterbird, novox/hq ADR 0316 §3), in place of the community package's entry:\n# it starts Betterbird through the module's start, and names the icon in the installation, with no link.\n\n[Desktop Action ComposeMessage]\nName=Write new message\nExec=/usr/local/bin/betterbird -compose\n\n[Desktop Action OpenAddressBook]\nName=Open address book\nExec=/usr/local/bin/betterbird -addressbook\n"
}
],
"build": {
@@ -49,6 +159,38 @@
"loads": [
"betterbird-tools"
]
},
{
"name": "archive",
"kind": "archive",
"url": "https://www.betterbird.eu/downloads/LinuxArchive/betterbird-153.4.0esr-bb10.en-US.linux-x86_64.tar.xz",
"sha256": "38dd24890a66b4cf9a45bc24e15192e00c26b929e1bb8df7974c16a4539129e2",
"version": "153.4.0esr-bb10",
"within": "betterbird",
"names-on-purpose": {
"www.betterbird.eu": "Betterbird's project publishes its Linux release there; the build seat fetches it once, by its pinned sha256 (novox/hq ADR 0316 §2)"
}
},
{
"name": "owl",
"kind": "archive",
"url": "https://www.beonex.com/owl/owl.xpi",
"sha256": "a54cc3d52113b34f97942ee19190947dee23561e2217f18db3d80ac023991221",
"version": "1.5.6.1",
"file": "owl.xpi",
"names-on-purpose": {
"www.beonex.com": "Owl's vendor publishes its xpi there; the build seat fetches it once, by its pinned sha256 (novox/hq ADR 0316 §6)"
}
},
{
"name": "distribution",
"kind": "archive",
"from": "install/distribution"
},
{
"name": "defaults",
"kind": "archive",
"from": "install/defaults-pref"
}
]
}
+16 -2
View File
@@ -7,9 +7,23 @@ machine that is off builds nothing and blocks nothing. The controller asks the r
the outcome names the machine that built it.
What a holding machine needs is what the builder always needed, said here once: a container runtime
(the socket is mounted), the artifact store and the package registry as provisions, a workspace, and
the bus credential. The code is `cmd/mesh-builder` in the mesh-controller repository, compiled from
(the socket is mounted), the artifact store as a provision, a workspace, and the bus credential. The code is `cmd/mesh-builder` in the mesh-controller repository, compiled from
that repository's main (`build.artifacts[].context`); this module ships the packaging.
Assign it to every machine with a container runtime. It replaces `builder`, the one-holder form of the
same thing; retire that once this is assigned where it was.
## The package registry (novox/hq ADR 0336)
The package registry (`npm-package-registry`, provided by the gitea module) is an optional requirement.
Genesis assigns the build agent before anything provides it, so the agent is assignable on a mesh with no
registry, and `mesh-controller.plan` and `node` list the requirement as unbound until the gitea module is
assigned; it then binds with no further act.
Until it binds, the agent on genesis's node reads the `genesis-package-registry` own-secret, which the
installer seals to it at the packages step: the forge it raised, the port, the npm path, the account
`mesh-builder` and its password. Issued outside the mesh, so no other machine is given one. A real binding
wins over it, and each build's log says which the build resolved from. A build that needs the registry (a
`package` artifact, an image whose Dockerfile names `.npmrc`, a TypeScript artifact with an `@novox/`
dependency) on an agent with neither fails before building, naming the machine. Go artifacts and images
that do not name `.npmrc` build without it. `manifest_test.go` holds the manifest to this.
+3
View File
@@ -0,0 +1,3 @@
module build-agent
go 1.22
+117
View File
@@ -0,0 +1,117 @@
// The build agent's manifest (novox/hq ADR 0336, issue 121). The build agent is assigned at genesis's
// step 13, before anything provides the package registry, so that requirement is optional
// (ADR 0330); until it binds, the agent on genesis's node reads the genesis document the installer
// seals to it at the packages step. The program is mesh-controller's cmd/mesh-builder; only the
// packaging lives here, and this is its test.
package buildagent
import (
"encoding/json"
"os"
"strings"
"testing"
)
type manifest struct {
Requires []json.RawMessage `json:"requires"`
Binds map[string]string `json:"binds"`
Secrets map[string]string `json:"secrets"`
OwnSecrets map[string]json.RawMessage `json:"own-secrets"`
Resources []map[string]any `json:"resources"`
}
func read(t *testing.T) manifest {
t.Helper()
raw, err := os.ReadFile("module.json")
if err != nil {
t.Fatal(err)
}
var m manifest
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
return m
}
// requirement is one entry of `requires`: its name, and whether it is optional.
func requirements(t *testing.T, m manifest) map[string]bool {
t.Helper()
out := map[string]bool{}
for _, raw := range m.Requires {
var name string
if json.Unmarshal(raw, &name) == nil {
out[name] = false
continue
}
var long struct {
Name string `json:"name"`
Optional bool `json:"optional"`
}
if err := json.Unmarshal(raw, &long); err != nil {
t.Fatalf("requires: %s: %v", raw, err)
}
out[long.Name] = long.Optional
}
return out
}
func TestThePackageRegistryIsOptionalAndTheArtifactStoreStaysHard(t *testing.T) {
r := requirements(t, read(t))
optional, has := r["npm-package-registry"]
if !has || !optional {
t.Errorf("npm-package-registry is not an optional requirement (ADR 0336 §1): %v", r)
}
if optional, has := r["artifact-store"]; !has || optional {
t.Errorf("artifact-store is not a hard requirement: %v", r)
}
}
// The binding and the credential of the registry are delivered only while it is bound: ADR 0330 §6
// composes neither for an absent requirement, so no file of the module may name them through a
// placeholder unless it is itself only-when-bound.
func TestTheRegistrysBindsAndSecretsAreOnlyWhenBound(t *testing.T) {
m := read(t)
if m.Binds["npm-package-registry"] == "" || m.Secrets["npm-package-registry"] == "" {
t.Fatalf("the agent is not told where the registry is once it binds: binds %v, secrets %v", m.Binds, m.Secrets)
}
for _, r := range m.Resources {
content, _ := r["content"].(string)
if strings.Contains(content, "${bound:npm-package-registry:") || strings.Contains(content, "${secret:npm-package-registry}") {
if r["only-when-bound"] != "npm-package-registry" {
t.Errorf("%v names the registry's binding and is delivered while it is absent", r["id"])
}
}
}
}
// The genesis document is an own-secret issued outside the mesh — by the installer at genesis's packages
// step (ADR 0336 §3) — so the mesh never makes one of its own on any other machine, and the agent is
// told where it lands.
func TestTheGenesisDocumentIsDeclaredAndIssuedOutside(t *testing.T) {
m := read(t)
raw, has := m.OwnSecrets["genesis-package-registry"]
if !has {
t.Fatalf("the build agent declares no genesis-package-registry own-secret: %v", m.OwnSecrets)
}
var own struct {
Path string `json:"path"`
IssuedBy string `json:"issued-by"`
}
if err := json.Unmarshal(raw, &own); err != nil || own.IssuedBy != "outside" {
t.Fatalf("the genesis document is not issued outside the mesh: %s (%v)", raw, err)
}
if !strings.HasPrefix(own.Path, "${dir:mesh-state}/") {
t.Fatalf("the genesis document is not where the container reads /run/mesh: %s", own.Path)
}
inside := "/run/mesh/" + strings.TrimPrefix(own.Path, "${dir:mesh-state}/")
for _, r := range m.Resources {
if r["id"] == "agent-env" {
content, _ := r["content"].(string)
if !strings.Contains(content, "MESH_GENESIS_PACKAGE_REGISTRY="+inside+"\n") {
t.Errorf("the agent is not told where the genesis document is (%s):\n%s", inside, content)
}
return
}
}
t.Fatal("the build agent has no environment file")
}
+19 -4
View File
@@ -14,7 +14,10 @@
],
"requires": [
"artifact-store",
"npm-package-registry"
{
"name": "npm-package-registry",
"optional": true
}
],
"binds": {
"npm-package-registry": "${dir:mesh-state}/package-registry.json"
@@ -23,7 +26,11 @@
"npm-package-registry": "${dir:mesh-state}/package-registry.secret"
},
"own-secrets": {
"broker": "${dir:mesh-state}/broker"
"broker": "${dir:mesh-state}/broker",
"genesis-package-registry": {
"path": "${dir:mesh-state}/genesis-package-registry.json",
"issued-by": "outside"
}
},
"data": {
"own": [
@@ -52,7 +59,7 @@
"type": "file",
"path": "${dir:mesh-state}/build-agent.env",
"mode": "0600",
"content": "MESH_BROKER_FILE=/run/mesh/broker\nMESH_NODE=${machine:name}\nMESH_REGISTRY=${bound:artifact-store:at}:${bound:artifact-store:port}\nMESH_PACKAGE_BINDING=/run/mesh/package-registry.json\nMESH_NPM_TOKEN_FILE=/run/mesh/package-registry.secret\nMESH_WORKSPACE=${dir:workspace}\n"
"content": "MESH_BROKER_FILE=/run/mesh/broker\nMESH_NODE=${machine:name}\nMESH_REGISTRY=${bound:artifact-store:at}:${bound:artifact-store:port}\nMESH_PACKAGE_BINDING=/run/mesh/package-registry.json\nMESH_NPM_TOKEN_FILE=/run/mesh/package-registry.secret\nMESH_GENESIS_PACKAGE_REGISTRY=/run/mesh/genesis-package-registry.json\nMESH_WORKSPACE=${dir:workspace}\n"
},
{
"id": "server",
@@ -70,7 +77,15 @@
"restart-on": [
"agent-env"
],
"network": "host"
"network": "host",
"health": {
"kind": "exec",
"command": "/usr/local/bin/mesh-builder ready",
"interval": "10s",
"timeout": "5s",
"looks": 3,
"grace": "60s"
}
}
],
"build": {
+36
View File
@@ -68,6 +68,7 @@ must see, a node that joins later included — kept, so it carries no secret eit
| 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 before registering was refused (hq issue 340) | 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. Nothing adds a key any more: a new tool server is the operator's, in the `mcp_servers` setting at the controller's terminal |
| a proposed instruction section (hq ADR 0274) | a key in the module's `proposals` state per proposal, the ask published on `operator-channel` under this module's name, and the router's record of it read under that name alone; what a node approved is its own file, never the bus |
| a session's question to the operator (hq ADR 0332) | a key `q-…` in the module's `questions` state per question, the ask published on `operator-channel` under this module's name, and the router's record of it read under that name alone; the router's word that it ended without a choice is kept on the question; two slot keys `slot-1`, `slot-2` name the questions open |
| 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. A hook, settings, or an item that would run something by itself is never taken, whenever it was put there (hq issue 340) |
## The mesh's tools first (hq ADR 0245)
@@ -160,6 +161,28 @@ a secret, more than 16 KiB, a name the terminal sets, and — at once, naming th
that proves its sender is there. The controller's hand-act log records each warrant once (`warranted`), read from the
router's record. The text is never logged.
**A session asks the operator a question on Telegram** (hq ADR 0332, issue 589). An agent that needs a decision while
the operator may be away from the terminal calls `claude_code_ask` with a headline, the context in plain words, two to
four options (a label and what choosing it means, exactly one recommended, with why), what it does if nobody
answers, its name as who@node (required), and an expiry of 5 to 1430 minutes (60 when absent). The ask is published under this
module's name on `operator-channel`; every option is at the level approve and binds the digest of the question and of
itself, so only a channel that proves who answered (Telegram) carries it, never the desk. Anything shaped like a
secret, or a character a phone does not show, in any of its words is refused before anything is kept. The tool waits
a few seconds for the router's word and answers within seconds with the question's key, or the router's refusal (the content rule: no path,
address, host name, commit id or secret's shape; `messenger_check` tests words first). `claude_code_answer` with the
key says waiting, chosen (the option, who answered, on which channel, when), ended without a choice (expired,
refused, cancelled, replaced), or not proven: the answer is read from the router's own record and held to the
question as `questions` keeps it, so a question rewritten after it was asked, a warrant for another ask or a choice
after expiry is never read as a choice. One call waits at most 60 s; the tool asks a session to read a question at
most once a minute, and nothing enforces it. At most two questions wait at once, mesh-wide: each holds one of two
slot keys in `questions`, taken by create or compare-and-set, so two nodes asking at once cannot both take the last,
and a slot that cannot be read refuses the question. Questions alone never fill the router's three open asks, so one
is left for a proposed instruction section while two are open. A question the router holds no record of, its refusal
not heard here, keeps its slot and reads as waiting until it expires: the router may still take it from its queue,
and no cancel can overtake it there (the queue offers work again after an error, and a cancel of an ask not yet
recorded is dropped); once expired, the router refuses it if taken late. A refused question keeps none of
its words. An answer is the operator's word to the session and authorises nothing; a question never defaults.
The home scope is registered through the tools **only where the home belongs to an agent account of its own**
(hq ADR 0266, issue 340), and only for the node whose module serves the call. Where the agent runs as the operator,
the home is the operator's own `~/.claude`, whose rules and skills load in every session the operator starts, and
@@ -192,6 +215,19 @@ what a session obeys as surely as placing one. Where it is taken:
A new session takes a change; a running one at `/reload-plugins`.
**One judgement** (hq ADR 0278): `claude_code_judge` (`question`, `schema`, optionally `model`) answers a question
only a language model can, for another module — the issue tracker's duplicate check. It runs
`/usr/bin/claude -p --output-format json --json-schema <schema> --model <model> --tools "" --safe-mode
--no-session-persistence` as the account agents run as here (through `sudo -n -H -u <agent>` where that is an account of
its own), in an empty directory, the question on its input: no built-in tools, no tool servers, plugins, hooks or
instruction files, no session kept, so it can only answer. It answers the structured answer, the model, the node
and the seconds, within 25 seconds — the process is ended past that, under the bus's 30 — and one at a time on a
node. A caller may ask a longer bound with `within` (seconds, at most 120), for a long question (hq issue 508): a
judgement not done in 20 seconds is then answered `pending` with a `ticket`, runs on to its bound, and
`claude_code_judged` (`ticket`) collects it, waiting up to 20 seconds a call. A ticket is answered once; an answer
nobody collects is dropped ten minutes after its bound, and none survives a restart of the module. Refused: an empty question or one over 64 KiB, a schema that is not an object, a model not `sonnet`, `haiku`
or `opus`, and a node holding no licence. The question is never logged.
## Settings
Per node or for the whole mesh, at the controller's terminal alone: `mesh-cli settings set claude-code '<json>'
@@ -285,6 +285,25 @@ func TestANewerGenerationFetchesTheTokenOnceByTheSeatsVerb(t *testing.T) {
}
}
// The node's line names the generation it applied and why the manager moved it, and who asked (novox/hq issue 625).
func TestAnAppliedBindingSaysWhyAndWho(t *testing.T) {
p, w := node(t, "laptop")
var asked []string
done, err := OnBinding(p, &BindingState{Licence: "personal", Kind: "subscription", Generation: 7,
Why: "the switch verb", By: "g14.node-tools"}, seat(t, "personal", "at-7", 7, &asked), writer(w))
if err != nil {
t.Fatal(err)
}
for _, want := range []string{`"generation":7`, `"why":"the switch verb"`, `"by":"g14.node-tools"`} {
if !strings.Contains(done, want) {
t.Fatalf("the line %s lacks %s", done, want)
}
}
if strings.Contains(done, "at-7") {
t.Fatal("the line carries the token")
}
}
func TestTheTokenANodeIsHandedReplacesALoginsGrantAndLeavesNoRefreshToken(t *testing.T) {
p, w := node(t, "laptop")
writeFile(t, p.credentials(), `{"claudeAiOauth":{"accessToken":"at-old","refreshToken":"rt-spent","expiresAt":`+
@@ -476,3 +495,22 @@ func TestALowerGenerationAfterTheManagerWasRebuiltIsStillApplied(t *testing.T) {
t.Fatalf("asked %v, credentials %v: a lower generation was ignored", asked, creds(t, p))
}
}
// The tips preference reaches the managed settings as spinnerTipsEnabled, either way, and is left out when the
// mesh filled none: set in one call through the controller, never at the terminal.
func TestTheTipsPreferenceIsRenderedAsSpinnerTipsEnabled(t *testing.T) {
off, on := false, true
for _, c := range []struct {
tips *bool
want any
}{{&off, false}, {&on, true}, {nil, nil}} {
out := Render(Facts{Console: "x"}, Settings{Preferences: Preferences{Tips: c.tips}}, nil, "/h", nil, Config{})
var managed map[string]any
if err := json.Unmarshal([]byte(out["managed-settings.json"]), &managed); err != nil {
t.Fatal(err)
}
if got, has := managed["spinnerTipsEnabled"]; got != c.want || has != (c.want != nil) {
t.Errorf("tips %v rendered spinnerTipsEnabled %v (present %v)", c.tips, got, has)
}
}
}
@@ -0,0 +1,387 @@
package main
// One judgement: a question answered by the node's agent with no tools (novox/hq ADR 0278 §5).
//
// A module that needs a judgement only a language model can make — the issue tracker's duplicate check — asks
// this module, which holds the agent and the licence the node is bound to, rather than taking the token itself
// (ADR 0206). The agent runs in print mode as the account agents run as on this node (ADR 0266), in an empty
// directory, with its built-in tools off, in safe mode (no tool servers, plugins, hooks or instruction files)
// and keeping no session: the session can do nothing but answer, so a question that carries text written to
// steer it can at most steer the answer. The answer is shaped by the caller's JSON schema.
import (
"bytes"
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"strings"
"sync"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
)
const (
// JudgeBound is how long one judgement may take, waiting for another included: under the bus's 30 seconds
// a tool call is answered in, so the caller hears this module's refusal rather than a timeout.
JudgeBound = 25 * time.Second
// MostJudgeBound is the longest bound a caller may ask with within: a long record's review takes longer than
// one bus call allows (novox/hq issue 508), and is then collected by its ticket.
MostJudgeBound = 120 * time.Second
// JudgeWait is how long one call waits for a judgement asked with a bound past JudgeBound before it answers
// the ticket: well under the bus's 30 seconds, so the call is always answered.
JudgeWait = 20 * time.Second
// JudgedKept is how long an answer nobody collected is kept past its judgement's bound.
JudgedKept = 10 * time.Minute
// MostQuestion is the largest question taken.
MostQuestion = 64 << 10
// MostSchema is the largest answer schema taken.
MostSchema = 16 << 10
// JudgeProgram is the agent's program as the module's package installs it.
JudgeProgram = "/usr/bin/claude"
)
// JudgeModels are the model aliases a judgement may name; the first is the default.
var JudgeModels = []string{"sonnet", "haiku", "opus"}
// JudgeAsk is one question.
type JudgeAsk struct {
Question string
Schema map[string]any
Model string
// Within is the bound the caller asks; JudgeBound when zero. Past JudgeBound the call answers a ticket if the
// judgement is not done within JudgeWait, and claude_code_judged collects it (novox/hq issue 508).
Within time.Duration
}
// Judged is one answer.
type Judged struct {
Answer json.RawMessage `json:"answer,omitempty"`
Model string `json:"model"`
Node string `json:"node"`
Seconds float64 `json:"seconds"`
// Ticket names a judgement asked with a bound past JudgeBound; Pending says it is not done yet, and
// claude_code_judged with the ticket collects it.
Ticket string `json:"ticket,omitempty"`
Pending bool `json:"pending,omitempty"`
}
// running is one judgement asked with a bound past JudgeBound, until it is collected.
type running struct {
done chan struct{}
out *Judged
err error
until time.Time
}
// Judge runs judgements on this node, one at a time.
type Judge struct {
P Paths
// Licensed says whether the agent's account holds a licence now.
Licensed func() bool
// Exec runs the command line with the question on its input and answers what it printed.
Exec func(ctx context.Context, argv []string, stdin []byte) ([]byte, error)
Now func() time.Time
// Bound is how long one judgement may take; JudgeBound unless a test says less.
Bound time.Duration
// Wait is how long a call waits for a long judgement before it answers its ticket; JudgeWait unless a test
// says less.
Wait time.Duration
turn chan struct{}
mu sync.Mutex
tickets map[string]*running
}
// NewJudge is the judge of this node.
func NewJudge(p Paths) *Judge {
return &Judge{P: p, Licensed: func() bool { return holdsAnyLicence(p) }, Exec: execJudgement, Now: time.Now,
turn: make(chan struct{}, 1)}
}
// Argv is the command line of one judgement: the agent in print mode with no tools, in safe mode, keeping no
// session, as the account agents run as here — through sudo when that is not the account this runs as.
func (j *Judge) Argv(schema, model string) []string {
argv := []string{JudgeProgram, "-p", "--output-format", "json", "--json-schema", schema, "--model", model,
"--tools", "", "--safe-mode", "--no-session-persistence"}
if j.P.Confined() {
argv = append([]string{"sudo", "-n", "-H", "-u", j.P.Agent, "--"}, argv...)
}
return argv
}
// Judge answers one question, or refuses in words.
func (j *Judge) Judge(a JudgeAsk) (*Judged, error) {
question := strings.TrimSpace(a.Question)
if question == "" {
return nil, errors.New("claude_code_judge needs a question")
}
if len(question) > MostQuestion {
return nil, fmt.Errorf("the question is %d bytes; a judgement takes at most %d (64 KiB)", len(question), MostQuestion)
}
if a.Schema == nil {
return nil, errors.New("claude_code_judge needs schema: the JSON schema of the answer, an object")
}
if t, _ := a.Schema["type"].(string); t != "object" {
return nil, errors.New("the answer's schema is an object's: its type is \"object\"")
}
schema, err := json.Marshal(a.Schema)
if err != nil || len(schema) > MostSchema {
return nil, fmt.Errorf("the answer's schema is not JSON of at most %d bytes", MostSchema)
}
model := strings.TrimSpace(a.Model)
if model == "" {
model = JudgeModels[0]
}
known := false
for _, m := range JudgeModels {
known = known || m == model
}
if !known {
return nil, fmt.Errorf("model %q is not one of %s", model, strings.Join(JudgeModels, ", "))
}
if !j.Licensed() {
return nil, fmt.Errorf("the agent on %s holds no licence, so nothing can judge here: the licence manager's "+
"bindings say which node holds which", j.P.Node)
}
bound := j.Bound
if bound <= 0 {
bound = JudgeBound
}
if a.Within > MostJudgeBound {
return nil, fmt.Errorf("within is %s; a judgement takes at most %s", a.Within, MostJudgeBound)
}
if a.Within > bound {
return j.start(string(schema), model, question, a.Within)
}
if a.Within > 0 {
bound = a.Within
}
return j.run(string(schema), model, question, bound)
}
// run is one judgement within its bound, waiting for its turn included.
func (j *Judge) run(schema, model, question string, bound time.Duration) (*Judged, error) {
start := j.Now()
ctx, cancel := context.WithTimeout(context.Background(), bound)
defer cancel()
select {
case j.turn <- struct{}{}:
defer func() { <-j.turn }()
case <-ctx.Done():
return nil, fmt.Errorf("another judgement held %s's agent for all of %s; ask again", j.P.Node, bound)
}
out, err := j.Exec(ctx, j.Argv(schema, model), []byte(question))
if ctx.Err() != nil {
return nil, fmt.Errorf("the agent on %s did not answer within %s; the judgement was ended", j.P.Node, bound)
}
if err != nil {
return nil, fmt.Errorf("the agent on %s could not judge: %v", j.P.Node, err)
}
answer, err := answerOf(out)
if err != nil {
return nil, fmt.Errorf("the agent on %s answered, and not in the shape asked: %v", j.P.Node, err)
}
return &Judged{Answer: answer, Model: model, Node: j.P.Node, Seconds: j.Now().Sub(start).Round(100 * time.Millisecond).Seconds()}, nil
}
// start runs a judgement whose bound is past what one bus call allows, and waits for it as long as a call may: its
// answer when it is done by then, its ticket otherwise (novox/hq issue 508).
func (j *Judge) start(schema, model, question string, bound time.Duration) (*Judged, error) {
var b [12]byte
_, _ = rand.Read(b[:])
ticket := hex.EncodeToString(b[:])
r := &running{done: make(chan struct{}), until: j.Now().Add(bound + JudgedKept)}
j.mu.Lock()
j.forget()
if j.tickets == nil {
j.tickets = map[string]*running{}
}
j.tickets[ticket] = r
j.mu.Unlock()
go func() {
r.out, r.err = j.run(schema, model, question, bound)
close(r.done)
}()
return j.Collect(ticket)
}
// Collect waits as long as a call may for the judgement under a ticket: its answer, why it failed, or that it is
// still pending. A ticket is answered once; an answer nobody collects is kept JudgedKept past its bound.
func (j *Judge) Collect(ticket string) (*Judged, error) {
j.mu.Lock()
j.forget()
r := j.tickets[ticket]
j.mu.Unlock()
if r == nil {
return nil, fmt.Errorf("no judgement on %s under ticket %q: it was collected, kept past its time, or this module "+
"restarted since; ask the judgement again", j.P.Node, ticket)
}
wait := j.Wait
if wait <= 0 {
wait = JudgeWait
}
select {
case <-r.done:
case <-time.After(wait):
return &Judged{Node: j.P.Node, Ticket: ticket, Pending: true}, nil
}
j.mu.Lock()
delete(j.tickets, ticket)
j.mu.Unlock()
if r.err != nil {
return nil, r.err
}
out := *r.out
out.Ticket = ticket
return &out, nil
}
// forget drops the answers nobody collected in time. Called with mu held.
func (j *Judge) forget() {
now := j.Now()
for t, r := range j.tickets {
if now.After(r.until) {
delete(j.tickets, t)
}
}
}
// answerOf reads print mode's result: the structured answer, or why there is none.
func answerOf(out []byte) (json.RawMessage, error) {
var r struct {
IsError bool `json:"is_error"`
Subtype string `json:"subtype"`
Result string `json:"result"`
Structured json.RawMessage `json:"structured_output"`
}
if err := json.Unmarshal(bytes.TrimSpace(out), &r); err != nil {
return nil, fmt.Errorf("its output is not print mode's result (%v)", err)
}
if r.IsError {
why := r.Result
if len(why) > 300 {
why = why[:300]
}
return nil, fmt.Errorf("it ended in an error (%s): %s", r.Subtype, why)
}
if len(r.Structured) == 0 || string(r.Structured) == "null" {
return nil, errors.New("it gave no structured answer")
}
var obj map[string]any
if err := json.Unmarshal(r.Structured, &obj); err != nil {
return nil, errors.New("its answer is not an object")
}
return r.Structured, nil
}
// execJudgement runs one judgement in an empty directory, ended when the context is.
func execJudgement(ctx context.Context, argv []string, stdin []byte) ([]byte, error) {
dir, err := os.MkdirTemp("", "claude-code-judge-")
if err != nil {
return nil, err
}
defer os.RemoveAll(dir)
// The agent's account must be able to stand in it.
_ = os.Chmod(dir, 0o755)
cmd := exec.CommandContext(ctx, argv[0], argv[1:]...)
cmd.Dir = dir
cmd.Stdin = bytes.NewReader(stdin)
cmd.WaitDelay = 2 * time.Second
var stderr bytes.Buffer
cmd.Stderr = &stderr
out, err := cmd.Output()
if err != nil {
why := strings.TrimSpace(stderr.String())
if len(why) > 300 {
why = why[:300]
}
if why == "" {
// Print mode says an error as its result on stdout.
if a, aerr := answerOf(out); aerr != nil && a == nil && len(out) > 0 {
why = aerr.Error()
}
}
return nil, fmt.Errorf("%v: %s", err, why)
}
return out, nil
}
// judgeTool is the tool.
func judgeTool(j *Judge) stdio.Tool {
return stdio.Tool{Name: "claude_code_judge",
Description: "One judgement by this node's agent, for a module that needs a question only a language model " +
"can answer (novox/hq ADR 0278): give the question in plain words and the JSON schema of the answer (an " +
"object); optionally the model (" + strings.Join(JudgeModels, ", ") + "; default " + JudgeModels[0] + "). The agent " +
"answers as the account agents run as here, under the licence this node is bound to, with no tools, " +
"no tool servers, no instructions and no session kept — it can only answer — within 25 seconds or " +
"not at all, unless within asks longer (at most 120 seconds, for a long question): then, when it is not done in " +
"20 seconds, it answers pending with a ticket, and claude_code_judged collects the answer (novox/hq issue 508). " +
"One at a time on a node. Refused: an empty or larger than 64 KiB question, a schema that " +
"is not an object, an unknown model, a node holding no licence. The question is never logged.",
Input: map[string]any{
"question": str("the question, with everything the answer needs"),
"schema": map[string]any{"type": "object", "description": "the JSON schema the answer follows"},
"model": map[string]any{"type": "string", "enum": JudgeModels, "description": "the model (default " + JudgeModels[0] + ")"},
"within": map[string]any{"type": "number", "description": "the bound in seconds, at most 120 (default 25); " +
"past 25 a judgement not done in 20 seconds answers pending with a ticket for claude_code_judged"},
},
Run: func(a map[string]any) (any, error) {
for k := range a {
if k != "question" && k != "schema" && k != "model" && k != "within" {
return nil, fmt.Errorf("claude_code_judge takes question, schema, model and within; not %s", k)
}
}
var ask JudgeAsk
var ok bool
if ask.Question, ok = a["question"].(string); !ok {
return nil, errors.New("question is text")
}
if v, given := a["schema"]; given {
if ask.Schema, ok = v.(map[string]any); !ok {
return nil, errors.New("schema is a JSON schema: an object")
}
}
if v, given := a["model"]; given && v != nil {
if ask.Model, ok = v.(string); !ok {
return nil, errors.New("model is text")
}
}
if v, given := a["within"]; given && v != nil {
secs, ok := v.(float64)
if !ok || secs <= 0 {
return nil, errors.New("within is a number of seconds")
}
ask.Within = time.Duration(secs * float64(time.Second))
}
return j.Judge(ask)
}}
}
// judgedTool collects a judgement asked with a bound past one bus call's (novox/hq issue 508).
func judgedTool(j *Judge) stdio.Tool {
return stdio.Tool{Name: "claude_code_judged",
Description: "The answer of a judgement claude_code_judge answered pending with a ticket (novox/hq issue 508): " +
"waits up to 20 seconds, then answers the judgement's answer, why it failed, or pending again. A ticket is " +
"answered once; one nobody collects is dropped ten minutes after its bound, and none survives a restart of " +
"this module.",
Input: map[string]any{"ticket": str("the ticket claude_code_judge answered")},
Run: func(a map[string]any) (any, error) {
for k := range a {
if k != "ticket" {
return nil, fmt.Errorf("claude_code_judged takes ticket; not %s", k)
}
}
ticket, ok := a["ticket"].(string)
if !ok || ticket == "" {
return nil, errors.New("claude_code_judged needs ticket, as claude_code_judge answered it")
}
return j.Collect(ticket)
}}
}
@@ -0,0 +1,267 @@
package main
import (
"context"
"encoding/json"
"errors"
"slices"
"strings"
"testing"
"time"
)
func testJudge(confined bool, licensed bool, exec func(ctx context.Context, argv []string, stdin []byte) ([]byte, error)) *Judge {
p := Paths{Node: "control", Operator: "op", Agent: "op"}
if confined {
p.Agent = "nox"
}
return &Judge{P: p, Licensed: func() bool { return licensed }, Exec: exec, Now: time.Now, turn: make(chan struct{}, 1)}
}
var verdictSchema = map[string]any{"type": "object", "properties": map[string]any{"verdict": map[string]any{"type": "string"}}}
// The session can do nothing but answer: built-in tools off, safe mode, no session kept, as the agent's account.
func TestAJudgementRunsTheAgentWithNoToolsAsItsAccount(t *testing.T) {
var got []string
var input string
j := testJudge(true, true, func(_ context.Context, argv []string, stdin []byte) ([]byte, error) {
got, input = argv, string(stdin)
return []byte(`{"is_error":false,"subtype":"success","structured_output":{"verdict":"none"}}`), nil
})
out, err := j.Judge(JudgeAsk{Question: "is 1 the same as 2?", Schema: verdictSchema})
if err != nil {
t.Fatal(err)
}
if string(out.Answer) != `{"verdict":"none"}` || out.Model != "sonnet" || out.Node != "control" {
t.Fatalf("%+v", out)
}
if input != "is 1 the same as 2?" {
t.Fatalf("the question goes on the input, never the command line: %q", input)
}
line := strings.Join(got, " ")
if !strings.HasPrefix(line, "sudo -n -H -u nox -- "+JudgeProgram+" -p ") {
t.Fatalf("not run as the agent's account: %s", line)
}
i := slices.Index(got, "--tools")
if i < 0 || got[i+1] != "" {
t.Fatalf("built-in tools are not off: %q", got)
}
for _, want := range []string{"--safe-mode", "--no-session-persistence", "--json-schema"} {
if !slices.Contains(got, want) {
t.Errorf("%s missing: %q", want, got)
}
}
for _, a := range got {
if strings.Contains(a, "is 1 the same") {
t.Fatal("the question is on the command line")
}
}
// Where agents run as the operator's account, no sudo.
j = testJudge(false, true, func(_ context.Context, argv []string, _ []byte) ([]byte, error) {
got = argv
return []byte(`{"structured_output":{"verdict":"none"}}`), nil
})
if _, err := j.Judge(JudgeAsk{Question: "q", Schema: verdictSchema, Model: "haiku"}); err != nil {
t.Fatal(err)
}
if got[0] != JudgeProgram || !slices.Contains(got, "haiku") {
t.Fatalf("%q", got)
}
}
func TestAJudgementRefusesWhatItCannotAnswer(t *testing.T) {
ran := false
ok := func(context.Context, []string, []byte) ([]byte, error) {
ran = true
return []byte(`{"structured_output":{}}`), nil
}
cases := map[string]struct {
j *Judge
ask JudgeAsk
say string
}{
"empty": {testJudge(true, true, ok), JudgeAsk{Question: " ", Schema: verdictSchema}, "needs a question"},
"too large": {testJudge(true, true, ok), JudgeAsk{Question: strings.Repeat("x", MostQuestion+1), Schema: verdictSchema}, "64 KiB"},
"no schema": {testJudge(true, true, ok), JudgeAsk{Question: "q"}, "needs schema"},
"not object": {testJudge(true, true, ok), JudgeAsk{Question: "q", Schema: map[string]any{"type": "string"}}, "object"},
"model": {testJudge(true, true, ok), JudgeAsk{Question: "q", Schema: verdictSchema, Model: "gpt"}, "not one of"},
"no licence": {testJudge(true, false, ok), JudgeAsk{Question: "q", Schema: verdictSchema}, "holds no licence"},
}
for name, c := range cases {
_, err := c.j.Judge(c.ask)
if err == nil || !strings.Contains(err.Error(), c.say) {
t.Errorf("%s: %v", name, err)
}
}
if ran {
t.Fatal("a refused judgement ran the agent")
}
}
func TestAJudgementThatFailsSaysWhy(t *testing.T) {
answers := map[string][]byte{
"an error": []byte(`{"is_error":true,"subtype":"error_during_execution","result":"rate limited"}`),
"no structure": []byte(`{"is_error":false,"result":"it is a duplicate"}`),
"not an object": []byte(`{"structured_output":[1,2]}`),
"not json": []byte(`Invalid API key`),
}
for name, out := range answers {
j := testJudge(true, true, func(context.Context, []string, []byte) ([]byte, error) { return out, nil })
if _, err := j.Judge(JudgeAsk{Question: "q", Schema: verdictSchema}); err == nil || !strings.Contains(err.Error(), "not in the shape") {
t.Errorf("%s: %v", name, err)
}
}
j := testJudge(true, true, func(context.Context, []string, []byte) ([]byte, error) {
return nil, errors.New("exit status 1: sudo: a password is required")
})
if _, err := j.Judge(JudgeAsk{Question: "q", Schema: verdictSchema}); err == nil || !strings.Contains(err.Error(), "could not judge") {
t.Errorf("%v", err)
}
}
// One at a time on a node, and never past the bound: a judgement still waiting at the bound is refused.
func TestAJudgementWaitsItsTurnWithinTheBound(t *testing.T) {
j := testJudge(true, true, func(ctx context.Context, _ []string, _ []byte) ([]byte, error) {
<-ctx.Done()
return nil, ctx.Err()
})
j.Bound = 300 * time.Millisecond
j.turn <- struct{}{} // another judgement holds the agent
start := time.Now()
_, err := j.Judge(JudgeAsk{Question: "q", Schema: verdictSchema})
if err == nil || !strings.Contains(err.Error(), "another judgement") {
t.Fatalf("%v", err)
}
if time.Since(start) > 2*time.Second {
t.Fatal("waited past the bound")
}
}
func TestAJudgementPastTheBoundIsEnded(t *testing.T) {
j := testJudge(true, true, func(ctx context.Context, _ []string, _ []byte) ([]byte, error) {
<-ctx.Done()
return nil, ctx.Err()
})
j.Bound = 200 * time.Millisecond
if _, err := j.Judge(JudgeAsk{Question: "q", Schema: verdictSchema}); err == nil || !strings.Contains(err.Error(), "did not answer within") {
t.Fatalf("%v", err)
}
}
func TestTheToolTakesOnlyItsArguments(t *testing.T) {
j := testJudge(true, true, func(context.Context, []string, []byte) ([]byte, error) {
return []byte(`{"structured_output":{"verdict":"none"}}`), nil
})
tool := judgeTool(j)
if _, err := tool.Run(map[string]any{"question": "q", "schema": verdictSchema, "by": "x"}); err == nil {
t.Fatal("an unknown argument was taken")
}
out, err := tool.Run(map[string]any{"question": "q", "schema": verdictSchema})
if err != nil {
t.Fatal(err)
}
raw, _ := json.Marshal(out)
if !strings.Contains(string(raw), `"answer":{"verdict":"none"}`) {
t.Fatalf("%s", raw)
}
}
// A long question may ask a longer bound than one bus call allows (novox/hq issue 508): the call answers a ticket
// when the judgement is not done within the call's wait, the judgement runs on to its own bound, and
// claude_code_judged collects its answer.
func TestALongJudgementIsCollectedByItsTicket(t *testing.T) {
var deadline time.Time
j := testJudge(true, true, func(ctx context.Context, _ []string, _ []byte) ([]byte, error) {
deadline, _ = ctx.Deadline()
select {
case <-time.After(400 * time.Millisecond):
return []byte(`{"structured_output":{"verdict":"none"}}`), nil
case <-ctx.Done():
return nil, ctx.Err()
}
})
j.Wait = 50 * time.Millisecond
start := time.Now()
out, err := j.Judge(JudgeAsk{Question: "a long record", Schema: verdictSchema, Within: 90 * time.Second})
if err != nil {
t.Fatal(err)
}
if !out.Pending || out.Ticket == "" || len(out.Answer) != 0 || out.Node != "control" {
t.Fatalf("not a ticket: %+v", out)
}
var got *Judged
for i := 0; i < 100; i++ {
got, err = j.Collect(out.Ticket)
if err != nil {
t.Fatal(err)
}
if !got.Pending {
break
}
}
if got.Pending || string(got.Answer) != `{"verdict":"none"}` || got.Ticket != out.Ticket {
t.Fatalf("%+v", got)
}
// Read once the judgement is collected: its end is what orders the agent's write before this read.
if left := deadline.Sub(start); left < 80*time.Second || left > 91*time.Second {
t.Fatalf("the judgement's bound is %s, not the 90s asked", left)
}
if _, err := j.Collect(out.Ticket); err == nil || !strings.Contains(err.Error(), "no judgement") {
t.Fatalf("a ticket collected twice: %v", err)
}
}
// A long judgement that fails says why when it is collected, and its bound is never past MostJudgeBound.
func TestALongJudgementIsBoundedAndSaysWhyItFailed(t *testing.T) {
j := testJudge(true, true, func(ctx context.Context, _ []string, _ []byte) ([]byte, error) {
<-ctx.Done()
return nil, ctx.Err()
})
if _, err := j.Judge(JudgeAsk{Question: "q", Schema: verdictSchema, Within: MostJudgeBound + time.Second}); err == nil ||
!strings.Contains(err.Error(), "at most") {
t.Fatalf("a bound past the most taken: %v", err)
}
j.Wait = 20 * time.Millisecond
j.Bound = 30 * time.Millisecond // the test's stand-in for JudgeBound: within is past it, so a ticket
out, err := j.Judge(JudgeAsk{Question: "q", Schema: verdictSchema, Within: 200 * time.Millisecond})
if err != nil || !out.Pending {
t.Fatalf("%+v %v", out, err)
}
for i := 0; i < 100; i++ {
got, err := j.Collect(out.Ticket)
if err != nil {
if !strings.Contains(err.Error(), "did not answer within") {
t.Fatalf("%v", err)
}
return
}
if !got.Pending {
t.Fatalf("answered: %+v", got)
}
}
t.Fatal("never ended")
}
// The tools take within and ticket, and nothing else.
func TestTheToolsTakeWithinAndTicket(t *testing.T) {
j := testJudge(true, true, func(context.Context, []string, []byte) ([]byte, error) {
return []byte(`{"structured_output":{"verdict":"none"}}`), nil
})
out, err := judgeTool(j).Run(map[string]any{"question": "q", "schema": verdictSchema, "within": float64(60)})
if err != nil {
t.Fatal(err)
}
if got := out.(*Judged); got.Pending || string(got.Answer) != `{"verdict":"none"}` {
t.Fatalf("a quick long judgement is answered in its call: %+v", got)
}
if _, err := judgeTool(j).Run(map[string]any{"question": "q", "schema": verdictSchema, "within": "long"}); err == nil {
t.Fatal("within as text was taken")
}
if _, err := judgedTool(j).Run(map[string]any{"ticket": "nope"}); err == nil || !strings.Contains(err.Error(), "no judgement") {
t.Fatalf("%v", err)
}
if _, err := judgedTool(j).Run(map[string]any{"ticket": "x", "by": "y"}); err == nil {
t.Fatal("an unknown argument was taken")
}
}
@@ -355,6 +355,8 @@ func tools(p Paths, servers ServerState, view *ServerView, config ConfigState, c
Description: "Read-only: everything registered through this module that a Claude Code session runs or obeys — the tool servers, the hooks, the settings and the skills, subagents, commands, output styles and instructions — by key, with who registered it, when, and whether it is still rendered (novox/hq issue 340), beside what the operator set for this node at the controller's terminal (managed_settings, mcp_servers). `by` is the node whose tool runner served the call, never the caller.",
Run: func(map[string]any) (any, error) { return Registrations(p, servers, config) }})
out = append(out, proposalTools(p)...)
judge := NewJudge(p)
out = append(out, judgeTool(judge), judgedTool(judge))
return append(out, configTools(p, config, configView)...)
}
@@ -124,6 +124,7 @@ func HomeScopeRefused(p Paths, target string) string {
func (p Paths) credentials() string { return filepath.Join(p.Home, ".claude", ".credentials.json") }
func (p Paths) account() string { return filepath.Join(p.Home, ".claude.json") }
func (p Paths) binding() string { return filepath.Join(p.State, "licence.json") }
func (p Paths) preferences() string { return filepath.Join(p.State, "preferences.json") }
func (p Paths) apiKey() string { return filepath.Join(p.keyDir(), "api-key") }
func (p Paths) helper() string { return filepath.Join(p.keyDir(), "api-key-helper") }
@@ -218,6 +219,7 @@ func RenderNow(p Paths, write WriteManaged) ([]string, error) {
}
var settings Settings
readJSON(p.Settings, &settings)
readJSON(p.preferences(), &settings.Preferences)
var binding *Binding
var b Binding
if readJSON(p.binding(), &b) {
@@ -298,6 +300,9 @@ type BindingState struct {
Licence string `json:"licence"`
Kind string `json:"kind"`
Generation int64 `json:"generation"`
// Why and By are what moved the binding and who asked, as the manager recorded it (novox/hq issue 625).
Why string `json:"why,omitempty"`
By string `json:"by,omitempty"`
}
// Current is what the seat answers to `current`: the licence this node is bound to and its token, sealed.
@@ -433,6 +438,15 @@ func OnBinding(p Paths, b *BindingState, ask Ask, write WriteManaged) (string, e
if err != nil {
return "", err
}
// The line says which binding this was and why the manager moved it, so a node's journal alone answers
// what changed here and who asked (novox/hq issue 625).
out["generation"] = b.Generation
if b.Why != "" {
out["why"] = b.Why
}
if b.By != "" {
out["by"] = b.By
}
raw, _ := json.Marshal(out)
return string(raw), nil
}
@@ -6,6 +6,7 @@ import (
"encoding/json"
"errors"
"strings"
"sync"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
@@ -20,6 +21,19 @@ type Proposing struct {
Record Recorder
Now func() time.Time
Write WriteManaged
// Questions is the `questions` state (novox/hq ADR 0332); nil is the bus's, made on first use.
Questions QuestionStore
questionsMu sync.Mutex
}
// questions is the `questions` state.
func (d *Proposing) questions() QuestionStore {
d.questionsMu.Lock()
defer d.questionsMu.Unlock()
if d.Questions == nil {
d.Questions = keptQuestions{stdio.State(QuestionsState)}
}
return d.Questions
}
var proposing *Proposing
@@ -60,6 +74,10 @@ func (d *Proposing) watch(p Paths) {
return stdio.Subscribe(asks.Seat+".decided."+Module, func(e stdio.Envelope) error {
var w asks.Warrant
if json.Unmarshal(e.Body, &w) == nil {
if strings.HasPrefix(w.Ask, QuestionPrefix) {
HeardQuestionEnd(d.questions(), w)
return nil
}
if line, err := HeardEnd(p, d.Store, w, d.Now()); err != nil {
say("taking the router's word on %s: %v", w.Ask, err)
} else if line != "" {
@@ -86,7 +104,7 @@ func (d *Proposing) watch(p Paths) {
func proposalTools(p Paths) []stdio.Tool {
notLaunched := errors.New("this module was not launched with the bus; nothing can be proposed")
return []stdio.Tool{
return append([]stdio.Tool{
{Name: "claude_code_instructions_propose",
Description: "Propose an instruction section for every Claude session on the mesh (scope mesh) or on some nodes " +
"(scope node): a rule, a hand-over, the context a later session needs — or, with remove, the removal of one " +
@@ -127,5 +145,83 @@ func proposalTools(p Paths) []stdio.Tool {
proposing.decideNow(p)
return ListProposals(p, proposing.Store, proposing.Bus, strArg(a, "id"), proposing.Now())
}},
}, questionTools(p, notLaunched)...)
}
// questionTools are a session's question to the operator and its answer (novox/hq ADR 0332).
func questionTools(p Paths, notLaunched error) []stdio.Tool {
return []stdio.Tool{
{Name: "claude_code_ask",
Description: "Ask the operator a question on Telegram when you need a decision and the operator may not be at the " +
"terminal (novox/hq ADR 0332): a headline, the context in plain words, two to four options with exactly one " +
"marked recommended and why, what you do if nobody answers, who you are (by, required), and when it expires (5 to " +
"1430 minutes, 60 when absent). Answers within seconds, once the router has taken it, with the question's " +
"key, or with the router's refusal; read the answer with claude_code_answer and keep working " +
"meanwhile. Only a channel that proves who answered carries it, never the desk. The words leave the mesh, so the " +
"router refuses a path, an address, a host name, a commit id or anything shaped like a secret: say it in " +
"words (messenger_check tests words first); anything shaped like a secret is refused before it is kept. At most two " +
"questions wait at once, mesh-wide. The answer is the " +
"operator's word to you; it authorises no call that needs the operator's approval.",
Input: map[string]any{
"headline": str("what is decided, in a few plain words, at most 60 characters, e.g. 'Merge the resolver fix tonight?'"),
"context": str("what the operator decides and why it matters, in plain words, at most 1500 bytes"),
"options": map[string]any{"type": "array", "description": "two to four answers; exactly one recommended",
"items": map[string]any{"type": "object", "properties": map[string]any{
"label": str("the button's words, at most 24 characters"),
"means": str("what you do when it is chosen, in plain words"),
"recommended": map[string]any{"type": "boolean", "description": "true on the one you recommend"},
}, "required": []string{"label", "means"}}},
"why": str("why the recommended option is the one you recommend"),
"on_expiry": str("what you do if nobody answers before it expires, e.g. 'I leave the pull request open'"),
"by": str("required: your session's name, as who@node or who, e.g. fix-589@g14"),
"expires_minutes": map[string]any{"type": "integer",
"description": "minutes until it expires, 5 to 1430; 60 when absent"},
},
Run: func(a map[string]any) (any, error) {
if proposing == nil {
return nil, notLaunched
}
in := QuestionInput{Headline: strArg(a, "headline"), Context: strArg(a, "context"), Why: strArg(a, "why"),
OnExpiry: strArg(a, "on_expiry"), By: strArg(a, "by"), Options: questionOptionsOf(a["options"])}
if m, ok := a["expires_minutes"].(float64); ok {
in.Expires = time.Duration(m * float64(time.Minute))
}
return AskQuestion(p, in, proposing.questions(), proposing.Bus, proposing.Now())
}},
{Name: "claude_code_answer",
Description: "Where a question asked with claude_code_ask stands, by its key (novox/hq ADR 0332): waiting; chosen, " +
"with the option, who answered, on which channel and when; ended without a choice (expired, refused, " +
"cancelled, replaced) with the router's words; or not proven, when the router's record does not hold to the " +
"question asked. Read from the router's own record. wait_seconds (at most 60) waits for an answer within the " +
"call; read a question at most once a minute (asked of you, not enforced).",
Input: map[string]any{
"key": str("the key claude_code_ask gave, e.g. q-0123456789abcdef"),
"wait_seconds": map[string]any{"type": "integer", "description": "wait up to this many seconds (at most 60) while it is waiting"},
},
Run: func(a map[string]any) (any, error) {
if proposing == nil {
return nil, notLaunched
}
wait := time.Duration(0)
if s, ok := a["wait_seconds"].(float64); ok && s > 0 {
wait = time.Duration(s * float64(time.Second))
}
return WaitForAnswer(proposing.questions(), proposing.Bus, strArg(a, "key"), wait, proposing.Now)
}},
}
}
// questionOptionsOf reads the `options` argument: a list of objects with label, means and recommended.
func questionOptionsOf(v any) []QuestionOption {
list, _ := v.([]any)
var out []QuestionOption
for _, item := range list {
m, _ := item.(map[string]any)
if m == nil {
out = append(out, QuestionOption{})
continue
}
out = append(out, QuestionOption{Label: strArg(m, "label"), Means: strArg(m, "means"), Recommended: boolArg(m, "recommended")})
}
return out
}
@@ -838,14 +838,20 @@ func HeardEnd(p Paths, store ProposalStore, w asks.Warrant, now time.Time) (stri
if !known || (w.About != "" && w.About != prop.Ask().About) {
return "", nil
}
endedMu.Lock()
ended[w.Ask] = w
endedMu.Unlock()
// Said to a proposer waiting on it only once the discard is kept: what it reads next is already there (the
// flake of the router's refusal test under a loaded build agent, 2026-10-12).
tell := func() {
endedMu.Lock()
ended[w.Ask] = w
endedMu.Unlock()
}
approved, err := ReadApproved(p)
if err != nil {
tell()
return "", err
}
if _, done := approved.Decided[w.Ask]; done {
tell()
return "", nil
}
// Heard, not read from a record: Reconcile still reads the router's record of it, so an ask the router took after
@@ -856,7 +862,9 @@ func HeardEnd(p Paths, store ProposalStore, w asks.Warrant, now time.Time) (stri
}
approved.Decided[w.Ask] = d
delete(approved.Refused, w.Ask)
if err := writeApproved(p, approved); err != nil {
err = writeApproved(p, approved)
tell()
if err != nil {
return "", err
}
return "proposal " + w.Ask + ": " + d.Outcome, nil
@@ -0,0 +1,628 @@
package main
// A question an agent session asks the operator, answered on a channel that proves who answered (novox/hq ADR 0332,
// issue 589).
//
// A session that needs a yes, a no or a choice asks it with claude_code_ask instead of waiting at a terminal the
// operator may not be watching. The ask is published under this module's name on the operator channel (ADR 0259):
// the bus grants that subject to this module alone, so the asker is proven by the bus, and the router stays the judge
// of answers, never an asker for others. Every option is at the level approve, so only a channel that proves who
// answered (Telegram, today) carries it, and each option binds the digest of the question and of itself.
//
// The session reads the answer by the question's key with claude_code_answer: from the router's own record of the
// ask, which only the router writes and only this module reads, held to the question as the `questions` state keeps
// it (asks.Warrant.For and asks.Option.Performs). A record that does not hold is said as not proven, never as a
// choice. An answer informs the session; it authorises nothing (ADR 0286 stays the way to an approved call).
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/asks"
)
// QuestionsState is the module's state the questions are kept in.
const QuestionsState = "questions"
// QuestionPrefix starts every question's key, so the router's word on one is never taken for a proposal's.
const QuestionPrefix = "q-"
// MostQuestions is how many questions may wait at once, mesh-wide: the router allows an asker three open asks
// (asks.MostOpen), and questions alone never fill them, so one is left for a proposed instruction section (ADR 0274)
// while two questions are open. Each open question holds one slot key in the `questions` state (slotKey), taken by
// create or by compare-and-set, so two nodes asking at once cannot both take the last one.
const MostQuestions = asks.MostOpen - 1
// A question the router holds no record of, and whose refusal was not heard here, keeps its slot until it expires:
// the router may still have it in its queue (down, or backlogged), and neither the queue's order nor a cancel can
// take it back — the queue offers a piece of work again after an error, so a cancel may be taken before the ask it
// cancels, and a cancel of an ask not yet recorded is dropped. Once the question has expired the router refuses it
// when it is taken (asks.Ask.Check), so the slot is free and the question has ended.
// slotKey is the key of one of the MostQuestions slots: not a question's key, so no question is ever read from it.
func slotKey(n int) string { return fmt.Sprintf("slot-%d", n) }
// slot is what a slot key holds: the question holding it.
type slot struct {
Question string `json:"question"`
At time.Time `json:"at"`
}
// The bounds of a question's life (ADR 0332 §2): at least five minutes, at most a day less ten minutes (the
// router's bound for an approval, with the margin a proposal keeps), an hour when none is given.
const (
QuestionShortest = 5 * time.Minute
QuestionLongest = ProposalLasts
QuestionDefault = time.Hour
)
// The bounds of what a question says.
const (
FewestQuestionOptions = 2
MostContextBytes = 1500
MostMeansBytes = 200
MostWhyBytes = 300
MostOnExpiryBytes = 200
)
// How long one claude_code_answer call may wait for the answer, and how often it looks meanwhile.
var (
AnswerWaitsAtMost = 60 * time.Second
AnswerLooksEvery = 2 * time.Second
)
// QuestionOption is one answer a session offers.
type QuestionOption struct {
ID string `json:"id"`
Label string `json:"label"`
Means string `json:"means"`
Recommended bool `json:"recommended,omitempty"`
}
// QuestionStore is the `questions` state: a value with its revision, created only when absent, updated only at the
// revision read.
type QuestionStore interface {
Get(key string) (json.RawMessage, uint64, bool, error)
Create(key string, value any) error
Update(key string, value any, revision uint64) error
Put(key string, value any) error
}
// keptQuestions is the `questions` state on the bus.
type keptQuestions struct{ s stdio.KeptState }
func (k keptQuestions) Get(key string) (json.RawMessage, uint64, bool, error) {
e, err := k.s.Get(key)
if err != nil || e == nil {
return nil, 0, false, err
}
return e.Value, e.Revision, true, nil
}
func (k keptQuestions) Create(key string, value any) error {
_, err := k.s.Create(key, value)
return err
}
func (k keptQuestions) Update(key string, value any, revision uint64) error {
_, err := k.s.Update(key, value, revision)
return err
}
func (k keptQuestions) Put(key string, value any) error { _, err := k.s.Put(key, value); return err }
// Question is one question, as the `questions` state keeps it. Every field the ask is composed from is here, so the
// answer is held to exactly the question asked.
type Question struct {
ID string `json:"id"`
Headline string `json:"headline"`
Context string `json:"context"`
Options []QuestionOption `json:"options"`
Why string `json:"why"`
OnExpiry string `json:"on-expiry"`
By string `json:"by"`
From string `json:"from"`
At time.Time `json:"at"`
Expires time.Time `json:"expires"`
// Asked is empty once the ask was published, or says why it was not; nothing composes from it.
Asked string `json:"asked,omitempty"`
// Ended is the router's word that the ask ended without a choice, as heard on the decided subject: a refused ask
// leaves no record of the router's to read it from. It never makes a choice.
Ended string `json:"ended,omitempty"`
}
// recommended is the option marked as recommended.
func (q Question) recommended() (QuestionOption, bool) {
for _, o := range q.Options {
if o.Recommended {
return o, true
}
}
return QuestionOption{}, false
}
// Act is what an answer binds: this question, whole, and the option chosen.
func (q Question) Act(option string) asks.Act {
act := asks.Act{"verb": Module + ".question", "option": option, "question": q.ID, "headline": q.Headline,
"context": q.Context, "why": q.Why, "on-expiry": q.OnExpiry, "by": q.By, "from": q.From,
"at": q.At.UTC().Format(time.RFC3339Nano), "expires": q.Expires.UTC().Format(time.RFC3339Nano),
"options": strconv.Itoa(len(q.Options))}
for i, o := range q.Options {
act[fmt.Sprintf("option.%d", i)] = o.ID + "\n" + o.Label + "\n" + o.Means + "\n" + strconv.FormatBool(o.Recommended)
}
return act
}
// Ask is the ask for a question, composed from it alone.
func (q Question) Ask() asks.Ask {
var b strings.Builder
b.WriteString(strings.TrimSpace(q.Context))
if rec, ok := q.recommended(); ok {
fmt.Fprintf(&b, "\n\nRecommended: %s, because %s", rec.Label, strings.TrimRight(strings.TrimSpace(q.Why), "."))
b.WriteString(".")
}
fmt.Fprintf(&b, "\n\nAsked by the agent session %s on %s through the claude-code module, at %s UTC. "+
"Your answer is told to that session; it performs nothing by itself.", q.By, q.From, q.At.UTC().Format("15:04 on 2 Jan"))
a := asks.Ask{ID: q.ID, Headline: q.Headline, Explanation: b.String(), Who: asks.Operator, Expires: q.Expires,
OnExpiry: q.OnExpiry}
for _, o := range q.Options {
does := o.Means
if o.Recommended {
does = "Recommended. " + does
}
binds, _ := asks.ActDigest(q.Act(o.ID))
a.Options = append(a.Options, asks.Option{ID: o.ID, Label: o.Label, Does: does, Level: asks.Approve, Binds: binds})
}
return a
}
var sessionName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]{0,47}$`)
// QuestionProblem says why a question cannot be asked, or "": every bound of ADR 0332 §2 that this module holds
// itself. The words are held to the content rule by the router, which says its refusal back.
func QuestionProblem(q Question) string {
var problems []string
add := func(format string, args ...any) { problems = append(problems, fmt.Sprintf(format, args...)) }
switch {
case strings.TrimSpace(q.Headline) == "":
add("it has no headline")
case len([]rune(q.Headline)) > asks.HeadlineLength:
add("its headline is %d characters, more than %d", len([]rune(q.Headline)), asks.HeadlineLength)
}
switch {
case strings.TrimSpace(q.Context) == "":
add("it gives no context: say in plain words what the operator decides and why it matters")
case len(q.Context) > MostContextBytes:
add("its context is %d bytes, more than the %d a phone shows in one glance", len(q.Context), MostContextBytes)
}
if n := len(q.Options); n < FewestQuestionOptions || n > asks.MostOptions {
add("it offers %d options; a question offers %d to %d", n, FewestQuestionOptions, asks.MostOptions)
}
recommended, labels := 0, map[string]bool{}
for i, o := range q.Options {
switch {
case strings.TrimSpace(o.Label) == "":
add("option %d has no label", i+1)
case len([]rune(o.Label)) > asks.LabelLength:
add("option %d's label is longer than %d characters", i+1, asks.LabelLength)
case labels[strings.ToLower(o.Label)]:
add("two options are labelled %q", o.Label)
}
labels[strings.ToLower(o.Label)] = true
if strings.TrimSpace(o.Means) == "" {
add("option %d does not say what choosing it means", i+1)
} else if len(o.Means) > MostMeansBytes {
add("what option %d means is longer than %d bytes", i+1, MostMeansBytes)
}
if !asks.UsableID(o.ID) {
add("option %d's id is not one", i+1)
}
if o.Recommended {
recommended++
}
}
if recommended != 1 {
add("%d options are marked recommended; exactly one is", recommended)
}
switch {
case strings.TrimSpace(q.Why) == "":
add("it does not say why the recommended option is recommended")
case len(q.Why) > MostWhyBytes:
add("why the option is recommended is longer than %d bytes", MostWhyBytes)
}
switch {
case strings.TrimSpace(q.OnExpiry) == "":
add("it does not say what the session does if nobody answers")
case len(q.OnExpiry) > MostOnExpiryBytes:
add("what happens if nobody answers is longer than %d bytes", MostOnExpiryBytes)
}
if !sessionName.MatchString(q.By) {
add("who asks is not a session's name: letters, digits, - and _, such as fix-589")
}
if !nodeName.MatchString(q.From) {
add("where it comes from is not a node's name")
}
if life := q.Expires.Sub(q.At); life < QuestionShortest || life > QuestionLongest {
add("it lasts %s; a question lasts %s to %s", life.Round(time.Minute), QuestionShortest, QuestionLongest)
}
if problem := questionWordsProblem(q); problem != "" {
add("%s", problem)
}
if len(problems) == 0 {
return ""
}
return strings.Join(problems, "; ")
}
// questionWordsProblem says why a question's words may not be kept at all, or "": anything shaped like a secret, or a
// character a phone does not show, in any word the operator is shown — the headline, the context, why, what happens
// on expiry, and each option's label and meaning. Judged before anything is kept: the `questions` state is read by
// every node, and the router judges the content rule only after the question was kept.
func questionWordsProblem(q Question) string {
fields := []struct{ name, text string }{{"the headline", q.Headline}, {"the context", q.Context}, {"why", q.Why},
{"what happens on expiry", q.OnExpiry}}
for i, o := range q.Options {
fields = append(fields, struct{ name, text string }{fmt.Sprintf("option %d's label", i+1), o.Label},
struct{ name, text string }{fmt.Sprintf("what option %d means", i+1), o.Means})
}
for _, f := range fields {
for _, re := range secretShaped {
if re.MatchString(f.text) {
return f.name + " holds something shaped like a secret, which is never kept or shown on a phone"
}
}
if what := invisible(f.text); what != "" {
return f.name + " holds " + what + ", which a phone does not show"
}
}
return ""
}
func newQuestionID() string {
var b [8]byte
_, _ = rand.Read(b[:])
return QuestionPrefix + hex.EncodeToString(b[:])
}
// QuestionInput is what a session asks.
type QuestionInput struct {
Headline string
Context string
Options []QuestionOption
Why string
OnExpiry string
By string
Expires time.Duration
}
// AskQuestion keeps a question and asks the operator. It answers within seconds, once the router has said whether it
// took the question, with the question's key; the answer is read with ReadAnswer.
func AskQuestion(p Paths, in QuestionInput, store QuestionStore, bus AskBus, now time.Time) (map[string]any, error) {
refuse := func(why string) (map[string]any, error) { return nil, errors.New(why + ". Nothing was asked") }
by := strings.TrimSpace(in.By)
if who, node, found := strings.Cut(by, "@"); found {
if node != p.Node {
return refuse(fmt.Sprintf("who asks names the node %q, and this is %s: ask on the node the session runs on", node, p.Node))
}
by = who
}
if by == "" {
return refuse("it does not say who asks: give by, your session's name as who@node, such as fix-589@g14")
}
life := in.Expires
if life == 0 {
life = QuestionDefault
}
q := Question{ID: newQuestionID(), Headline: strings.TrimSpace(in.Headline), Context: strings.TrimSpace(in.Context),
Why: strings.TrimSpace(in.Why), OnExpiry: strings.TrimSpace(in.OnExpiry), By: by, From: p.Node, At: now.UTC(),
Expires: now.UTC().Add(life)}
for i, o := range in.Options {
q.Options = append(q.Options, QuestionOption{ID: fmt.Sprintf("o%d", i+1), Label: strings.TrimSpace(o.Label),
Means: strings.TrimSpace(o.Means), Recommended: o.Recommended})
}
if problem := QuestionProblem(q); problem != "" {
return refuse(problem)
}
ask := q.Ask()
if err := ask.Check(now); err != nil {
return refuse(err.Error())
}
// The question is kept before its slot is taken, so a slot always names a question another node can judge.
if err := store.Create(q.ID, q); err != nil {
return nil, fmt.Errorf("the question could not be kept, so the operator was not asked: %w", err)
}
if open, err := takeSlot(store, bus, q, now); err != nil {
_ = store.Put(q.ID, tombstone(q, "not asked: "+err.Error()))
return refuse(err.Error())
} else if open != nil {
_ = store.Put(q.ID, tombstone(q, "not asked: two questions were open"))
return refuse(fmt.Sprintf("%d questions already wait for the operator's answer (%s), and sessions hold at most %d "+
"at once, so one of the router's three open asks is left for a proposed instruction section; read them with "+
"claude_code_answer, or wait for one to end", len(open), strings.Join(open, ", "), MostQuestions))
}
if err := bus.Publish(asks.AskSubject(Module), ask, ask.ID); err != nil {
_ = store.Put(q.ID, tombstone(q, "not asked: "+err.Error()))
return nil, fmt.Errorf("the operator could not be asked: %v", err)
}
say("question %s from the session %s asked of the operator", q.ID, q.By)
taken := "the router has not said yet whether it took the question; claude_code_answer says"
defer forgetQuestionEnd(q.ID)
for deadline := time.Now().Add(RouterAnswersWithin); time.Now().Before(deadline); {
if w, ended := questionEnd(q.ID); ended {
return nil, fmt.Errorf("the router did not ask the operator: the question %s (%s)", w.Outcome, w.Words)
}
if entry, err := bus.Record(q.ID); err == nil && entry != nil {
taken = "the router took the question and shows it where who answers is proven (Telegram)"
break
}
time.Sleep(RouterAnswersEvery)
}
return map[string]any{
"key": q.ID,
"router": taken,
"asked": "\"" + q.Headline + "\", with the answers " + labelsOf(q),
"expires": q.Expires.Format(time.RFC3339),
"next": "read the answer with claude_code_answer {\"key\": \"" + q.ID + "\"} (it may wait up to a minute); " +
"keep working meanwhile, and if it expires do what you said: " + q.OnExpiry,
}, nil
}
func labelsOf(q Question) string {
var out []string
for _, o := range q.Options {
out = append(out, o.Label)
}
return strings.Join(out, ", ")
}
// questionOpen says whether a question still waits: kept, asked, not ended, not expired, and open in the router's
// record or not recorded yet. An unreadable record is open: a slot is never freed on a guess.
func questionOpen(store QuestionStore, bus AskBus, id string, now time.Time) (bool, error) {
q, ok, err := readQuestionErr(store, id)
if err != nil {
return true, err
}
if !ok || q.Asked != "" || q.Ended != "" || !now.Before(q.Expires) {
return false, nil
}
entry, err := bus.Record(q.ID)
if err != nil {
return true, err
}
if entry == nil {
return true, nil
}
var rec routerRecord
if json.Unmarshal(entry.Value, &rec) != nil {
return true, nil
}
return rec.State == "" || rec.State == "open", nil
}
// takeSlot takes one of the MostQuestions slots for q: a slot no key holds, by create, or one whose question no longer
// waits, by compare-and-set at the revision read. Of two nodes taking the same slot only one wins; the other tries
// the next. It answers the open questions when every slot is held, and an error — refusing, never guessing — when a
// slot or its question cannot be read.
func takeSlot(store QuestionStore, bus AskBus, q Question, now time.Time) ([]string, error) {
mine := slot{Question: q.ID, At: q.At}
var open []string
for n := 1; n <= MostQuestions; n++ {
key := slotKey(n)
raw, rev, found, err := store.Get(key)
if err != nil {
return nil, fmt.Errorf("the open questions could not be counted (%v), so none more is asked", err)
}
if !found {
if err := store.Create(key, mine); err == nil {
return nil, nil
} else if !errors.Is(err, stdio.ErrChanged) {
return nil, fmt.Errorf("the open questions could not be counted (%v), so none more is asked", err)
}
if raw, rev, found, err = store.Get(key); err != nil || !found {
return nil, fmt.Errorf("the open questions could not be counted, so none more is asked")
}
}
var held slot
if json.Unmarshal(raw, &held) == nil && held.Question != "" {
waits, err := questionOpen(store, bus, held.Question, now)
if err != nil {
return nil, fmt.Errorf("the open questions could not be counted (%v), so none more is asked", err)
}
if waits {
open = append(open, held.Question)
continue
}
}
if err := store.Update(key, mine, rev); err == nil {
return nil, nil
} else if !errors.Is(err, stdio.ErrChanged) {
return nil, fmt.Errorf("the open questions could not be counted (%v), so none more is asked", err)
}
open = append(open, "one taken just now")
}
sort.Strings(open)
return open, nil
}
// tombstone is what is kept of a question that was not asked or that the router refused: its key, who asked, when, and
// why it ended — none of its words, which the router judged unfit to leave the mesh or never saw.
func tombstone(q Question, ended string) Question {
return Question{ID: q.ID, By: q.By, From: q.From, At: q.At, Expires: q.Expires, Ended: ended}
}
func readQuestion(store QuestionStore, key string) (Question, bool) {
q, ok, _ := readQuestionErr(store, key)
return q, ok
}
func readQuestionErr(store QuestionStore, key string) (Question, bool, error) {
if !strings.HasPrefix(key, QuestionPrefix) {
return Question{}, false, nil
}
raw, _, found, err := store.Get(key)
if err != nil || !found {
return Question{}, false, err
}
var q Question
if json.Unmarshal(raw, &q) != nil || q.ID != key {
return Question{}, false, nil
}
return q, true, nil
}
// The router's word that a question ended without a choice, as heard on the decided subject.
var (
questionEndsMu sync.Mutex
questionEnds = map[string]asks.Warrant{}
)
func questionEnd(id string) (asks.Warrant, bool) {
questionEndsMu.Lock()
defer questionEndsMu.Unlock()
w, ok := questionEnds[id]
return w, ok
}
func forgetQuestionEnd(id string) {
questionEndsMu.Lock()
delete(questionEnds, id)
questionEndsMu.Unlock()
}
// HeardQuestionEnd takes the router's word that a question ended without a choice — refused, expired, cancelled or
// replaced — and keeps it on the question, because a refused ask leaves no record of the router's. A refused question
// keeps none of its words (tombstone). A choice is never taken from this word: only from the router's record, held to
// the question (ReadAnswer).
func HeardQuestionEnd(store QuestionStore, w asks.Warrant) {
if w.Asker != Module || w.Outcome == asks.OutcomeChosen || !strings.HasPrefix(w.Ask, QuestionPrefix) {
return
}
q, known := readQuestion(store, w.Ask)
if !known {
return
}
if q.Ended == "" {
ended := string(w.Outcome)
if w.Words != "" {
ended += ": " + w.Words
}
if w.Outcome == asks.OutcomeRefused {
q = tombstone(q, ended)
} else {
q.Ended = ended
}
_ = store.Put(q.ID, q)
}
// Kept on the question first, then said to a session waiting on it: what it reads next is already there.
questionEndsMu.Lock()
questionEnds[w.Ask] = w
questionEndsMu.Unlock()
}
// The answer's states, as claude_code_answer says them.
const (
AnswerWaiting = "waiting"
AnswerChosen = "chosen"
AnswerEnded = "ended"
AnswerNotProven = "not proven"
)
// ReadAnswer is where a question stands: waiting, chosen (and what), ended without a choice, or not proven.
func ReadAnswer(store QuestionStore, bus AskBus, key string, now time.Time) (map[string]any, error) {
q, ok := readQuestion(store, strings.TrimSpace(key))
if !ok {
return nil, fmt.Errorf("no question %q is kept: the key is the one claude_code_ask gave", key)
}
out := map[string]any{"key": q.ID, "headline": q.Headline, "asked": q.At.Format(time.RFC3339),
"expires": q.Expires.Format(time.RFC3339)}
if q.Asked != "" {
out["state"], out["words"] = AnswerEnded, q.Asked
return out, nil
}
entry, err := bus.Record(q.ID)
if err != nil {
return nil, fmt.Errorf("the router's record of the question could not be read now (%v); ask again", err)
}
var rec routerRecord
if entry != nil {
if err := json.Unmarshal(entry.Value, &rec); err != nil {
out["state"], out["words"] = AnswerNotProven, "the router's record of it cannot be read: "+err.Error()
return out, nil
}
}
switch {
case entry == nil && q.Ended != "":
out["state"], out["words"] = AnswerEnded, q.Ended
case entry == nil && !now.Before(q.Expires):
out["state"], out["words"] = AnswerEnded, "expired without the router recording it: refused (its refusal was not "+
"heard here) or never taken; the router refuses it now if it is taken late"
out["now"] = "do what you said: " + q.OnExpiry
case entry == nil:
out["state"] = AnswerWaiting
out["words"] = "not yet recorded by the router: it may still be in the router's queue, or was refused without the " +
"refusal being heard here. It holds its slot, and the operator may still be asked, until it expires at " +
q.Expires.Format(time.RFC3339)
case entry == nil, rec.State == "open", rec.Warrant == nil:
out["state"] = AnswerWaiting
if q.Ended != "" {
out["words"] = "the router said it ended (" + q.Ended + "), and its record says it is open; read again"
}
default:
w := *rec.Warrant
if w.Outcome != asks.OutcomeChosen {
out["state"], out["words"] = AnswerEnded, string(w.Outcome)
if w.Words != "" {
out["words"] = string(w.Outcome) + ": " + w.Words
}
if w.Outcome == asks.OutcomeExpired {
out["now"] = "do what you said: " + q.OnExpiry
}
return out, nil
}
if problem := QuestionProblem(q); problem != "" {
out["state"], out["words"] = AnswerNotProven, "the question kept is not one this module asks: "+problem
return out, nil
}
option, err := w.For(Module, q.Ask())
if err == nil {
err = option.Performs(q.Act(option.ID))
}
if err != nil {
say("REFUSED the answer to question %s: %v", q.ID, err)
out["state"], out["words"] = AnswerNotProven, "the router's record does not hold to the question asked: "+err.Error()
return out, nil
}
chosen := map[string]any{"id": option.ID, "label": option.Label}
for _, o := range q.Options {
if o.ID == option.ID {
chosen["means"], chosen["recommended"] = o.Means, o.Recommended
}
}
out["state"], out["chosen"], out["by"], out["channel"], out["at"] = AnswerChosen, chosen, w.Says(), w.Channel,
w.At.Format(time.RFC3339)
out["note"] = "the operator's word to this session; it authorises no call that needs the operator's approval"
}
return out, nil
}
// WaitForAnswer reads the answer, and while it is waiting reads it again until wait has passed.
func WaitForAnswer(store QuestionStore, bus AskBus, key string, wait time.Duration, now func() time.Time) (map[string]any, error) {
if wait > AnswerWaitsAtMost {
wait = AnswerWaitsAtMost
}
deadline := time.Now().Add(wait)
for {
out, err := ReadAnswer(store, bus, key, now())
if err != nil || out["state"] != AnswerWaiting || !time.Now().Before(deadline) {
return out, err
}
time.Sleep(AnswerLooksEvery)
}
}
@@ -0,0 +1,513 @@
package main
// A session's question to the operator (novox/hq ADR 0332, issue 589): asked under this module's name at the level
// approve, refused outside its bounds, at most two waiting, and its answer read back by its key only when the
// router's record holds to the question asked.
import (
"encoding/json"
"errors"
"strings"
"sync"
"testing"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/asks"
)
func mergeTonight() QuestionInput {
return QuestionInput{
Headline: "Merge the resolver fix tonight?",
Context: "The fix for the resolver on the laptop is reviewed and both checks are green. Merging now rolls it to every node within the hour.",
Options: []QuestionOption{
{Label: "Merge now", Means: "I merge it and follow the rollout", Recommended: true},
{Label: "Wait", Means: "I leave the pull request open until you say"},
},
Why: "it is reviewed and the checks are green",
OnExpiry: "I leave the pull request open",
By: "fix-589@g14",
}
}
func askMergeTonight(t *testing.T, p Paths, store *memQuestions, r *router, at time.Time) string {
t.Helper()
out, err := AskQuestion(p, mergeTonight(), store, r, at)
if err != nil {
t.Fatal(err)
}
return out["key"].(string)
}
func TestAQuestionIsAskedUnderTheModulesNameAtTheLevelApprove(t *testing.T) {
p, _ := node(t, "g14")
store, r := newMemQuestions(), newRouter()
out, err := AskQuestion(p, mergeTonight(), store, r, t0)
if err != nil {
t.Fatal(err)
}
key := out["key"].(string)
if !strings.HasPrefix(key, QuestionPrefix) || !strings.Contains(out["router"].(string), "took the question") {
t.Fatalf("answered %v", out)
}
if len(r.subjects) != 1 || r.subjects[0] != asks.AskSubject(Module) {
t.Fatalf("published on %v", r.subjects)
}
a := r.published[key]
if a.Who != asks.Operator || a.OnExpiry != "I leave the pull request open" || !a.Expires.Equal(t0.Add(QuestionDefault)) {
t.Errorf("ask %+v", a)
}
if len(a.Options) != 2 {
t.Fatalf("options %+v", a.Options)
}
for _, o := range a.Options {
if o.Level != asks.Approve || o.Binds == "" {
t.Errorf("option %s is %s binding %q: every option is an approval bound to the question", o.ID, o.Level, o.Binds)
}
}
if !strings.HasPrefix(a.Options[0].Does, "Recommended. ") || strings.HasPrefix(a.Options[1].Does, "Recommended") {
t.Errorf("the recommendation is not on the recommended option alone: %+v", a.Options)
}
for _, want := range []string{"Recommended: Merge now, because it is reviewed and the checks are green.",
"the agent session fix-589 on g14", "performs nothing by itself"} {
if !strings.Contains(a.Explanation, want) {
t.Errorf("the explanation does not say %q:\n%s", want, a.Explanation)
}
}
if err := a.Check(t0); err != nil {
t.Errorf("the ask does not pass the asks contract: %v", err)
}
}
func TestTheChosenAnswerIsReadBackByItsKey(t *testing.T) {
p, _ := node(t, "g14")
store, r := newMemQuestions(), newRouter()
key := askMergeTonight(t, p, store, r, t0)
out, err := ReadAnswer(store, r, key, t0.Add(time.Minute))
if err != nil || out["state"] != AnswerWaiting {
t.Fatalf("before an answer: %v %v", out, err)
}
r.close(r.chose(key, "o2", t0.Add(10*time.Minute)))
out, err = ReadAnswer(store, r, key, t0.Add(11*time.Minute))
if err != nil || out["state"] != AnswerChosen {
t.Fatalf("after the choice: %v %v", out, err)
}
chosen := out["chosen"].(map[string]any)
if chosen["id"] != "o2" || chosen["label"] != "Wait" || chosen["means"] != "I leave the pull request open until you say" {
t.Errorf("chosen %v", chosen)
}
if !strings.Contains(out["by"].(string), "the operator, via telegram") || out["channel"] != "telegram" {
t.Errorf("by %v on %v", out["by"], out["channel"])
}
}
func TestAnAnswerThatDoesNotHoldToTheQuestionIsNotProven(t *testing.T) {
p, _ := node(t, "g14")
store, r := newMemQuestions(), newRouter()
key := askMergeTonight(t, p, store, r, t0)
w := r.chose(key, "o1", t0.Add(time.Minute))
// The question rewritten on the bus after it was asked: the operator was shown other words.
var q Question
_ = json.Unmarshal(store.m[key], &q)
q.Context = "Merging now deletes nothing and rolls it to every node."
_ = store.Put(key, q)
r.close(w)
if out, _ := ReadAnswer(store, r, key, t0.Add(2*time.Minute)); out["state"] != AnswerNotProven {
t.Errorf("a question changed after it was asked read as %v", out)
}
// A warrant for another ask's digest.
store2, r2 := newMemQuestions(), newRouter()
key2 := askMergeTonight(t, p, store2, r2, t0)
w2 := r2.chose(key2, "o1", t0.Add(time.Minute))
w2.AskDigest = "sha256:0000"
r2.close(w2)
if out, _ := ReadAnswer(store2, r2, key2, t0.Add(2*time.Minute)); out["state"] != AnswerNotProven {
t.Errorf("a warrant for another ask read as %v", out)
}
// A choice made after the question expired.
store3, r3 := newMemQuestions(), newRouter()
key3 := askMergeTonight(t, p, store3, r3, t0)
r3.close(r3.chose(key3, "o1", t0.Add(QuestionDefault+time.Minute)))
if out, _ := ReadAnswer(store3, r3, key3, t0.Add(2*QuestionDefault)); out["state"] != AnswerNotProven {
t.Errorf("a choice after expiry read as %v", out)
}
}
func TestAQuestionIsRefusedOutsideItsBounds(t *testing.T) {
p, _ := node(t, "g14")
for name, change := range map[string]func(*QuestionInput){
"one option": func(in *QuestionInput) { in.Options = in.Options[:1] },
"five options": func(in *QuestionInput) { in.Options = append(in.Options, in.Options[1], in.Options[1], in.Options[1]) },
"none recommended": func(in *QuestionInput) { in.Options[0].Recommended = false },
"two recommended": func(in *QuestionInput) { in.Options[1].Recommended = true },
"no why": func(in *QuestionInput) { in.Why = "" },
"no context": func(in *QuestionInput) { in.Context = "" },
"no on-expiry": func(in *QuestionInput) { in.OnExpiry = "" },
"a long headline": func(in *QuestionInput) { in.Headline = strings.Repeat("Merge ", 11) },
"a long label": func(in *QuestionInput) { in.Options[0].Label = "Merge it now and follow the rollout" },
"an option unsaid": func(in *QuestionInput) { in.Options[1].Means = "" },
"too short": func(in *QuestionInput) { in.Expires = time.Minute },
"too long": func(in *QuestionInput) { in.Expires = 2 * 24 * time.Hour },
"another node": func(in *QuestionInput) { in.By = "fix-589@shanks" },
"an invisible char": func(in *QuestionInput) { in.Context += "\u200b" },
"invisible label": func(in *QuestionInput) { in.Options[0].Label = "Merge\u200bnow" },
"invisible means": func(in *QuestionInput) { in.Options[1].Means += "\u202e" },
"a token, context": func(in *QuestionInput) { in.Context += " The key is ghp_abcdefghijklmnopqrstuvwxyz0123456789." },
"a token, headline": func(in *QuestionInput) { in.Headline = "Use sk-abcdefghijklmnopqrstuv?" },
"a token, why": func(in *QuestionInput) { in.Why = "password: hunter2 works" },
"a token, expiry": func(in *QuestionInput) { in.OnExpiry = "I keep AKIAABCDEFGHIJKLMNOP" },
"a token, label": func(in *QuestionInput) { in.Options[0].Label = "token=abc" },
"a token, means": func(in *QuestionInput) { in.Options[1].Means = "-----BEGIN PRIVATE KEY-----" },
"no by": func(in *QuestionInput) { in.By = "" },
"61 characters": func(in *QuestionInput) { in.Headline = strings.Repeat("x", 60) + "?" },
"1431 minutes": func(in *QuestionInput) { in.Expires = 1431 * time.Minute },
} {
store, r := newMemQuestions(), newRouter()
in := mergeTonight()
in.Options = append([]QuestionOption(nil), in.Options...)
change(&in)
if _, err := AskQuestion(p, in, store, r, t0); err == nil || !strings.Contains(err.Error(), "Nothing was asked") {
t.Errorf("%s: %v", name, err)
}
if len(r.subjects) != 0 || len(store.m) != 0 {
t.Errorf("%s: asked or kept anyway", name)
}
}
}
func TestAtMostTwoQuestionsWaitSoAProposalFindsRoom(t *testing.T) {
p, _ := node(t, "g14")
store, r := newMemQuestions(), newRouter()
first := askMergeTonight(t, p, store, r, t0)
askMergeTonight(t, p, store, r, t0)
if _, err := AskQuestion(p, mergeTonight(), store, r, t0); err == nil || !strings.Contains(err.Error(), "2 questions already wait") {
t.Fatalf("a third question: %v", err)
}
r.close(r.chose(first, "o1", t0.Add(time.Minute)))
if _, err := AskQuestion(p, mergeTonight(), store, r, t0.Add(2*time.Minute)); err != nil {
t.Fatalf("a question once one was answered: %v", err)
}
// A proposal is still asked beside two waiting questions.
pstore := &memProposals{m: map[string]json.RawMessage{}}
if _, err := Propose(p, ProposeInput{Scope: ScopeMesh, Name: "x", Text: "Text.\n"}, pstore, r, t0); err != nil {
t.Errorf("a proposal beside two questions: %v", err)
}
}
// questionRefusingRouter refuses every ask as the router does for words that may not leave the mesh: on the decided
// subject, with no record.
type questionRefusingRouter struct {
*router
store QuestionStore
id string
}
func (r *questionRefusingRouter) Publish(subject string, body any, id string) error {
r.id = id
go func() {
time.Sleep(50 * time.Millisecond)
HeardQuestionEnd(r.store, asks.Warrant{Ask: id, Asker: Module, Outcome: asks.OutcomeRefused,
Words: "its words carry path (a file path), which may not leave the mesh", At: t0})
}()
return nil
}
func TestTheRoutersRefusalIsSaidAndKept(t *testing.T) {
p, _ := node(t, "g14")
store := newMemQuestions()
refusing := &questionRefusingRouter{router: newRouter(), store: store}
within, every := RouterAnswersWithin, RouterAnswersEvery
RouterAnswersWithin, RouterAnswersEvery = time.Second, 10*time.Millisecond
t.Cleanup(func() { RouterAnswersWithin, RouterAnswersEvery = within, every })
_, err := AskQuestion(p, mergeTonight(), store, refusing, t0)
if err == nil || !strings.Contains(err.Error(), "may not leave the mesh") {
t.Fatalf("refusal: %v", err)
}
out, err := ReadAnswer(store, refusing, refusing.id, t0.Add(time.Minute))
if err != nil || out["state"] != AnswerEnded || !strings.Contains(out["words"].(string), "refused") {
t.Errorf("read after the refusal: %v %v", out, err)
}
if waits, err := questionOpen(store, refusing, refusing.id, t0.Add(time.Minute)); waits || err != nil {
t.Errorf("a refused question still counts as waiting: %v %v", waits, err)
}
var kept Question
_ = json.Unmarshal(store.m[refusing.id], &kept)
if kept.Headline != "" || kept.Context != "" || len(kept.Options) != 0 || kept.Why != "" || kept.OnExpiry != "" {
t.Errorf("a refused question keeps its words: %+v", kept)
}
}
func TestAnExpiredQuestionSaysWhatTheSessionDoes(t *testing.T) {
p, _ := node(t, "g14")
store, r := newMemQuestions(), newRouter()
key := askMergeTonight(t, p, store, r, t0)
r.close(asks.Warrant{Ask: key, Outcome: asks.OutcomeExpired, At: t0.Add(QuestionDefault)})
out, err := ReadAnswer(store, r, key, t0.Add(QuestionDefault+time.Minute))
if err != nil || out["state"] != AnswerEnded || out["now"] != "do what you said: I leave the pull request open" {
t.Errorf("expired: %v %v", out, err)
}
}
func TestTheQuestionToolsAreServed(t *testing.T) {
p, _ := node(t, "g14")
r := newRouter()
questions := newMemQuestions()
proposing = &Proposing{Store: &memProposals{m: map[string]json.RawMessage{}}, Bus: r, Now: func() time.Time { return t0 },
Write: writer(map[string]string{}), Questions: questions}
t.Cleanup(func() { proposing = nil })
s := serve(t, p, &bus{kept: map[string]map[string]any{}}, memConfig{})
text, isErr := s.call(t, "claude_code_ask", map[string]any{
"headline": "Merge the resolver fix tonight?", "context": "Reviewed and green.",
"options": []any{
map[string]any{"label": "Merge now", "means": "I merge it", "recommended": true},
map[string]any{"label": "Wait", "means": "I leave it open"},
},
"why": "it is reviewed", "on_expiry": "I leave it open", "by": "fix-589@g14", "expires_minutes": 30,
})
if isErr || !strings.Contains(text, `"key"`) {
t.Fatalf("%v %s", isErr, text)
}
var asked map[string]any
_ = json.Unmarshal([]byte(text), &asked)
key, _ := asked["key"].(string)
if a := r.published[key]; !a.Expires.Equal(t0.Add(30 * time.Minute)) {
t.Errorf("expires %v", a.Expires)
}
r.close(r.chose(key, "o1", t0.Add(time.Minute)))
text, isErr = s.call(t, "claude_code_answer", map[string]any{"key": key})
if isErr || !strings.Contains(text, `"chosen"`) || !strings.Contains(text, "Merge now") {
t.Fatalf("%v %s", isErr, text)
}
}
// One read waits at most AnswerWaitsAtMost (60 s), whatever the session asks for, and says waiting when nobody
// answered meanwhile.
func TestAReadWaitsNoLongerThanItsBound(t *testing.T) {
if AnswerWaitsAtMost != 60*time.Second {
t.Fatalf("a read may wait %s; ADR 0332 says 60 s", AnswerWaitsAtMost)
}
p, _ := node(t, "g14")
store, r := newMemQuestions(), newRouter()
key := askMergeTonight(t, p, store, r, t0)
most, every := AnswerWaitsAtMost, AnswerLooksEvery
AnswerWaitsAtMost, AnswerLooksEvery = 100*time.Millisecond, 10*time.Millisecond
t.Cleanup(func() { AnswerWaitsAtMost, AnswerLooksEvery = most, every })
start := time.Now()
out, err := WaitForAnswer(store, r, key, time.Hour, func() time.Time { return t0.Add(time.Minute) })
if err != nil || out["state"] != AnswerWaiting {
t.Fatalf("%v %v", out, err)
}
if took := time.Since(start); took > 2*time.Second {
t.Errorf("a read asked to wait an hour took %s", took)
}
}
// memQuestions is the `questions` state: values with revisions, create-only and compare-and-set, safe for the
// goroutines a test starts.
type memQuestions struct {
mu sync.Mutex
m map[string]json.RawMessage
rev map[string]uint64
n uint64
// failGet fails every read of a key with this prefix.
failGet string
// afterGet runs after a read, outside the lock: another node writing between a read and a write.
afterGet func(key string)
}
func newMemQuestions() *memQuestions {
return &memQuestions{m: map[string]json.RawMessage{}, rev: map[string]uint64{}}
}
func (s *memQuestions) Get(key string) (json.RawMessage, uint64, bool, error) {
s.mu.Lock()
if s.failGet != "" && strings.HasPrefix(key, s.failGet) {
s.mu.Unlock()
return nil, 0, false, errors.New("the bus did not answer")
}
raw, ok := s.m[key]
rev, after := s.rev[key], s.afterGet
s.mu.Unlock()
if after != nil {
after(key)
}
return raw, rev, ok, nil
}
func (s *memQuestions) put(key string, value any) error {
raw, err := json.Marshal(value)
s.n++
s.m[key], s.rev[key] = raw, s.n
return err
}
func (s *memQuestions) Create(key string, value any) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.m[key]; ok {
return stdio.ErrChanged
}
return s.put(key, value)
}
func (s *memQuestions) Update(key string, value any, revision uint64) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.rev[key] != revision {
return stdio.ErrChanged
}
return s.put(key, value)
}
func (s *memQuestions) Put(key string, value any) error {
s.mu.Lock()
defer s.mu.Unlock()
return s.put(key, value)
}
// The edges of ADR 0332 §2 that are allowed: a 60-character headline and 1430 minutes.
func TestTheEdgesOfAQuestionAreAsked(t *testing.T) {
p, _ := node(t, "g14")
in := mergeTonight()
in.Headline = strings.Repeat("x", 59) + "?"
in.Expires = 1430 * time.Minute
if _, err := AskQuestion(p, in, newMemQuestions(), newRouter(), t0); err != nil {
t.Fatalf("a 60-character headline lasting 1430 minutes: %v", err)
}
}
// A warrant naming another asker or another ask, or an option the question did not offer, is no answer to it.
func TestAWarrantForAnotherAskOrOptionIsNotProven(t *testing.T) {
p, _ := node(t, "g14")
for name, change := range map[string]func(*asks.Warrant){
"another asker": func(w *asks.Warrant) { w.Asker = "mesh-controller" },
"another ask": func(w *asks.Warrant) { w.Ask = "q-0000000000000000" },
"an option unoffered": func(w *asks.Warrant) { w.Option, w.Label = "o9", "Delete everything" },
} {
store, r := newMemQuestions(), newRouter()
key := askMergeTonight(t, p, store, r, t0)
w := r.chose(key, "o1", t0.Add(time.Minute))
change(&w)
r.records[key], _ = json.Marshal(map[string]any{"state": "chosen", "warrant": w})
if out, _ := ReadAnswer(store, r, key, t0.Add(2*time.Minute)); out["state"] != AnswerNotProven {
t.Errorf("%s read as %v", name, out)
}
}
}
// Two nodes asking at the same moment take two slots at most: of three sessions racing, one is refused.
func TestTheCapHoldsAcrossNodesAskingAtOnce(t *testing.T) {
store, r := newMemQuestions(), &lockedRouter{router: newRouter()}
nodes := []string{"g14", "shanks", "ace"}
var wg sync.WaitGroup
errs := make([]error, len(nodes))
for i, name := range nodes {
p, _ := node(t, name)
wg.Add(1)
go func(i int, p Paths) {
defer wg.Done()
in := mergeTonight()
in.By = "fix-589"
_, errs[i] = AskQuestion(p, in, store, r, t0)
}(i, p)
}
wg.Wait()
refused := 0
for _, err := range errs {
if err != nil {
if !strings.Contains(err.Error(), "questions already wait") {
t.Errorf("refused for another reason: %v", err)
}
refused++
}
}
if refused != 1 {
t.Errorf("%d of three questions asked at once were refused; one is", refused)
}
}
// A slot that cannot be read refuses the question: the cap fails closed.
func TestTheCapFailsClosed(t *testing.T) {
p, _ := node(t, "g14")
store, r := newMemQuestions(), newRouter()
store.failGet = "slot-"
if _, err := AskQuestion(p, mergeTonight(), store, r, t0); err == nil || !strings.Contains(err.Error(), "could not be counted") {
t.Fatalf("asked with the slots unreadable: %v", err)
}
if len(r.subjects) != 0 {
t.Error("the operator was asked anyway")
}
}
// A question whose refusal was not heard here, and of which the router holds no record, keeps its slot and reads as
// waiting until it expires — the router may still take it from its queue — and only then frees its slot and ends.
func TestAQuestionTheRouterHasNotRecordedHoldsItsSlotUntilItExpires(t *testing.T) {
p, _ := node(t, "g14")
store, r := newMemQuestions(), newRouter()
first := askMergeTonight(t, p, store, r, t0)
askMergeTonight(t, p, store, r, t0)
delete(r.records, first) // the router down or backlogged, or its refusal never heard here
later := t0.Add(QuestionDefault - time.Minute)
if _, err := AskQuestion(p, mergeTonight(), store, r, later); err == nil {
t.Fatal("the slot of a question the router may still show was freed before it expired")
}
out, _ := ReadAnswer(store, r, first, later)
if out["state"] != AnswerWaiting || !strings.Contains(out["words"].(string), "not yet recorded") {
t.Errorf("before expiry read as %v", out)
}
expired := t0.Add(QuestionDefault)
if out, _ := ReadAnswer(store, r, first, expired); out["state"] != AnswerEnded ||
out["now"] != "do what you said: I leave the pull request open" {
t.Errorf("at expiry read as %v", out)
}
if _, err := AskQuestion(p, mergeTonight(), store, r, expired); err != nil {
t.Errorf("the slot of an expired question was not freed: %v", err)
}
}
// A slot is taken only at the revision read: when another node takes both slots between this node's read and its
// write, this node's writes are refused and the question is refused — a plain write would take a slot it lost.
func TestASlotIsTakenOnlyAtTheRevisionRead(t *testing.T) {
p, _ := node(t, "g14")
store, r := newMemQuestions(), newRouter()
// Two questions that ended: their slots are free to take again.
for _, id := range []string{askMergeTonight(t, p, store, r, t0), askMergeTonight(t, p, store, r, t0)} {
r.close(asks.Warrant{Ask: id, Outcome: asks.OutcomeExpired, At: t0.Add(time.Minute)})
}
shanks, _ := node(t, "shanks")
other := newRouter()
store.afterGet = func(key string) {
if !strings.HasPrefix(key, "slot-") {
return
}
// Another node asks between this node's read of the slot and its write: its question waits.
q := Question{ID: newQuestionID(), Headline: "Another", Context: "c", Why: "w", OnExpiry: "e", By: "other",
From: shanks.Node, At: t0, Expires: t0.Add(time.Hour),
Options: []QuestionOption{{ID: "o1", Label: "A", Means: "a", Recommended: true}, {ID: "o2", Label: "B", Means: "b"}}}
_ = store.Put(q.ID, q)
_ = other.Publish(asks.AskSubject(Module), q.Ask(), q.ID)
r.records[q.ID] = other.records[q.ID]
_ = store.Put(key, slot{Question: q.ID, At: t0})
}
_, err := AskQuestion(p, mergeTonight(), store, r, t0.Add(2*time.Minute))
store.afterGet = nil
if err == nil || !strings.Contains(err.Error(), "questions already wait") {
t.Fatalf("a slot lost to another node was taken: %v", err)
}
}
// lockedRouter is the router fake, safe for sessions asking at once.
type lockedRouter struct {
mu sync.Mutex
*router
}
func (l *lockedRouter) Publish(subject string, body any, id string) error {
l.mu.Lock()
defer l.mu.Unlock()
return l.router.Publish(subject, body, id)
}
func (l *lockedRouter) Record(id string) (*stdio.StateEntry, error) {
l.mu.Lock()
defer l.mu.Unlock()
return l.router.Record(id)
}
@@ -42,6 +42,15 @@ type Settings struct {
MCPServers map[string]map[string]any `json:"mcp_servers"`
// ManagedSettings are keys of the agent's managed settings the operator sets, for the mesh or a node.
ManagedSettings map[string]any `json:"managed_settings"`
// Preferences are this module's own preferences, from its untrusted preferences file: set in one call
// with the controller's `settings` verb, for the mesh or a node, like any module's preference (ADR 0262).
Preferences Preferences `json:"-"`
}
// Preferences are the module's preferences as the mesh filled them; nil is a value the file did not carry.
type Preferences struct {
// Tips is whether Claude Code shows a tip while it works (`spinnerTipsEnabled`).
Tips *bool `json:"tips"`
}
// Binding is the licence this node holds, as it was last applied.
@@ -121,6 +130,11 @@ func RenderWithMesh(facts Facts, settings Settings, binding *Binding, helperPath
// The operator's `managed_settings` (ADR 0213), set at the controller's terminal alone: nothing registered
// through this module's tools is laid over it (novox/hq issue 340).
managed := mergeSettings(map[string]any{}, settings.ManagedSettings)
// The module's preferences: keys of how a session looks, never of what it may do, so they are set in one
// call through the controller rather than at the terminal, and laid over the operator's keys of the same name.
if settings.Preferences.Tips != nil {
managed["spinnerTipsEnabled"] = *settings.Preferences.Tips
}
// The mesh's own keys are laid last: a setting never replaces them.
managed["attribution"] = map[string]any{"commit": "", "pr": ""}
managed["allowAllClaudeAiMcps"] = true
+23 -2
View File
@@ -1,6 +1,13 @@
{
"module": "claude-code",
"version": "1",
"settings": {
"tips": {
"kind": "preference",
"default": true,
"why": "Claude Code shows a tip while it works; false turns tips off in every session on the machine, written as spinnerTipsEnabled in its managed settings"
}
},
"slug": "agent",
"capabilities": [
"package-manager"
@@ -18,7 +25,8 @@
"servers",
"holdings",
"config",
"proposals"
"proposals",
"questions"
],
"reads": [
"claude-licence-manager.bindings"
@@ -54,6 +62,8 @@
"claude_code_instructions_unregister",
"claude_code_instructions_propose",
"claude_code_proposals",
"claude_code_ask",
"claude_code_answer",
"claude_code_settings_get",
"claude_code_settings_set",
"claude_code_settings_clear",
@@ -66,7 +76,9 @@
"claude_code_home_show",
"claude_code_home_remove",
"claude_code_home_removed",
"claude_code_home_restore"
"claude_code_home_restore",
"claude_code_judge",
"claude_code_judged"
],
"data": {
"own": [
@@ -141,6 +153,15 @@
"owner": "${machine:account}",
"merge": "json",
"content": "{\n \"role\": \"\",\n \"mcp_servers\": {},\n \"managed_settings\": {},\n \"instructions\": {},\n \"output_styles\": {},\n \"skills\": {},\n \"commands\": {},\n \"agents\": {}\n}\n"
},
{
"id": "preferences",
"trusted": false,
"type": "file",
"path": "${dir:state}/preferences.json",
"mode": "0600",
"owner": "${machine:account}",
"content": "{\n \"tips\": ${setting:tips}\n}\n"
}
],
"shell": [
@@ -253,6 +253,7 @@ func text(a map[string]any, k string) (string, error) {
}
// verb is one of the seat's verbs: listed as `<seat>.<verb>`, so the runtime serves it on the seat's subject.
// What it changes is recorded as asked by its caller, as the runtime proved it (novox/hq issues 365 and 625).
func verb(name, description string, input map[string]any, run func(ctx context.Context, m *Manager, a map[string]any) (any, error)) stdio.Tool {
return stdio.Tool{Name: Seat + "." + name, Description: description, Input: input,
Run: func(a map[string]any) (any, error) {
@@ -260,7 +261,8 @@ func verb(name, description string, input map[string]any, run func(ctx context.C
if err != nil {
return nil, err
}
return run(context.Background(), m, a)
ctx := WithCause(context.Background(), Cause{Why: "the " + name + " verb", By: stdio.CallerOf(a)})
return run(ctx, m, a)
}}
}
@@ -288,7 +290,7 @@ func tools() []stdio.Tool {
if err != nil {
return nil, err
}
return m.Bind(ctx, c, l, "bind")
return m.Bind(ctx, c, l)
}),
verb("switch", "Move a consumer to another licence. Its node fetches the new licence's token at once and points the agent's account at it. A login on a node does this by itself (ADR 0209); this is for moving one without a login, or back.",
map[string]any{"consumer": consumer, "licence": str("the licence to move to")},
@@ -297,7 +299,7 @@ func tools() []stdio.Tool {
if err != nil {
return nil, err
}
return m.Bind(ctx, c, l, "switch")
return m.Bind(ctx, c, l)
}),
verb("release", "Unbind a consumer. Its node keeps its last token, which expires within hours.",
map[string]any{"consumer": consumer},
@@ -306,7 +308,7 @@ func tools() []stdio.Tool {
if err != nil {
return nil, err
}
return m.Release(ctx, c, "release")
return m.Release(ctx, c)
}),
verb("refresh", "Refresh a licence now, or every due licence when none is named; under each licence's lease, so it never races the daemon. Answers the outcome, never a token.",
map[string]any{"licence": str("a licence; absent for every due one")},
@@ -59,6 +59,54 @@ type BindingState struct {
Licence string `json:"licence"`
Kind string `json:"kind"`
Generation int64 `json:"generation"`
// Why and By are what moved it and who asked (novox/hq issue 625), so the node can say so when it applies it.
Why string `json:"why,omitempty"`
By string `json:"by,omitempty"`
}
// Cause is why a consumer's binding changed and who or what asked for it (novox/hq issue 625): Why is the verb
// called or the rule that fired, By the caller the runtime proved (stdio.CallerOf) or this module itself.
type Cause struct {
Why string
By string
}
// Itself names this module where it moved a binding by a rule of its own, with no caller.
const Itself = "claude-licence-manager"
type causeKey struct{}
// WithCause is ctx carrying why what runs in it changes a binding, and who asked.
func WithCause(ctx context.Context, c Cause) context.Context {
return context.WithValue(ctx, causeKey{}, c)
}
// CauseOf is the cause ctx carries, or "a rule of this module's own" when none: never empty, so a record always
// says something, and a change nobody named a cause for reads as this module's own.
func CauseOf(ctx context.Context) Cause {
if c, ok := ctx.Value(causeKey{}).(Cause); ok && c.By != "" {
return c
}
return Cause{Why: "a rule of this module's own", By: Itself}
}
// record says one change of what a consumer holds, the same fields in three places (novox/hq issue 625): a line
// in the journal, a row in the audit table, and the event `licence.<what>` on the bus. Never a token.
func (m *Manager) record(ctx context.Context, what, line string, detail map[string]any) {
_ = m.Store.Audit(ctx, what, detail)
m.Log("%s", line)
_ = m.Emit("licence."+what, detail)
}
// moved records a consumer bound or switched: from which licence to which, at which generation, why and by whom.
func (m *Manager) moved(ctx context.Context, consumer, from, licence string, generation int64, c Cause) {
what, line := "bound", fmt.Sprintf("bound %s to %s", consumer, licence)
if from != "" {
what, line = "switched", fmt.Sprintf("switched %s from %s to %s", consumer, from, licence)
}
m.record(ctx, what, fmt.Sprintf("%s (generation %d): %s; asked by %s", line, generation, c.Why, c.By),
map[string]any{"consumer": consumer, "licence": licence, "from": from, "generation": generation,
"why": c.Why, "by": c.By})
}
// Settings are the manager's own, declared with defaults (design 39 §8).
@@ -267,11 +315,12 @@ func (m *Manager) bindReporters(ctx context.Context, reports []Holdings) error {
if err != nil {
return err
}
_ = m.Store.Audit(ctx, "bound", map[string]any{"consumer": rep.Node, "licence": l.Name, "by": "its account's report"})
if err := m.PutBinding(ctx, rep.Node, BindingState{Licence: l.Name, Kind: l.Kind, Generation: b.Generation}); err != nil {
c := Cause{Why: rep.Node + " reported the account of " + l.Name + " and was bound to nothing", By: Itself}
m.moved(ctx, rep.Node, "", l.Name, b.Generation, c)
if err := m.PutBinding(ctx, rep.Node, BindingState{Licence: l.Name, Kind: l.Kind, Generation: b.Generation,
Why: c.Why, By: c.By}); err != nil {
return err
}
m.Log("bound %s to %s, the licence its account already has", rep.Node, l.Name)
}
return nil
}
@@ -351,21 +400,21 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings)
// A login moves its node (ADR 0209): the node this login came from is bound to its licence —
// switched, if it was bound to another. Then every node reporting this account and bound to nothing.
// Who asked is the person who logged in there; the mesh knows them only as that login (novox/hq issue 625).
cause := Cause{Why: "a login to " + l.Name + " on " + c.Node + " (ADR 0209)", By: Itself}
ctx = WithCause(ctx, cause)
if b, err := m.Store.Binding(ctx, c.Node); err != nil {
return "", err
} else if b == nil || b.Licence != l.Name {
if _, err := m.Store.Bind(ctx, c.Node, l.Name); err != nil {
moved, err := m.Store.Bind(ctx, c.Node, l.Name)
if err != nil {
return "", err
}
from := ""
if b != nil {
from = b.Licence
}
_ = m.Store.Audit(ctx, map[bool]string{true: "switched", false: "bound"}[b != nil],
map[string]any{"consumer": c.Node, "licence": l.Name, "from": from, "by": "a login there"})
if b != nil {
m.Log("%s moved from %s to %s: a login there", c.Node, from, l.Name)
}
m.moved(ctx, c.Node, from, l.Name, moved.Generation, cause)
}
for _, rep := range reports {
if rep.Identity == nil || rep.Identity.AccountUUID != c.Identity.AccountUUID {
@@ -374,8 +423,9 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings)
if b, err := m.Store.Binding(ctx, rep.Node); err != nil || b != nil {
continue
}
if _, err := m.Store.Bind(ctx, rep.Node, l.Name); err == nil {
_ = m.Store.Audit(ctx, "bound", map[string]any{"consumer": rep.Node, "licence": l.Name, "by": "its login"})
if b, err := m.Store.Bind(ctx, rep.Node, l.Name); err == nil {
m.moved(ctx, rep.Node, "", l.Name, b.Generation,
Cause{Why: rep.Node + " reports the account of " + l.Name + ", logged in on " + c.Node + ", and was bound to nothing", By: Itself})
}
}
if err := m.publishAdvance(ctx, l); err != nil {
@@ -399,14 +449,17 @@ func (m *Manager) keep(l *Licence, g FullGrant) {
l.RotatedAt = m.Now().UnixMilli()
}
// publishAdvance gives every consumer of a licence a new generation, and tells each in the state.
// publishAdvance gives every consumer of a licence a new generation, and tells each in the state with the
// cause ctx carries, so the node says why it fetched again (novox/hq issue 625).
func (m *Manager) publishAdvance(ctx context.Context, l Licence) error {
bindings, err := m.Store.Advance(ctx, l.Name)
if err != nil {
return err
}
c := CauseOf(ctx)
for _, b := range bindings {
if err := m.PutBinding(ctx, b.Consumer, BindingState{Licence: b.Licence, Kind: l.Kind, Generation: b.Generation}); err != nil {
if err := m.PutBinding(ctx, b.Consumer, BindingState{Licence: b.Licence, Kind: l.Kind, Generation: b.Generation,
Why: c.Why, By: c.By}); err != nil {
return err
}
}
@@ -482,7 +535,19 @@ func (m *Manager) Rotate(ctx context.Context, name string, force bool) (map[stri
if err := m.Store.SaveLicence(ctx, *l); err != nil {
return nil, err
}
_ = m.Store.Audit(ctx, "rotated", map[string]any{"licence": name, "expiresAt": l.AccessExpiresAt})
c := CauseOf(ctx)
if force {
c.Why = "a refresh asked for: " + c.Why
} else {
c = Cause{Why: "due: older than its cadence or near its expiry", By: Itself}
if by := CauseOf(ctx); by.By != Itself {
c.By = by.By
}
}
ctx = WithCause(ctx, Cause{Why: "a rotation of " + name + " (" + c.Why + ")", By: c.By})
m.record(ctx, "rotated", fmt.Sprintf("rotated %s, its token now expiring %s: %s; asked by %s", name,
time.UnixMilli(l.AccessExpiresAt).UTC().Format(time.RFC3339), c.Why, c.By),
map[string]any{"licence": name, "expiresAt": l.AccessExpiresAt, "why": c.Why, "by": c.By})
if err := m.publishAdvance(ctx, *l); err != nil {
return nil, err
}
@@ -594,8 +659,9 @@ func (m *Manager) Current(ctx context.Context, consumer, publicKey string) (map[
return out, nil
}
// Bind binds or switches a consumer: a person's act (ADR 0183), told to the consumer as a new generation.
func (m *Manager) Bind(ctx context.Context, consumer, licence, by string) (map[string]any, error) {
// Bind binds or switches a consumer: a person's act (ADR 0183), told to the consumer as a new generation, and
// recorded with the cause ctx carries — the verb and its proven caller (novox/hq issue 625).
func (m *Manager) Bind(ctx context.Context, consumer, licence string) (map[string]any, error) {
l, err := m.Store.Licence(ctx, licence)
if err != nil {
return nil, err
@@ -611,19 +677,21 @@ func (m *Manager) Bind(ctx context.Context, consumer, licence, by string) (map[s
if err != nil {
return nil, err
}
from, what := "", "bound"
from := ""
if before != nil {
from, what = before.Licence, "switched"
from = before.Licence
}
_ = m.Store.Audit(ctx, what, map[string]any{"consumer": consumer, "licence": licence, "from": from, "by": by})
if err := m.PutBinding(ctx, consumer, BindingState{Licence: licence, Kind: l.Kind, Generation: b.Generation}); err != nil {
c := CauseOf(ctx)
m.moved(ctx, consumer, from, licence, b.Generation, c)
if err := m.PutBinding(ctx, consumer, BindingState{Licence: licence, Kind: l.Kind, Generation: b.Generation,
Why: c.Why, By: c.By}); err != nil {
return nil, err
}
return map[string]any{"consumer": consumer, "licence": licence, "generation": b.Generation, "from": from}, nil
}
// Release unbinds a consumer; its node keeps its last token, which expires within hours.
func (m *Manager) Release(ctx context.Context, consumer, by string) (map[string]any, error) {
func (m *Manager) Release(ctx context.Context, consumer string) (map[string]any, error) {
was, err := m.Store.Binding(ctx, consumer)
if err != nil {
return nil, err
@@ -634,7 +702,9 @@ func (m *Manager) Release(ctx context.Context, consumer, by string) (map[string]
if err := m.DeleteBinding(ctx, consumer); err != nil {
return nil, err
}
_ = m.Store.Audit(ctx, "released", map[string]any{"consumer": consumer, "licence": was.Licence, "by": by})
c := CauseOf(ctx)
m.record(ctx, "released", fmt.Sprintf("released %s from %s: %s; asked by %s", consumer, was.Licence, c.Why, c.By),
map[string]any{"consumer": consumer, "licence": was.Licence, "generation": was.Generation, "why": c.Why, "by": c.By})
return map[string]any{"consumer": consumer, "released": true, "was": was.Licence}, nil
}
@@ -685,7 +755,10 @@ func (m *Manager) adoptKey(ctx context.Context, name, key, from string) (map[str
if err := m.Store.SaveLicence(ctx, l); err != nil {
return nil, err
}
_ = m.Store.Audit(ctx, "adopted", map[string]any{"licence": name, "kind": "api-key", "from": from})
c := CauseOf(ctx)
_ = m.Store.Audit(ctx, "adopted", map[string]any{"licence": name, "kind": "api-key", "from": from, "why": c.Why, "by": c.By})
m.Log("adopted the API key %s from %s; asked by %s", name, from, c.By)
ctx = WithCause(ctx, Cause{Why: "the API key " + name + " adopted from " + from, By: c.By})
if err := m.publishAdvance(ctx, l); err != nil {
return nil, err
}
@@ -132,8 +132,13 @@ func TestAManagerWithNoLicenceAdoptsTheNewestLoginThatRefreshesAndNeverExchanges
if len(bs) != 2 || mm.state["laptop"].Licence != licence1 || mm.state["server"].Licence != licence1 {
t.Fatalf("bindings %v, state %v", bs, mm.state)
}
if !strings.HasPrefix(strings.Join(mm.events, "|"), "licence.adopted") {
t.Fatalf("events %v", mm.events)
// The adoption is said, and so is each binding it made (novox/hq issue 625).
events := strings.Join(mm.events, "|")
for _, want := range []string{"licence.adopted ", `licence.bound {"by":"claude-licence-manager","consumer":"laptop"`,
`licence.bound {"by":"claude-licence-manager","consumer":"server"`} {
if !strings.Contains(events, want) {
t.Fatalf("events lack %s: %v", want, mm.events)
}
}
}
@@ -234,13 +239,13 @@ func TestARotationAndASwitchEachGiveANewerGeneration(t *testing.T) {
if _, err := mm.m.AdoptKey(ctx, "api", file); err != nil {
t.Fatal(err)
}
if _, err := mm.m.Bind(ctx, "laptop", "api", "test"); err != nil {
if _, err := mm.m.Bind(ctx, "laptop", "api"); err != nil {
t.Fatal(err)
}
if mm.state["laptop"].Licence != "api" || mm.state["laptop"].Generation <= g1 {
t.Fatalf("a switch to a licence rotated less often went backwards: %v", mm.state["laptop"])
}
if _, err := mm.m.Release(ctx, "laptop", "test"); err != nil {
if _, err := mm.m.Release(ctx, "laptop"); err != nil {
t.Fatal(err)
}
if _, ok := mm.state["laptop"]; ok {
@@ -454,3 +459,73 @@ func TestWhatIsNotAnAPIKeyIsRefused(t *testing.T) {
t.Fatal("nothing was kept")
}
}
// A change of what a consumer holds says what, why and who in the journal, the audit and an event (novox/hq
// issue 625): a login, a switch through the verb by a proven caller, a rotation and a release.
func TestEveryMoveSaysWhatWhyAndWho(t *testing.T) {
mm := newMesh(t)
var lines []string
mm.m.Log = func(format string, args ...any) { lines = append(lines, fmt.Sprintf(format, args...)) }
ctx := context.Background()
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
if s := mm.state["laptop"]; !strings.Contains(s.Why, "a login to "+licence1+" on laptop") || s.By != Itself {
t.Fatalf("a login's binding said %+v", s)
}
file := filepath.Join(t.TempDir(), "key")
_ = os.WriteFile(file, []byte("sk-ant-api03-key-0123456789abcdefghij\n"), 0o600)
if _, err := mm.m.AdoptKey(ctx, "api", file); err != nil {
t.Fatal(err)
}
asked := WithCause(ctx, Cause{Why: "the switch verb", By: "g14.node-tools"})
if _, err := mm.m.Bind(asked, "laptop", "api"); err != nil {
t.Fatal(err)
}
if s := mm.state["laptop"]; s.Why != "the switch verb" || s.By != "g14.node-tools" {
t.Fatalf("the node is told %+v", s)
}
said := strings.Join(lines, "\n")
if !strings.Contains(said, "switched laptop from "+licence1+" to api (generation ") ||
!strings.Contains(said, "the switch verb; asked by g14.node-tools") {
t.Fatalf("the journal said:\n%s", said)
}
var switched string
for _, e := range mm.events {
if strings.HasPrefix(e, "licence.switched ") {
switched = e
}
}
for _, want := range []string{`"by":"g14.node-tools"`, `"from":"` + licence1 + `"`, `"licence":"api"`, `"consumer":"laptop"`, `"generation":`} {
if !strings.Contains(switched, want) {
t.Fatalf("the event %q lacks %s; events %v", switched, want, mm.events)
}
}
last := mm.store.Audits[len(mm.store.Audits)-1]
if raw, _ := json.Marshal(last); !strings.Contains(string(raw), `"by":"g14.node-tools"`) || !strings.Contains(string(raw), "switched") {
t.Fatalf("the audit kept %s", raw)
}
_, _ = mm.m.Rotate(WithCause(ctx, Cause{Why: "the refresh verb", By: "novox.node-tools"}), licence1, true)
if !strings.Contains(strings.Join(mm.events, "|"), `licence.rotated {"by":"novox.node-tools"`) {
t.Fatalf("a rotation asked for said no caller: %v", mm.events)
}
if _, err := mm.m.Release(WithCause(ctx, Cause{Why: "the release verb", By: "person.operator"}), "laptop"); err != nil {
t.Fatal(err)
}
if said := strings.Join(lines, "\n"); !strings.Contains(said, "released laptop from api: the release verb; asked by person.operator") {
t.Fatalf("the journal said:\n%s", said)
}
for _, e := range mm.events {
if strings.Contains(e, "rt-a") || strings.Contains(e, "sk-ant-") || strings.Contains(e, "local-laptop") {
t.Fatalf("an event carries a secret: %s", e)
}
}
}
// A call with no cause is said as this module's own, never as nobody.
func TestNoCauseReadsAsTheModulesOwn(t *testing.T) {
if c := CauseOf(context.Background()); c.By != Itself || c.Why == "" {
t.Fatalf("no cause read as %+v", c)
}
}
+1 -1
View File
@@ -3,7 +3,7 @@ module claude-licence-manager
go 1.25.0
require (
git.novox.be/novox/mesh-sdk/go v0.1.7
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261012082113-9747f3e1b1ac
github.com/jackc/pgx/v5 v5.11.0
)
+2 -2
View File
@@ -1,5 +1,5 @@
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=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261012082113-9747f3e1b1ac h1:jnZ/n+fFMo7VQPNNXK1gjStHIJPw3SPbzPxsf/PpZqw=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261012082113-9747f3e1b1ac/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+9 -1
View File
@@ -8,7 +8,11 @@
],
"contributes": {
"postgres-database": {
"name": "claude_licences"
"name": "claude_licences",
"readable": [
"audit",
"binding"
]
}
},
"binds": {
@@ -59,6 +63,10 @@
"emits": [
"licence.adopted",
"licence.refused",
"licence.bound",
"licence.switched",
"licence.released",
"licence.rotated",
"licence.failing",
"usage.read"
],
+8 -1
View File
@@ -9,7 +9,14 @@ each machine the operator sits at (the `node-notifier` seat), and says which but
acknowledgements (Silence for a week, Details) and says in the words where an approval can be given.
- **An answer is taken only on a machine its message was shown on**, and once; a refused click is said where it
was made. A message with buttons stays until it is dismissed.
- **A notifier that started again** (`dunst.started`) is shown again every open message with buttons it showed.
- **What it remembers is never shown again** (issue 384: on 2026-10-10 a notifier that started again was
shown nine old popups from the desk's memory, resolved and silenced ones among them). Its memory — which
notification each handle has on each machine — serves only to replace or edit a message in place. When the
notifier's bundle starts again on a machine (`dunst.started`), its notifications stay on screen and the
answers they waited for are lost: the desk keeps their ids and says in its standing when that happened
(`answers-lost-at`); the router, which alone knows what is still open and unanswered, says that again under
its handle, replacing the notification in place so its buttons work again. Nothing resolved, silenced or
cancelled comes back.
- A message no machine took is said to the router as failed; the router tries it again.
- **One notification per message** (issue 373): an `edit`, and a `send` or `show` under a handle already shown (a
reminder, an escalation), replace that message's notification on each machine by its id (the notifier's
+85 -37
View File
@@ -9,8 +9,12 @@ package main
// acknowledgements, and says where an approval can be given.
// - **An answer is bound to the machines it was shown on**: a click on another machine is refused here, and
// said there. A message with buttons stays until it is dismissed.
// - **A notifier that started again lost the answers it waited for**: every open message with buttons shown
// on that machine is shown there again.
// - **What it remembers is never shown again** (issue 384). Its memory — which notification a handle has on
// each machine — serves only to replace or edit a message in place. When the notifier's bundle started again
// on a machine (`dunst.started`), the notifications stay on screen but the answers they waited for are lost:
// the desk says in its standing when that happened (`answers-lost-at`), and the router, which alone knows
// what is still open and unanswered, says that again under its handle — replacing the notification in place,
// so its buttons work again. Nothing resolved, silenced or cancelled comes back.
// - **Standing**: it can send when machines are named in its settings; which of them answered last is in its
// status. A message no machine took is said to the router as failed, and tried again by it.
@@ -19,7 +23,6 @@ import (
"errors"
"fmt"
"html"
"sort"
"strconv"
"strings"
"sync"
@@ -47,6 +50,15 @@ type pauseSeen struct {
// StandingEvery is how often the channel says whether it can send, at least.
const StandingEvery = 5 * time.Minute
// Standing is what the desk says of itself: the bench's word, and when a named machine's notifier last lost
// the answers its notifications waited for (its bundle started again). The router then says again what is
// still open and unanswered (issue 384). It belongs in asks.Standing once the SDK carries it; until then the
// field travels as JSON the router reads under the same name. Zero (as "0001-01-01T00:00:00Z") until then.
type Standing struct {
asks.Standing
AnswersLostAt time.Time `json:"answers-lost-at"`
}
// Shown is one of the router's messages as the desk shows it: the message, and its notification on each
// machine.
type Shown struct {
@@ -75,10 +87,12 @@ type Desk struct {
mu sync.Mutex
last map[string]string // machine -> its last error, "" after a success
clickedOn map[string]string // a choice's id -> the machine it was made on, for the router's reply
said asks.Standing
said Standing
saidAt time.Time
acted map[string]bool
pause map[string]pauseSeen
// lost is when each machine's notifier last started again: the answers its notifications waited for are lost.
lost map[string]time.Time
}
func (d *Desk) init() {
@@ -90,6 +104,9 @@ func (d *Desk) init() {
if d.pause == nil {
d.pause = map[string]pauseSeen{}
}
if d.lost == nil {
d.lost = map[string]time.Time{}
}
if d.Now == nil {
d.Now = time.Now
}
@@ -251,13 +268,7 @@ func (d *Desk) show(machine string, m asks.Message, replace int) (int, error) {
// machine is the event's own node, said by the runtime; the same event is acted on once.
func (d *Desk) Chosen(event, machine, ticket string) {
d.init()
d.mu.Lock()
done := d.acted[event]
if event != "" {
d.acted[event] = true
}
d.mu.Unlock()
if done {
if d.again(event) {
return
}
handle, shown := d.find(ticket)
@@ -309,34 +320,48 @@ func (d *Desk) find(ticket string) (string, *Shown) {
return "", nil
}
// Started shows again, on a machine whose notifier started again, every message with buttons shown there.
func (d *Desk) Started(machine string) int {
// again says whether an event was acted on before (a redelivery), and marks it acted on.
func (d *Desk) again(event string) bool {
if event == "" {
return false
}
d.mu.Lock()
defer d.mu.Unlock()
done := d.acted[event]
if len(d.acted) > 2000 {
d.acted = map[string]bool{}
}
d.acted[event] = true
return done
}
// Started takes a notifier's word that its bundle started again on a machine (dunst's event `started`): the
// notifications stay on screen, and the answers they waited for are lost. Nothing is shown from memory (issue
// 384: nine old popups, resolved and silenced ones among them, shown again from it). The notification ids
// are kept, so what the router says again replaces each notification in place, and the standing says when
// the answers were lost; the router alone knows what is still open and unanswered. It answers how many
// messages with buttons that machine shows.
func (d *Desk) Started(event, machine string) int {
d.init()
handles, err := d.Store.Handles()
if err != nil {
if d.again(event) {
return 0
}
sort.Strings(handles)
d.mu.Lock()
d.lost[machine] = d.Now()
d.mu.Unlock()
n := 0
for _, h := range handles {
s, err := d.Store.Get(h)
if err != nil || s == nil || len(s.Message.Buttons) == 0 {
continue
if handles, err := d.Store.Handles(); err == nil {
for _, h := range handles {
if s, err := d.Store.Get(h); err == nil && s != nil && len(s.Message.Buttons) > 0 {
if _, here := s.On[machine]; here {
n++
}
}
}
if _, here := s.On[machine]; !here {
continue
}
id, err := d.show(machine, s.Message, 0)
if err != nil {
continue
}
s.On[machine] = id
_ = d.Store.Put(h, *s)
n++
}
if n > 0 {
d.Logf("[desk-channel] the notifier on %s started again: %d message(s) with answers shown there again", machine, n)
}
d.Logf("[desk-channel] the notifier on %s started again: the answers of %d message(s) shown there are lost; none shown "+
"from memory, the router says again what is still open", machine, n)
d.SayStanding()
return n
}
@@ -378,9 +403,9 @@ func (d *Desk) seeing() ([]string, string) {
// Standing is whether the desk can send: a named machine whose notifier said lately that it is not paused
// (the review of 2026-10-09). The router shows no link's code while the desk is not ready.
func (d *Desk) Standing() asks.Standing {
func (d *Desk) Standing() Standing {
d.init()
s := asks.Standing{At: d.Now()}
s := Standing{Standing: asks.Standing{At: d.Now()}, AnswersLostAt: d.answersLostAt()}
if _, why := d.seeing(); why != "" {
s.Why = why
return s
@@ -389,11 +414,27 @@ func (d *Desk) Standing() asks.Standing {
return s
}
// answersLostAt is the latest start of a named machine's notifier: zero while none started again since this
// desk started.
func (d *Desk) answersLostAt() time.Time {
machines := d.Machines() // read outside the lock: it reads the settings file
var at time.Time
d.mu.Lock()
defer d.mu.Unlock()
for _, m := range machines {
if t := d.lost[m]; t.After(at) {
at = t
}
}
return at
}
// SayStanding says the standing when it changed, or every StandingEvery.
func (d *Desk) SayStanding() {
s := d.Standing()
d.mu.Lock()
same := s.Ready == d.said.Ready && s.Why == d.said.Why && s.At.Sub(d.saidAt) < StandingEvery
same := s.Ready == d.said.Ready && s.Why == d.said.Why && s.AnswersLostAt.Equal(d.said.AnswersLostAt) &&
s.At.Sub(d.saidAt) < StandingEvery
d.mu.Unlock()
if same {
return
@@ -424,12 +465,19 @@ func (d *Desk) Status() map[string]any {
"it shows the router's messages on the notifier of each machine named in desktop-machines, and says which button was chosen there; the router judges every answer (novox/hq ADR 0259)",
"it proves nothing about who clicked: any process of the operator's account can choose an action, so it answers acknowledgements only",
"an answer is taken only on a machine its message was shown on; a message with buttons stays until dismissed",
"a notifier that started again is shown again every open message with buttons it showed",
"what it remembers is never shown again: when a notifier started again, the answers its notifications waited for are lost; the desk says when in its standing (answers-lost-at), and the router says again what is still open and unanswered, replacing those notifications in place — nothing resolved, silenced or cancelled comes back (issue 384)",
"it can send while a named machine's notifier said lately that it is not paused; a link's code is shown only on such a machine, kept nowhere, and sent secret so the notifier keeps it out of its history",
}}
if s.Why != "" {
out["why_not"] = s.Why
}
if !s.AnswersLostAt.IsZero() {
started := map[string]string{}
for m, at := range d.lost {
started[m] = at.Format(time.RFC3339)
}
out["notifier_started"] = started
}
return out
}
@@ -103,20 +103,74 @@ func TestAClickOnAMachineTheMessageWasShownOnIsSaidToTheRouter(t *testing.T) {
}
}
func TestAnEditReplacesTheNotificationAndANotifierThatStartedAgainIsShownItsQuestions(t *testing.T) {
d, shown, _, store := newDesk(t, "laptop")
func TestAnEditReplacesTheNotification(t *testing.T) {
d, shown, _, _ := newDesk(t, "laptop")
take(t, d, asks.Show, asks.Message{Handle: "m1", Title: "q", Buttons: []asks.Button{{Label: "Silence for a week", Ticket: "t1"}}})
take(t, d, asks.Edit, asks.Message{Handle: "m1", Title: "Silence for a week: chosen"})
if last := (*shown)[len(*shown)-1]; last.args["replace_id"] != 1 || last.args["actions"] != nil {
t.Errorf("the edit: %+v", last.args)
}
take(t, d, asks.Show, asks.Message{Handle: "m2", Title: "q2", Buttons: []asks.Button{{Label: "Silence for a week", Ticket: "t2"}}})
}
// Issue 384: a notifier that started again (nine old popups, resolved and silenced ones among them, shown from
// the desk's own memory on 2026-10-10). Its bundle started again, so the notifications are still on screen and
// the answers they waited for are lost. With three messages remembered on the machine — one open with answers,
// one silenced, one resolved — nothing is shown, the notification ids are kept, and the standing says when the
// answers were lost. The router then says the open one again under its handle: it replaces its notification in
// place (one popup, its buttons alive again), and a click on it is said to the router. The same start event
// is acted on once.
func TestANotifierThatStartedAgainIsShownNothingFromMemory(t *testing.T) {
d, shown, said, store := newDesk(t, "laptop")
d.Paused("laptop", false)
d.SayStanding()
take(t, d, asks.Show, asks.Message{Handle: "open", Title: "2 failed services on shanks",
Buttons: []asks.Button{{Label: "Silence for a week", Ticket: "t-open"}}})
take(t, d, asks.Show, asks.Message{Handle: "silenced", Title: "Agent-home shrank",
Buttons: []asks.Button{{Label: "Silence for a week", Ticket: "t-silenced"}}})
take(t, d, asks.Edit, asks.Message{Handle: "silenced", Title: "Silenced until Saturday: Agent-home shrank", Silent: true})
take(t, d, asks.Show, asks.Message{Handle: "resolved", Title: "Questions for you not delivered",
Buttons: []asks.Button{{Label: "Details", Ticket: "t-resolved"}}})
take(t, d, asks.Edit, asks.Message{Handle: "resolved", Title: "Resolved: questions for you are delivered", Silent: true})
openID := store["open"].On["laptop"]
before := len(*shown)
if n := d.Started("laptop"); n != 1 || len(*shown) != before+1 {
t.Errorf("shown again: %d", n)
standings := len(said[asks.IntakeSubject(asks.StandingWhat, Kind)])
later := d.Now().Add(time.Hour)
d.Now = func() time.Time { return later }
d.Paused("laptop", false)
if n := d.Started("e-start", "laptop"); n != 1 {
t.Fatalf("messages with buttons on the machine: %d, want 1 (the open one)", n)
}
if store["m2"].On["laptop"] != len(*shown) {
t.Errorf("the new notification is not kept: %+v", store["m2"])
if len(*shown) != before {
t.Fatalf("shown from memory after the notifier started again: %+v", (*shown)[before:])
}
if store["open"].On["laptop"] != openID {
t.Fatalf("the open message's notification was forgotten: %+v", store["open"].On)
}
got := said[asks.IntakeSubject(asks.StandingWhat, Kind)]
var s Standing
if len(got) != standings+1 || json.Unmarshal(got[len(got)-1], &s) != nil || !s.Ready || !s.AnswersLostAt.Equal(later) {
t.Fatalf("the standing after the start: %d said, last %s", len(got)-standings, got[len(got)-1])
}
// The router says the open one again under its handle: in place of its notification, with its answers.
take(t, d, asks.Show, asks.Message{Handle: "open", Title: "2 failed services on shanks",
Buttons: []asks.Button{{Label: "Silence for a week", Ticket: "t-open"}}})
last := (*shown)[len(*shown)-1]
if len(*shown) != before+1 || last.args["replace_id"] != openID || last.args["actions"] == nil {
t.Fatalf("the open message said again: %+v", last.args)
}
d.Chosen("e2", "laptop", "t-open")
if len(said[asks.IntakeSubject(asks.Chosen, Kind)]) != 1 {
t.Error("a click on the message said again was not said to the router")
}
// The same start event again (a redelivery) does nothing more.
standings = len(said[asks.IntakeSubject(asks.StandingWhat, Kind)])
if n := d.Started("e-start", "laptop"); n != 0 || len(said[asks.IntakeSubject(asks.StandingWhat, Kind)]) != standings {
t.Errorf("a redelivered start was acted on again: %d", n)
}
// A desk none of whose notifiers started again says a zero time, which the router reads as none.
fresh, _, _, _ := newDesk(t, "laptop")
if !fresh.Standing().AnswersLostAt.IsZero() {
t.Error("a desk whose notifier never started again says a time")
}
}
@@ -118,7 +118,7 @@ func run(d *Desk) {
}
switch e.Key {
case StartedEvent:
d.Started(machine)
d.Started(e.Headers["x-event-id"], machine)
case PausedEvent:
// Only from the event's own node: a word about another machine is no word at all.
if body.Paused != nil && e.Node != "" {
+26
View File
@@ -0,0 +1,26 @@
# dev-tools
The tools a machine needs to develop the mesh's own code (novox/hq issue 626). Every node is a development
machine: an agent working on a change builds it and runs its tests there, with these tools, before it opens a
pull request. The lab is the QA step after that: it tests the work in a sandbox, through its seat at the mesh
scope, and is not where development happens.
Assign it to every node.
## Owns
| what | package | class (ADR 0182) |
|---|---|---|
| the Go toolchain, for `go build` and `go test` in every Go module | `go` | package |
| a C compiler, for `go test -race` and cgo | `gcc` | package |
| make | `make` | package |
| Node.js and npm, for the TypeScript modules' `npm ci` and `npm test` | `nodejs`, `npm` | package |
| Python, for the scripts some checks run | `python` | package |
| git and jq | `git`, `jq` | package |
All are in the distribution's official repositories, so the node-engine's package manager installs them on any
node. The versions are the distribution's: the toolchain images that run `mesh/repo-check` stay the judge of a
pull request, and a local run is the developer's early answer, not the verdict.
Nothing else is placed: no file, no service, no environment. A module's own dependencies come from its lock
file (`go.mod`, `package-lock.json`), as in the toolchain images.
+49
View File
@@ -0,0 +1,49 @@
{
"module": "dev-tools",
"version": "1",
"capabilities": [
"package-manager"
],
"resources": [
{
"id": "go",
"type": "package",
"package": "go"
},
{
"id": "c-compiler",
"type": "package",
"package": "gcc"
},
{
"id": "make",
"type": "package",
"package": "make"
},
{
"id": "nodejs",
"type": "package",
"package": "nodejs"
},
{
"id": "npm",
"type": "package",
"package": "npm"
},
{
"id": "python",
"type": "package",
"package": "python"
},
{
"id": "git",
"type": "package",
"package": "git"
},
{
"id": "jq",
"type": "package",
"package": "jq"
}
]
}
-62
View File
@@ -1,62 +0,0 @@
// dnsmasq's own code, living in the module (novox/hq ADR 0039). dnsmasq here answers the mesh's
// generated wildcard names (<service>.<node>.<suffix>) itself and forwards everything else to fixed
// upstreams — the predecessor's arrangement, so the machine and its containers ask it for the world.
// Its code reads what it was told to answer, and can resolve through itself to prove that it does.
import { readFile } from "node:fs/promises";
import { Resolver } from "node:dns/promises";
export interface AnsweredName {
/** A machine's internal name, e.g. "anchor.internal" — it and everything under it resolve here. */
name: string;
address: string;
}
export class DnsmasqClient {
constructor(
private readonly resolverPath: string,
private readonly address: string,
) {}
/**
* Build from the environment. Both values are node-local facts with mesh-chosen defaults — the
* wildcard file the module is sent, and the loopback address its config listens on (127.0.0.1,
* where the predecessor's resolver answered and every machine's resolv.conf already points) — so
* there is nothing to be unconfigured about; it never throws.
*/
static fromEnv(env: NodeJS.ProcessEnv = process.env): DnsmasqClient {
return new DnsmasqClient(
env.MESH_DNSMASQ_RESOLVER_PATH ?? "/etc/mesh-resolver/nodes.conf",
env.MESH_DNSMASQ_ADDRESS ?? "127.0.0.1",
);
}
/** The names this resolver answers, read from the mesh-generated wildcard file. */
async answeredNames(): Promise<AnsweredName[]> {
let text: string;
try {
text = await readFile(this.resolverPath, "utf8");
} catch {
return []; // not yet on the network, or the file has not been written — no names, not an error
}
const names: AnsweredName[] = [];
for (const line of text.split("\n")) {
// dnsmasq wildcard syntax the mesh writes: address=/<node>.<suffix>/<address>. The file also
// carries local=/<suffix>/, which names no machine and is not matched here.
const match = line.match(/^address=\/([^/]+)\/(.+)$/);
if (match) names.push({ name: match[1], address: match[2] });
}
return names;
}
/** Resolve a name through this node's own resolver — the check that wildcard-resolution answers. */
async resolve(name: string): Promise<string[]> {
const resolver = new Resolver();
resolver.setServers([this.address]);
try {
return await resolver.resolve4(name);
} catch {
return await resolver.resolve6(name);
}
}
}
+200
View File
@@ -0,0 +1,200 @@
package main
import (
"context"
"encoding/binary"
"errors"
"fmt"
"math/rand/v2"
"net"
"strings"
"syscall"
"time"
)
// A real question to the resolver, as any program on the machine asks it: one name, one record type, over
// UDP or TCP, to one address. Written out here rather than taken from a library: the module needs the
// header's answer code and the A records, nothing else, and Go's own resolver would read /etc/hosts and
// /etc/resolv.conf instead of asking the one address it is told (novox/hq issue 348).
// Answer is what one question got.
type Answer struct {
// Server is where it was asked, and Over the transport.
Server string `json:"server"`
Over string `json:"over"`
Name string `json:"name"`
// Answered says a reply came; Code is its answer code (NOERROR, NXDOMAIN, …) and Addresses its A
// records.
Answered bool `json:"answered"`
Code string `json:"code,omitempty"`
Addresses []string `json:"addresses,omitempty"`
// Failed is why no reply came, in words: refused (nothing listens there), no answer within, or what
// the system said.
Failed string `json:"failed,omitempty"`
// Took is how long it took, in milliseconds.
Took int64 `json:"took_ms"`
}
// rcodes are the answer codes by number.
var rcodes = map[int]string{0: "NOERROR", 1: "FORMERR", 2: "SERVFAIL", 3: "NXDOMAIN", 4: "NOTIMP", 5: "REFUSED"}
// query is one question for name's A record, with id.
func query(name string, id uint16) ([]byte, error) {
name = strings.TrimSuffix(name, ".")
if name == "" {
return nil, errors.New("no name to ask")
}
msg := make([]byte, 12, 64)
binary.BigEndian.PutUint16(msg[0:], id)
binary.BigEndian.PutUint16(msg[2:], 0x0100) // a standard query, recursion desired
binary.BigEndian.PutUint16(msg[4:], 1) // one question
for _, label := range strings.Split(name, ".") {
if label == "" || len(label) > 63 {
return nil, fmt.Errorf("%q is not a name that can be asked", name)
}
msg = append(msg, byte(len(label)))
msg = append(msg, label...)
}
msg = append(msg, 0, 0, 1, 0, 1) // root; type A; class IN
return msg, nil
}
// readAnswer reads a reply to the question with id: its code and A records.
func readAnswer(msg []byte, id uint16) (string, []string, error) {
if len(msg) < 12 {
return "", nil, errors.New("a reply too short to be one")
}
if binary.BigEndian.Uint16(msg[0:]) != id {
return "", nil, errors.New("a reply to another question")
}
flags := binary.BigEndian.Uint16(msg[2:])
code, ok := rcodes[int(flags&0xF)]
if !ok {
code = fmt.Sprintf("RCODE%d", flags&0xF)
}
qd, an := int(binary.BigEndian.Uint16(msg[4:])), int(binary.BigEndian.Uint16(msg[6:]))
at := 12
for i := 0; i < qd; i++ {
var err error
if at, err = skipName(msg, at); err != nil {
return code, nil, err
}
at += 4
}
var addresses []string
for i := 0; i < an; i++ {
var err error
if at, err = skipName(msg, at); err != nil {
return code, addresses, err
}
if at+10 > len(msg) {
return code, addresses, errors.New("a reply cut short")
}
kind, length := binary.BigEndian.Uint16(msg[at:]), int(binary.BigEndian.Uint16(msg[at+8:]))
at += 10
if at+length > len(msg) {
return code, addresses, errors.New("a reply cut short")
}
if kind == 1 && length == 4 {
addresses = append(addresses, net.IP(msg[at:at+4]).String())
}
at += length
}
return code, addresses, nil
}
// skipName steps over a name in a message, a pointer ending it.
func skipName(msg []byte, at int) (int, error) {
for at < len(msg) {
n := int(msg[at])
switch {
case n == 0:
return at + 1, nil
case n&0xC0 == 0xC0:
return at + 2, nil
}
at += n + 1
}
return at, errors.New("a name runs past the reply")
}
// Ask asks server (an address, port 53 unless given) for name's A record over "udp" or "tcp", within
// timeout.
func Ask(ctx context.Context, server, over, name string, timeout time.Duration) Answer {
if _, _, err := net.SplitHostPort(server); err != nil {
server = net.JoinHostPort(server, "53")
}
a := Answer{Server: server, Over: over, Name: name}
started := time.Now()
defer func() { a.Took = time.Since(started).Milliseconds() }()
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
id := uint16(rand.UintN(1 << 16))
q, err := query(name, id)
if err != nil {
a.Failed = err.Error()
return a
}
conn, err := (&net.Dialer{}).DialContext(ctx, over, server)
if err != nil {
a.Failed = failedWords(err, timeout)
a.Took = time.Since(started).Milliseconds()
return a
}
defer conn.Close()
if deadline, ok := ctx.Deadline(); ok {
_ = conn.SetDeadline(deadline)
}
var reply []byte
if over == "tcp" {
framed := binary.BigEndian.AppendUint16(nil, uint16(len(q)))
if _, err = conn.Write(append(framed, q...)); err == nil {
head := make([]byte, 2)
if _, err = readFull(conn, head); err == nil {
reply = make([]byte, binary.BigEndian.Uint16(head))
_, err = readFull(conn, reply)
}
}
} else if _, err = conn.Write(q); err == nil {
buf := make([]byte, 1500)
var n int
n, err = conn.Read(buf)
reply = buf[:n]
}
if err != nil {
a.Failed = failedWords(err, timeout)
a.Took = time.Since(started).Milliseconds()
return a
}
a.Answered = true
a.Code, a.Addresses, err = readAnswer(reply, id)
if err != nil {
a.Failed = err.Error()
}
a.Took = time.Since(started).Milliseconds()
return a
}
func readFull(conn net.Conn, buf []byte) (int, error) {
read := 0
for read < len(buf) {
n, err := conn.Read(buf[read:])
read += n
if err != nil {
return read, err
}
}
return read, nil
}
// failedWords is why no reply came: refused when nothing listens, no answer when nothing came in time.
func failedWords(err error, timeout time.Duration) string {
var timedOut interface{ Timeout() bool }
switch {
case errors.Is(err, syscall.ECONNREFUSED):
return "refused: nothing listens there"
case errors.Is(err, context.DeadlineExceeded) || (errors.As(err, &timedOut) && timedOut.Timeout()):
return fmt.Sprintf("no answer within %s", timeout)
}
return err.Error()
}
@@ -0,0 +1,559 @@
package main
import (
"context"
"encoding/binary"
"errors"
"fmt"
"net"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"testing"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
)
// A stand-in resolver: answers name's A record with address, on one address, over UDP and TCP, until
// stopped — as dnsmasq answers the machine's own name from the roster.
type standIn struct {
udp net.PacketConn
tcp net.Listener
wg sync.WaitGroup
}
func reply(q []byte, address string) []byte {
// The question back, with the answer: header flags QR+RD+RA, one answer, a pointer to the name.
out := append([]byte{}, q...)
binary.BigEndian.PutUint16(out[2:], 0x8180)
binary.BigEndian.PutUint16(out[6:], 1)
ip := net.ParseIP(address).To4()
out = append(out, 0xC0, 12, 0, 1, 0, 1, 0, 0, 0, 60, 0, 4)
return append(out, ip...)
}
func startStandIn(t *testing.T, at, port, address string) *standIn {
t.Helper()
s := &standIn{}
var err error
if s.udp, err = net.ListenPacket("udp", net.JoinHostPort(at, port)); err != nil {
t.Fatal(err)
}
if s.tcp, err = net.Listen("tcp", net.JoinHostPort(at, port)); err != nil {
t.Fatal(err)
}
s.wg.Add(2)
go func() {
defer s.wg.Done()
buf := make([]byte, 1500)
for {
n, from, err := s.udp.ReadFrom(buf)
if err != nil {
return
}
_, _ = s.udp.WriteTo(reply(buf[:n], address), from)
}
}()
go func() {
defer s.wg.Done()
for {
c, err := s.tcp.Accept()
if err != nil {
return
}
head := make([]byte, 2)
if _, err := readFull(c, head); err == nil {
q := make([]byte, binary.BigEndian.Uint16(head))
if _, err := readFull(c, q); err == nil {
r := reply(q, address)
_, _ = c.Write(append(binary.BigEndian.AppendUint16(nil, uint16(len(r))), r...))
}
}
_ = c.Close()
}
}()
t.Cleanup(s.stop)
return s
}
func (s *standIn) stop() {
_ = s.udp.Close()
_ = s.tcp.Close()
s.wg.Wait()
}
// freePort is a port free on both loopback addresses the tests use.
func freePort(t *testing.T) string {
t.Helper()
for i := 0; i < 20; i++ {
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
port := strconv.Itoa(l.Addr().(*net.TCPAddr).Port)
_ = l.Close()
ok := true
for _, at := range []string{"127.0.0.1", "127.0.0.2"} {
for _, over := range []string{"tcp", "udp"} {
if over == "tcp" {
if c, err := net.Listen(over, net.JoinHostPort(at, port)); err == nil {
_ = c.Close()
} else {
ok = false
}
} else if c, err := net.ListenPacket(over, net.JoinHostPort(at, port)); err == nil {
_ = c.Close()
} else {
ok = false
}
}
}
if ok {
return port
}
}
t.Fatal("no port free on both loopback addresses")
return ""
}
// aResolverMachine is a machine whose resolver listens on 127.0.0.2 (standing for its private address)
// and 127.0.0.1, as the module's configuration says, and whose roster names it at 127.0.0.2.
func aResolverMachine(t *testing.T, port string, now func() time.Time) *Machine {
t.Helper()
root := t.TempDir()
write := func(path, text string) {
p := filepath.Join(root, path)
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte(text), 0o644); err != nil {
t.Fatal(err)
}
}
write(ConfigFile, "bind-interfaces\nlisten-address=127.0.0.2\nlisten-address=127.0.0.1\nno-resolv\n")
write(NodesFile, "local=/internal/\naddress=/novox.internal/127.0.0.2\nhost-record=novox.internal,127.0.0.2\n"+
"address=/ace.internal/10.77.0.2\nhost-record=ace.internal,10.77.0.2\n")
if err := os.MkdirAll(filepath.Join(root, filepath.Dir(StateFile)), 0o755); err != nil {
t.Fatal(err)
}
return &Machine{Root: root, Ask: Ask, Now: now, Port: port}
}
func TestTheConfigurationAndTheRosterSayWhereAndWhatToAsk(t *testing.T) {
listen := ListenAddresses("bind-dynamic\nlisten-address=10.77.0.1\n# listen-address=9.9.9.9\nlisten-address=127.0.0.1\n")
if strings.Join(listen, " ") != "10.77.0.1 127.0.0.1" {
t.Fatalf("listen addresses %v", listen)
}
names := AnsweredNames("local=/internal/\naddress=/ace.internal/10.77.0.2\nhost-record=ace.internal,10.77.0.2\n" +
"host-record=novox.internal,10.77.0.1\n")
q, ok := Question(listen, names)
if !ok || q.Name != "novox.internal" || q.Address != "10.77.0.1" {
t.Fatalf("asks %+v", q)
}
if q, _ := Question([]string{"127.0.0.1"}, names); q.Name != "ace.internal" {
t.Fatalf("with no own name in the roster, asks %+v", q)
}
}
// **The outage of 2026-10-09** (novox/hq issue 348): the resolver kept running and answering on loopback,
// and refused every question on its private address. Its unit said healthy. The look does not.
func TestALookFindsAResolverThatStoppedAnsweringOnOneAddress(t *testing.T) {
port := freePort(t)
m := aResolverMachine(t, port, time.Now)
private := startStandIn(t, "127.0.0.2", port, "127.0.0.2")
startStandIn(t, "127.0.0.1", port, "127.0.0.2")
if l := m.Look(t.Context()); !l.Healthy || len(l.Answers) != 4 || l.Asked.Name != "novox.internal" {
t.Fatalf("a resolver answering everywhere: %+v", l)
}
private.stop()
l := m.Look(t.Context())
if l.Healthy || len(l.Wrong) != 2 {
t.Fatalf("a resolver that stopped answering on its private address: %+v", l)
}
if why := l.Why(); !strings.Contains(why, "127.0.0.2:"+port+" over udp did not answer novox.internal: refused") ||
!strings.Contains(why, "over tcp") || strings.Contains(why, "127.0.0.1:") {
t.Fatalf("said as %q", why)
}
}
func TestAWrongAnswerIsNotAnAnswer(t *testing.T) {
q := Named{Name: "novox.internal", Address: "10.77.0.1"}
for _, c := range []struct {
a Answer
want string
}{
{Answer{Server: "s", Over: "udp", Answered: true, Code: "NOERROR", Addresses: []string{"10.77.0.1"}}, ""},
{Answer{Server: "s", Over: "udp", Answered: true, Code: "NXDOMAIN"}, "with NXDOMAIN"},
{Answer{Server: "s", Over: "udp", Answered: true, Code: "NOERROR", Addresses: []string{"1.2.3.4"}}, "not 10.77.0.1"},
{Answer{Server: "s", Over: "udp", Failed: "no answer within 2s"}, "did not answer"},
} {
if got := wrongWords(c.a, q); (c.want == "") != (got == "") || !strings.Contains(got, c.want) {
t.Errorf("%+v said %q, want %q", c.a, got, c.want)
}
}
}
func failing(at time.Time) Look {
return Look{At: at, Answers: []Answer{{Server: "10.77.0.1:53", Over: "udp", Failed: "refused: nothing listens there"}},
Wrong: []string{"10.77.0.1:53 over udp did not answer novox.internal: refused: nothing listens there"}}
}
func healthy(at time.Time) Look { return Look{At: at, Healthy: true} }
// The watch restarts after Confirm failing looks, not one; says whether the restart helped from the looks
// after it; waits RestartGap before another; and stops at RestartsPerHour, saying why.
func TestTheWatchRestartsOnASecondFailingLookWithinItsBudget(t *testing.T) {
t0 := time.Date(2026, 10, 9, 10, 57, 57, 0, time.UTC)
var s State
var why string
if s, why = Step(s, failing(t0), "active"); why != "" || s.Failing != 1 {
t.Fatalf("one failing look restarted: %q", why)
}
if s, why = Step(s, failing(t0.Add(LookEvery)), "active"); why == "" || !strings.Contains(why, "2 look(s) in a row since 10:57:57 UTC") {
t.Fatalf("a second failing look did not restart: %+v %q", s, why)
}
s.Restarts = append(s.Restarts, Restart{At: t0.Add(LookEvery), Why: why})
// It helped.
s, why = Step(s, healthy(t0.Add(2*LookEvery)), "active")
if why != "" || s.Failing != 0 || s.Restarts[0].Helped == nil || !*s.Restarts[0].Helped {
t.Fatalf("a healthy look after a restart: %+v %q", s, why)
}
// Failing again soon after: held for the gap, then restarted; and so on until the budget is spent.
at := t0.Add(3 * LookEvery)
restarts := 1
for at.Add(LookEvery).Sub(t0) < 50*time.Minute {
at = at.Add(LookEvery)
s, why = Step(s, failing(at), "active")
if why == "" {
continue
}
last := s.Restarts[len(s.Restarts)-1].At
if at.Sub(last) < RestartGap {
t.Fatalf("restarted %s after the last", at.Sub(last))
}
s.Restarts = append(s.Restarts, Restart{At: at, Why: why})
restarts++
}
if restarts != RestartsPerHour {
t.Fatalf("%d restarts within the hour, want %d", restarts, RestartsPerHour)
}
if !strings.Contains(s.Held, "the most the watch makes") {
t.Fatalf("the spent budget is not said: %+v", s)
}
for _, r := range s.Restarts[1:] {
if r.Helped == nil || *r.Helped {
t.Fatalf("a restart followed only by failing looks is said as %v", r.Helped)
}
}
// A look that could not ask — no configuration — restarts nothing.
var none State
broken := Look{At: t0, Problems: []string{"its configuration cannot be read"}}
none, _ = Step(none, broken, "active")
if _, why := Step(none, broken, "active"); why != "" {
t.Fatalf("a missing configuration restarted the resolver: %q", why)
}
}
// **The acceptance, end to end on a stand-in**: the resolver stops answering on its private address; the
// watch notices on its second look, restarts the unit (which brings the listener back), says the restart
// helped, and the health tool answers healthy — the operator hears nothing. And when the restart does not
// help, the health tool says so, with the restart, so the node-engine states it and the mesh tells the
// operator.
func TestTheWatchRestartsAResolverThatStoppedAnsweringAndHealthSaysWhetherItHelped(t *testing.T) {
port := freePort(t)
clock := time.Date(2026, 10, 9, 10, 57, 57, 0, time.UTC)
var mu sync.Mutex
now := func() time.Time { mu.Lock(); defer mu.Unlock(); return clock }
pass := func(d time.Duration) { mu.Lock(); clock = clock.Add(d); mu.Unlock() }
m := aResolverMachine(t, port, now)
startStandIn(t, "127.0.0.1", port, "127.0.0.2")
private := startStandIn(t, "127.0.0.2", port, "127.0.0.2")
private.stop()
fixes := true
var asked []string
run := func(_ context.Context, name string, args ...string) (string, error) {
asked = append(asked, name+" "+strings.Join(args, " "))
if fixes {
private = startStandIn(t, "127.0.0.2", port, "127.0.0.2")
}
return "", nil
}
var s State
look := func() {
t.Helper()
next, why := Step(s, m.Look(t.Context()), "active")
if why != "" {
r := Restart{At: now().UTC(), Why: why}
if _, err := run(t.Context(), "systemctl", "restart", Unit); err != nil {
r.Error = err.Error()
}
next.Restarts = append(next.Restarts, r)
}
s = next
if err := m.WriteState(s); err != nil {
t.Fatal(err)
}
pass(LookEvery)
}
look()
if h := m.Health(t.Context()); h.Healthy || !strings.Contains(h.Why, "the watch has not restarted it") {
t.Fatalf("after one failing look: %+v", h.Why)
}
look()
if len(asked) != 1 || asked[0] != "systemctl restart dnsmasq.service" {
t.Fatalf("restarted with %v", asked)
}
look()
if r := s.Restarts[0]; r.Helped == nil || !*r.Helped {
t.Fatalf("the restart that brought it back is said as %+v", r)
}
if h := m.Health(t.Context()); !h.Healthy || h.Watch == nil || len(h.Watch.Restarts) != 1 {
t.Fatalf("after the restart helped: %+v", h)
}
// It stops again, and this time a restart does not help.
private.stop()
fixes = false
pass(RestartGap)
for i := 0; i < 2+int(HelpedWithin/LookEvery)+1; i++ {
look()
}
h := m.Health(t.Context())
if h.Healthy || !strings.Contains(h.Why, "did not answer novox.internal: refused") ||
!strings.Contains(h.Why, "which did not help") {
t.Fatalf("a restart that did not help: %q", h.Why)
}
// And a watch that stopped looking is said, whatever the resolver does.
pass(Stale + time.Minute)
if h := m.Health(t.Context()); h.Healthy || !strings.Contains(h.Why, "the watch last looked") {
t.Fatalf("a stale watch: %q", h.Why)
}
}
// A healthy look later than HelpedWithin after a restart does not say the restart helped: the resolver came
// back on its own, or by something else.
func TestARestartHelpedOnlyWhenTheResolverAnsweredSoonAfterIt(t *testing.T) {
t0 := time.Date(2026, 10, 9, 10, 57, 57, 0, time.UTC)
s := State{Restarts: []Restart{{At: t0}}}
s, _ = Step(s, healthy(t0.Add(HelpedWithin+time.Second)), "active")
if h := s.Restarts[0].Helped; h == nil || *h {
t.Fatalf("a healthy look %s after the restart said it helped: %v", HelpedWithin+time.Second, h)
}
s = State{Restarts: []Restart{{At: t0}}}
s, _ = Step(s, healthy(t0.Add(HelpedWithin)), "active")
if h := s.Restarts[0].Helped; h == nil || !*h {
t.Fatalf("a healthy look within %s after the restart did not say it helped: %v", HelpedWithin, h)
}
}
// A restart is made only of a unit that runs: one stopped on purpose, still starting at boot or failed is
// held, said, and not counted against the budget (review of PR 161, C2).
func TestTheWatchRestartsOnlyAUnitThatRuns(t *testing.T) {
t0 := time.Date(2026, 10, 9, 10, 57, 57, 0, time.UTC)
for _, unit := range []string{"inactive", "activating", "failed", ""} {
var s State
var why string
for i := 0; i < 4; i++ {
s, why = Step(s, failing(t0.Add(time.Duration(i)*LookEvery)), unit)
if why != "" {
t.Fatalf("a unit %q was restarted", unit)
}
}
if !strings.Contains(s.Held, "not running") || s.Unit != unit || len(s.Restarts) != 0 {
t.Fatalf("a unit %q: %+v", unit, s)
}
// Running again and still not answering: restarted, the held looks counted as failing.
if _, why = Step(s, failing(t0.Add(5*LookEvery)), "active"); why == "" {
t.Fatalf("a unit running again after %q, not answering, was not restarted", unit)
}
}
}
// The watch loop: it reads the budget it kept before it was restarted, asks the unit's state, restarts
// without waiting for the start, and writes what it saw (review of PR 161, C2 and C4).
func TestTheWatchLoopKeepsItsBudgetAcrossItsOwnRestart(t *testing.T) {
port := freePort(t)
clock := time.Date(2026, 10, 9, 10, 57, 57, 0, time.UTC)
m := aResolverMachine(t, port, func() time.Time { return clock })
m.Ask = func(_ context.Context, server, over, name string, _ time.Duration) Answer {
return Answer{Server: server, Over: over, Name: name, Failed: "refused: nothing listens there"}
}
before := LookEvery
LookEvery = time.Millisecond
t.Cleanup(func() { LookEvery = before })
run := func(spent []Restart) ([]string, State) {
t.Helper()
if err := m.WriteState(State{Restarts: spent}); err != nil {
t.Fatal(err)
}
var asked []string
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
looks := 0
var last State
err := m.Watch(ctx, func(_ context.Context, name string, args ...string) (string, error) {
asked = append(asked, name+" "+strings.Join(args, " "))
if args[0] == "show" {
return "active\n", nil
}
return "", nil
}, func(s State) error {
last = s
if looks++; looks == 3 {
cancel()
}
return m.WriteState(s)
})
if err != nil {
t.Fatal(err)
}
return asked, last
}
// Three restarts within the hour before it was restarted: it restarts nothing more.
spent := []Restart{{At: clock.Add(-50 * time.Minute)}, {At: clock.Add(-30 * time.Minute)}, {At: clock.Add(-10 * time.Minute)}}
asked, last := run(spent)
for _, a := range asked {
if strings.Contains(a, "restart") {
t.Fatalf("restarted with its budget spent before it started: %v", asked)
}
}
if !strings.Contains(last.Held, "the most the watch makes") || len(last.Restarts) != 3 {
t.Fatalf("the budget it kept: %+v", last)
}
// None spent: restarted on its second failing look, not waiting for the start.
asked, last = run(nil)
restarts := 0
for _, a := range asked {
if strings.HasPrefix(a, "systemctl restart") {
restarts++
if a != "systemctl restart --no-block dnsmasq.service" {
t.Fatalf("restarted as %q", a)
}
}
}
if restarts != 1 || len(last.Restarts) != 1 || last.Unit != "active" {
t.Fatalf("restarts %d: %v %+v", restarts, asked, last)
}
}
// The health tool says a watch that wrote nothing, one that stopped looking, and a unit not running.
func TestHealthSaysWhenTheWatchIsNotLookingOrTheUnitIsNotRunning(t *testing.T) {
port := freePort(t)
clock := time.Date(2026, 10, 9, 11, 0, 0, 0, time.UTC)
m := aResolverMachine(t, port, func() time.Time { return clock })
startStandIn(t, "127.0.0.1", port, "127.0.0.2")
startStandIn(t, "127.0.0.2", port, "127.0.0.2")
if h := m.Health(t.Context()); h.Healthy || !strings.Contains(h.Why, "the watch has written nothing yet") {
t.Fatalf("no watch: %+v", h.Why)
}
if err := m.WriteState(State{Last: Look{At: clock.Add(-Stale - time.Second), Healthy: true}, Unit: "active"}); err != nil {
t.Fatal(err)
}
if h := m.Health(t.Context()); h.Healthy || !strings.Contains(h.Why, "the watch last looked 2m1s ago") {
t.Fatalf("a stale watch: %+v", h.Why)
}
if err := m.WriteState(State{Last: Look{At: clock, Healthy: true}, Unit: "active"}); err != nil {
t.Fatal(err)
}
if h := m.Health(t.Context()); !h.Healthy || h.Why != "" {
t.Fatalf("a watch looking, a resolver answering: %+v", h)
}
if err := m.WriteState(State{Last: Look{At: clock}, Unit: "inactive"}); err != nil {
t.Fatal(err)
}
if h := m.Health(t.Context()); !strings.Contains(h.Why, "the unit is inactive") {
t.Fatalf("a unit stopped: %+v", h.Why)
}
if err := os.WriteFile(filepath.Join(m.Root, StateFile), []byte("{"), 0o644); err != nil {
t.Fatal(err)
}
if h := m.Health(t.Context()); h.Healthy || !strings.Contains(h.Why, "is not the watch's") {
t.Fatalf("an unreadable state: %+v", h.Why)
}
}
// dnsmasq_resolve asks only an address the resolver listens on (review of PR 161, C3).
func TestResolveAsksOnlyAListenAddress(t *testing.T) {
port := freePort(t)
m := aResolverMachine(t, port, time.Now)
startStandIn(t, "127.0.0.2", port, "127.0.0.2")
startStandIn(t, "127.0.0.1", port, "127.0.0.2")
if _, err := m.Resolve(t.Context(), "novox.internal", "8.8.8.8", ""); err == nil || !strings.Contains(err.Error(), "not one the resolver listens on") {
t.Fatalf("another address was asked: %v", err)
}
got, err := m.Resolve(t.Context(), "novox.internal", "127.0.0.2", "tcp")
if err != nil {
t.Fatal(err)
}
answers := got.(map[string]any)["answers"].([]Answer)
if len(answers) != 1 || answers[0].Over != "tcp" || answers[0].Code != "NOERROR" || answers[0].Addresses[0] != "127.0.0.2" {
t.Fatalf("%+v", answers)
}
if got, _ := m.Resolve(t.Context(), "novox.internal", "", ""); len(got.(map[string]any)["answers"].([]Answer)) != 4 {
t.Fatalf("every listen address over both: %+v", got)
}
if _, err := m.Resolve(t.Context(), "novox.internal", "", "quic"); err == nil {
t.Fatal("over quic was accepted")
}
if _, err := m.Resolve(t.Context(), "", "", ""); err == nil {
t.Fatal("no name was accepted")
}
}
// The announcer: names primed silently, restarts made before the bundle started said, an event remembered
// only once emitted, and whether a restart helped said (review of PR 161, C5).
func TestTheAnnouncerSaysWhatChangedAndRemembersOnlyWhatWasSaid(t *testing.T) {
port := freePort(t)
t0 := time.Date(2026, 10, 9, 11, 0, 0, 0, time.UTC)
m := aResolverMachine(t, port, func() time.Time { return t0 })
yes := true
if err := m.WriteState(State{Restarts: []Restart{{At: t0, Why: "before the bundle started", Helped: &yes}}}); err != nil {
t.Fatal(err)
}
var said []string
failing := true
emit := func(kind string, body any, _ ...stdio.EmitOptions) error {
if failing && kind == "restarted" {
return errors.New("no bus")
}
said = append(said, kind+" "+fmt.Sprint(body.(map[string]any)["name"], body.(map[string]any)["helped"]))
return nil
}
var a Announcer
Tell(m, &a, emit)
if strings.Join(said, "|") != "restart-judged <nil> true" {
t.Fatalf("at the start: %v", said)
}
failing, said = false, nil
Tell(m, &a, emit)
if strings.Join(said, "|") != "restarted <nil> <nil>" {
t.Fatalf("a restart whose emit failed was not said again: %v", said)
}
said = nil
Tell(m, &a, emit)
if len(said) != 0 {
t.Fatalf("said twice: %v", said)
}
// The roster changes: one machine added, one gone. An unreadable roster says nothing.
nodes := filepath.Join(m.Root, NodesFile)
if err := os.WriteFile(nodes, []byte("host-record=novox.internal,127.0.0.2\nhost-record=shanks.internal,10.77.0.4\n"), 0o644); err != nil {
t.Fatal(err)
}
Tell(m, &a, emit)
if got := strings.Join(said, "|"); !strings.Contains(got, "name.added shanks.internal") ||
!strings.Contains(got, "name.removed ace.internal") || len(said) != 2 {
t.Fatalf("the roster's change: %v", said)
}
said = nil
if err := os.Remove(nodes); err != nil {
t.Fatal(err)
}
Tell(m, &a, emit)
if len(said) != 0 {
t.Fatalf("an unreadable roster said %v", said)
}
}
+207
View File
@@ -0,0 +1,207 @@
package main
import (
"bufio"
"context"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
)
// A look asks the resolver one real question on every address its configuration tells it to answer on,
// over UDP and over TCP: the machine's own name, which the mesh's roster gives every holder of the seat
// (novox/hq ADR 0194, 0223). On 2026-10-09 the control node's resolver stayed active and running, its
// journal silent, and refused every question on its private address for twenty minutes; its unit said
// healthy throughout (novox/hq issue 348). A unit that runs is not a resolver that answers.
// The files a look reads, under the machine's root.
const (
ConfigFile = "/etc/dnsmasq.conf"
NodesFile = "/etc/mesh-resolver/nodes.conf"
)
// AskWithin bounds each question.
const AskWithin = 2 * time.Second
// Machine is a filesystem root (the real one, or a test's tree), how a question is asked, and a clock.
type Machine struct {
Root string
Ask func(ctx context.Context, server, over, name string, timeout time.Duration) Answer
Now func() time.Time
// Port is where the resolver answers; 53 but in a test.
Port string
}
// Here is this machine.
func Here() *Machine {
return &Machine{Root: "/", Ask: Ask, Now: time.Now, Port: "53"}
}
func (m *Machine) read(path string) (string, error) {
b, err := os.ReadFile(filepath.Join(m.Root, path))
return string(b), err
}
// ListenAddresses are the addresses the resolver's configuration tells it to answer on, in its order.
func ListenAddresses(config string) []string {
var out []string
sc := bufio.NewScanner(strings.NewReader(config))
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
value, ok := strings.CutPrefix(line, "listen-address=")
if !ok {
continue
}
for _, a := range strings.Split(value, ",") {
if a = strings.TrimSpace(a); a != "" {
out = append(out, a)
}
}
}
return out
}
// Named is a machine's name and its private address, as the roster gave them to the resolver.
type Named struct {
Name string `json:"name"`
Address string `json:"address"`
}
// AnsweredNames are the machines' names the roster tells the resolver to answer, each with its address:
// its `host-record=<name>,<address>` lines.
func AnsweredNames(nodes string) []Named {
var out []Named
sc := bufio.NewScanner(strings.NewReader(nodes))
for sc.Scan() {
value, ok := strings.CutPrefix(strings.TrimSpace(sc.Text()), "host-record=")
if !ok {
continue
}
name, address, ok := strings.Cut(value, ",")
if ok && name != "" && address != "" {
out = append(out, Named{Name: name, Address: strings.TrimSpace(address)})
}
}
return out
}
// Question is the name a look asks, and the address a right answer carries: the machine's own when the
// roster names an address the resolver listens on, else the roster's first.
func Question(listen []string, names []Named) (Named, bool) {
for _, n := range names {
for _, a := range listen {
if n.Address == a {
return n, true
}
}
}
if len(names) > 0 {
return names[0], true
}
return Named{}, false
}
// Look is one look: every listen address asked over both transports.
type Look struct {
At time.Time `json:"at"`
// Healthy says every question was answered rightly.
Healthy bool `json:"healthy"`
Asked Named `json:"asked"`
Answers []Answer `json:"answers"`
// Wrong is every question not answered rightly, in words; Problems what kept the look from asking.
Wrong []string `json:"wrong,omitempty"`
Problems []string `json:"problems,omitempty"`
}
// Why is what is wrong in one sentence: empty when healthy.
func (l Look) Why() string {
if l.Healthy {
return ""
}
return strings.Join(append(append([]string{}, l.Problems...), l.Wrong...), "; ")
}
// Look asks every listen address its question, over UDP and TCP, all at once.
func (m *Machine) Look(ctx context.Context) Look {
l := Look{At: m.Now().UTC()}
config, err := m.read(ConfigFile)
if err != nil {
l.Problems = append(l.Problems, fmt.Sprintf("its configuration %s cannot be read: %v", ConfigFile, err))
return l
}
listen := ListenAddresses(config)
if len(listen) == 0 {
l.Problems = append(l.Problems, fmt.Sprintf("%s names no listen-address, so there is nowhere to ask", ConfigFile))
return l
}
nodes, err := m.read(NodesFile)
if err != nil {
l.Problems = append(l.Problems, fmt.Sprintf("the roster %s cannot be read: %v", NodesFile, err))
return l
}
q, ok := Question(listen, AnsweredNames(nodes))
if !ok {
l.Problems = append(l.Problems, fmt.Sprintf("the roster %s names no machine, so there is nothing to ask", NodesFile))
return l
}
l.Asked = q
var mu sync.Mutex
var wg sync.WaitGroup
for _, address := range listen {
for _, over := range []string{"udp", "tcp"} {
wg.Add(1)
go func(address, over string) {
defer wg.Done()
a := m.Ask(ctx, address+":"+m.Port, over, q.Name, AskWithin)
mu.Lock()
l.Answers = append(l.Answers, a)
mu.Unlock()
}(address, over)
}
}
wg.Wait()
sort.Slice(l.Answers, func(i, j int) bool {
if l.Answers[i].Server != l.Answers[j].Server {
return l.Answers[i].Server < l.Answers[j].Server
}
return l.Answers[i].Over > l.Answers[j].Over
})
for _, a := range l.Answers {
if w := wrongWords(a, q); w != "" {
l.Wrong = append(l.Wrong, w)
}
}
l.Healthy = len(l.Wrong) == 0
return l
}
// wrongWords is what is wrong with one answer, in words; empty when it is right: NOERROR with the
// machine's address.
func wrongWords(a Answer, q Named) string {
at := a.Server + " over " + a.Over
switch {
case !a.Answered:
return fmt.Sprintf("%s did not answer %s: %s", at, q.Name, a.Failed)
case a.Code != "NOERROR":
return fmt.Sprintf("%s answered %s with %s", at, q.Name, a.Code)
case a.Failed != "":
return fmt.Sprintf("%s answered %s with a reply that cannot be read: %s", at, q.Name, a.Failed)
}
for _, x := range a.Addresses {
if x == q.Address {
return ""
}
}
return fmt.Sprintf("%s answered %s with %s, not %s", at, q.Name, orNothing(a.Addresses), q.Address)
}
func orNothing(s []string) string {
if len(s) == 0 {
return "no address"
}
return strings.Join(s, ", ")
}
+145
View File
@@ -0,0 +1,145 @@
// The dnsmasq module's Go bundle: one of the mesh's resolvers (novox/hq ADR 0194, 0223).
//
// Started with no arguments it is served by the node's runtime as the operator account, over MCP on stdio
// (ADR 0188, ADR 0193): the module's tools and its health, and beside them the events the resolver's
// answers give — a machine's name it now answers or no longer does, and a restart the watch made. Started
// as `dnsmasq-tools watch` it is the module's long-running process, as root: it asks the resolver a real
// question on every listen address and restarts it, within a budget, when it stops answering (watch.go,
// novox/hq issue 348). stdout is the MCP channel; everything else is said on stderr.
package main
import (
"context"
"fmt"
"os"
"os/signal"
"syscall"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
)
func main() {
if len(os.Args) > 1 {
if os.Args[1] != "watch" || len(os.Args) != 2 {
fmt.Fprintln(os.Stderr, "usage: dnsmasq-tools [watch]")
os.Exit(2)
}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, os.Interrupt)
defer stop()
m := Here()
if err := m.Watch(ctx, ExecRunner, m.WriteState); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
return
}
m := Here()
go Announce(m, stdio.Emit)
if err := stdio.Serve("", Tools(m)); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
// AnnounceEvery is how often the bundle reads the roster and the watch for what to say.
var AnnounceEvery = 30 * time.Second
// Announcer remembers what was said. The names are primed silently, so the bundle starting again does not
// say every name again; the watch's restarts are not — a restart made while the bundle was down is said when
// it starts (the watch keeps the last hour's). Nothing is remembered as said until its emit succeeded.
type Announcer struct {
names map[string]string
primed bool
// said is every restart said, by when it was made; judged every one whose outcome was said.
said, judged map[time.Time]bool
}
// Event is one event to emit, and what saying it records.
type Event struct {
Type string
Body map[string]any
said func()
}
// Next is what to say since the last read: names the resolver now answers (`name.added`) or no longer does
// (`name.removed`) — the mesh's names having reached this resolver, apart from the controller's own events;
// restarts the watch made (`restarted`), with why; and, once the looks after a restart decide it, whether it
// helped (`restart-judged`). read says the roster was read: an unreadable roster says nothing about names,
// rather than every name removed. An event is remembered by Said, once it was emitted.
func (a *Announcer) Next(names []Named, read bool, s State) []Event {
if a.said == nil {
a.said, a.judged, a.names = map[time.Time]bool{}, map[time.Time]bool{}, map[string]string{}
}
var out []Event
if read {
now := map[string]string{}
for _, n := range names {
now[n.Name] = n.Address
}
if !a.primed {
a.names, a.primed = now, true
} else {
for name, address := range now {
if _, had := a.names[name]; !had {
out = append(out, Event{"name.added", map[string]any{"name": name, "address": address},
func() { a.names[name] = address }})
}
}
for name := range a.names {
if _, has := now[name]; !has {
out = append(out, Event{"name.removed", map[string]any{"name": name}, func() { delete(a.names, name) }})
}
}
}
}
for _, r := range s.Restarts {
at := r.At
if !a.said[at] {
body := map[string]any{"unit": Unit, "at": at, "why": r.Why}
if r.Error != "" {
body["error"] = r.Error
}
out = append(out, Event{"restarted", body, func() { a.said[at] = true }})
}
if r.Helped != nil && !a.judged[at] {
out = append(out, Event{"restart-judged", map[string]any{"unit": Unit, "at": at, "helped": *r.Helped},
func() { a.judged[at] = true }})
}
}
return out
}
// Said remembers an event as said.
func (e Event) Said() {
if e.said != nil {
e.said()
}
}
// Announce says the events for as long as the bundle runs.
func Announce(m *Machine, emit func(string, any, ...stdio.EmitOptions) error) {
time.Sleep(2 * time.Second) // the SDK reaches the bus once Serve runs
var a Announcer
for {
Tell(m, &a, emit)
time.Sleep(AnnounceEvery)
}
}
// Tell is one read and what it says: an event that could not be emitted is said again at the next read.
func Tell(m *Machine, a *Announcer, emit func(string, any, ...stdio.EmitOptions) error) {
var names []Named
text, err := m.read(NodesFile)
if err == nil {
names = AnsweredNames(text)
}
s, _, _ := m.ReadState()
for _, e := range a.Next(names, err == nil, s) {
if err := emit(e.Type, e.Body); err != nil {
fmt.Fprintf(os.Stderr, "dnsmasq: %s not said, said again at the next read: %v\n", e.Type, err)
continue
}
e.Said()
}
}
+166
View File
@@ -0,0 +1,166 @@
package main
import (
"context"
"fmt"
"slices"
"strings"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
)
// Stale is how old the watch's last look may be before the health tool says it stopped looking.
const Stale = 2 * time.Minute
// Health is the module's health answer (novox/hq ADR 0240): healthy only when the resolver answers its
// real question on every listen address, over UDP and TCP, and the watch is looking.
type Health struct {
Healthy bool `json:"healthy"`
Why string `json:"why,omitempty"`
Look Look `json:"look"`
// Watch is what the watch last saw and did; absent when it has written nothing yet.
Watch *State `json:"watch,omitempty"`
}
// Health asks the resolver now, and reads what the watch did.
func (m *Machine) Health(ctx context.Context) Health {
l := m.Look(ctx)
h := Health{Healthy: l.Healthy, Look: l}
var why []string
if !l.Healthy {
why = append(why, "the resolver does not answer as it must: "+l.Why())
}
s, had, err := m.ReadState()
switch {
case err != nil:
h.Healthy = false
why = append(why, "what the watch saw cannot be read: "+err.Error())
case !had:
h.Healthy = false
why = append(why, "the watch has written nothing yet, so nothing restarts the resolver when it stops answering")
default:
h.Watch = &s
if age := m.Now().Sub(s.Last.At); age > Stale {
h.Healthy = false
why = append(why, fmt.Sprintf("the watch last looked %s ago, so nothing restarts the resolver when it "+
"stops answering", age.Round(time.Second)))
}
if !l.Healthy {
why = append(why, restartWords(s))
}
if s.Unit != "" && s.Unit != "active" {
why = append(why, "the unit is "+s.Unit)
}
}
h.Why = strings.Join(nonEmpty(why), "; ")
return h
}
// restartWords is what the watch did about it, in words.
func restartWords(s State) string {
if len(s.Restarts) == 0 {
return "the watch has not restarted it" + orEmpty("; ", s.Held)
}
r := s.Restarts[len(s.Restarts)-1]
words := fmt.Sprintf("the watch restarted it at %s", r.At.Format("15:04:05 UTC"))
switch {
case r.Error != "":
words += ", and the restart failed: " + r.Error
case r.Helped != nil && !*r.Helped:
words += ", which did not help"
case r.Helped == nil:
words += ", and its next looks decide whether that helped"
}
if s.Held != "" {
words += "; " + s.Held
}
return words
}
func nonEmpty(s []string) []string {
var out []string
for _, x := range s {
if x != "" {
out = append(out, x)
}
}
return out
}
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)
}
// Tools are the module's tools.
func Tools(m *Machine) []stdio.Tool {
return []stdio.Tool{
{Name: "dnsmasq_health", Description: "Whether this machine's mesh resolver answers: its real question — this " +
"machine's own name — asked now on every address its configuration listens on, over UDP and TCP, each " +
"answer with its code, addresses and time; and what the module's watch last saw, the restarts it made " +
"within the hour, whether each helped, and why it holds back from another. Healthy only when every " +
"question was answered rightly and the watch is looking (novox/hq ADR 0240, issue 348). (r)",
Input: map[string]any{},
Run: func(map[string]any) (any, error) {
return m.Health(context.Background()), nil
}},
{Name: "dnsmasq_names", Description: "The mesh's machine names this machine's resolver answers, each with its " +
"private address, as the roster wrote them (" + NodesFile + "). (r)",
Input: map[string]any{},
Run: func(map[string]any) (any, error) {
text, err := m.read(NodesFile)
if err != nil {
return nil, fmt.Errorf("the roster %s cannot be read: %w", NodesFile, err)
}
return map[string]any{"names": AnsweredNames(text)}, nil
}},
{Name: "dnsmasq_resolve", Description: "Ask this machine's resolver one name's IPv4 address, directly, on one " +
"address it listens on (default every one) over udp or tcp (default both): the answer code, the addresses " +
"and the time each took, or why none came — refused when nothing listens there. Replaces dig @address. (r)",
Input: map[string]any{"name": str("the name to ask, e.g. plex.anchor.internal"),
"address": str("one listen address to ask (default: every one in its configuration)"),
"over": str("udp or tcp (default: both)")},
Run: func(a map[string]any) (any, error) {
return m.Resolve(context.Background(), arg(a, "name"), arg(a, "address"), arg(a, "over"))
}},
}
}
// Resolve asks name on one listen address or every one, over one transport or both.
func (m *Machine) Resolve(ctx context.Context, name, address, over string) (any, error) {
if name == "" {
return nil, fmt.Errorf("name is required")
}
config, err := m.read(ConfigFile)
if err != nil {
return nil, fmt.Errorf("its configuration %s cannot be read: %w", ConfigFile, err)
}
addresses := ListenAddresses(config)
if address != "" {
if !slices.Contains(addresses, address) {
return nil, fmt.Errorf("address %q is not one the resolver listens on (%s); netcheck_dns asks any resolver",
address, strings.Join(addresses, ", "))
}
addresses = []string{address}
}
overs := []string{"udp", "tcp"}
switch over {
case "":
case "udp", "tcp":
overs = []string{over}
default:
return nil, fmt.Errorf("over is udp or tcp, not %q", over)
}
var answers []Answer
for _, a := range addresses {
for _, o := range overs {
answers = append(answers, m.Ask(ctx, a+":"+m.Port, o, name, AskWithin))
}
}
return map[string]any{"name": name, "answers": answers}, nil
}
+240
View File
@@ -0,0 +1,240 @@
package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
// The watch is the module's long-running process, as root. Every LookEvery it asks the resolver its real
// question on every listen address (look.go); after Confirm failing looks in a row it restarts the
// resolver's unit, within a budget, and writes what it saw and did for the tools, which say it to the mesh
// (novox/hq issue 348).
//
// **Why the module restarts its own resolver.** A resolver that stops answering on a machine can cut that
// machine off from the bus by name, and then nothing outside the machine — neither the gate's rollback nor
// a healer in the controller — reaches it (ADR 0236 on the network path). The repair has to be made where
// the resolver runs, as the identity provider repairs its own administrator (ADR 0224 §5). It is braked,
// and it never says it worked: its next look does. A restart that did not help is said by the health
// tool as the resolver's failure, which the node-engine states and the controller raises — the operator
// hears of it only then.
// The watch's clocks and its budget.
var (
LookEvery = 15 * time.Second
// Confirm failing looks in a row are a resolver that does not answer: one look can be wrong (issue 277).
Confirm = 2
// RestartGap is the least time between two restarts; RestartsPerHour the most within an hour.
RestartGap = 2 * time.Minute
RestartsPerHour = 3
// HelpedWithin is how soon after a restart a healthy look says the restart helped.
HelpedWithin = time.Minute
)
// Unit is the resolver's service.
const Unit = "dnsmasq.service"
// StateFile is where the watch writes what it saw, for the tools to read: readable by the operator
// account the tools run as, written only by the watch.
const StateFile = "/var/lib/dnsmasq-watch/watch.json"
// Restart is one restart the watch made.
type Restart struct {
At time.Time `json:"at"`
Why string `json:"why"`
// Error is what the service manager said when the restart itself failed.
Error string `json:"error,omitempty"`
// Helped is the next looks' word: true once a look within HelpedWithin answered rightly, false once
// HelpedWithin passed without one, absent until then.
Helped *bool `json:"helped,omitempty"`
}
// State is the watch's last look and what it did, as the tools read it.
type State struct {
Last Look `json:"last"`
// Failing is how many looks in a row failed; Since when the first of them looked.
Failing int `json:"failing"`
Since *time.Time `json:"since,omitempty"`
// Restarts are the restarts within the last hour and the last one ever, newest last.
Restarts []Restart `json:"restarts,omitempty"`
// Held is why a restart that was due was not made: the budget is spent, or the unit is not running.
Held string `json:"held,omitempty"`
// Unit is the resolver unit's state at the last look, as the service manager said it: active,
// activating, inactive, failed …
Unit string `json:"unit,omitempty"`
}
// Runner runs one command; injected, so the watch is tested over recorded answers.
type Runner func(ctx context.Context, name string, args ...string) (string, error)
// ExecRunner runs a command on the machine, bounded to a minute.
func ExecRunner(ctx context.Context, name string, args ...string) (string, error) {
ctx, cancel := context.WithTimeout(ctx, time.Minute)
defer cancel()
var out bytes.Buffer
cmd := exec.CommandContext(ctx, name, args...)
cmd.Stdout, cmd.Stderr = &out, &out
err := cmd.Run()
return strings.TrimSpace(out.String()), err
}
// Step is one look of the watch and what follows from it: the state after, and whether to restart and why.
// unit is the resolver unit's state at the look. **Only a unit that runs is restarted** (review of
// mesh-catalog PR 161): one stopped or disabled on purpose is a person's, one still starting — waiting at
// boot for the private address — or failed is the service manager's, and none of them is a resolver that
// runs and does not answer. Pure.
func Step(s State, l Look, unit string) (State, string) {
now := l.At
s.Last, s.Unit = l, unit
// What the earlier restarts are now known to have done.
for i := range s.Restarts {
r := &s.Restarts[i]
if r.Helped != nil || r.Error != "" {
continue
}
switch {
case l.Healthy && now.Sub(r.At) <= HelpedWithin:
yes := true
r.Helped = &yes
case now.Sub(r.At) > HelpedWithin:
no := false
r.Helped = &no
}
}
// The budget's window: the last hour, and always the newest.
kept := s.Restarts[:0]
for i, r := range s.Restarts {
if now.Sub(r.At) <= time.Hour || i == len(s.Restarts)-1 {
kept = append(kept, r)
}
}
s.Restarts = kept
if l.Healthy {
s.Failing, s.Since, s.Held = 0, nil, ""
return s, ""
}
if s.Failing == 0 {
at := now
s.Since = &at
}
s.Failing++
if s.Failing < Confirm || len(l.Problems) > 0 && len(l.Answers) == 0 {
// Not yet confirmed; or the look could not ask at all — a restart answers nothing a missing
// configuration or roster is missing.
return s, ""
}
if unit != "active" {
s.Held = fmt.Sprintf("the unit is %s, not running: the watch restarts only a resolver that runs and does not "+
"answer — one stopped on purpose, still starting or failed is left to the service manager", orUnknown(unit))
return s, ""
}
within := 0
var newest *Restart
for i, r := range s.Restarts {
if now.Sub(r.At) <= time.Hour {
within++
}
newest = &s.Restarts[i]
}
switch {
case newest != nil && now.Sub(newest.At) < RestartGap:
s.Held = ""
return s, "" // the last restart is still settling
case within >= RestartsPerHour:
s.Held = fmt.Sprintf("%d restart(s) within the last hour, the most the watch makes; it restarts nothing "+
"more until the oldest is an hour old, and the resolver's failure is said as its health", within)
return s, ""
}
s.Held = ""
return s, fmt.Sprintf("%d look(s) in a row since %s failed: %s", s.Failing, s.Since.Format("15:04:05 UTC"), l.Why())
}
// Watch runs the watch until ctx ends.
func (m *Machine) Watch(ctx context.Context, run Runner, write func(State) error) error {
var s State
if raw, err := m.read(StateFile); err == nil {
_ = json.Unmarshal([]byte(raw), &s) // the budget survives the watch's own restart
}
tick := time.NewTicker(LookEvery)
defer tick.Stop()
for {
l := m.Look(ctx)
unit, err := run(ctx, "systemctl", "show", "--property=ActiveState", "--value", Unit)
if err != nil {
unit = ""
}
next, why := Step(s, l, strings.TrimSpace(unit))
if why != "" {
r := Restart{At: m.Now().UTC(), Why: why}
// Not waited for: a start that waits for the private address is the service manager's to time,
// and the next looks say whether it answers.
if out, err := run(ctx, "systemctl", "restart", "--no-block", Unit); err != nil {
r.Error = strings.TrimSpace(err.Error() + " " + out)
}
fmt.Fprintf(os.Stderr, "dnsmasq-watch: restarted %s: %s%s\n", Unit, why, orEmpty(" — the restart failed: ", r.Error))
next.Restarts = append(next.Restarts, r)
} else if !l.Healthy && next.Failing == Confirm {
fmt.Fprintf(os.Stderr, "dnsmasq-watch: the resolver does not answer: %s%s\n", l.Why(), orEmpty("; ", next.Held))
}
s = next
if err := write(s); err != nil {
fmt.Fprintln(os.Stderr, "dnsmasq-watch: what it saw could not be written:", err)
}
select {
case <-ctx.Done():
return nil
case <-tick.C:
}
}
}
func orUnknown(s string) string {
if s == "" {
return "not known (the service manager did not say)"
}
return s
}
func orEmpty(prefix, s string) string {
if s == "" {
return ""
}
return prefix + s
}
// WriteState writes the state whole, replacing the file in one rename.
func (m *Machine) WriteState(s State) error {
path := filepath.Join(m.Root, StateFile)
body, err := json.MarshalIndent(s, "", " ")
if err != nil {
return err
}
tmp := path + ".new"
if err := os.WriteFile(tmp, body, 0o644); err != nil {
return err
}
return os.Rename(tmp, path)
}
// ReadState is the watch's last state, and false when it has written none.
func (m *Machine) ReadState() (State, bool, error) {
raw, err := m.read(StateFile)
if errors.Is(err, os.ErrNotExist) {
return State{}, false, nil
}
if err != nil {
return State{}, false, err
}
var s State
if err := json.Unmarshal([]byte(raw), &s); err != nil {
return State{}, false, fmt.Errorf("%s is not the watch's: %w", StateFile, err)
}
return s, true, nil
}
+5
View File
@@ -0,0 +1,5 @@
module dnsmasq
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=
-38
View File
@@ -1,38 +0,0 @@
// dnsmasq's events. The resolver's answered set changes whenever the mesh rewrites the wildcard file
// and restarts it — a machine joined the private network or left it. This watches that file and
// announces, from the resolver's own vantage, that a name became (or stopped being) resolvable on
// this node: DNS having actually propagated here, distinct from the mesh's own node.* events.
//
// Emits (novox/hq ADR 0041/0042):
// module.dnsmasq.name.added — this node's resolver now answers a machine's name
// module.dnsmasq.name.removed — it no longer does
import { emit } from "@novox/mesh-sdk/events";
import { DnsmasqClient } from "./client.js";
const dnsmasq = DnsmasqClient.fromEnv();
// name -> address, primed silently so a restart does not re-announce every name it already answered.
const known = new Map<string, string>();
let primed = false;
async function poll(): Promise<void> {
const now = new Map((await dnsmasq.answeredNames()).map((a) => [a.name, a.address]));
if (primed) {
for (const [name, address] of now) {
if (!known.has(name)) await emit("name.added", { name, address });
}
for (const [name] of known) {
if (!now.has(name)) await emit("name.removed", { name });
}
}
known.clear();
for (const [name, address] of now) known.set(name, address);
primed = true;
}
const run = (): void => void poll().catch((err) => console.error(`[dnsmasq] ${err}`));
setInterval(run, 30_000);
run();
console.log("[dnsmasq] watching the resolver's answered names");
File diff suppressed because one or more lines are too long
-9
View File
@@ -1,9 +0,0 @@
{
"name": "@novox/module-dnsmasq",
"version": "0.1.0",
"description": "dnsmasq — the mesh's resolver: answers wildcard node names and forwards the rest upstream. Its tools and events live here.",
"type": "module",
"private": true,
"dependencies": { "@novox/mesh-sdk": "^0.1.0" },
"devDependencies": { "@types/node": "^22.0.0", "typescript": "^5.6.0" }
}
-31
View File
@@ -1,31 +0,0 @@
// dnsmasq's tools — a resolver's two useful questions: what does it answer, and does it answer a
// given name. Moved into the module (novox/hq ADR 0039); the mesh serves them through the sdk.
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
import { DnsmasqClient } from "../client.js";
export function getDnsmasqTools(dnsmasq: DnsmasqClient): ToolDefinition[] {
return [
{
name: "dnsmasq_names",
description: "The mesh names this node's resolver answers — each a machine and everything under it.",
input: {},
run: async () => ({ names: await dnsmasq.answeredNames() }),
},
{
name: "dnsmasq_resolve",
description: "Resolve a mesh name through this node's own resolver — the check that wildcard-resolution answers.",
input: { name: { type: "string", description: "a name to resolve, e.g. plex.anchor.internal" } },
run: async (args) => {
const name = String(args.name);
try {
return { name, addresses: await dnsmasq.resolve(name) };
} catch (err) {
return { name, addresses: [], error: err instanceof Error ? err.message : String(err) };
}
},
},
];
}
registerModuleTools("dnsmasq", (env) => getDnsmasqTools(DnsmasqClient.fromEnv(env)));
-12
View File
@@ -1,12 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["client.ts", "index.ts", "tools/index.ts"]
}
+26 -3
View File
@@ -14,7 +14,9 @@ tools below are the module's own.
| `socket` | `docker.socket` running, enabled at boot | given back as found when the module goes (ADR 0118) |
| `prune-service`, `prune-timer` | `/etc/systemd/system/docker-prune.{service,timer}`, written whole | removed with the module |
| `prune` | `docker-prune.timer` running, enabled at boot; restarted when either file changes | stopped and disabled with the module (the mesh made the unit) |
| `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 |
| `state`, `log-rotation` | the directory the mesh places for the module, `<root>/mesh/docker` (`/var/lib/mesh/docker` on a node with the default root), and in it `log-rotation.conf`: the settings `log-max-size` and `log-max-file` as the step judges them (0600). Never `/var/lib/docker`, which is Docker's own and where the node-engine places nothing (novox/hq issues 339, 452) | removed with the module |
| `log-rotation-check` | a step, `docker-tools check-log-rotation`, run as root when `log-rotation` changes; it refuses a bad value, and the rest of the module (`daemon` with it) is then skipped | removed with the module |
| `daemon` | `live-restore`, the mesh's registry in `insecure-registries`, `log-driver` and `log-opts` 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
@@ -64,8 +66,27 @@ once, for `daemon`. The address is the same one, so the runtime's trust does not
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.
## Log rotation (novox/hq issue 452)
`daemon` writes `"log-driver": "json-file"` and `"log-opts": {"max-size": …, "max-file": …}` from two
preferences: `log-max-size` (default `100m`) and `log-max-file` (default `10`), about 1 GB of log per
container. The defaults are what the one machine with rotation set by hand already had, so that machine
does not change; the others gain rotation. Both settings are terminal-only (`"trusted": true`).
**A bad value never reaches `daemon.json`.** The controller pastes a setting into a file as text and judges
only that it is one line (novox/hq issue 346), and the runtime is the only reader of `daemon.json`: a bad
size would be found as a runtime that does not start, and a quote could add any key. So the same two values
are written into `log-rotation.conf` first, and the step `log-rotation-check` judges them before `daemon`
is applied: a size is a whole number from 1 with `k`, `m` or `g` after it, a count is 1 to 99. Refused, the
step fails, the host skips the rest of this module (ADR 0136), `daemon.json` keeps what it had, and the
apply's report names the setting.
**Nothing restarts.** The runtime reads `log-driver` and `log-opts` only when it starts. A change to
`daemon` reloads it, as before; the reload takes neither key, and stops no container. The new rotation
holds from the runtime's next start (the machine's next boot, for one), and then only for
containers created after it: a container keeps the log options it was created with until it is created
again. `docker_daemon_config` lists the keys the daemon has not taken yet.
## What it does not declare yet, and why
@@ -242,6 +263,8 @@ The tests run against a fake runner and cover:
- a printed secret found by name and never answered by value, in the scan and in `docker_logs`;
- size parsing;
- what the daemon has not yet taken;
- the log rotation: the defaults `daemon.json` takes, the step declared before `daemon`, a bad size or
count refused, a good one read, and the runtime never restarted for it;
- event filtering;
- volume ownership;
- that the tools served are exactly the manifest's `tools`.
+63 -4
View File
@@ -991,30 +991,53 @@ func (c *Client) Volumes(ctx context.Context, unmountedOnly, sizes bool) (map[st
}
// Events is what the runtime did in a window ending now, the latest last.
//
// **The runtime keeps only its last events in memory** (about 256), and the images' own health checks
// make three exec events each per look: on a machine with a few checked containers that history is ten
// minutes long or less, all of it exec events (hq research 032). A window asked for further back than
// the history reaches is answered with what is still held AND says where the history begins, so an
// empty list never reads as "nothing happened" (hq issue 348: three hours asked across an outage,
// answered zero). The type filter is applied here rather than by the runtime, for the same reason: the
// history's start is only known from all of it.
func (c *Client) Events(ctx context.Context, minutes int, kind string, limit int, execs bool) (map[string]any, error) {
args := []string{"events", "--since", fmt.Sprintf("%dm", minutes), "--until", "0s", "--format", "{{json .}}"}
return c.eventsAt(ctx, time.Now(), minutes, kind, limit, execs)
}
func (c *Client) eventsAt(ctx context.Context, now time.Time, minutes int, kind string, limit int, execs bool) (map[string]any, error) {
if kind != "" {
switch kind {
case "container", "image", "network", "volume", "daemon", "plugin", "builder":
default:
return nil, fmt.Errorf("type %q: container, image, network, volume, daemon, plugin or builder", kind)
}
args = append(args, "--filter", "type="+kind)
}
// The whole of what the runtime holds, from the epoch: its buffer, which says where its history really
// begins and whether it is full; the window is cut from it here.
args := []string{"events", "--since", "0", "--until", "0s", "--format", "{{json .}}"}
out, err := c.docker(ctx, args...)
if err != nil {
return nil, err
}
raw, err := jsonLines[runtimeEvent](out)
all, err := jsonLines[runtimeEvent](out)
if err != nil {
return nil, err
}
start := now.Add(-time.Duration(minutes) * time.Minute)
raw := make([]runtimeEvent, 0, len(all))
for _, e := range all {
if !time.Unix(0, e.TimeNano).Before(start) {
raw = append(raw, e)
}
}
// 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{}
for _, e := range raw {
if kind != "" && e.Type != kind {
continue
}
if !execs && strings.HasPrefix(e.Action, "exec_") {
continue
}
@@ -1042,14 +1065,50 @@ func (c *Client) Events(ctx context.Context, minutes int, kind string, limit int
if len(events) > limit {
events = events[len(events)-limit:]
}
answer := map[string]any{"minutes": minutes, "count": total, "shown": len(events), "events": events}
answer := map[string]any{"minutes": minutes, "count": total, "shown": len(events), "events": events,
"held": len(all)}
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)"
}
for k, v := range historyReach(all, start) {
answer[k] = v
}
return answer, nil
}
// RuntimeEventsKept is how many events the runtime keeps in memory (moby's events.eventsLimit): a buffer
// holding fewer has every event since the runtime started; a full one has lost what came before its oldest.
const RuntimeEventsKept = 256
// historyReach says how far back the runtime's buffer reaches, read from all of it: complete for a window
// that starts at or after the oldest event a full buffer holds, or for any window when the buffer is not
// full — then it holds everything since the runtime started. Otherwise where the history begins, and that
// nothing before it can be answered from here.
func historyReach(all []runtimeEvent, start time.Time) map[string]any {
if len(all) == 0 {
return map[string]any{"complete": true, "oldest_held": nil,
"history": "the runtime holds no events at all: none happened since it started"}
}
oldest := time.Unix(0, all[0].TimeNano)
for _, e := range all {
if t := time.Unix(0, e.TimeNano); t.Before(oldest) {
oldest = t
}
}
at := oldest.UTC().Format(time.RFC3339)
if len(all) < RuntimeEventsKept {
return map[string]any{"complete": true, "oldest_held": at,
"history": fmt.Sprintf("the runtime holds %d events, fewer than the %d it keeps: every event since it started", len(all), RuntimeEventsKept)}
}
if !oldest.After(start) {
return map[string]any{"complete": true, "oldest_held": at}
}
return map[string]any{"complete": false, "oldest_held": at,
"history": fmt.Sprintf("the runtime keeps only its last %d events in memory and its oldest is from %s; the window asked starts at %s, so what happened before %s is gone from here, not absent — the images' health checks fill the history with exec events (hq research 032)",
len(all), at, start.UTC().Format(time.RFC3339), oldest.UTC().Format("15:04:05Z"))}
}
func sortedSet(set map[string]bool) []string {
out := make([]string, 0, len(set))
for k := range set {
+71 -2
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"io/fs"
"os"
"reflect"
@@ -299,7 +300,7 @@ func TestEventsAreABoundedWindowWithoutExecNoise(t *testing.T) {
{"Type":"container","Action":"die","Actor":{"ID":"aaaaaaaaaaaaaaaa","Attributes":{"name":"web","mesh-host.id":"hello-web.server","exitCode":"137"}},"timeNano":2}
`
f := (&fake{}).on("docker events", Ran{Stdout: out})
got, err := client(f, 1000).Events(context.Background(), 30, "container", 10, false)
got, err := client(f, 1000).eventsAt(context.Background(), time.Unix(0, 3), 30, "container", 10, false)
if err != nil {
t.Fatal(err)
}
@@ -307,7 +308,8 @@ func TestEventsAreABoundedWindowWithoutExecNoise(t *testing.T) {
if len(evs) != 1 || evs[0]["action"] != "die" || evs[0]["mesh_held"] != true || evs[0]["exit_code"] != "137" {
t.Fatalf("%v", evs)
}
if !f.ran("docker events --since 30m --until 0s --format {{json .}} --filter type=container") {
// The type is filtered here, not by the runtime: the history's start is only known from all of it (issue 348).
if !f.ran("docker events --since 0 --until 0s --format {{json .}}") || f.ran("docker events --since 0 --until 0s --format {{json .}} --filter") {
t.Fatalf("%+v", f.calls)
}
if _, err := client(f, 1000).Events(context.Background(), 30, "secret", 10, false); err == nil {
@@ -362,3 +364,70 @@ func TestProblemsNameWhyAndUnlabelledIsTheCleanupList(t *testing.T) {
t.Fatalf("%v %v", u, err)
}
}
// eventLine is one line of `docker events --format '{{json .}}'`.
func eventLine(at time.Time, kind, action, name string) string {
return fmt.Sprintf(`{"Type":%q,"Action":%q,"Actor":{"ID":"abcdef0123456789","Attributes":{"name":%q}},"timeNano":%d}`+"\n",
kind, action, name, at.UnixNano())
}
// Issue 348: three hours asked across an outage answered zero, because the runtime's history held only the
// last ten minutes of health-check execs. The answer must say the history is short, not that nothing happened.
func TestEventsSayWhereTheRuntimesHistoryBeginsWhenTheWindowReachesFurther(t *testing.T) {
now := time.Date(2026, 10, 9, 11, 24, 0, 0, time.UTC)
var out strings.Builder
for i := 0; i < RuntimeEventsKept; i++ {
out.WriteString(eventLine(now.Add(-10*time.Minute+time.Duration(i)*2*time.Second), "container", "exec_die", "mailu-webmail"))
}
got, err := client((&fake{}).on("docker events", Ran{Stdout: out.String()}), 1000).eventsAt(context.Background(), now, 180, "", 200, false)
if err != nil {
t.Fatal(err)
}
if got["count"] != 0 || got["held"] != RuntimeEventsKept {
t.Fatalf("count %v held %v, want 0 shown of %d held", got["count"], got["held"], RuntimeEventsKept)
}
if got["complete"] != false || !strings.Contains(fmt.Sprint(got["history"]), "gone from here, not absent") ||
got["oldest_held"] != "2026-10-09T11:14:00Z" {
t.Fatalf("an empty answer from a short history reads as quiet: %v", got)
}
}
func TestEventsCoveringTheWholeWindowSayItIsCompleteAndFilterTheTypeHere(t *testing.T) {
now := time.Date(2026, 10, 9, 11, 24, 0, 0, time.UTC)
// A quiet machine: three events in the whole buffer, the oldest inside the window. Complete — the buffer
// is not full, so it holds every event since the runtime started.
out := eventLine(now.Add(-90*time.Minute), "container", "start", "old") +
eventLine(now.Add(-59*time.Minute), "network", "destroy", "build-net") +
eventLine(now.Add(-30*time.Minute), "container", "die", "build-bus") +
eventLine(now.Add(-1*time.Minute), "network", "create", "build-net")
f := (&fake{}).on("docker events", Ran{Stdout: out})
got, err := client(f, 1000).eventsAt(context.Background(), now, 60, "network", 200, false)
if err != nil {
t.Fatal(err)
}
if got["complete"] != true || got["count"] != 2 || got["held"] != 4 || !strings.Contains(fmt.Sprint(got["history"]), "every event since it started") {
t.Fatalf("a quiet machine's window: %v", got)
}
if f.ran("docker events --since 0 --until 0s --format {{json .}} --filter") {
t.Fatal("the type was filtered by the runtime, so the history's start is unknown")
}
// A quiet machine asked for ten minutes, its newest event an hour old: complete, and none.
got, _ = client(f, 1000).eventsAt(context.Background(), now.Add(time.Hour), 10, "", 200, false)
if got["complete"] != true || got["count"] != 0 {
t.Fatalf("a quiet machine read as one whose history was lost: %v", got)
}
// No events at all: none since the runtime started.
got, _ = client((&fake{}).on("docker events", Ran{}), 1000).eventsAt(context.Background(), now, 60, "", 200, false)
if got["complete"] != true || got["count"] != 0 {
t.Fatalf("an empty buffer: %v", got)
}
// A full buffer whose oldest is before the window: complete.
var full strings.Builder
for i := 0; i < RuntimeEventsKept; i++ {
full.WriteString(eventLine(now.Add(-2*time.Hour+time.Duration(i)*time.Second), "container", "exec_die", "x"))
}
got, _ = client((&fake{}).on("docker events", Ran{Stdout: full.String()}), 1000).eventsAt(context.Background(), now, 60, "", 200, false)
if got["complete"] != true || got["history"] != nil {
t.Fatalf("a full buffer reaching past the window: %v", got)
}
}
@@ -0,0 +1,80 @@
package main
// The container runtime's log rotation, checked before it reaches daemon.json (novox/hq issue 452).
//
// **Why a step and not the file alone.** daemon.json's log-driver and log-opts are filled by the controller
// from this module's settings log-max-size and log-max-file, pasted as text (novox/hq issue 346): the
// controller judges nothing but that a value is one line. The runtime is the only reader of daemon.json and
// reads log-opts only when it starts, so a bad value would be found at the runtime's next start, as a runtime
// that does not start, and a value with a quote in it could add any key to the file. So the module's
// manifest writes the same two values into log-rotation.conf first, and runs this as a step before daemon.json:
// a value refused here fails the step, and the host skips the rest of the module (novox/hq ADR 0136), so
// daemon.json keeps what it had, and the refusal is in the apply's report.
import (
"fmt"
"os"
"regexp"
"strings"
)
// logRotation is what log-rotation.conf says, both values as the runtime takes them: text.
type logRotation struct{ Size, Files string }
var (
// A size as the json-file driver reads it, with the unit required and in lower case, as decided on issue
// 452: a whole number from 1 with k, m or g, at most nine digits, so no value overflows the runtime's count.
logSize = regexp.MustCompile(`^[1-9][0-9]{0,8}[kmg]$`)
// A count of files from 1 to 99, without a leading zero.
logFiles = regexp.MustCompile(`^[1-9][0-9]?$`)
)
// parseLogRotation reads `size=` and `files=` once each; a comment or an empty line is skipped, and any other
// line is refused, so a value that brought a line of its own is never read past.
func parseLogRotation(text string) (logRotation, error) {
var got logRotation
seen := map[string]bool{}
for _, line := range strings.Split(text, "\n") {
if line == "" || strings.HasPrefix(line, "#") {
continue
}
key, value, ok := strings.Cut(line, "=")
if !ok || (key != "size" && key != "files") {
return logRotation{}, fmt.Errorf("log-rotation.conf has a line that is not size= or files=: %q", line)
}
if seen[key] {
return logRotation{}, fmt.Errorf("log-rotation.conf says %s twice", key)
}
seen[key] = true
switch key {
case "size":
if !logSize.MatchString(value) {
return logRotation{}, fmt.Errorf("log-max-size %q is not a size: a whole number from 1 with k, m or g "+
"after it, such as 100m; daemon.json was left as it is", value)
}
got.Size = value
case "files":
if !logFiles.MatchString(value) {
return logRotation{}, fmt.Errorf("log-max-file %q is not a count from 1 to 99; daemon.json was left "+
"as it is", value)
}
got.Files = value
}
}
for _, key := range []string{"size", "files"} {
if !seen[key] {
return logRotation{}, fmt.Errorf("log-rotation.conf does not say %s", key)
}
}
return got, nil
}
// checkLogRotationFile is the step: the file the manifest wrote, judged.
func checkLogRotationFile(path string) error {
raw, err := os.ReadFile(path)
if err != nil {
return err
}
_, err = parseLogRotation(string(raw))
return err
}
@@ -0,0 +1,214 @@
package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"reflect"
"regexp"
"strings"
"testing"
)
// The container runtime's log rotation is a preference (novox/hq issue 452).
type manifestForLogs struct {
Settings map[string]struct {
Kind string `json:"kind"`
Default any `json:"default"`
Why string `json:"why"`
} `json:"settings"`
Resources []map[string]any `json:"resources"`
}
func readManifestForLogs(t *testing.T) manifestForLogs {
t.Helper()
raw, err := os.ReadFile("../../module.json")
if err != nil {
t.Fatal(err)
}
var m manifestForLogs
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
return m
}
func resourceByID(m manifestForLogs, id string) (map[string]any, int) {
for i, r := range m.Resources {
if r["id"] == id {
return r, i
}
}
return nil, -1
}
// filled is a file's content as the controller fills it from the settings' defaults alone: each
// ${setting:<key>} replaced by the default as text, the way the controller writes a value into a file.
func filled(t *testing.T, m manifestForLogs, content string) string {
t.Helper()
ref := regexp.MustCompile(`\$\{setting:([a-z0-9][a-z0-9_.-]*)\}`)
var missing []string
out := ref.ReplaceAllStringFunc(content, func(s string) string {
key := ref.FindStringSubmatch(s)[1]
d, ok := m.Settings[key]
if !ok || d.Kind != "preference" {
missing = append(missing, key)
return s
}
switch v := d.Default.(type) {
case float64:
return fmt.Sprintf("%d", int64(v))
default:
return fmt.Sprint(v)
}
})
if len(missing) > 0 {
t.Fatalf("the content asks for %v, which the manifest gives no preference default", missing)
}
return strings.ReplaceAll(out, "${seat:mesh-artifact-store:reach}", "registry.example:5100")
}
func TestTheDefaultLogRotationIsTheOneTheHandSetMachineHas(t *testing.T) {
m := readManifestForLogs(t)
daemon, _ := resourceByID(m, "daemon")
if daemon == nil {
t.Fatal("no daemon resource")
}
var keys map[string]any
if err := json.Unmarshal([]byte(filled(t, m, daemon["content"].(string))), &keys); err != nil {
t.Fatalf("daemon.json's keys with the defaults are not JSON: %v", err)
}
if keys["log-driver"] != "json-file" {
t.Errorf("log-driver is %v, want json-file (the driver every machine runs today)", keys["log-driver"])
}
want := map[string]any{"max-size": "100m", "max-file": "10"}
if !reflect.DeepEqual(keys["log-opts"], want) {
t.Errorf("log-opts is %v, want %v", keys["log-opts"], want)
}
if keys["live-restore"] != true {
t.Errorf("live-restore is %v", keys["live-restore"])
}
// The values a person set are the terminal's only (novox/hq issue 346): the file says so.
if daemon["trusted"] != true {
t.Errorf("daemon asks for settings and says trusted %v, want true", daemon["trusted"])
}
}
func TestTheLogRotationIsCheckedBeforeDaemonJSONIsWritten(t *testing.T) {
m := readManifestForLogs(t)
conf, confAt := resourceByID(m, "log-rotation")
check, checkAt := resourceByID(m, "log-rotation-check")
_, daemonAt := resourceByID(m, "daemon")
if conf == nil || check == nil {
t.Fatal("no log-rotation file or log-rotation-check step")
}
// The host applies a module's resources in declared order, and a step that fails skips the rest
// of its module (novox/hq ADR 0136): declared before daemon, a refused value never reaches it.
if !(confAt < checkAt && checkAt < daemonAt) {
t.Errorf("order: log-rotation %d, log-rotation-check %d, daemon %d", confAt, checkAt, daemonAt)
}
if check["type"] != "process" || check["run-once"] != true || check["artifact"] != "tools" {
t.Errorf("the check is not a step of the tools bundle: %v", check)
}
if !reflect.DeepEqual(check["run"], []any{"./docker-tools", "check-log-rotation"}) {
t.Errorf("run %v", check["run"])
}
if !reflect.DeepEqual(check["restart-on"], []any{"log-rotation"}) {
t.Errorf("restart-on %v", check["restart-on"])
}
if _, asRoot := check["user"]; asRoot {
t.Errorf("the check reads a 0600 file and must run as root")
}
if conf["trusted"] != true {
t.Errorf("log-rotation says trusted %v, want true", conf["trusted"])
}
// The step reads the very values daemon.json takes: the same keys, filled the same way.
values, err := parseLogRotation(filled(t, m, conf["content"].(string)))
if err != nil {
t.Fatalf("the defaults are refused: %v", err)
}
if values != (logRotation{Size: "100m", Files: "10"}) {
t.Errorf("defaults read as %+v", values)
}
daemon, _ := resourceByID(m, "daemon")
for _, key := range []string{"${setting:log-max-size}", "${setting:log-max-file}"} {
if !strings.Contains(daemon["content"].(string), key) || !strings.Contains(conf["content"].(string), key) {
t.Errorf("%s is not in both daemon and log-rotation", key)
}
}
}
func TestTheRuntimeIsNeverRestartedForTheLogRotation(t *testing.T) {
m := readManifestForLogs(t)
runtime, _ := resourceByID(m, "runtime")
if _, restarts := runtime["restart-on"]; restarts {
t.Errorf("docker.service restarts on %v: a restart stops every container", runtime["restart-on"])
}
if !reflect.DeepEqual(runtime["reload-on"], []any{"daemon"}) {
t.Errorf("reload-on %v", runtime["reload-on"])
}
}
func TestABadLogRotationIsRefused(t *testing.T) {
good := map[string]string{"size": "100m", "files": "10"}
conf := func(over map[string]string) string {
lines := []string{"# a comment"}
for _, k := range []string{"size", "files"} {
v := good[k]
if o, ok := over[k]; ok {
v = o
}
lines = append(lines, k+"="+v)
}
return strings.Join(lines, "\n") + "\n"
}
for _, bad := range []map[string]string{
{"size": "100"}, {"size": "10x"}, {"size": "0m"}, {"size": "-1"}, {"size": ""}, {"size": "m"},
{"size": "1.5m"}, {"size": "10M"}, {"size": "100m\""}, {"size": `100m", "data-root": "/tmp`},
{"size": "100m\nfiles=3"}, {"size": " 100m"}, {"size": "99999999999m"},
{"files": "0"}, {"files": "100"}, {"files": "-1"}, {"files": ""}, {"files": "01"}, {"files": "1\""},
{"files": "1\nfiles=2"}, {"files": "3.0"}, {"files": "ten"},
} {
if v, err := parseLogRotation(conf(bad)); err == nil {
t.Errorf("%q accepted as %+v", bad, v)
}
}
for _, broken := range []string{"size=100m\n", "files=10\n", "size=100m\nfiles=10\nfiles=3\n",
"size=100m\nfiles=10\nmode=x\n", "size=100m\nfiles\n"} {
if v, err := parseLogRotation(broken); err == nil {
t.Errorf("%q accepted as %+v", broken, v)
}
}
}
func TestAGoodLogRotationIsAccepted(t *testing.T) {
for _, c := range []struct{ size, files string }{{"100m", "10"}, {"512k", "1"}, {"2g", "99"}, {"50m", "3"}} {
got, err := parseLogRotation("# comment\nsize=" + c.size + "\nfiles=" + c.files + "\n")
if err != nil {
t.Errorf("%v refused: %v", c, err)
continue
}
if got != (logRotation{Size: c.size, Files: c.files}) {
t.Errorf("%v read as %+v", c, got)
}
}
// And from the file the step is handed.
path := filepath.Join(t.TempDir(), "log-rotation.conf")
if err := os.WriteFile(path, []byte("size=50m\nfiles=3\n"), 0o600); err != nil {
t.Fatal(err)
}
if err := checkLogRotationFile(path); err != nil {
t.Errorf("a good file refused: %v", err)
}
if err := os.WriteFile(path, []byte("size=50\nfiles=3\n"), 0o600); err != nil {
t.Fatal(err)
}
if err := checkLogRotationFile(path); err == nil || !strings.Contains(err.Error(), "log-max-size") {
t.Errorf("a bad size was not refused by its setting's name: %v", err)
}
if err := checkLogRotationFile(filepath.Join(t.TempDir(), "absent")); err == nil {
t.Error("an absent file was accepted")
}
}
+29 -1
View File
@@ -16,6 +16,20 @@ import (
)
func main() {
// Started as `docker-tools check-log-rotation` it is the module's step that judges the log rotation before
// daemon.json takes it (logrotation.go, novox/hq issue 452): run as root by the host, reading the file
// DOCKER_LOG_ROTATION names. With no argument, the runtime's tools bundle.
if len(os.Args) > 1 {
if os.Args[1] != "check-log-rotation" || len(os.Args) != 2 {
fmt.Fprintln(os.Stderr, "usage: docker-tools [check-log-rotation]")
os.Exit(2)
}
if err := checkLogRotationFile(os.Getenv("DOCKER_LOG_ROTATION")); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
return
}
// An empty name serves as the module the runtime names (MESH_SERVED_MODULE): docker.
if err := stdio.Serve("", tools(NewClient())); err != nil {
fmt.Fprintln(os.Stderr, err)
@@ -70,6 +84,19 @@ func tools(c *Client) []stdio.Tool {
return c.Inspect(ctx, ref)
},
},
{
Name: "docker_resolv_conf",
Description: "The resolver file one container really uses (/etc/resolv.conf as it sees it, read through the runtime's archive, no exec): " +
"its nameservers, search and options and the raw text, beside the machine's own nameservers and whether they are the same.",
Input: map[string]any{"container": containerArg},
Run: func(args map[string]any) (any, error) {
ref, err := text(args, "container")
if err != nil {
return nil, err
}
return c.ResolvConfOf(ctx, ref)
},
},
{
Name: "docker_logs",
Description: "The last lines one container wrote, both streams merged in order, each with its timestamp (default 200, at most 2000 lines; a line is cut at 4 KiB). " +
@@ -262,7 +289,8 @@ func tools(c *Client) []stdio.Tool {
{
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; " +
"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.",
"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. " +
"The runtime keeps only its last ~256 events in memory, which health-check execs fill in minutes: complete=false and history say where what it holds begins, and an empty list then means gone, not quiet.",
Input: map[string]any{
"minutes": map[string]any{"type": "integer", "description": "how far back (default 60, at most 1440)"},
"type": map[string]any{"type": "string", "description": "only one kind: container, image, network, volume, daemon, plugin or builder"},
+111
View File
@@ -0,0 +1,111 @@
package main
// What resolver a container really asks (hq issue 348). A container gets its own copy of the machine's
// resolver file, made by the runtime when it is created and kept under the runtime's root-only
// directory; a container on the host's network gets a copy too. During the 2026-10-09 outage the
// control node's route-proxy failed every lookup against 10.10.0.1 alone for twenty minutes while the
// machine's file listed both mesh resolvers — and nothing could show what its copy said. This reads the
// file as the container sees it, through the runtime's archive endpoint (`docker cp … -`, a tar stream):
// no exec into the container, nothing written, and it works where the program has no shell.
import (
"archive/tar"
"context"
"errors"
"fmt"
"io"
"strings"
)
// ContainerResolvConf is the path read inside a container.
const ContainerResolvConf = "/etc/resolv.conf"
// HostResolvConf is the machine's own resolver file, read for comparison.
const HostResolvConf = "/etc/resolv.conf"
// ResolvConf is a resolver file, parsed.
type ResolvConf struct {
Nameservers []string `json:"nameservers"`
Search []string `json:"search,omitempty"`
Options []string `json:"options,omitempty"`
Raw string `json:"raw"`
}
// parseResolvConf reads nameserver, search (and domain) and options lines; comments are kept in Raw only.
func parseResolvConf(text string) ResolvConf {
out := ResolvConf{Nameservers: []string{}, Raw: text}
for _, line := range strings.Split(text, "\n") {
f := strings.Fields(line)
if len(f) == 0 || strings.HasPrefix(f[0], "#") || strings.HasPrefix(f[0], ";") {
continue
}
switch f[0] {
case "nameserver":
if len(f) > 1 {
out.Nameservers = append(out.Nameservers, f[1])
}
case "search", "domain":
out.Search = append(out.Search, f[1:]...)
case "options":
out.Options = append(out.Options, f[1:]...)
}
}
return out
}
// fileFromArchive is the one regular file a `docker cp <ref>:<path> -` stream carries.
func fileFromArchive(stream string) (string, error) {
tr := tar.NewReader(strings.NewReader(stream))
for {
h, err := tr.Next()
if errors.Is(err, io.EOF) {
return "", errors.New("the runtime's archive held no file")
}
if err != nil {
return "", fmt.Errorf("the runtime's archive could not be read: %w", err)
}
switch h.Typeflag {
case tar.TypeReg:
body, err := io.ReadAll(io.LimitReader(tr, 64<<10))
if err != nil {
return "", fmt.Errorf("the runtime's archive could not be read: %w", err)
}
return string(body), nil
case tar.TypeSymlink:
return "", fmt.Errorf("inside the container %s is a link to %s, which the runtime's archive does not follow", h.Name, h.Linkname)
}
}
}
// ResolvConfOf is a container's resolver file as it sees it, beside the machine's own, and whether they list
// the same resolvers in the same order.
func (c *Client) ResolvConfOf(ctx context.Context, ref string) (map[string]any, error) {
ref, err := Ref(ref)
if err != nil {
return nil, err
}
answer := map[string]any{"container": ref, "path": ContainerResolvConf}
stream, err := c.docker(ctx, "cp", ref+":"+ContainerResolvConf, "-")
if err != nil {
return nil, fmt.Errorf("the container's %s could not be read: %w", ContainerResolvConf, err)
}
text, err := fileFromArchive(stream)
if err != nil {
return nil, fmt.Errorf("the container's %s could not be read: %w", ContainerResolvConf, err)
}
inside := parseResolvConf(text)
answer["resolv_conf"] = inside
if raw, err := c.ReadFile(HostResolvConf); err != nil {
answer["machine"] = "the machine's " + HostResolvConf + " could not be read for comparison: " + err.Error()
} else {
host := parseResolvConf(string(raw))
answer["machine_nameservers"] = host.Nameservers
same := strings.Join(host.Nameservers, " ") == strings.Join(inside.Nameservers, " ")
answer["same_as_machine"] = same
if !same {
answer["note"] = "the container asks other resolvers than the machine lists: its copy was made when it was created, " +
"or the runtime's dns setting chose them; a container is given the machine's current file only when it is created again"
}
}
return answer, nil
}
@@ -0,0 +1,96 @@
package main
import (
"archive/tar"
"bytes"
"context"
"errors"
"strings"
"testing"
)
func archiveOf(t *testing.T, h tar.Header, body string) string {
t.Helper()
var buf bytes.Buffer
w := tar.NewWriter(&buf)
h.Size = int64(len(body))
if h.Typeflag == tar.TypeSymlink {
h.Size = 0
}
if err := w.WriteHeader(&h); err != nil {
t.Fatal(err)
}
if h.Typeflag != tar.TypeSymlink {
if _, err := w.Write([]byte(body)); err != nil {
t.Fatal(err)
}
}
w.Close()
return buf.String()
}
// Issue 348: route-proxy asked 10.10.0.1 alone while the machine listed both mesh resolvers; its copy
// of the file could not be read by anything on the mesh.
func TestAContainersResolverFileIsReadThroughTheArchiveAndComparedWithTheMachines(t *testing.T) {
inside := "# Generated by Docker Engine.\nnameserver 10.10.0.1\nsearch novox.internal\noptions timeout:1 attempts:2\n"
f := (&fake{}).on("docker cp route-proxy:/etc/resolv.conf -",
Ran{Stdout: archiveOf(t, tar.Header{Name: "resolv.conf", Typeflag: tar.TypeReg, Mode: 0o644}, inside)})
c := client(f, 1000)
c.ReadFile = func(p string) ([]byte, error) {
if p != HostResolvConf {
t.Fatalf("read %s", p)
}
return []byte("nameserver 10.10.0.1\nnameserver 10.10.0.2\noptions timeout:1 attempts:2 edns0\n"), nil
}
got, err := c.ResolvConfOf(context.Background(), "route-proxy")
if err != nil {
t.Fatal(err)
}
rc := got["resolv_conf"].(ResolvConf)
if strings.Join(rc.Nameservers, ",") != "10.10.0.1" || strings.Join(rc.Search, ",") != "novox.internal" ||
strings.Join(rc.Options, ",") != "timeout:1,attempts:2" || rc.Raw != inside {
t.Fatalf("parsed: %+v", rc)
}
if got["same_as_machine"] != false || got["note"] == nil {
t.Fatalf("a copy listing fewer resolvers than the machine is not said: %v", got)
}
if f.ran("docker exec") {
t.Fatal("read by exec, not through the archive")
}
}
func TestAResolverFileThatCannotBeReadIsSaidPlainly(t *testing.T) {
c := client((&fake{}).on("docker cp", Ran{Status: 1, Stderr: "Error response from daemon: No such container: nope\n"}), 1000)
if _, err := c.ResolvConfOf(context.Background(), "nope"); err == nil || !strings.Contains(err.Error(), "could not be read") {
t.Fatalf("a missing container: %v", err)
}
link := archiveOf(t, tar.Header{Name: "resolv.conf", Typeflag: tar.TypeSymlink, Linkname: "/run/systemd/resolve/stub-resolv.conf"}, "")
c = client((&fake{}).on("docker cp", Ran{Stdout: link}), 1000)
if _, err := c.ResolvConfOf(context.Background(), "x"); err == nil || !strings.Contains(err.Error(), "link to") {
t.Fatalf("a link inside: %v", err)
}
c = client((&fake{}).on("docker cp", Ran{Stdout: "not a tar"}), 1000)
if _, err := c.ResolvConfOf(context.Background(), "x"); err == nil {
t.Fatal("garbage was read as a file")
}
ok := archiveOf(t, tar.Header{Name: "resolv.conf", Typeflag: tar.TypeReg, Mode: 0o644}, "nameserver 10.10.0.1\n")
c = client((&fake{}).on("docker cp", Ran{Stdout: ok}), 1000)
c.ReadFile = func(string) ([]byte, error) { return nil, errors.New("permission denied") }
got, err := c.ResolvConfOf(context.Background(), "x")
if err != nil || !strings.Contains(got["machine"].(string), "could not be read") || got["same_as_machine"] != nil {
t.Fatalf("an unreadable machine file: %v, %v", got, err)
}
}
// The container is named as docker knows it, never as something docker would read as an option.
func TestAResolverFileIsAskedOnlyOfAContainerRef(t *testing.T) {
f := &fake{}
for _, ref := range []string{"--privileged", "a b", "", "x;rm"} {
if _, err := client(f, 1000).ResolvConfOf(context.Background(), ref); err == nil {
t.Errorf("%q was asked of the runtime", ref)
}
}
if len(f.calls) != 0 {
t.Fatalf("the runtime was asked: %+v", f.calls)
}
}
@@ -5,6 +5,7 @@ import (
"encoding/json"
"strings"
"testing"
"time"
)
// The shape of the leak in hq issue 268: a server password announced at start and a database URI
@@ -210,7 +211,7 @@ 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)
got, err := client(f, 1000).eventsAt(context.Background(), time.Unix(0, 1791320130000000000), 30, "", 100, true)
if err != nil {
t.Fatal(err)
}
@@ -0,0 +1,119 @@
package main
import (
"fmt"
"regexp"
"strings"
"testing"
)
// Docker's own directory is never the mesh's (novox/hq issue 452). The node-engine places nothing in or
// below the container runtimes' data (novox/hq issue 339), so a module directory that resolves there is
// refused on the machine and fails the whole apply. The merge gate composes a change but does not apply it,
// so it passed #205's `"place": "."`, which the controller resolves to <root>/<module>: /var/lib/docker.
// runtimeTrees are the trees the node-engine refuses to place in (mesh-host internal/apply/placement_guard.go).
var runtimeTrees = []string{"/var/lib/docker", "/var/lib/containers", "/var/lib/containerd"}
// defaultDataRoot is the controller's root for placed directories when a node states none
// (mesh-controller internal/catalogue/dir_into.go).
const defaultDataRoot = "/var/lib"
// placedDirs resolves each directory resource as the controller's dirsFor does on a node with the default
// data root: a stated path as written, `"place": "."` as <root>/<module>, `"place": "mesh"` as
// <root>/mesh/<module>, and neither as <root>/<module>/<id>.
func placedDirs(t *testing.T, m manifestForLogs) map[string]string {
t.Helper()
dirs := map[string]string{}
for _, r := range m.Resources {
if r["type"] != "directory" {
continue
}
id := fmt.Sprint(r["id"])
if p, ok := r["path"].(string); ok && p != "" {
dirs[id] = strings.TrimRight(p, "/")
continue
}
switch r["place"] {
case ".":
dirs[id] = defaultDataRoot + "/docker"
case "mesh":
dirs[id] = defaultDataRoot + "/mesh/docker"
default:
dirs[id] = defaultDataRoot + "/docker/" + id
}
}
return dirs
}
func inRuntimeTree(p string) string {
for _, tree := range runtimeTrees {
if p == tree || strings.HasPrefix(p, tree+"/") {
return tree
}
}
return ""
}
// TestNothingIsPlacedInTheRuntimesData: no directory of the module resolves into Docker's own directory or
// another runtime's, and no file, process or environment value names a path there through ${dir:…} or as
// written.
func TestNothingIsPlacedInTheRuntimesData(t *testing.T) {
m := readManifestForLogs(t)
dirs := placedDirs(t, m)
for id, p := range dirs {
if tree := inRuntimeTree(p); tree != "" {
t.Errorf("directory %q resolves to %s, inside %s: the container runtime's data, never the mesh's (novox/hq issues 339, 452)", id, p, tree)
}
}
ref := regexp.MustCompile(`\$\{dir:([a-z0-9][a-z0-9-]*)\}`)
fill := func(s string) string {
return ref.ReplaceAllStringFunc(s, func(r string) string {
if p, ok := dirs[ref.FindStringSubmatch(r)[1]]; ok {
return p
}
return r
})
}
for _, r := range m.Resources {
id := fmt.Sprint(r["id"])
var named []string
if p, ok := r["path"].(string); ok {
named = append(named, p)
}
if env, ok := r["env"].(map[string]any); ok {
for _, v := range env {
if s, ok := v.(string); ok {
named = append(named, s)
}
}
}
for _, p := range named {
if tree := inRuntimeTree(fill(p)); tree != "" {
t.Errorf("resource %q names %s, inside %s: the container runtime's data, never the mesh's (novox/hq issues 339, 452)", id, fill(p), tree)
}
}
}
}
// TestLogRotationIsInTheModulesOwnDirectory: the step reads the file the mesh writes, in a directory the mesh
// places for the module, not one Docker keeps.
func TestLogRotationIsInTheModulesOwnDirectory(t *testing.T) {
m := readManifestForLogs(t)
dirs := placedDirs(t, m)
if got, want := dirs["state"], "/var/lib/mesh/docker"; got != want {
t.Fatalf("the module's state directory resolves to %q, want %q (novox/hq issue 452)", got, want)
}
file, _ := resourceByID(m, "log-rotation")
step, _ := resourceByID(m, "log-rotation-check")
if file == nil || step == nil {
t.Fatal("log-rotation and log-rotation-check are declared")
}
env, _ := step["env"].(map[string]any)
if file["path"] != env["DOCKER_LOG_ROTATION"] {
t.Errorf("the step reads %v, the mesh writes %v: one file", env["DOCKER_LOG_ROTATION"], file["path"])
}
if file["path"] != "${dir:state}/log-rotation.conf" {
t.Errorf("log-rotation is at %v, want ${dir:state}/log-rotation.conf", file["path"])
}
}

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