Compare commits

...
Author SHA1 Message Date
jochen e7ff7c3045 Write a provisioner client's files only on a change over the bus, never on a timer (issue 1129, ADR 0349 clause 4)
mesh/merge-gate head e7ff7c3045fc merged into trunk afb94d925e94 — pass: builds mesh-provisioner-client → ace, g14, novox, shanks; no bus step; every machi…
mesh/repo-check head e7ff7c3045fc merged into trunk afb94d925e94 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
2026-10-18 18:08:50 +02:00
mesh-tools cfb90d7b2f Merge pull request 'The gitea module makes the organisation's repositories from its config, protected before any team reaches them; no tool creates in the organisation (issue 1124, ADR 0428)' (#363) from fix/1124-repositories-config into main 2026-10-18 15:05:04 +00:00
jschoubben 11761637cf Put main's rule right again once a new repository is in the organisation: the forge drops a merger without access, and under the setup account the tools' account has none (issue 1124, ADR 0428 §3, review round 1)
mesh/merge-gate head 11761637cf81 merged into trunk e47d0694c752 — pass: builds gitea → novox; no bus step; every machine composes with the change as it di…
mesh/repo-check head 11761637cf81 merged into trunk e47d0694c752 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
2026-10-18 16:58:17 +02:00
jschoubben 8c587d0f00 Let the repositories pass's failed exit be its gate: a run-once step the node-engine runs again until it passes, its stderr naming each refused repository, step and forge answer, instead of a health check of its own (issue 1124, ADR 0428 §6) 2026-10-18 16:52:29 +02:00
jschoubben df275b3c6e Make the organisation's repositories from the gitea module's config value, protected under the setup account before they join the organisation, and take the owner argument from gitea_create_repo, since a repository's maker becomes its administrator (issue 1124, ADR 0428) 2026-10-18 16:49:12 +02:00
jschoubben 94b49a296f Test that the gitea module makes the organisation's repositories from its config, protected before any team reaches them, and that no tool creates in the organisation (issue 1124, ADR 0428) 2026-10-18 16:45:49 +02:00
mesh-tools e47d0694c7 Merge pull request 'gitea_create_repo creates a repository in an organisation, saying the forge's refusal (issue 1124)' (#362) from fix/1124-create-repo-in-org into main 2026-10-18 13:50:31 +00:00
jschoubben 0d3f0fd3fc Let gitea_create_repo create in an organisation, saying the forge's refusal, so novox repositories can be made through the tools (issue 1124)
mesh/merge-gate head 0d3f0fd3fc89 merged into trunk 7f77ce663a9e — pass: builds gitea → novox; no bus step; every machine composes with the change as it di…
mesh/repo-check head 0d3f0fd3fc89 merged into trunk 7f77ce663a9e — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
2026-10-18 15:45:01 +02:00
jschoubben 50fee22d0a Test that gitea_create_repo creates in an organisation and says the forge's refusal (issue 1124) 2026-10-18 15:44:38 +02:00
mesh-tools 7f77ce663a Merge pull request 'mesh-delivery waits for nothing: a check is asked at once and a standing verdict adopted (issue 1115)' (#361) from fix/zero-delay into main 2026-10-18 10:38:05 +00:00
jschoubben 6954467292 mesh-delivery bus test waits for the group's composed check, not a member's own check asked at once (issue 1115)
mesh/merge-gate head 695446729264 merged into trunk 580fadb63ecb — pass: builds mesh-delivery → novox; no bus step; every machine composes with the change …
mesh/repo-check head 695446729264 merged into trunk 580fadb63ecb — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
2026-10-18 12:33:02 +02:00
jschoubben 5f002441b7 mesh-delivery takes an empty answer to a check ask as a refusal (issue 1115)
mesh/merge-gate head 5f002441b7f0 merged into trunk 580fadb63ecb — pass: builds mesh-delivery → novox; no bus step; every machine composes with the change …
mesh/repo-check head 5f002441b7f0 merged into trunk 580fadb63ecb — fail: its merge-check.sh failed: --- FAIL: TestAGroupOfTwoWithGreenChecksGetsOneAskAndOn…
mesh/delivery superseded: a newer head of the same pull request
2026-10-18 12:28:03 +02:00
jschoubben fc9ddd9185 mesh-delivery adopts a standing verdict only when every required check stands, at every look, and counts an ask with no id as none (issue 1115) 2026-10-18 12:25:53 +02:00
jschoubben 1760b94824 mesh-delivery waits for nothing: a check is asked at once and a standing verdict adopted (issue 1115) 2026-10-18 12:22:31 +02:00
mesh-tools 580fadb63e Merge pull request 'A pull request at the same head as one closed unmerged takes its delivery over (issue 1114)' (#360) from fix/takeover-on-close into main 2026-10-18 09:59:02 +00:00
jschoubben 30d3bb9466 A pull request announced at a live head takes its delivery over when the other one closes or is edited (issue 1114)
mesh/merge-gate head 30d3bb94667f merged into trunk e40ec47058e6 — pass: builds mesh-delivery → novox; no bus step; every machine composes with the change …
mesh/repo-check head 30d3bb94667f merged into trunk e40ec47058e6 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
2026-10-18 11:53:28 +02:00
mesh-tools e40ec47058 Merge pull request 'mesh-delivery hears a pull request closed unmerged from the forge's own event (issue 1107)' (#359) from fix/close-heard-from-forge-event into main 2026-10-18 09:00:18 +00:00
jschoubben bc1acddaa4 mesh-delivery hears the forge's own pull-request closed event as a close unmerged (issue 1107)
mesh/merge-gate head bc1acddaa4e5 merged into trunk 798d6fd68589 — pass: builds mesh-delivery → novox; no bus step; every machine composes with the change …
mesh/repo-check head bc1acddaa4e5 merged into trunk 798d6fd68589 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
2026-10-18 04:55:48 +02:00
mesh-tools 798d6fd685 Merge pull request 'A group member refused for its own pending repo-check is asked its own check, not the composed one (issue 1049)' (#358) from fix/1049-own-check-on-refusal into main 2026-10-18 02:41:25 +00:00
jschoubben b0ec4f6f14 A group member refused for a pending status of its own asks its own check once, not the composed check (issue 1049)
mesh/merge-gate head b0ec4f6f1405 merged into trunk d3dd46a9fdb4 — pass: builds mesh-delivery → novox; no bus step; every machine composes with the change …
mesh/repo-check head b0ec4f6f1405 merged into trunk d3dd46a9fdb4 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
2026-10-18 04:27:42 +02:00
mesh-tools d3dd46a9fd Merge pull request 'Readers of the assignments skip an ended or pending one (design 47 milestone 5, issue 1098)' (#357) from feat/chain-on-the-bus into main 2026-10-18 02:04:16 +00:00
jschoubben 3bc4215d87 Take the bus's backup guard out of modules/nats: it would move the bus (issue 1098, guard follows in issue 1104)
mesh/repo-check head 3bc4215d872c merged into trunk 44e7002d3c84 beside mesh-controller#378@0149a3d7 — pass: its merge-check.sh passed, each part in its to…
mesh/delivery stopped: its walk plan-1792289060772890935 was stopped by a person (mesh-controller): the operator, via telegram (user id verified), chose …
mesh/merge-gate out of date: judged against trunk 798d6fd68589, the trunk is now e40ec47058e6; judged again on a recheck or when it is merged
mesh/delivery-group group feat/chain-on-the-bus closed: a member was closed before the group merged
2026-10-18 03:55:50 +02:00
jschoubben 68e3326d4c Providers and the provisioner client take the SDK that reads an ended assignment as a removed key (issue 1098)
mesh/repo-check head 68e3326d4cbc merged into trunk 44e7002d3c84 beside mesh-controller#378@b56c1d00 — pass: its merge-check.sh passed, each part in its to…
mesh/delivery-group group feat/chain-on-the-bus ready: every member ready, and composed together they pass
mesh/merge-gate head 68e3326d4cbc merged into trunk 44e7002d3c84 — pass: group feat/chain-on-the-bus composed: every machine composes with the change as it…
mesh/delivery superseded: a newer head of the same pull request
2026-10-18 02:40:18 +02:00
jschoubben 84af0ed180 Guard that the bus storage stays declared as data with a backup (issue 1098) 2026-10-18 02:37:28 +02:00
jschoubben 9edd16eaeb mesh-provisioner-client takes a module off the node when its assignment is ended (issue 1098) 2026-10-18 02:37:28 +02:00
jschoubben 60a3823f0e mesh-vault reads an ended assignment as a removed key (issue 1098) 2026-10-18 02:37:28 +02:00
jschoubben bdbca4b921 mesh-config drops an ended assignment as a removed one (issue 1098) 2026-10-18 02:37:28 +02:00
mesh-tools 44e7002d3c Merge pull request 'A group approved together merges in one pass and goes out in one delivery plan (design 47 milestone 3 fix: issues 1049, 1082, 1047, 1083, 1079)' (#356) from feat/group-one-pass into main 2026-10-17 23:44:21 +00:00
jschoubben 7383bc3170 A stale composed pass counts members judged since it was asked, whichever verdict lands first (issue 1047, review round 2)
mesh/merge-gate head 7383bc31703e merged into trunk 0c56f603059b — pass: builds gitea, mesh-delivery → novox; no bus step; every machine composes with the …
mesh/repo-check head 7383bc31703e merged into trunk 0c56f603059b — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
2026-10-18 01:20:18 +02:00
jschoubben ebd29223f3 Prove over a real bus that an edit of the description asks the Approve again (issue 1079, review round 1) 2026-10-18 01:18:43 +02:00
jschoubben e8a74765f8 A composed check that lands judged against a trunk that has moved is out of date and asked again (issue 1047, review round 1) 2026-10-18 01:18:13 +02:00
jschoubben 6556e61485 Read a pull request's review record again when its description is edited, and ask the Approve again if it was unanswered (issue 1079) 2026-10-18 01:12:40 +02:00
jschoubben 93365be98f A new pull request at a commit whose delivery was stopped and unmerged takes that delivery over (issue 1083) 2026-10-18 01:10:51 +02:00
jschoubben 814c1a991c A trunk move asks at once the check of a group member whose Approve is not asked yet (issue 1047) 2026-10-18 01:10:12 +02:00
jschoubben 983e4a6444 A closed group's member whose merge was refused asks the composed check again at once, and a refusal loop past ten minutes is said (issue 1082) 2026-10-18 01:09:37 +02:00
jschoubben da094008ea A group's composed verdict names each member's siblings at the heads it composed, so a sibling's own merge is no move (issue 1049) 2026-10-18 01:08:26 +02:00
mesh-tools 0c56f60305 Merge pull request 'Let a proposed record be withdrawn (issue 1081)' (#355) from fix/1081-withdraw into main 2026-10-17 22:15:25 +00:00
jochen 684e8801c4 Take the ask back on the router when withdrawing a record (issue 1081, review round 1)
mesh/merge-gate head 684e8801c420 merged into trunk 3395af96bc34 — pass: builds mesh-knowledge → novox; no bus step; every machine composes with the change…
mesh/repo-check head 684e8801c420 merged into trunk 3395af96bc34 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
WithdrawRecord only ended the waiting ask locally; the router kept it open, so
the operator still saw Approve/Decline for a withdrawn record and an answer to
it was dropped silently. It now also publishes a cancel on the router's
subject once the withdraw is saved, as the controller's asker does; a cancel
the router cannot be told of is logged and the withdraw still stands. The
module's existing "uses": ["operator-channel"] already grants this; no manifest
change needed, since the ask and cancel subjects are both under that seat.
2026-10-18 00:09:16 +02:00
jochen 6394a62a02 Merge remote-tracking branch 'origin/main' into fix/1081-withdraw 2026-10-18 00:03:16 +02:00
jochen ddf7a5ab09 Let a proposed record be withdrawn (issue 1081)
A dropped proposal or a stale reservation stayed proposed forever: there was
no way to say the operator decided against it. knowledge-base.withdraw takes
a reserved or proposed record to withdrawn as a new version naming why, ends
a waiting operator's ask and clears a pending review in the same version,
and refuses an already accepted, superseded or withdrawn record. The number
stays spent either way.

The old Withdraw (the background job for reservations never proposed) is
renamed WithdrawStale to make room for the new, caller-driven WithdrawRecord.
2026-10-18 00:02:48 +02:00
mesh-tools 3395af96bc Merge pull request 'mesh-delivery holds a delivery whose backup failed and release asks again; restic records a backup that cannot start (issues 1067, 1068)' (#353) from feat/backup-before-send into main 2026-10-17 21:48:51 +00:00
jschoubben 4d03755c65 Bring the branch up to main after #354 (issue 1047: the trunk move asked no check)
mesh/repo-check head 4d03755c6598 merged into trunk ad80048c22d8 beside mesh-controller#375@91a7c1ea — pass: its merge-check.sh passed, each part in its to…
mesh/merge-gate head 4d03755c6598 merged into trunk ad80048c22d8 — pass: group feat/backup-before-send composed: every machine composes with the change as …
mesh/delivery delivered
mesh/delivery-group group feat/backup-before-send closed: a member was closed before the group merged
2026-10-17 23:43:21 +02:00
mesh-tools ad80048c22 Merge pull request 'Tools to read the knowledge base and the issue tracker in fewer, smaller calls (issues 1064, 1069, 1070, 1071)' (#354) from feat/kb-tracker-tools into main 2026-10-17 21:34:20 +00:00
jochen bf3b3a5e36 Review round 1: list's pending ignores an ask that has ended, and its before cursor never cuts a page between a decision and research effort of the same number (issue 1064)
mesh/merge-gate head bf3b3a5e362b merged into trunk f076cadecedc — pass: builds mesh-issues, mesh-knowledge → novox; no bus step; every machine composes wi…
mesh/repo-check head bf3b3a5e362b merged into trunk f076cadecedc — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
2026-10-17 23:09:46 +02:00
jochen 7bdb8f4782 Review round 1: list numbers combines search and before as AND, its limit defaults to the count asked and said names a cut; README names brief, numbers and worker (issues 1070, 1071) 2026-10-17 23:07:29 +02:00
jochen b387cddc7c Let the knowledge base answer its whole-set findings, whole or by record (issue 1069) 2026-10-17 23:01:22 +02:00
jochen 1cfbcf8e30 Let the knowledge base list its records by kind, status, topic or a flag (issue 1064) 2026-10-17 22:59:09 +02:00
jschoubben c898a937a1 Order the events of the failed-backup bus test: each after the module took the one before (issue 1068)
mesh/merge-gate waits for its group's composed check
mesh/repo-check head c898a937a1e3 merged into trunk f076cadecedc beside mesh-controller#375@a39d6ce7 — pass: its merge-check.sh passed, each part in its to…
mesh/delivery-group group feat/backup-before-send checking: 1 of 2 member(s) ready
mesh/delivery superseded: a newer head of the same pull request
2026-10-17 22:57:44 +02:00
jschoubben 82693a441c A release the controller refuses to take leaves the delivery held for its failed backup (issue 1068)
mesh/merge-gate waits for its group's composed check
mesh/repo-check head 82693a441cf0 merged into trunk f076cadecedc beside mesh-controller#375@a39d6ce7 — fail: its merge-check.sh failed: --- FAIL: TestAPlan…
mesh/delivery-group group feat/backup-before-send rejected: a member's own check failed
mesh/delivery superseded: a newer head of the same pull request
2026-10-17 22:53:43 +02:00
jochen 1124f17912 Let issue-tracker.list take numbers and worker (issue 1070) 2026-10-17 22:50:09 +02:00
jschoubben 4799f79242 Read the plan's backups as the controller says them: machine, module, state, words (issue 1068) 2026-10-17 22:45:46 +02:00
jochen 01c9ab0cf5 Let issue-tracker.get answer briefly, with only its last note (issue 1071) 2026-10-17 22:45:42 +02:00
jschoubben 8f7aeeeb81 Hold a delivering delivery for a failed backup before the send; release asks the backups again (issue 1068) 2026-10-17 22:45:03 +02:00
jschoubben 914099774e Record a backup that cannot start as the module's last night (issue 1067) 2026-10-17 22:42:21 +02:00
mesh-tools f076cadece Merge pull request 'No ready mark: the Approve is asked once the checks pass, and a group merges in one pass on its one Approve (issues 1022, 978)' (#352) from feat/one-group-one-plan into main 2026-10-17 20:11:06 +00:00
jschoubben 0771797431 Merge remote-tracking branch 'origin/main' into feat/one-group-one-plan
mesh/merge-gate head 0771797431ed merged into trunk f69f84b9e54c beside mesh-controller#374@2d24fbb7 — pass: builds gitea, mesh-delivery → novox; no bus st…
mesh/repo-check head 0771797431ed merged into trunk f69f84b9e54c beside mesh-controller#374@2d24fbb7 — pass: its merge-check.sh passed, each part in its to…
mesh/delivery delivered
mesh/delivery-group group feat/one-group-one-plan merged: every member is merged
2026-10-17 21:40:08 +02:00
mesh-tools f69f84b9e5 Merge pull request 'Run the integration-tagged tests in the merge check, before any test carries the tag (issue 895)' (#351) from fix/895-catalog-script-first into main 2026-10-17 19:38:15 +00:00
jschoubben 47e42806ff Leave a composed member's gate to the group's verdict, reword the mark leftovers, restore main's merge-check.sh (issues 1022, 978) 2026-10-17 21:33:03 +02:00
jschoubben 86d5706182 Run the integration-tagged tests in the merge check, before any test carries the tag (issue 895)
mesh/delivery delivered
mesh/merge-gate head 86d5706182fd merged into trunk f69f84b9e54c — pass: the change touches no module of the mesh's graph
mesh/repo-check head 86d5706182fd merged into trunk f69f84b9e54c — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
2026-10-17 21:32:23 +02:00
jschoubben 2d3b61ec45 Merge a group in one pass on its one Approve, with no order and no turns (issue 978)
A group has no order: mesh-delivery asks the controller for none, reads no after:
line (it says once that the line is not read), merges every member in one pass
once the composed check passed and the Approve covers the heads, and gives the
plan its word once every member is merged. A failed member stops no other. The
composed verdict sets each member's mesh/merge-gate (pending, saying why, until
one stands), the groups answer says composed_gate, and a trunk move asks the
out-of-date members' checks at once and merges on the same Approve (issue 1047).
2026-10-17 20:54:16 +02:00
jschoubben 668444785d Ask the Approve as soon as the checks pass, with no ready mark (issue 1022)
The review finishes before the push and its record is a Reviewed-by line in the
description, so the mark repeated what the push meant. mesh-delivery asks the
operator when a pull request's checks pass, keeps an ask through a trunk move
by asking the check again at once, and hands the merge tool the approved heads,
which replace the forge review of each sibling.
2026-10-17 20:44:54 +02:00
mesh-tools 5e7567e5a0 Merge pull request 'Every directory says how it treats files it does not know, and the catalogue checks it (issue 1024)' (#349) from feat/owned-paths into main 2026-10-17 18:14:12 +00:00
mesh-tools 7104df6fa2 Take in main's move of build-agent and route-proxy to mesh-controller (issue 1024)
mesh/merge-gate head 7104df6fa284 merged into trunk 5e6f5b8f6d99 — pass, with a note: builds artifact-store-tools, asus-zephyrus-g14, audit-logger, baserow…
mesh/repo-check head 7104df6fa284 merged into trunk 5e6f5b8f6d99 — pass: THE CHANGE ALTERS ITS OWN CHECK (merge-check.sh): main's version judged it; the ch…
mesh/delivery delivered
mesh/delivery-group group feat/owned-paths merged: every member is merged
mesh-catalog #350 removed both modules from the catalogue; this change's edits to them go with them, and mesh-issues keeps both #350's change and its `unknown`. The branch's tree is the reviewed tree merged with main 5e6f5b8f6d.
2026-10-17 17:55:07 +00:00
mesh-tools 5e6f5b8f6d Merge pull request 'build-agent and route-proxy leave for mesh-controller (issue 1019, ADR 0420)' (#350) from feat/whole-modules into main 2026-10-17 17:44:16 +00:00
jschoubben 629aafdea2 Check again after the group's delivery stopped (issue 1019)
mesh/merge-gate head 629aafdea205 merged into trunk 54b3ce503e79 — pass: builds mesh-issues, new: modules/build-agent, modules/route-proxy → novox; no bus …
mesh/repo-check head 629aafdea205 merged into trunk 54b3ce503e79 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery covered after its stop: a later walk that contains it was delivered: plan-1792260860441586760 (novox/mesh-catalog at 5e7567e5 into main, do…
mesh-controller #370 merged and registered route-proxy from mesh-controller; its plan then built
route-proxy from the catalogue, which still held it, and failed. This member removes it there.
2026-10-17 19:36:31 +02:00
jschoubben 795806efa7 Declare the group's order: after novox/mesh-controller (issue 1019)
mesh/merge-gate waiting in the build queue since 19:32: asked by the merge tool against trunk 54b3ce503e79
mesh/repo-check waiting in the build queue since 19:32: asked by the merge tool against trunk 54b3ce503e79
mesh/delivery stopped: its group feat/whole-modules stopped: novox/mesh-controller@874a91a3c64f failed
mesh/delivery-group group feat/whole-modules closed: a member was closed before the group merged
mesh-delivery reads a pull request's after: lines only when it records a new head, so the
line added to #350's description takes effect with this head.
2026-10-17 18:43:08 +02:00
mesh-tools 652b00b25b Say on every directory how it treats files it does not know, and check it (issue 1024, ADR 0422)
mesh/merge-gate checking again: the gate refused `unknown` because the controller judged with a node-engine validator pinned before mesh-host #117; mesh-co…
mesh/repo-check checking again: the gate refused `unknown` because the controller judged with a node-engine validator pinned before mesh-host #117; mesh-co…
mesh/delivery-group group feat/owned-paths checking: 0 of 2 member(s) ready
mesh/delivery superseded: a newer head of the same pull request
Every directory and archive resource carries `unknown`: move-out for a home folder whose program writes nothing there (the node-engine, delivered in mesh-host #117, moves the rest to ~/.local/state/mesh-moved/<module>/ with a list), module for ~/.ssh and ~/.claude, program elsewhere; `knows` keeps what a program or the operator relies on (powerlevel10k's compiled files, i3's scripts and the asus/g14 drop-ins, dunst's slack rule, betterbird's records, node-env's forge credential, issue 1033). Two shared folders are no longer declared. Each module's README says it. checks/owned holds every manifest to it; checks/sshdir stays while main's merge check runs it (issue 1035). Tests that pin a manifest follow the new key only. Reviewed in three rounds.
2026-10-17 16:23:03 +00:00
jschoubben c47d949258 Repoint the build-agent and route-proxy owners to mesh-controller (issue 1019)
mesh/delivery-group group feat/whole-modules rejected: its order contradicts itself: novox/mesh-catalog@c47d94925811 and novox/mesh-controller@48f5a7d3a99a: bu…
mesh/merge-gate checking again: issue 1043: mesh-controller #370's new head 0125ccd7 lets a member's own gate see the move through the clones beside it; th…
mesh/repo-check checking again: issue 1043: mesh-controller #370's new head 0125ccd7 lets a member's own gate see the move through the clones beside it; th…
mesh/delivery superseded: a newer head of the same pull request
2026-10-17 18:16:40 +02:00
jschoubben 4c6e0b625e Build-agent and route-proxy leave for mesh-controller (issue 1019) 2026-10-17 18:16:10 +02:00
mesh-tools 54b3ce503e Merge pull request 'mesh-delivery announces each merge with the Approve it merged on (issue 1025, ADR 0420)' (#348) from feat/move-a-module into main 2026-10-17 16:00:29 +00:00
jschoubben 40eed3a036 Merge remote-tracking branch 'origin/main' into feat/move-a-module
mesh/merge-gate head 40eed3a036db merged into trunk 0d2fc5e4d79c — pass: builds mesh-delivery → novox; no bus step; every machine composes with the change …
mesh/repo-check head 40eed3a036db merged into trunk 0d2fc5e4d79c — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
mesh/delivery-group group feat/move-a-module merged: every member is merged
2026-10-17 17:19:44 +02:00
mesh-tools 0d2fc5e4d7 Merge pull request 'ssh-client's sweep keeps what ssh reads itself and linked key folders, and fails loudly (issue 1020, ADR 0417)' (#347) from fix/1020-sweep-edges into main 2026-10-17 15:13:46 +00:00
jschoubben 3ec2881a27 issue 1025: the merged announcement is an owed effect, and a group's merge is tested 2026-10-17 15:41:43 +02:00
jschoubben 4c0ed75090 issue 1025: mesh-delivery announces each merge on the Approve as the event merged, with its warrant 2026-10-17 15:40:00 +02:00
mesh-tools 05e6b583e2 Keep what ssh reads itself and linked key folders in ~/.ssh, and fail the sweep loudly (issue 1020, ADR 0417)
mesh/merge-gate head 05e6b583e2e3 merged into trunk ce56b491cffd — pass: builds ssh-client → ace, g14, novox, shanks; no bus step; every machine composes w…
mesh/repo-check head 05e6b583e2e3 merged into trunk ce56b491cffd — pass: THE CHANGE ALTERS ITS OWN CHECK (merge-check.sh): main's version judged it; the ch…
mesh/delivery delivered
The sweep from issue 1017 moved known_hosts2, rc and environment, which ssh
and sshd read by default, and moved a link to a folder of keys, breaking every
login through it. It could move a file a tool was still writing. When ~/.local
was on another file system, every sweep failed at its first move and left an
empty folder each round, seen only in the journal. Its list was written after
the moves with errors ignored.

Now those files and linked key folders stay; a link whose target, or the folder
it names, cannot be checked is left and named; a copy of authorized_keys is
moved at any depth; a path changed in the last minute, at any depth, waits for
the next sweep; a retired folder on another file system fails the sweep before
anything is made; the list names each move before it is made; and each sweep's
outcome is kept for ssh_client_check, which shows a failed sweep as a problem.
A key's first line is read only so far.

The catalogue's new checks/sshdir refuses any manifest but ssh-client naming a
path under ~/.ssh (ADR 0415 §6). The README cites §7 for config.d.
2026-10-17 13:36:27 +00:00
mesh-tools ce56b491cf Merge pull request 'ssh-client keeps ~/.ssh to what it knows: sweep every other path into a kept folder (issue 1017, ADR 0415)' (#346) from fix/1017-retire-ssh-leftovers into main 2026-10-17 13:07:05 +00:00
mesh-tools 9675babf52 Merge pull request 'mesh-delivery acts on events and keeps each pull request's key; the forge's announcements carry a message id (issues 975, 976, design 47 milestone 2)' (#345) from feat/events-and-queues into main 2026-10-17 12:52:42 +00:00
jschoubben 94f65cccd9 issue 975: an empty commit so mesh-delivery reads the group's after: line, which it reads only when a head is announced (the operator's word, 2026-10-17)
mesh/merge-gate head 94f65cccd9c1 merged into trunk a7d526838ab0 beside mesh-controller#368@0f21fe78 — pass: builds build-agent, gitea, mesh-delivery → ace…
mesh/repo-check head 94f65cccd9c1 merged into trunk a7d526838ab0 beside mesh-controller#368@0f21fe78 — pass: its merge-check.sh passed, each part in its to…
mesh/delivery delivered
mesh/delivery-group group feat/events-and-queues merged: every member is merged
2026-10-17 14:39:21 +02:00
mesh-tools acf075fa63 ssh-client keeps ~/.ssh to what it knows: sweep every other path into a kept folder (issue 1017, ADR 0415)
mesh/merge-gate head acf075fa63cc merged into trunk 9675babf528b — pass: builds ssh-client → ace, g14, novox, shanks; no bus step; every machine composes w…
mesh/repo-check head acf075fa63cc merged into trunk 9675babf528b — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
ssh-client owns ~/.ssh, but treated every file it did not place as found, so a
predecessor's drop-in and old copies stayed there for weeks. Its process now
sweeps at start and every ten minutes: keys at any depth (and links to them),
authorized_keys, authorized_keys2 and sockets stay, and a folder holding one is
gone into; everything else moves to ~/.local/state/ssh-client/retired/<UTC
time>/, a folder never shared with another sweep, with a list beside it, which
ssh_client_check shows. Nothing is deleted.
2026-10-17 12:38:16 +00:00
jschoubben 52f70f42b7 issue 975: a merge heard again changes nothing, and a verdict naming no trunk commit is not written to the key
mesh/delivery-group group feat/events-and-queues rejected: its order contradicts itself: novox/mesh-catalog@52f70f42b749 and novox/mesh-controller@0f21fe7888be…
mesh/merge-gate head 52f70f42b749 merged into trunk a7d526838ab0 beside mesh-controller#368@0f21fe78 — pass: builds build-agent, gitea, mesh-delivery → ace…
mesh/repo-check head 52f70f42b749 merged into trunk a7d526838ab0 beside mesh-controller#368@0f21fe78 — pass: its merge-check.sh passed, each part in its to…
mesh/delivery superseded: a newer head of the same pull request
2026-10-17 14:10:14 +02:00
jschoubben add91ff048 issue 976: the forge's announcements get a deterministic message id; the announcement files go, the poll's watermark is kept in the module's state 2026-10-17 13:52:47 +02:00
jschoubben 2c8c1543ba issue 975: the pull request's key written by compare-and-set, the ready flag on a ready mark's check, build-agent reads the key 2026-10-17 13:48:54 +02:00
jschoubben 27ca8bbcff issue 975: the acceptance over a real bus: a ready mark and its Approve are merged within seconds of their events 2026-10-17 13:28:21 +02:00
jschoubben 0809a1f982 issue 975: a test that fails on a ticker and on a timer that names no bounded step it retries 2026-10-17 13:25:33 +02:00
jschoubben b5225d3a2f issue 975: no forge poll; the open pull requests are read at start and when a merge check's status, a new head or a ready mark says they changed 2026-10-17 13:25:03 +02:00
jschoubben d675f5e7c6 issue 975: no tick and no reconcile loop; a look after each event, one timer for the steps that wait on the clock 2026-10-17 13:23:25 +02:00
jschoubben 95e11464fe issue 975: ready marks, verdicts and Approves ask the merge as events, not at the next minute 2026-10-17 13:19:01 +02:00
mesh-tools a7d526838a Merge pull request 'One forge user, nox-agent, for every machine's agents' SSH pushes, each machine's key titled by machine (issue 991, ADR 0410)' (#344) from fix/991-one-forge-agent-user into main 2026-10-17 00:51:08 +00:00
mesh-tools 3a2560dc51 One forge user, nox-agent, for every machine's agents' SSH pushes, each machine's key titled by machine (issue 991, ADR 0410)
mesh/merge-gate head 3a2560dc51fc merged into trunk 82df3bd58913 — pass: builds gitea → novox; no bus step; every machine composes with the change as it di…
mesh/repo-check head 3a2560dc51fc merged into trunk 82df3bd58913 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
2026-10-17 00:46:43 +00:00
mesh-tools 82df3bd589 Merge pull request 'Give the forge an ssh name of its own, so it no longer merges with the operator's entry for its machine (issue 955, ADR 0409)' (#343) from fix/955-forge-ssh-name into main 2026-10-17 00:05:57 +00:00
mesh-tools 661b83249c Give the forge an ssh name of its own, so it no longer merges with the operator's entry for its machine (issue 955, ADR 0409)
mesh/merge-gate head 661b83249c2a merged into trunk bb291b5ad8af — pass: builds gitea, ssh-client → ace, g14, novox, shanks; no bus step; every machine com…
mesh/repo-check head 661b83249c2a merged into trunk bb291b5ad8af — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
2026-10-17 00:01:42 +00:00
mesh-tools bb291b5ad8 Merge pull request 'Keep gitea's forge-account provider running when a watch is refused, so a later grant is picked up (issue 955)' (#342) from fix/955-provider-keeps-running into main 2026-10-16 23:30:28 +00:00
mesh-tools b159efd656 Keep gitea's forge-account provider running when a watch is refused, so a later grant is picked up (issue 955)
mesh/merge-gate head b159efd65673 merged into trunk 522780d5fcfa — pass: builds gitea → novox; no bus step; every machine composes with the change as it di…
mesh/repo-check head b159efd65673 merged into trunk 522780d5fcfa — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
2026-10-16 23:26:01 +00:00
mesh-tools 522780d5fc Merge pull request 'Let gitea's forge-account provider read the assignments it learns its consumers from (issue 955)' (#341) from fix/955-gitea-reads-assignments into main 2026-10-16 23:11:16 +00:00
mesh-tools 065ecd85f5 Let gitea's forge-account provider read the assignments it learns its consumers from (issue 955)
mesh/merge-gate head 065ecd85f53e merged into trunk 734a77f5a91a — pass: builds gitea → novox; no bus step; every machine composes with the change as it di…
mesh/repo-check head 065ecd85f53e merged into trunk 734a77f5a91a — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
2026-10-16 23:05:10 +00:00
mesh-tools 734a77f5a9 Merge pull request 'Feature agents-write-git, step 2: git over SSH with a key made on each machine (issue 955)' (#340) from feat/agents-write-git-ssh into main 2026-10-16 22:31:17 +00:00
mesh-tools ae9cda8531 Hand ssh-client's forge files to the provisioner client (issue 955)
mesh/merge-gate head ae9cda8531b3 merged into trunk 017b729bc95d — pass: builds gitea, ssh-client → ace, g14, novox, shanks; no bus step; every machine com…
mesh/repo-check head ae9cda8531b3 merged into trunk 017b729bc95d — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery covered after its failure: a later walk that contains it was delivered: plan-1792195559895153198 (novox/mesh-catalog at 82df3bd5 into main,…
mesh/delivery-group group feat/agents-write-git-ssh merged: every member is merged
2026-10-16 22:05:52 +00:00
jschoubben 6f45570d4c Do not remove keys or entries before the machines' keys are read (issue 955 F)
mesh/merge-gate head 6f45570d4c23 merged into trunk 017b729bc95d — fail: builds gitea, ssh-client → ace, g14, novox, shanks; no bus step; 4 problem(s) the …
mesh/repo-check head 6f45570d4c23 merged into trunk 017b729bc95d — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery-group group feat/agents-write-git-ssh rejected: a member's own check failed
mesh/delivery superseded: a newer head of the same pull request
2026-10-16 22:54:31 +02:00
jschoubben 6fdf3629d3 Remove a machine's mesh keys and entry when its key is no longer reported (issue 955 F) 2026-10-16 22:53:22 +02:00
jschoubben 896f1d2854 Merge feat/agents-write-git-ssh into feat/agents-write-git-ssh-f (issue 955 F) 2026-10-16 22:48:06 +02:00
jschoubben 5c64b71cd7 Test the forge-account provider whole against a real bus and a fake forge (issue 955 F5) 2026-10-16 22:47:59 +02:00
jschoubben 1081a5f3ea Declare the forge-account provision, its provider bundle, its fact and the keys it reads (issue 955 F4) 2026-10-16 22:46:27 +02:00
jschoubben c346bba34e Add the forge-account provider: an agent account, its team and its machine key per consumer (issue 955 F3) 2026-10-16 22:45:51 +02:00
fix-955-ssh 165cbcb35f Merge branch 'feat/agents-write-git-ssh' into feat/agents-write-git-ssh-s 2026-10-16 22:44:43 +02:00
fix-955-ssh 9cb615f121 Prove the forge key process over a real bus in the merge check (issue 955 S4) 2026-10-16 22:44:36 +02:00
fix-955-ssh ad5a0f8e44 Place the forge's known host, SSH Host and git rewrite (issue 955 S3) 2026-10-16 22:43:51 +02:00
fix-955-ssh a4cdd05cb2 Declare ssh-client's keys state and its forge-account requirement (issue 955 S2) 2026-10-16 22:43:29 +02:00
fix-955-ssh b2009f1bb5 Keep the machine's forge SSH key and its keys entry (issue 955 S1) 2026-10-16 22:43:18 +02:00
jschoubben 8131c13634 Register and prune an agent account's mesh SSH keys through the forge API (issue 955 F2) 2026-10-16 22:42:04 +02:00
jschoubben c8dfbdc813 Lift the agent account code into internal/forgeaccount for the forge-account provider (issue 955 F1) 2026-10-16 22:41:11 +02:00
mesh-tools 017b729bc9 Merge pull request 'Serve postgres's consumers the port its server is published on, not the container's 5432 (issue 957)' (#339) from fix/957-postgres-serves-published-port into main 2026-10-16 20:15:33 +00:00
lead-57 deb2020bd4 Serve postgres's consumers the port its server is published on, not the container's 5432 (issue 957)
mesh/merge-gate head deb2020bd47d merged into trunk 5652a1494493 — pass: builds postgres, sent nowhere; no bus step; 2 wait(s) for a person; every machine …
mesh/repo-check head deb2020bd47d merged into trunk 5652a1494493 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
On novox postgres is published 6852:5432; handed 5432, gitea and keycloak reached
nothing once their files were filled from postgres's entry.
2026-10-16 21:56:15 +02:00
mesh-tools 5652a14944 Merge pull request 'Feature agents-write-git, step 1: agents write with their own accounts, main stays closed, a merge into main waits for the operator (issues 948, 954)' (#338) from feat/agents-write-git into main 2026-10-16 19:45:36 +00:00
fix-deliv-awg@novox 9acd1f51c5 Merge branch 'feat/agents-write-git' into feat/agents-write-git-d
mesh/merge-gate head 9acd1f51c5cf merged into trunk 8d012fe4e270 — pass: builds gitea, mesh-delivery, mesh-provisioner-client → ace, g14, novox, shanks; no…
mesh/repo-check head 9acd1f51c5cf merged into trunk 8d012fe4e270 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
2026-10-16 21:36:10 +02:00
fix-deliv-awg@novox 4cccd817a0 End a group's ask and Approve when any member is pushed (issue 948, ADR 0398 §3) 2026-10-16 21:36:06 +02:00
fix-forge-awg 3cdea45f90 Keep main's rule right at every start and copy a covering glob's checks (issue 948, review round 1) 2026-10-16 21:35:51 +02:00
lead-57 4215b40547 Read the whole plan the controller writes: at, from, as, names and credential-tool (issue 954)
The client decodes a plan strictly, and since the controller carries these fields
(issues 923 and 928) it refused the plans of gitea, keycloak and claude-code.
2026-10-16 21:32:13 +02:00
fix-deliv-awg@novox 0bfece71da Merge branch 'feat/agents-write-git' into feat/agents-write-git-d 2026-10-16 21:29:58 +02:00
fix-deliv-awg@novox f61e00aeaa Ask the operator to Approve a merge into main and merge only on the warrant (issue 948, ADR 0398 §3) 2026-10-16 21:29:55 +02:00
fix-forge-awg 789aa0133f Keep the guard's push wording for main (issue 948) 2026-10-16 21:28:49 +02:00
fix-forge-awg 7807135a1b Main takes no push and only mesh-tools merges, set by setup and guarded (issue 948, ADR 0398 §2) 2026-10-16 21:28:43 +02:00
fix-deliv-awg@novox 417f69567c Merge only into main: a ready mark on another base is left to its author (issue 948, ADR 0398 §4) 2026-10-16 21:26:53 +02:00
fix-forge-awg b0f86671b6 Give the agent's token write scopes and the agents write team (issue 948, ADR 0398 §1) 2026-10-16 21:25:59 +02:00
mesh-tools 8d012fe4e2 Merge pull request 'Feature providers-after-switch-on, step 1: providers answer after the switch-on (issues 923, 928, 849, 945, 825)' (#337) from feat/providers-after-switch-on into main 2026-10-16 19:13:01 +00:00
lead-57 d2992f81d1 Take mesh-sdk a4e7f48 in the modules that use its provider helper (issues 825, 849, 945)
mesh/merge-gate head d2992f81d17f merged into trunk 3817099b2ca2 beside mesh-controller#363@df5f01bf — pass: builds keycloak, mesh-provisioner-client, mesh…
mesh/repo-check head d2992f81d17f merged into trunk 3817099b2ca2 beside mesh-controller#363@df5f01bf — pass: its merge-check.sh passed, each part in its to…
mesh/delivery delivered
mesh/delivery-group group feat/providers-after-switch-on merged: every member is merged
postgres, keycloak, mesh-provisioner-client, route-proxy and route-adapter now
build against the feature's mesh-sdk: the harness package, the vault-name key
and the helper's fixes.
2026-10-16 20:59:14 +02:00
fix-cat-pas@novox dcedd8d2b2 Give the route plan entries their credential-form, and read the entry's vault-name in the route providers' tests (issues 923, 945) 2026-10-16 19:44:19 +02:00
fix-cat-pas@novox 302eab1431 Restore the test that both keycloak paths leave the same client, over the SDK's moved path (issue 849) 2026-10-16 19:37:54 +02:00
fix-cat-pas@novox b0dc3323c9 Run keycloak on the SDK's harness and moved path, dropping its copies (issue 849) 2026-10-16 19:36:02 +02:00
fix-cat-pas@novox 33759f8b57 Run postgres on the SDK's harness and moved path, dropping its copies (issue 849) 2026-10-16 19:35:22 +02:00
fix-vault-pas@novox 2f09dd8501 Merge branch 'feat/providers-after-switch-on' into feat/providers-after-switch-on-v 2026-10-16 19:34:15 +02:00
fix-vault-pas@novox 5a00596908 Make the vault ask a software-issuing provider's tool itself for holders that hold nothing (issue 928) 2026-10-16 19:34:11 +02:00
fix-cat-pas@novox a51e2c0fb4 Pin route-proxy's acme templates beside its manifest (issue 849) 2026-10-16 19:32:20 +02:00
fix-vault-pas@novox 044a548cee Pin that the client asks a refused or not-yet secret again (issue 928) 2026-10-16 19:31:40 +02:00
fix-vault-pas@novox 0dab4475cd Answer an unreadable assignment as not yet, never a refusal (issue 928) 2026-10-16 19:30:15 +02:00
fix-cat-pas@novox 0b056edaaa Prove the client fills gitea's origins and keycloak's hostname from route-proxy's entry for a controller-shaped plan entry (issue 923) 2026-10-16 19:29:30 +02:00
fix-cat-pas@novox cc9e7a72ee Name the vault's credential vault-name in the client's bound-entry fixtures (issue 945) 2026-10-16 19:29:30 +02:00
mesh-tools 3817099b2c Merge pull request 'Ask the plans again when the moved path starts before its runtime answers (issue 943)' (#336) from fix/943-moved-path-waits-for-runtime into main 2026-10-16 16:55:41 +00:00
mesh-tools 6005c6001e Ask the plans again when the moved path starts before its runtime answers (issue 943)
mesh/merge-gate head 6005c6001e2f merged into trunk 832a0afd1814 — pass: builds keycloak, postgres, sent nowhere; no bus step; 3 wait(s) for a person; ever…
mesh/repo-check head 6005c6001e2f merged into trunk 832a0afd1814 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
postgres and keycloak start their moved path beside Serve, and a watch
asked before Serve runs is refused by the runtime. Run returned on that
refusal and main only logged it, so a start that lost the race left the
provider answering nobody and retiring nothing until its next restart:
postgres on novox at 16:52, 18:29 and 18:48, on ace at 16:22, which kept
issue 937's retirement condition on novox from clearing. Run now asks
again, the wait doubling from a second to a minute, until the runtime
answers or its context ends.
2026-10-16 16:52:52 +00:00
mesh-tools 832a0afd18 Merge pull request 'Say once after a restart that nothing waits, so an old retirement condition clears (issue 937)' (#335) from fix/937-settle-after-restart into main 2026-10-16 16:45:30 +00:00
mesh-tools d7a880a608 Say once after a restart that nothing waits, so an old retirement condition clears (issue 937)
mesh/merge-gate head d7a880a60878 merged into trunk 3ee754eb3b0c — pass: builds keycloak, postgres, sent nowhere; no bus step; 3 wait(s) for a person; ever…
mesh/repo-check head d7a880a60878 merged into trunk 3ee754eb3b0c — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
The controller clears a provider's retire-waiting condition only on its
settled word, and a provider says settled only for a set its own memory
held. A provider restarted after the set stopped waiting (here: the
delivery of the grant-file fix) never says it, so the condition kept
asking the operator to approve retiring what nobody waits on. The first
pass of a process that finds every consumer asked now says settled once,
with no set; the controller clears both conditions on it.
2026-10-16 16:42:39 +00:00
mesh-tools 3ee754eb3b Merge pull request 'Keep serving the consumers postgres's grant file gives on its moved path (issue 937)' (#334) from fix/937-postgres-grants-on-moved-path into main 2026-10-16 16:32:21 +00:00
mesh-tools 6268f8f94b Merge pull request 'Hold an ask no channel can carry now and offer it when one can (issue 926, ADR 0397)' (#333) from fix/926-a-held-ask-waits-for-a-channel into main 2026-10-16 16:28:21 +00:00
mesh-tools 41cdd03208 Keep serving the consumers postgres's grant file gives on its moved path (issue 937)
mesh/merge-gate head 41cdd032084e merged into trunk 6268f8f94bda — pass: builds keycloak, postgres, sent nowhere; no bus step; 3 wait(s) for a person; ever…
mesh/repo-check head 41cdd032084e merged into trunk 6268f8f94bda — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
Only a moved consumer's plan names postgres, so on the moved path every
consumer still given by the controller's grant file was counted unasked
and offered for retirement (ace 5 of 5, novox 7 of 9), and could have
been retired unasked where the bound allowed. The moved path now reads
MESH_RECEIVES again: its logins are asked, the ones no plan names are
made and checked by the harness's grant pass until R1 (issue 682), and
an unreadable grant file retires nothing (issue 241). The harness's
grant pass becomes apply, identically in keycloak's copy.
2026-10-16 16:25:52 +00:00
jschoubben 77d751fe83 Test the router whole over the real bus: a held ask is offered when a channel can carry it (issue 926, ADR 0397)
mesh/merge-gate head 77d751fe830d merged into trunk 08188663faff — pass: builds mesh-knowledge, messenger → novox; no bus step; every machine composes with…
mesh/repo-check head 77d751fe830d merged into trunk 08188663faff — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
2026-10-16 17:57:52 +02:00
jschoubben c211762813 Hold an ask no channel can carry now and offer it when one can; show held and why (issue 926, ADR 0397)
The router refused such an ask with 'no channel', so the asker waited for an Approve nobody saw. It is now held as an open ask with the per-kind reason, judged again when the kinds or a channel's standing change and every minute, and the knowledge base says held and why.
2026-10-16 17:51:32 +02:00
mesh-tools 08188663fa Merge pull request 'mesh-files holds node-files: file work on a node with trash, owned paths and an act log, no shell needed (issue 900, ADR 0394)' (#332) from feat/900-node-files into main 2026-10-16 14:25:12 +00:00
jschoubben d4a9260847 Merge remote-tracking branch 'origin/main' into feat/900-node-files
mesh/merge-gate head d4a9260847ec merged into trunk 8b53a3663244 beside mesh-controller#360@f4ddddd5 — pass: builds new: modules/mesh-files, sent nowhere; …
mesh/repo-check head d4a9260847ec merged into trunk 8b53a3663244 beside mesh-controller#360@f4ddddd5 — pass: its merge-check.sh passed, each part in its to…
mesh/delivery delivered
mesh/delivery-group group feat/900-node-files merged: every member is merged
2026-10-16 16:15:45 +02:00
jschoubben 8b53a36632 Merge pull request 'mesh-delivery: mark-ready writes label then comment in the order the forge counts; a label awaiting its comment is not cleared (issue 913, issue 873)' (#331) from feat/913-mark-ready into main
Reviewed-on: #331
2026-10-16 13:47:38 +00:00
jschoubben 5d98bcc0db mesh-files: the holder of node-files, reads and writes with trash, owned paths and an act log, so file work on a node needs no shell (issue 900, ADR 0394)
mesh/delivery-group group feat/900-node-files checking: composed check out of date: novox/mesh-controller@210f7cf5e64b was judged against trunk 611e2275cf7c, t…
mesh/merge-gate out of date: judged against trunk 3db85a0dde83, the trunk is now 8b53a3663244; judged again on a recheck or when it is merged
mesh/repo-check out of date: judged against trunk 3db85a0dde83, the trunk is now 8b53a3663244; judged again on a recheck or when it is merged
mesh/delivery superseded: a newer head of the same pull request
2026-10-16 14:50:03 +02:00
jschoubben 2d1b20364f mesh-delivery: an empty-head ready mark is cleared only after a minute, and not right after mark-ready; clearer wording (issue 913, issue 873)
mesh/merge-gate head 2d1b20364f9b merged into trunk 3db85a0dde83 beside mesh-controller#357@114c99e0 — pass: builds mesh-delivery → novox; no bus step; eve…
mesh/repo-check head 2d1b20364f9b merged into trunk 3db85a0dde83 beside mesh-controller#357@114c99e0 — pass: its merge-check.sh passed, each part in its to…
mesh/delivery delivered
mesh/delivery-group group feat/913-mark-ready merged: every member is merged
2026-10-16 14:35:58 +02:00
jschoubben 741b729ad4 mesh-delivery: mark-ready labels then comments the head in the order the forge counts; an empty-head clear says why (issue 913, issue 873) 2026-10-16 14:33:50 +02:00
mesh-tools 3db85a0dde Merge pull request 'Feature 57 (config and provisioning): release the catalogue' (#330) from release/57-config-and-provisioning into main 2026-10-16 10:57:56 +00:00
mesh-tools a31ad48b02 Restore the blank last line the patch route dropped from ten files, so the branch holds the reviewed tree (issue 875)
mesh/merge-gate head a31ad48b024d merged into trunk ee1b15d7f066 — pass, with a note: builds adwaita, artifact-store-tools, asus-zephyrus-g14, baserow, bet…
mesh/repo-check head a31ad48b024d merged into trunk ee1b15d7f066 — pass: THE CHANGE ALTERS ITS OWN CHECK (merge-check.sh): main's version judged it; the ch…
mesh/delivery delivered
2026-10-16 09:43:59 +00:00
mesh-tools 0260ae92f6 Feature 57 (config and provisioning): release the catalogue, the reviewed release branch merged with main, on top of main 2026-10-16 09:42:45 +00:00
mesh-tools ee1b15d7f0 Merge pull request 'A delivering group says success on mesh/delivery-group, so its later members can merge (issue 902)' (#329) from fix/902-group-member-merges-while-delivering into main 2026-10-16 09:16:36 +00:00
mesh-tools 8efe499db9 A delivering group says success on mesh/delivery-group, so its later members can merge (issue 902)
mesh/merge-gate head 8efe499db965 merged into trunk 173c5d5ef434 — pass: builds mesh-delivery → novox; no bus step; every machine composes with the change …
mesh/repo-check head 8efe499db965 merged into trunk 173c5d5ef434 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
2026-10-16 09:12:53 +00:00
mesh-tools 173c5d5ef4 Merge pull request 'Put a group's composed check out of date when a member is rechecked (issue 867, ADR 0323 §6)' (#327) from fix/867-recheck-asks-the-group-again into main 2026-10-16 01:20:20 +00:00
mesh-toolsandClaude c7c784161d Put a group's composed check out of date when a member is rechecked (issue 867, ADR 0323 §6)
mesh/merge-gate head c7c784161dcd merged into trunk 0fdacb98399d — pass: builds mesh-delivery → novox; no bus step; every machine composes with the change …
mesh/repo-check head c7c784161dcd merged into trunk 0fdacb98399d — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
After mesh-host#114's recheck passed, group 57-config-and-provisioning kept its failed composed verdict for the same heads and was never asked again. A recheck now marks the member's group's composed check out of date, answered or still running, so it is asked again once every member is judged again and ready; a late answer from the old run is dropped.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-16 01:17:58 +00:00
mesh-tools 0fdacb9839 Merge pull request 'mesh-mail: a send ask the operator channel can carry (issue 868)' (#326) from fix/868-a-send-ask-the-channel-can-carry into main 2026-10-16 00:08:21 +00:00
jochen b0e4a12c88 mail_send's ask: judge messenger's real words, carry the content rule at run time, match display-name recipients (issue 868, review round 1)
mesh/merge-gate head b0e4a12c8840 merged into trunk 21a499ba5976 — pass: builds mesh-mail → novox; no bus step; every machine composes with the change as i…
mesh/repo-check head b0e4a12c8840 merged into trunk 21a499ba5976 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
Review round 1: the test judged Headline+Explanation+Details, but messenger refuses an ask as a whole on
Headline, Explanation, OnExpiry and each option's Label and Does alone (asks.go's TakeAsk); Details is
filtered line by line instead (holder.go's sayOn). send.go's Ask() now builds that exact judged text and
checks it with messenger's real content rule, carried at run time as content_rule.go (messenger's
content.go, renamed, byte-identical, pinned by copies_test.go) rather than only in a test copy. When a
subject or first line the masking does not catch (an IP, a hardware address, a path-shaped reference, a
hex hash, ...) would still refuse the whole ask, the Explanation drops the subject and first lines and
points at Details instead, so the ask always reaches the operator. A recipient written with a display
name ('Name <addr>') is now matched against the operator's own accounts, and masked, by its address alone
(net/mail.ParseAddress).
2026-10-16 02:04:32 +02:00
jochen a76b0742ff mail_send's ask names recipients and masks addresses so the operator channel can carry it (issue 868)
messenger's content rule (ADR 0253 §3) refuses any message carrying a mail address, so mail_send's ask
never reached the operator: no send could ever be approved. A recipient that is one of the operator's own
mesh-mail accounts is now named by that account's name; any other loses its address and host; the subject
and body's first lines have every address and link masked before they reach the ask. The mail itself is
sent unchanged.

Tests run messenger's real content rule against the asks mail_send builds (content.go copied verbatim as
content_test.go, pinned byte-identical by copies_test.go, since messenger's package main cannot be
imported from another module).
2026-10-16 01:57:06 +02:00
mesh-tools 21a499ba59 Merge pull request 'Group siblings: mesh-delivery names member heads; the forge re-pends and refuses on a sibling's move (issue 867, ADR 0382)' (#325) from fix/867-group-siblings into main 2026-10-15 23:53:10 +00:00
mesh-toolsandClaude de9d29dc65 Keep a group member's siblings and refuse its merge until the group passed and every sibling is reviewed (issue 867, ADR 0382)
mesh/merge-gate head de9d29dc657d merged into trunk 62ec68355cd1 — pass: builds gitea, mesh-delivery → novox; no bus step; every machine composes with the …
mesh/repo-check head de9d29dc657d merged into trunk 62ec68355cd1 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
mesh/delivery-group group fix/867-group-siblings merged: every member is merged
mesh-delivery's groups names each member's pull request, head, base and whether it is open (member_details), so the controller can read a member's siblings beside its check. The forge's holder keeps the siblings each verdict read, puts that verdict out of date when a sibling gets a new head, closes, or its base moves by anything but its own merge, and the merge tool merges such a member only when mesh/delivery-group passed at the heads read and each sibling head is approved. The statuses name the siblings, or say they could not be learned.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-15 23:27:13 +00:00
mesh-tools 62ec68355c Merge pull request 'mesh-mail-notifier asks its own machine's node-notifier (issue 865)' (#324) from fix/865-notifier-asks-its-own-machine into main 2026-10-15 21:34:27 +00:00
mesh-tools 6a5e6510ca Merge pull request 'The clipboard service waits for an older daemon's lock, and the history sees the daemon (hq issues 860, 862)' (#323) from fix/860-862-the-clipboard-takes-over-quietly into main 2026-10-15 21:31:46 +00:00
jochen 3f97d2d50d mesh-mail-notifier: Send names its own machine's node-notifier (issue 865)
mesh/merge-gate head 3f97d2d50d57 merged into trunk 6a5e6510cab2 — pass: builds mesh-mail-notifier → g14, shanks; no bus step; every machine composes with …
mesh/repo-check head 3f97d2d50d57 merged into trunk 6a5e6510cab2 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
2026-10-15 23:27:24 +02:00
jschoubben 6112233649 Probe clipmenud's lock with flock's default conflict status, which BusyBox shares (issue 862)
mesh/merge-gate head 6112233649d3 merged into trunk 8c2d7e5f97f1 — pass: builds clipmenu → g14, shanks; no bus step; every machine composes with the change…
mesh/repo-check head 6112233649d3 merged into trunk 8c2d7e5f97f1 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
2026-10-15 23:27:19 +02:00
mesh-tools 8c2d7e5f97 Merge pull request 'mesh-mail-notifier: the watcher runs in its runtime, not a process that exits at once (issue 859)' (#322) from fix/859-notifier-runs-in-its-runtime into main 2026-10-15 21:21:12 +00:00
jschoubben 21ebbffed4 Count clipmenud when it runs as a bash script (issue 860)
mesh/merge-gate head 21ebbffed469 merged into trunk 8c2d7e5f97f1 — pass: builds clipmenu → g14, shanks; no bus step; every machine composes with the change…
mesh/repo-check head 21ebbffed469 merged into trunk 8c2d7e5f97f1 — fail: its merge-check.sh failed: --- FAIL: TestTheLauncherWaitsQuietlyWhileAnotherClipme…
mesh/delivery superseded: a newer head of the same pull request
2026-10-15 23:18:22 +02:00
jochen 31120ed580 mesh-mail-notifier: watch retries a subscribe refusal instead of exiting (issue 859, review round 1)
mesh/merge-gate head 31120ed58029 merged into trunk c9848f4fb85c — pass: builds mesh-mail-notifier → g14, shanks; no bus step; every machine composes with …
mesh/repo-check head 31120ed58029 merged into trunk c9848f4fb85c — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
A subscribe the runtime refuses no longer calls os.Exit(1), which took mail-notifier.status and
mail_notifier_health down with it: watch now retries with backoff (2s doubling to 1 minute), logging each
refusal, as mesh-knowledge's hear does. Also: the run() test proves liveness by having the watcher answer a
ping after run returns, instead of checking a channel only the test's own deferred cleanup could close; and
the manifest test is renamed to say what it checks (no process resource at all).
2026-10-15 23:17:14 +02:00
jschoubben cf304d8629 Wait quietly for clipmenud's session lock; count the operator's clipmenud (issues 860, 862) 2026-10-15 23:15:35 +02:00
jochen 12d7852082 mesh-mail-notifier: the watcher runs in the base invocation, not a separate notify process (issue 859)
The notify process's stdin is empty under systemd, so stdio.Serve returned at EOF, main returned, and
systemd restarted it every five seconds: the watch loop never ran long enough to show a notice. The watch
loop now starts beside stdio.Serve in the one process the node's runtime launches and keeps alive, as
mesh-knowledge's serve does. No resource declares a separate notify process any more.
2026-10-15 23:13:34 +02:00
mesh-tools c9848f4fb8 Merge pull request 'The clipboard keeps its history running and keeps it to itself (hq issues 743, 750)' (#320) from fix/743-750-the-clipboard-keeps-history-and-keeps-it-to-itself into main 2026-10-15 21:02:04 +00:00
jschoubben c070bea782 Declare how the clipboard daemon is ready, so the count of resources without health stays at 62 (ADR 0240 rule 8, issues 743, 750)
mesh/merge-gate head c070bea782aa merged into trunk 7443938fef69 — pass: builds clipmenu → g14, shanks; no bus step; every machine composes with the change…
mesh/repo-check head c070bea782aa merged into trunk 7443938fef69 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
2026-10-15 22:59:27 +02:00
mesh-tools 7443938fef Merge pull request 'gitea_commit takes a large patch from a hash-checked spool file (issue 852)' (#319) from fix/852-commit-a-patch-file into main 2026-10-15 20:55:15 +00:00
mesh-tools aee97bf8ad Merge pull request 'The kernel module's transparent-huge-pages defaults to madvise (hq issue 856, ADR 0381)' (#321) from fix/856-the-kernel-module-runs-everywhere into main 2026-10-15 20:47:50 +00:00
jschoubben c816cafc00 The kernel module's transparent-huge-pages defaults to madvise, the mesh's chosen value (hq issue 856, ADR 0381 §2, §3)
mesh/merge-gate head c816cafc00bd merged into trunk de6657c846b9 — pass: builds kernel → ace, g14, novox, shanks; no bus step; every machine composes with …
mesh/repo-check head c816cafc00bd merged into trunk de6657c846b9 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
2026-10-15 22:45:07 +02:00
jschoubben 2353350d79 The clipboard keeps its history running and keeps it to itself (hq issues 743, 750)
mesh/merge-gate out of date: judged against trunk aee97bf8ad42, the trunk is now 7443938fef69; judged again on a recheck or when it is merged
mesh/repo-check out of date: judged against trunk aee97bf8ad42, the trunk is now 7443938fef69; judged again on a recheck or when it is merged
mesh/delivery superseded: a newer head of the same pull request
clipmenud is declared as the operator's user unit, enabled and running, with a launcher that finds the display. Copy reads the clipboard back and fails with the reason. History and paste answer counts, times and lengths, never content.
2026-10-15 22:43:42 +02:00
mesh-toolsandClaude d4b8b24701 Let gitea_commit take a large patch from a hash-checked spool file (issue 852)
mesh/merge-gate head d4b8b2470159 merged into trunk aee97bf8ad42 — pass: builds gitea → novox; no bus step; every machine composes with the change as it di…
mesh/repo-check head d4b8b2470159 merged into trunk aee97bf8ad42 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
A feature's diff is megabytes, too large to type into a call. gitea_commit now also takes patch_file, the
random hex name of a file in the module's spool directory /var/lib/mesh/forge-patches (mode 0733, owned by
the machine's account), with patch_sha256. The tool reads only that directory, never through a symlink or a
hard link, refuses a file whose SHA-256 differs, applies it as it applies patch, and removes it.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-15 20:39:13 +00:00
mesh-tools de6657c846 Merge pull request 'An active research effort takes new versions, reviewed as proposals (hq issue 844)' (#318) from fix/844-an-active-effort-takes-new-versions into main 2026-10-15 19:35:07 +00:00
jschoubben f75e1a91d9 Carry the proposal day only into the dated rules, so an old effort's marked insight stands (hq issue 844)
mesh/merge-gate head f75e1a91d957 merged into trunk a95b3fa053e0 — pass: builds mesh-knowledge → novox; no bus step; every machine composes with the change…
mesh/repo-check head f75e1a91d957 merged into trunk a95b3fa053e0 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
2026-10-15 21:29:55 +02:00
jschoubben ebfec39937 Hold an active effort's new version to the dated rules of the day it is proposed (hq issue 844) 2026-10-15 21:26:55 +02:00
jschoubben 24d05701d3 An active research effort takes new versions, reviewed as proposals (hq issue 844) 2026-10-15 21:22:17 +02:00
mesh-tools a95b3fa053 Merge pull request 'Mail seats: mesh-mail declares them, mesh-mail-notifier shows new mail, messenger tells (issues 800, 797)' (#317) from feat/mail-seats into main 2026-10-15 18:04:19 +00:00
jochen c2cd2e1eb1 issue 800: fix review round 3 finding, a true sliding window instead of a session quota
mesh/merge-gate head c2cd2e1eb15e merged into trunk 789b0a9474c8 — pass: builds mesh-mail, messenger, new: modules/mesh-mail-notifier → novox; no bus step;…
mesh/repo-check head c2cd2e1eb15e merged into trunk 789b0a9474c8 — pass: THE CHANGE ALTERS ITS OWN CHECK (merge-check.sh): main's version judged it; the ch…
mesh/delivery delivered
mesh/delivery-group group feat/mail-seats merged: every member is merged
A session that only reset after a quiet minute kept counting, and so folding, a steady
trickle of mail forever even with no burst (one mail every 50s). N is now the account's
qualifying mail arrived in the trailing 60 seconds, counted fresh on every arrival; a fold
holds only its own overflow and the notice names just that count, not the whole history.
2026-10-15 19:03:05 +02:00
jochen aa62de2e78 issue 797: a dropped tell no longer crowds the conditions' cap out of its own history
A dropped tell was kept in the same bounded send history (KeptSends)
that the conditions' and notify's cap counts sends from. A flood of
drops could push real, still-recent sends out of that bounded history,
undercounting sentLastHour and letting the conditions' cap stop
holding. A dropped tell is still said once an hour in the log and
named in Tell's own "dropped" answer; it is simply never recorded into
that shared history any more.
2026-10-15 18:51:26 +02:00
jochen 66713c5a1e issue 800: fix review round 2 findings in mesh-mail-notifier
A buffer was replaced, discarding whatever it still held unsent, once
Window had passed since its first mail — losing a failed send waiting
for its retry, or a folded mail waiting for its minute's end, if a new
mail arrived meanwhile. The window now slides on the gap since the
last mail, not on time elapsed since the first: a session's count
never resets, and its buffer is never replaced, while anything in it
is still unsent.

The fold notice now counts every mail of the account in that minute,
not only the overflow, and says "mail" for one and "mails" for more.
2026-10-15 18:50:21 +02:00
jochen b771794f1c issue 800: keep notify's health as unit; the gate refuses a one-process module judged only by its own tool
ADR 0227 rule 8 / ADR 0240 rule 2 refuse a module with no health check of
another kind beside a tool check. mesh-mail-notifier has one process
(notify); wiring mail_notifier_health as its manifest health, as the review
note's wording suggested (mirroring mesh-mail's watch/mail_health), trips
that rule because mesh-mail also has keep (kind unit) beside watch.
mail_notifier_health stays built, tested and callable; notify's manifest
health stays kind unit until the lead decides how a one-process module
gets this automatically.
2026-10-15 18:33:22 +02:00
jochen 6c63a17cfa Merge branch 'feat/mail-seats' into ms-800-r1 2026-10-15 17:54:33 +02:00
jochen ed66a2e873 issue 800: fix review round 1 findings in mesh-mail-notifier
Flush now sorts accounts instead of walking a map, so notice order is
stable. A failed node-notifier.send keeps its mail pending and retries
at most once a minute, without holding up other accounts' notices, and
the failure is kept in the Store for a new health tool
(mail_notifier_health) that is unhealthy past ten minutes of failure.
The burst rule now follows the lead's reading of ADR 0378 S9: within an
account's sliding minute, the first three qualifying mails are shown
one by one and the rest are folded into one notice at the window's
end; there is no separate quiet-gap timer. The dedupe key is the
account and message id, falling back to folder/uidvalidity/uid only
when no message id is given, so a UIDVALIDITY reset cannot suppress a
new mail, and a redelivered event already buffered is never recounted.
status now lists every granted account, not only those with a notice,
and caches mail-store.accounts for a minute.
2026-10-15 17:54:27 +02:00
jochen cc73b38671 issue 797: tells no longer eat the conditions'/notify's cap, and a dropped tell is logged once an hour
Review round 1 of mesh-catalog found sentLastHour counting tells
against CapPerHour (ADR 0380 §8 says tells keep their own cap, never
shared), and the drop log firing on every drop instead of once an
hour per channel as the comment already said.
2026-10-15 17:46:39 +02:00
jochen 0993c31ad4 Merge branch 'feat/mail-seats' into ms-797 2026-10-15 17:37:22 +02:00
jochen 82475691b2 issue 797: messenger gains the tell verb for a plain notice
ADR 0380 §8: the operator channel's notify is shaped for conditions (a key that
stays open, escalation, a plain rule that rewrites a subject), so a module has
no way to say a plain notice such as new mail. tell sends a headline and one or
two lines at once to every channel, bypassing routing, the burst window and the
walk hold; it offers Details like any message, is kept in the history, but its
record is marked cleared at once so it never stays open. Its cap (10 a channel
an hour) is its own, apart from notify's and the conditions'.
2026-10-15 17:37:15 +02:00
jochen 89046e6613 mesh-mail's mail-notifier needs a list of node seats, as the controller reads it (issue 800) 2026-10-15 17:34:00 +02:00
jochen dc89995d96 Add mesh-mail-notifier, the default holder of mail-notifier (issue 800 part B)
ADR 0378 §9 as amended by ADR 0379: a module of its own, built from mesh-mail's
event shape, runs as its own account to hear mesh-mail's restricted MAIL stream
(ADR 0376 §2), shows each new inbox mail through node-notifier.send at most
once, folds more than three from one account into one notice, and emits its
own shown event.
2026-10-15 17:33:15 +02:00
jochen 6c10288f0b mesh-mail declares the mail seats and claims mail-store (issue 800 part A)
Declares mail-store and mail-webclient (held once for the mesh) and mail-notifier
(held once per machine, needing node-notifier) per ADR 0378 as amended by ADR 0379.
Claims mail-store and serves its four verbs through the same handlers as the
existing mail_* tools. Moves the event shape (EventKey, Changes) into its own
file, mailevent.go, so mesh-mail-notifier (issue 800 part B) can carry a
byte-identical copy. Adds checks/mailstore, refusing any manifest but mesh-mail's
own that claims mail-store (ADR 0378 §3), wired into merge-check.sh.
2026-10-15 17:16:53 +02:00
mesh-tools 789b0a9474 Merge pull request 'Mail and bus hardening: mesh-mail sends on Approve, desk prompt says what for, duplicate PR delivery, cpu_top (issues 790, 789, 783, 741, 811)' (#316) from feat/mail-hardening into main 2026-10-15 14:59:25 +00:00
jochen eb97a3d9a2 mesh-mail: refuse a send whose undecided index cannot be written, and never overwrite a corrupt index (issue 811, review round 3)
mesh/merge-gate head eb97a3d9a2d7 merged into trunk 0914a33fba5a — pass: builds disk-load, mesh-delivery, mesh-mail, rofi → ace, g14, novox, shanks; no bus…
mesh/repo-check head eb97a3d9a2d7 merged into trunk 0914a33fba5a — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
mesh/delivery-group group feat/mail-hardening merged: every member is merged
2026-10-15 15:40:16 +02:00
jochen 8b2e2300d8 mesh-mail: a send whose ask was never heard settles as ended, and the sweep reads only undecided sends (issue 811)
A send whose ask never reached the router (the publish failed, or it was otherwise never recorded) stayed kept and
undecided forever, so mail_send_status kept saying "waiting" and the minute sweep kept reading it. Settle it as
ended once its asked time plus SendAskLasts has passed, with the words that say why. Keep an index of undecided
send ids beside the sends (written when kept, removed once decided) so the sweep's work grows with undecided
sends only, not with every send ever kept.
2026-10-15 15:27:56 +02:00
jochen 2f2daea415 Merge branch 'feat/mail-hardening' into mh-790-r1 2026-10-15 15:08:21 +02:00
jochen 3b48f5962a mesh-mail: send on the operator's Approve itself, sweep, stale-send status, no header injection (issue 790, review round 1) 2026-10-15 15:07:46 +02:00
jochen 2b1678f315 disk-load: cpu_top's shares use the unrounded busy time, and key units by cgroup path (issue 741) 2026-10-15 15:06:06 +02:00
jochen ab739d02dd mesh-delivery: a merge that starts over a stopped duplicate is made from the forge's word too (issue 783) 2026-10-15 15:01:23 +02:00
jochen a60020b51a issue 741: merge feature branch head 2026-10-15 14:51:12 +02:00
jochen a8d84d1730 Add disk-load's cpu_top tool: CPU by systemd unit and container (issue 741)
No tool named which units or processes used CPU outside containers during
the novox overload (issue 739). cpu_top reads each cgroup's cpu.stat (no
root needed), ranks units or containers busiest first, and says the
machine's CPU in use and the busy time no unit accounts for.
2026-10-15 14:51:08 +02:00
jochen 9e8521afb5 mesh-delivery: a stopped duplicate's delivery follows the pull request that merges it (issue 783)
A delivery keyed by commit stayed stopped forever once an earlier, closed
duplicate pull request with the same head had stopped it, even though a
later pull request went on to merge that very commit: the merge only set
merged_as on the stopped record, and the walk that carried it waited for
this owner's word with no delivery left to give it. PullUpdated and
PullMerged now start such a delivery over, under the same id, for the
pull request that announces or merges it next, naming both pull requests
in the transition's why. Any other stop (a person's, a group's, the same
pull request's own close) is untouched.
2026-10-15 14:50:57 +02:00
jochen 8c60604eb7 Show what a desk's secret prompt is for, and declare mesh-mail's (issue 789)
The desk's prompt named only the module and the secret's key, so the operator
asking for one of mesh-mail's mail-credential-* accounts could not tell which
one. node-launcher.secret (modules/rofi) now shows the controller's optional
what as its own line under the module and secret names, escaped as text so
the catalogue's words never reach the prompt as markup; omitted as before
when none is declared. mesh-mail's mail-credential-* family now declares that
line with the long-form own-secrets shape the controller's manifest parser
reads (novox/hq issue 789 part b; part a landed in mesh-controller).
2026-10-15 14:28:45 +02:00
jochen 13cd710071 mesh-mail: add mail_send, asked and sent once on the operator's Approve (issue 790) 2026-10-15 13:57:47 +02:00
mesh-tools 0914a33fba Merge pull request 'mesh-mail: byte-bounded fetch, accounts only to granted callers, grants error kept, secret only stat'ed (hq issue 733)' (#315) from adr-359-mesh-mail-733 into main 2026-10-15 08:49:32 +00:00
jochen b5417c28d0 Merge remote-tracking branch 'origin/main' into adr-359-mesh-mail-733
mesh/merge-gate head b5417c28d026 merged into trunk 5f6a41146c3a — pass: builds mesh-mail, sent nowhere; no bus step; every machine composes with the chang…
mesh/repo-check head b5417c28d026 merged into trunk 5f6a41146c3a — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery covered after its stop: a later walk that contains it was delivered: plan-1792054175111921309 (novox/mesh-catalog at 0914a33f into main, do…
2026-10-15 10:44:37 +02:00
mesh-tools 5f6a41146c Merge pull request 'mesh-mail: the operator's mail accounts, watched and said on the bus — stage 1, own server read-only (hq ADR 0359)' (#300) from adr-359-mesh-mail into main 2026-10-15 08:44:07 +00:00
mesh-tools c71c513027 Merge pull request 'Read branch protection as the forge's administrator, so builds register again (issue 772, ADR 0375)' (#313) from 772-protection-read-as-admin into main 2026-10-15 00:22:49 +00:00
mesh-toolsandClaude 2e32f47e45 Read branch protection as the forge's administrator, so builds register again
mesh/merge-gate head 2e32f47e4596 merged into trunk 9492278d934b — pass: builds gitea → novox; no bus step; every machine composes with the change as it di…
mesh/repo-check head 2e32f47e4596 merged into trunk 9492278d934b — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery covered after its failure: a later walk that contains it was delivered: plan-1792097717281845096 (novox/mesh-catalog at 7443938f into main,…
The forge answers a repository's protection rules to its administrator
alone, and the tools' account no longer is one (ADR 0365), so every
delivery building a module failed at the controller's build-source check
with a 403. gitea_branch_protection_get now reads the rules with the
administrator account (ADR 0375); it only reads, and the protection set
and every write stay refused to the tools' account. Issue 772.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-15 00:18:39 +00:00
mesh-admin 9492278d93 Merge pull request 'Make the forge's tools act as an account of their own, not the administrator' (#311) from 365-forge-identities into main 2026-10-14 22:51:36 +00:00
mesh-admin 5ce2d07c7f Merge pull request 'Give a rollout the word of a stopped delivery that waits for it (hq issue 753)' (#312) from fix/753-a-rollout-is-never-left-without-a-word into main 2026-10-14 22:35:38 +00:00
jschoubben 373f339750 Give a walk the word of a stopped delivery that waits for it (hq issue 753)
mesh/merge-gate head 373f33975077 merged into trunk 7913b56d66f7 — pass: builds mesh-delivery → novox; no bus step; every machine composes with the change …
mesh/repo-check head 373f33975077 merged into trunk 7913b56d66f7 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
A delivery its group stopped for a member delivered since waits for a later
walk of its repository that contains its merge (issue 747). When that walk
carried no living delivery, nothing gave it its word and it waited forever.
The delivery now takes its turn on the walk that answers its merge, in its
group's order. A walk owed such a word stays under the orphans' timer, so
past the grace without it `stalled` lists it with why (held, or the
controller's refusal), and a walk no living delivery holds names the ended
deliveries it carries.
2026-10-15 00:30:26 +02:00
mesh-adminandClaude Opus 5.5 48f0662fde Make the forge's tools act as an account of their own, not the administrator (issue 469, ADR 0365 clause 2)
mesh/merge-gate head 48f0662fdea2 merged into trunk 5ce2d07c7f47 — pass: builds gitea → novox; no bus step; every machine composes with the change as it di…
mesh/repo-check head 48f0662fdea2 merged into trunk 5ce2d07c7f47 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
The tools' token reached everything the administrator may do, so their boundary rested on their own guards.
The gitea module now makes a plain account, mesh-tools, with the admin login, gives it write on code, issues
and pull requests of every novox repository through one team, and mints the tools' token for it; the admin's
old token goes only after the new one is kept. The Go forge bundle sends that token, never the admin login.
The forge shows protection rules only to a repository administrator, so every check reads a branch's
protection from the branch. mesh-admin stays a ready author, so marks made before the move keep counting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 22:28:25 +00:00
mesh-admin 7913b56d66 Merge pull request 'Add the kernel module: transparent huge pages set per machine (hq issue 746, ADR 0369)' (#310) from fix/746-huge-pages-madvise into main 2026-10-14 21:52:49 +00:00
jschoubben ab1811d003 Reload, not restart, the huge pages unit when the setting changes (hq issue 746)
mesh/merge-gate head ab1811d00357 merged into trunk c79337579d6b — pass: builds new: modules/kernel, sent nowhere; no bus step; every machine composes with…
mesh/repo-check head ab1811d00357 merged into trunk c79337579d6b — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
A restart ran restore then apply, so every change passed back through the
found mode and rotated the journal again. Reload applies the new value
directly; restore also drops its state; no rotation while the machine boots;
the unit is sandboxed.
2026-10-14 23:42:49 +02:00
mesh-admin c79337579d Merge pull request 'Make the forge's webhook a system one, and say whether its events are heard (hq issue 729)' (#309) from fix/729-the-forge-events-are-heard-or-said into main 2026-10-14 21:40:44 +00:00
jschoubben 3a84044ece Add the kernel module: transparent huge pages set per machine (hq issue 746)
With THP 'always' journald's mmapped journal files sit in 2 MiB folios, so each small
journal write dirties and writes back 2 MiB; on the control node that saturated the
root mirror holding the bus store and the store, and the controller lost its lease.
The module sets the mode from a per-machine setting (default always, the kernel's own,
so assigning it changes nothing until a machine sets madvise), verifies it took, rotates
the journal once so new files are mapped under the new mode, gives back what it found
when stopped, and serves kernel_huge_pages to measure file_thp per unit.
2026-10-14 23:31:13 +02:00
mesh-admin c51357f8af Merge pull request 'Cover a failed or stopped delivery once a later finished walk contains it (hq issue 747)' (#308) from fix/747-a-failed-member-is-covered-by-a-later-walk into main 2026-10-14 21:30:10 +00:00
jschoubben 2833a049aa Make the forge's webhook a system one, and say whether its events are heard (hq issue 729)
mesh/merge-gate head 2833a049aa0f merged into trunk c51357f8afd4 — pass: builds gitea → novox; no bus step; every machine composes with the change as it di…
mesh/repo-check head 2833a049aa0f merged into trunk c51357f8afd4 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
POST /admin/hooks on gitea 1.27.3 makes a default webhook unless
config.is_system_webhook is "true": a default webhook fires for no existing
repository, and the system listing does not name it, so every start made
another and no event reached the receiver. Its edit never changes a secret
or a kind, so the new system webhook is made first and read back, then every
other hook at the receiver is deleted: system, default, and each
repository's copy of a default one, which would send its events twice.

gitea_events says where the receiver listens, the hook as ensured and what
it replaced, the verified and refused deliveries and the polls' pace. When
nothing verified for ten minutes, since the start or the last delivery,
while the poll found two heads or merges no event said, it is logged loudly
once per silent spell and answered unhealthy; so are deliveries refused
since the last verified one.
2026-10-14 23:28:41 +02:00
jschoubben 6f7f07635e Cover a failed or stopped delivery once a later finished walk contains it (hq issue 747)
mesh/merge-gate head 6f7f07635e02 merged into trunk 0f76b6e232a5 — pass: builds mesh-delivery → novox; no bus step; every machine composes with the change …
mesh/repo-check head 6f7f07635e02 merged into trunk 0f76b6e232a5 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
A group member whose walk failed in an outage stayed failed after a later walk
of its repository, containing its merge, was done; its group then stopped every
later walk of the other repository, and the catalogue could deliver nothing.

A failed or stopped delivery now reads covered when a done walk of its
repository, made after its merge, whose commit contains it (the forge's word,
as issue 362's containment), built every module it moves. A group counts it as
delivered, and a member the group stopped for it no longer holds a walk back:
the group stops a walk only for a member whose change is still undelivered.
A stop covered under issue 480 now likewise makes its group read reached, not
stopped.

While the forge is asked, the walk is held back rather than stopped, for at
most ten minutes from the first ask: a forge that keeps erroring or cannot say
then lets the walk be asked to leave the stopped merge out, as before. The
hold is said by waits, stalled and stalled walks. The pass reads kept walks
each tick, so today's stuck deliveries clear after a restart.
2026-10-14 23:19:06 +02:00
mesh-admin 0f76b6e232 Merge pull request 'Let an agent's session run the TypeScript check outside the toolchain image (issue 742)' (#307) from 742-ts-toolchain into main 2026-10-14 21:06:32 +00:00
mesh-adminandClaude Opus 5.5 3e16c055be Let an agent's session run the TypeScript check outside the toolchain image (issue 742)
mesh/merge-gate head 3e16c055be8e merged into trunk 0361c44a03a0 — pass: the change touches no module of the mesh's graph
mesh/repo-check head 3e16c055be8e merged into trunk 0361c44a03a0 — pass: THE CHANGE ALTERS ITS OWN CHECK (merge-check-typescript.sh): main's version judged…
mesh/delivery delivered
Outside the image, assemble the same node_modules in the check's own
workspace from mesh-tools' lock on the forge, so a module's TypeScript is
checked locally before a push.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 21:03:53 +00:00
mesh-admin 0361c44a03 Merge pull request 'Clear a forge token name held twice, and mint one process at a time (hq issue 740)' (#306) from fix/a-token-name-held-twice-is-cleared into main 2026-10-14 21:03:29 +00:00
jschoubben d1debdcce8 Clear a forge token name held twice, and mint one process at a time (hq issue 740)
mesh/merge-gate head d1debdcce816 merged into trunk 0e7d9e74d22a — pass: builds gitea → novox; no bus step; every machine composes with the change as it di…
mesh/repo-check head d1debdcce816 merged into trunk 0e7d9e74d22a — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
The runtime starts the watcher and the tools as two processes over one kept
token. Both minted at once on 2026-10-14 and left two tokens named
mesh-tools; gitea refuses a delete by a name held twice, so every mint
failed and every forge tool with it. A replace now deletes each token by
the name by its id, from the account's whole list, and a mint holds a lock
file beside the kept token and reuses what another process minted.
2026-10-14 22:38:38 +02:00
mesh-admin 0e7d9e74d2 Merge pull request 'Say every forge event on the bus through a signed system webhook (hq issue 729)' (#304) from feat/729-the-forge-says-every-event-on-the-bus into main 2026-10-14 20:28:01 +00:00
jochen be53f5167a mesh-mail: bound a fetch by bytes, show mail_accounts only to its grantees, say why grants are unread, judge a secret without reading it (hq issue 733)
mesh/merge-gate out of date: judged against trunk 2130f9961291, the trunk is now 5f6a41146c3a; judged again on a recheck or when it is merged
mesh/repo-check out of date: judged against trunk 2130f9961291, the trunk is now 5f6a41146c3a; judged again on a recheck or when it is merged
mesh/delivery superseded: a newer head of the same pull request
The review of #300 found a batch of 25 whole messages could hold a gigabyte,
mail_accounts told ungranted callers folder names, counts and server errors,
an unreadable grants setting was refused without its reason, and the watch
read the password only to learn it was there.
2026-10-14 22:24:11 +02:00
jschoubben 1d8a244ba4 Say every forge event on the bus through a signed system webhook, and announce a head or merge at once (novox/hq issue 729)
mesh/merge-gate out of date: judged against trunk 2edef5a00ae0, the trunk is now 0e7d9e74d22a; judged again on a recheck or when it is merged
mesh/repo-check out of date: judged against trunk 2edef5a00ae0, the trunk is now 0e7d9e74d22a; judged again on a recheck or when it is merged
mesh/delivery covered after its stop: a later walk that contains it was delivered: plan-1792011830935225963 (novox/mesh-catalog at 0361c44a into main, do…
The gitea module keeps one system webhook for every event family, made or
corrected at start with the admin account (POST /admin/hooks, forge 1.27.3)
and a secret it mints and keeps. Its receiver, at the docker bridge's
gateway, refuses an unsigned request before reading it, a body over 5 MB,
and a signature that does not verify; it answers a verified delivery at once,
drops one already heard by its delivery id, and publishes each as
forge.<kind>.<action> on the module's events (its own stream, ADR 0363,
follows issue 725). A pull request opened, pushed to or merged is announced
at once. The polls look every five minutes only while deliveries arrive, and
every 30 s again, saying so loudly, when one finds what no event said or ten
minutes pass without a delivery. The announced records keep both paths from
announcing twice.
2026-10-14 22:21:22 +02:00
mesh-admin 2edef5a00a Merge pull request 'Build build-agent and route-proxy without a build ID (hq issue 721)' (#301) from fix/a-module-moves-only-when-its-program-changes into main 2026-10-14 20:21:12 +00:00
jochen 2130f99612 mesh-mail: say mail events on its own stream MAIL, granted per account by grants (hq ADR 0363)
mesh/merge-gate head 2130f9961291 merged into trunk c71c513027e5 — pass: builds new: modules/mesh-mail, sent nowhere; no bus step; every machine composes w…
mesh/repo-check head 2130f9961291 merged into trunk c71c513027e5 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
ADR 0359 asked for a stream of its own and a bus grant from the module's
grants; ADR 0363 built both. The events leave the shared EVENTS stream, and
one family pattern per change replaces the literal per-account emits, so a
new account changes no manifest.
2026-10-14 22:17:09 +02:00
jochen ed5690cee4 Merge remote-tracking branch 'origin/main' into adr-359-mesh-mail 2026-10-14 22:16:50 +02:00
mesh-admin fadf5025cc Merge pull request 'Merge a pull request marked ready at its head once its checks pass (issue 722)' (#303) from feat/merge-on-ready into main 2026-10-14 19:57:00 +00:00
jschoubben baa5ef802e Name the keys gitea's mergeable config takes: none (ADR 0326)
mesh/merge-gate head baa5ef802e82 merged into trunk 7e3e62062bef — pass: builds gitea, mesh-delivery → novox; no bus step; every machine composes with the …
mesh/repo-check head baa5ef802e82 merged into trunk 7e3e62062bef — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
gitea now declares a setting, so every mergeable file of it must name its
keys; the runtime config reads no setting, and ready-authors reaches its
own file.
2026-10-14 21:50:05 +02:00
jschoubben 5b20e419ec Count a ready mark only from a listed account, after the last push and the label (issue 722)
mesh/merge-gate head 5b20e419ec1f merged into trunk 7e3e62062bef — fail: builds gitea, mesh-delivery → novox; no bus step; a manifest the change touches fa…
mesh/repo-check head 5b20e419ec1f merged into trunk 7e3e62062bef — fail: its merge-check.sh failed: files asking for a setting without saying whether it is…
mesh/delivery superseded: a newer head of the same pull request
Review of #303: anyone who can comment could turn a stale label into a mark,
and a long thread hid the newest comment. The forge now reads the pull
request's whole timeline and takes a "ready at" comment only from the
accounts in the trusted setting ready-authors (default the mesh's own forge
account and the operator's), written after the label was last added and after
the last push; any other author is ignored and said in the log.
2026-10-14 21:45:39 +02:00
mesh-admin 7e3e62062b Merge pull request 'mesh-catalog: hear the build seat that builds now (hq issue 712)' (#298) from fix-712-catalogue-hears-build-agent into main 2026-10-14 19:35:13 +00:00
jschoubben 7e0001e304 Merge a pull request marked ready at its head once its checks pass (issue 722)
mesh/merge-gate out of date: judged against trunk 482232e8c51c, the trunk is now 7e3e62062bef; judged again on a recheck or when it is merged
mesh/repo-check out of date: judged against trunk 482232e8c51c, the trunk is now 7e3e62062bef; judged again on a recheck or when it is merged
mesh/delivery superseded: a newer head of the same pull request
The operator wants a reviewed pull request merged without a session calling
the merge tool again after every recheck. The reviewer marks it ready (label
mesh/ready and a comment "ready at <full head sha>"); the forge's open-pulls
read carries the mark, and mesh-delivery merges through gitea_merge_pull_request
at that head, in a group's order once its composed check passed. A push after
the mark clears it; a failed check is said on the pull request and as a
stalled line.
2026-10-14 21:23:26 +02:00
jschoubben d94764f001 Build build-agent and route-proxy without a build ID (hq issue 721)
mesh/merge-gate head d94764f00168 merged into trunk fadf5025cc5a — pass: builds build-agent, route-proxy → ace, g14, novox, shanks; no bus step; every mach…
mesh/repo-check head d94764f00168 merged into trunk fadf5025cc5a — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery covered after its stop: a later walk that contains it was delivered: plan-1792148283013560219 (novox/mesh-catalog at 3db85a0d into main, do…
mesh/delivery-group group fix/a-module-moves-only-when-its-program-changes closed: a member was closed before the group merged
Go's build ID hashes every input package, so a change mesh-builder never reaches
still changed its bytes; without it, a rebuild of the same program is the same
file, and the check can say the module is unchanged.
2026-10-14 21:07:26 +02:00
mesh-admin 482232e8c5 Merge pull request 'Give the build agent a judge cache of its own (issue 704, ADR 0358)' (#297) from fix/704-release-branch-judge into main 2026-10-14 18:19:31 +00:00
jochen 70c1110c53 Add mesh-mail: watch the operator's own mailbox read-only and say each mail on the bus
mesh/merge-gate out of date: judged against trunk e22326240833, the trunk is now 482232e8c51c; judged again on a recheck or when it is merged
mesh/repo-check out of date: judged against trunk e22326240833, the trunk is now 482232e8c51c; judged again on a recheck or when it is merged
mesh/delivery superseded: a newer head of the same pull request
ADR 0359 decided one module holds the operator's mail accounts. This is its
first stage: an imap adapter that keeps IDLE on the inbox and polls the other
folders read-only, indexes each message in its own database, keeps each
attachment once by its SHA-256 in its bucket, and says every added, changed or
removed message as a metadata-only event. Bodies and attachments are served
only by its tools, to callers the trusted setting grants names. Its health
waits for the account's own secret until it is given at the desk (issue 710).
2026-10-14 20:14:42 +02:00
mesh-admin e223262408 Merge pull request 'An answered ask keeps its question whole, its answer below it (issue 714)' (#299) from fix/an-answered-ask-keeps-its-text into main 2026-10-14 18:11:06 +00:00
jochen dbe2949f84 mesh-catalog: pass over a build off its repository's trunk, as the controller does (ADR 0238)
mesh/merge-gate head dbe2949f8456 merged into trunk 482232e8c51c — pass: builds mesh-catalog → novox; no bus step; every machine composes with the change a…
mesh/repo-check head dbe2949f8456 merged into trunk 482232e8c51c — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
A branch built through the build verb is a real build, and placing it would make it the catalogue's
current version though the controller never registers it. Say which of the controller's rules are
mirrored and which need its records.
2026-10-14 20:09:39 +02:00
jschoubben b7d9590fdc An answered ask keeps its question whole, its answer below it (issue 714)
mesh/merge-gate head b7d9590fdc95 merged into trunk e1c8965ddbf1 — pass: builds messenger, telegram → novox; no bus step; every machine composes with the c…
mesh/repo-check head b7d9590fdc95 merged into trunk e1c8965ddbf1 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
Issue 372's fix with issue 639's one line edited an answered ask down to
'You chose X at HH:MM · headline', so the operator could not read back what
they approved. The headline stays the title, the summary, points, link and
Details stay the body, and how it ended and who acts on it become the note.
Telegram cuts the body from its end past 4096 characters, never the title or
the note.
2026-10-14 20:06:24 +02:00
jochen 7ee2869d60 mesh-catalog: narrow the placing by a string, which the check's non-strict compile can follow
mesh/merge-gate head 7ee2869d60eb merged into trunk e1c8965ddbf1 — pass: builds mesh-catalog → novox; no bus step; every machine composes with the change a…
mesh/repo-check head 7ee2869d60eb merged into trunk e1c8965ddbf1 — 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
2026-10-14 20:04:37 +02:00
jochen 0b74c04aa8 mesh-catalog: hear the build seat that builds now, so a merged build is registered when it is built (hq issue 712)
mesh/delivery superseded: a newer head of the same pull request
mesh/merge-gate head 0b74c04aa89f merged into trunk e1c8965ddbf1 — pass: builds mesh-catalog → novox; no bus step; every machine composes with the change a…
mesh/repo-check head 0b74c04aa89f merged into trunk e1c8965ddbf1 — fail: merge-check.sh (go) passed; its merge-check-typescript.sh failed: TypeScript modul…
Since the build role moved to node-build-agent (ADR 0190), the catalogue consumed only the retired
mesh-build-machine.built, so builds reached it only through the replay it asks for at its start: mailu
e1c8965d, built two minutes after the last replay, was never registered. Consume node-build-agent.built,
and pass over the seat's merge checks, tries, dry runs and failures, which the old seat never said.
2026-10-14 20:03:25 +02:00
mesh-admin e1c8965ddb Merge pull request 'mailu: declare its unhealthy urgent (hq ADR 0355 §5, issue 691)' (#296) from adr-355-mailu-urgent into main 2026-10-14 17:01:22 +00:00
mesh-adminandClaude Opus 5.5 47ef2644ac Give the build agent a judge cache of its own, and prove a release branch's move puts its verdicts out of date
mesh/merge-gate head 47ef2644aca1 merged into trunk e22326240833 — pass: builds build-agent, gitea → ace, g14, novox, shanks; no bus step; every machine co…
mesh/repo-check head 47ef2644aca1 merged into trunk e22326240833 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
mesh/delivery-group group fix/704-release-branch-judge merged: every member is merged
Every merge check's judge is built where no pull request can reach (hq ADR 0358 §6,
issue 704): the build agent needs a directory beside its workspace, mounted at the
same path, that only judge builds mount. ADR 0358 §5 already holds in the forge's
holder; this test keeps it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:48:30 +00:00
mesh-admin cb7bf94a77 Merge pull request 'mailu: ban clients that try to log in on port 25 (hq issue 708)' (#295) from fix-708-mailu-jail-port25 into main 2026-10-14 13:39:51 +00:00
jochen 8e821ef449 Say mail's failure urgently, on every channel
mesh/merge-gate head 8e821ef44992 merged into trunk cb7bf94a77ea — pass: builds mailu, sent nowhere; no bus step; 1 wait(s) for a person; every machine com…
mesh/repo-check head 8e821ef44992 merged into trunk cb7bf94a77ea — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
A broken mailu was a warning, said only on the desk; refused or stuck
mail must reach the operator wherever they are (ADR 0355 §5, issue 691).
2026-10-14 15:37:08 +02:00
jochen 07b9bd0ed4 Ban clients that try to log in on mail port 25
mesh/merge-gate head 07b9bd0ed44a merged into trunk 02427a60ef78 — pass: builds mailu, sent nowhere; no bus step; 1 wait(s) for a person; every machine com…
mesh/repo-check head 07b9bd0ed44a merged into trunk 02427a60ef78 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
Port 25 takes no login, so a client that tries is guessing passwords;
mailu's jail only matched Dovecot's failures and never banned them
(issue 708). Relay attempts stay unbanned: postfix already refuses them.
2026-10-14 15:34:55 +02:00
mesh-admin 02427a60ef Merge pull request 'mailu: judge the smtp container by whether mail flows (hq issue 690)' (#294) from fix-690-mail-flows into main 2026-10-14 13:29:25 +00:00
jochen 8661bcfa22 Declare mailu's tools, so the controller knows the one its check asks
mesh/merge-gate head 8661bcfa2275 merged into trunk 50861c74f8b4 — pass: builds mailu, sent nowhere; no bus step; 1 wait(s) for a person; every machine com…
mesh/repo-check head 8661bcfa2275 merged into trunk 50861c74f8b4 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
2026-10-14 15:21:39 +02:00
jochen 6049537655 Judge mailu's deferred queue by this side's faults, not one dead recipient
mesh/merge-gate head 60495376551d merged into trunk 50861c74f8b4 — fail: builds mailu, sent nowhere; no bus step; 1 wait(s) for a person; a manifest the ch…
mesh/repo-check head 60495376551d merged into trunk 50861c74f8b4 — fail: its merge-check.sh failed: files asking for a setting without saying whether it is…
mesh/delivery superseded: a newer head of the same pull request
A message to a far host that is down stays deferred for up to five days,
and mailu's unhealthy now pages the operator, so one dead recipient would
page for days. Overdue deferred mail is unhealthy only when a lookup failed
here (a name-service failure, as in issue 687) or when it reaches three or
more domains; one or two far domains are said in the answer, not alarmed.
2026-10-14 15:19:08 +02:00
jochen bf3e801f67 Judge mailu by whether mail flows, not only by its parts
mesh/merge-gate head bf3e801f6700 merged into trunk 50861c74f8b4 — fail: builds mailu, sent nowhere; no bus step; 1 wait(s) for a person; a manifest the ch…
mesh/repo-check head bf3e801f6700 merged into trunk 50861c74f8b4 — fail: its merge-check.sh failed: files asking for a setting without saying whether it is…
mesh/delivery superseded: a newer head of the same pull request
On 2026-10-14 every mailu container read healthy while postfix could not
resolve a name: inbound mail was refused with 450 and outbound mail sat in
the deferred queue for days (issue 690). The smtp container is now judged
by a tool check, mailu_health, asked inside it: postfix runs (the image's
own check, kept), no message waits deferred longer than 30 minutes, and a
public mail host resolves through the resolver postfix is given.
2026-10-14 15:14:51 +02:00
mesh-admin 50861c74f8 Merge pull request 'mailu: judge the front by SMTP and its certificate, not only its web health (hq issue 688)' (#285) from fix-688-mailu-health into main 2026-10-14 13:06:45 +00:00
mesh-admin b15846dd6e Merge pull request 'mailu: move the resolver to Mailu 2024.06.63, which follows the root key change (hq issue 687)' (#284) from fix-687-mailu-resolver into main 2026-10-14 12:32:36 +00:00
mesh-admin a44d490d97 Merge pull request 'Five postgres consumers say which provision their check exercises (hq issue 145, to-be 48 Phase E)' (#289) from fix/145-a-consumer-is-held-under-its-provider into main 2026-10-14 05:26:47 +00:00
mesh-admin af5cf53a34 Merge pull request 'lab: name the base image's age, refuse a stale one, and rebuild it (issue 698)' (#288) from fix/698-the-lab-names-its-base-age into main 2026-10-14 05:12:37 +00:00
jochen 2bb5d344f6 Hold umami's check to the share route's own 404, so an unknown route cannot pass it (hq issue 145, review 262)
mesh/merge-gate head 2bb5d344f6f1 merged into trunk af5cf53a3450 — pass: builds gitea, mesh-knowledge, n8n, nextcloud, umami → ace, novox; no bus step; eve…
mesh/repo-check head 2bb5d344f6f1 merged into trunk af5cf53a3450 — 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@054dc9618ce3 (merged as b15846dd into main, walk plan-17919811644…
mesh/delivery-group group fix/145-a-consumer-is-held-under-its-provider merged: every member is merged
Any route umami does not serve answers 404 too, as an HTML page. The
share route answers JSON with code not-found only after the database
was asked; with the database down it answers 500.
2026-10-14 07:07:11 +02:00
mesh-admin e846c289c8 Merge pull request 'lab: a run whose suite proved nothing reads as failed, with its skips (issue 693)' (#287) from fix/693-a-run-that-proved-nothing-is-not-passed into main 2026-10-14 05:05:40 +00:00
jochen 0fcc560fec lab: one job at a time with no await between check and start, and no rebuild left holding anything (issue 698, review 256)
mesh/merge-gate head 0fcc560fecd6 merged into trunk e846c289c878 — pass: builds lab → shanks; no bus step; every machine composes with the change as it did…
mesh/repo-check head 0fcc560fecd6 merged into trunk e846c289c878 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
A run and a rebuild each asked the package registry between checking for
the other and writing their status, so both could start and a bed could
raise machines from an image being replaced. The registry is asked first
now. A hard-killed rebuild's credential is cleaned like a run's; a job that
never recorded its process is lost rather than blocking every run; and
lab_check reports a missing or unreadable base image.
2026-10-14 03:34:46 +02:00
jochen acf8a76646 lab: read the suite's verdict only after its recorded line, NOT REACHED in any case (issue 693, review 254)
mesh/merge-gate head acf8a76646de merged into trunk ebf7f3773dc7 — pass: builds lab → shanks; no bus step; every machine composes with the change as it did…
mesh/repo-check head acf8a76646de merged into trunk ebf7f3773dc7 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
mesh/delivery-group group fix/693-a-run-that-proved-nothing-is-not-passed merged: every member is merged
A test's own output, printed before the suite reports, could carry a line
shaped like the suite's NOT PASSED or skipped line and stand for its verdict.
2026-10-14 03:28:20 +02:00
jochen b62bb15839 Say which provision five consumers' checks exercise, so a database down is one condition (hq issue 145)
mesh/merge-gate out of date: judged against trunk ebf7f3773dc7, the trunk is now e846c289c878; judged again on a recheck or when it is merged
mesh/repo-check out of date: judged against trunk ebf7f3773dc7, the trunk is now e846c289c878; judged again on a recheck or when it is merged
mesh/delivery-group group fix/145-a-consumer-is-held-under-its-provider checking: composed check out of date: novox/mesh-catalog@b62bb158398d was judged agains…
mesh/delivery superseded: a newer head of the same pull request
gitea, nextcloud, umami and n8n gain an http check that reaches their
database through their own endpoint; mesh-knowledge's watch already
asks the database and now names it. While postgres is unhealthy, their
findings are held under its condition instead of raising one each.
2026-10-14 03:21:53 +02:00
jochen ff9d3ee6a0 lab: name the base image's age, refuse a stale one, and rebuild it (issue 698)
mesh/merge-gate head ff9d3ee6a06c merged into trunk ebf7f3773dc7 — pass: builds lab → shanks; no bus step; every machine composes with the change as it did…
mesh/repo-check head ff9d3ee6a06c merged into trunk ebf7f3773dc7 — 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 bed machine carries the package database its base image was built with,
and the mirrors stop serving it within weeks: genesis-keeps-its-bus failed
on a 404 from an image 44 days old, with nothing saying its age and no mesh
tool to rebuild it. lab_check and lab_run now say the age, lab_run refuses
an image older than 14 days and names the remedy, and lab_base reads the
image and rebuilds it with mesh-lab's base build when asked.
2026-10-14 03:21:23 +02:00
jochen c8255eb15d lab: a run whose suite proved nothing reads as failed, with its skips (issue 693)
mesh/merge-gate head c8255eb15d12 merged into trunk ebf7f3773dc7 — pass: builds lab → shanks; no bus step; every machine composes with the change as it did…
mesh/repo-check head c8255eb15d12 merged into trunk ebf7f3773dc7 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery-group group fix/693-a-run-that-proved-nothing-is-not-passed checking: 1 of 2 member(s) ready
mesh/delivery superseded: a newer head of the same pull request
lab_status took the suite's exit as the verdict, and the runner exits 0 when
every test skipped: a genesis bed that skipped its only test as NOT REACHED was
recorded as passed. A run that ended now has its outcome read from its log and
kept in its status - totals, each skip with its reason, and why it did not pass
- and one whose exit said 0 while no test passed reads as failed. Read from the
lab's own lines when it prints them, from the runner's summary for a lab of
before.
2026-10-14 03:14:06 +02:00
jochen 4ded07bc63 Judge mailu's front by what mail needs, not only its web health
mesh/merge-gate head 4ded07bc635f merged into trunk b15846dd6e78 — pass: builds mailu, sent nowhere; no bus step; 1 wait(s) for a person; every machine com…
mesh/repo-check head 4ded07bc635f merged into trunk b15846dd6e78 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
The image's own check asks nginx's /health and that dovecot runs, so the
front stayed healthy while mail could not flow (issue 688). It now also
needs port 25 to greet over SMTP and the served certificate to hold for
14 more days; certbot renews at 30, so a renewal stuck for two weeks is
said long before the certificate expires.
2026-10-14 02:19:54 +02:00
jochen 054dc9618c Move mailu's resolver to Mailu 2024.06.63, which follows the root key change
mesh/merge-gate head 054dc9618ce3 merged into trunk a44d490d974c — pass: builds mailu, sent nowhere; no bus step; 1 wait(s) for a person; every machine com…
mesh/repo-check head 054dc9618ce3 merged into trunk a44d490d974c — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
The root zone signs with KSK-2024 since 2026-10-11. The pinned resolver
image (2024.06.59) trusted only KSK-2017 and kept its anchor inside the
container, so every lookup failed with SERVFAIL and mail was refused or
stuck (issue 687). 2024.06.63 refreshes the anchor with unbound-anchor at
every start and fails its health check on SERVFAIL, which the old
'dig @127.0.0.1' never did (issue 688).
2026-10-14 02:17:25 +02:00
mesh-admin ebf7f3773d Merge pull request 'build-agent: declare busy, so a replaced agent finishes the check it runs (issue 594, hq ADR 0335)' (#283) from feat/335-the-build-agent-declares-busy into main 2026-10-13 22:39:50 +00:00
jschoubben c876ad4a83 Merge remote-tracking branch 'origin/main' into feat/335-the-build-agent-declares-busy
mesh/merge-gate head c876ad4a8303 merged into trunk 23a2e1e20467 — pass: builds build-agent → ace, g14, novox, shanks; no bus step; every machine composes …
mesh/repo-check head c876ad4a8303 merged into trunk 23a2e1e20467 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
# Conflicts:
#	modules/build-agent/README.md
2026-10-14 00:11:50 +02:00
mesh-admin 23a2e1e204 Merge pull request 'mesh-delivery: a closed pull request's check builds go, and a walk says what each build did (ADR 0350, issue 649)' (#282) from feat/350-one-build-per-change into main 2026-10-13 21:30:30 +00:00
jschoubben 66d22dd53e Let a closed pull request's check builds go, and show what each walk build did
mesh/merge-gate head 66d22dd53eff merged into trunk 7ed119995f69 — pass: builds mesh-delivery → novox; no bus step; every machine composes with the change …
mesh/repo-check head 66d22dd53eff merged into trunk 7ed119995f69 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
mesh/delivery-group group feat/350-one-build-per-change merged: every member is merged
A check's builds for its merge have no taker once the pull request closes
unmerged, and the delivery should say whether a module was reused from the
check or built, and why (novox/hq ADR 0350 §4, §5).
2026-10-13 23:04:41 +02:00
mesh-admin 7ed119995f Merge pull request 'gitea serves the registry's public root; the lab trusts a tarball only there or at the forge's own origin (issue 624, ADR 0346)' (#280) from fix/624-the-forge-serves-its-public-root into main 2026-10-13 19:52:05 +00:00
mesh-admin faebc3569a Merge pull request 'An ask reads at a glance on the phone: a headline, a summary and its points, the rest in a note (issues 383 and 639)' (#278) from fix/383-an-ask-reads-at-a-glance into main 2026-10-13 19:02:21 +00:00
jochen 1faee6e279 lab: a forge setting that is not http(s) is never a trusted origin (hq issue 624, review 222)
mesh/merge-gate head 1faee6e27976 merged into trunk faebc3569acf — pass: builds gitea, lab → novox, shanks; no bus step; every machine composes with the ch…
mesh/repo-check head 1faee6e27976 merged into trunk faebc3569acf — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
2026-10-13 20:33:13 +02:00
jochen 6f7d06726c gitea serves the registry's public root, and the lab trusts a tarball only at it or the forge's own origin (hq ADR 0346, issue 624)
The forge writes every tarball at its public name and the lab reaches it at
a private address, so npm 12 refused every locked @novox package. The public
name now comes from the mesh's own composition of the forge's route, through
the lab's binding, and the lab compares scheme, host and port.
2026-10-13 20:33:02 +02:00
jochen f09daabec3 A withheld change line in the note keeps its key's name; SDK at 5933eb7 (the review of mesh-controller #304)
mesh/merge-gate head f09daabec3a8 merged into trunk 9c166398f204 — pass: builds claude-code, desk-channel, mesh-knowledge, messenger, telegram → ace, g14, …
mesh/repo-check head f09daabec3a8 merged into trunk 9c166398f204 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
mesh/delivery-group group fix/383-an-ask-reads-at-a-glance merged: every member is merged
The router's fallback said a refused note line as "(a line carrying … is withheld here)", so the operator
could not tell which setting held a value they were not shown. A change's line now keeps its key; any other
line, or one whose key is refused too, is said withheld whole. The SDK pin moves to mesh-sdk 5933eb7, where
no two asks digest the same.
2026-10-13 20:32:50 +02:00
jochen bb5a342863 Every value an approval sets is in the note, whole where the sender is proven; the note is kept whole (issue 383)
mesh/merge-gate head bb5a342863ee merged into trunk 9c166398f204 — pass: builds claude-code, desk-channel, mesh-knowledge, messenger, telegram → ace, g14, …
mesh/repo-check head bb5a342863ee merged into trunk 9c166398f204 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery-group group fix/383-an-ask-reads-at-a-glance checking: 1 of 2 member(s) ready
mesh/delivery superseded: a newer head of the same pull request
The review of mesh-controller #304: a proposal of more than three keys showed only the names of the rest,
and its Details could carry no value, so Approve bound values the operator never saw. The router now
shows an ask's WholeDetails in the note in place of its Details on a kind that proves who answers, held
to the secret rule alone, and refuses an ask whose whole details carry a secret's shape. Telegram keeps
the note whole when a message is too long, cutting the body first and saying so, and escapes the note
as every other part. SDK at mesh-sdk f474544.
2026-10-13 17:16:50 +02:00
jochen eadf301f9f An ask reads at a glance on the phone: a headline, a summary and its points, the rest in a note (issues 383 and 639)
The operator would not read the Telegram ask for ADR 0343: thirty plain lines, every clause on one, no bold,
no summary. A settings proposal was the same wall of text, its values masked so the approval was blind.

- messenger: an ask's message is the asker's headline and explanation alone; who asked and when, what each
  answer does, the expiry, where else it is answered and the asker's Details travel as the message's note
  (asks.Message.Note). Answered, the message is one line, "You chose Approve at 16:06 on Telegram ·
  <headline>". An explanation that does not read at a glance (asks.Glance) is shown and logged. On a kind
  that proves who answers, an ask's values are shown whole (asks.Ask.Whole), held to the secret rule alone
  (from the earlier work on issue 383, #178).
- telegram: every message in HTML, never reworded: the title bold, a key line monospace, the note apart in
  a collapsed blockquote; markup escaped. A tap is compared by its words, whitespace aside, and a message
  shown before is still compared by the digest it was kept under.
- desk-channel: the note follows the body.
- mesh-knowledge: a record that needs the operator carries a brief (headline, summary, at most three
  points), the 30th per-record rule; its ask shows the brief and never the clauses. An ask made before is
  composed again as it was asked (form), so its warrant still holds.
- claude-code: an instructions proposal's explanation is one summary line (the proposer's, or the
  module's), its text in the Details; a question's context is at most 300 characters and its recommendation
  one point. Older proposals and questions keep their form.

The SDK is pinned at mesh-sdk 9588871 (fix/639-an-ask-reads-at-a-glance), which adds Message.Note and
asks.Glance.
2026-10-13 17:16:50 +02:00
mesh-admin 9c166398f2 Merge pull request 'Revert #277: no showmissed lock in betterbird's policy (issue 640)' (#281) from fix/640-revert-the-showmissed-lock into main 2026-10-13 15:06:09 +00:00
mesh-admin e209d46faf Merge pull request 'claude-code: the memory watch only notes a change, so it starts on a node with hundreds of files (issue 367)' (#279) from fix/367-memory-watch-takes-fast into main 2026-10-13 14:58:35 +00:00
jochen f1f866050e Revert the showmissed lock of #277: the operator wants no special handling in betterbird's config (hq issue 640)
mesh/merge-gate head f1f866050eee merged into trunk e209d46faf3d — pass: builds betterbird, sent nowhere; no bus step; 2 wait(s) for a person; every machin…
mesh/repo-check head f1f866050eee merged into trunk e209d46faf3d — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
The fix is the approved settings layer (proposal s09bf8c99da7ac8b9:
calendar.alarms.showmissed false), pushed to g14 and shanks.
2026-10-13 16:54:51 +02:00
mesh-admin 4d15a155e6 Merge pull request 'betterbird: a passed meeting's reminder does not come back at every Owl reload (issue 640)' (#277) from fix/640-a-dismissed-reminder-stays-dismissed into main 2026-10-13 14:47:58 +00:00
jschoubben e46eeb8298 claude-code: the memory watch only notes a change, so it starts on a node with hundreds of files (issue 367)
mesh/merge-gate head e46eeb829853 merged into trunk 4d15a155e634 — pass: builds claude-code → ace, g14, novox, shanks; no bus step; every machine composes …
mesh/repo-check head e46eeb829853 merged into trunk 4d15a155e634 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
The runtime hands a watch its current values one at a time and gives them ten
seconds. The handler read the state, waited for a running look and wrote files
for each value, so with about 450 memory values the watch never started on g14
or shanks, and a file written on g14 never reached shanks. The handler now
notes each key's newest revision and returns; a worker applies what was noted
at the state's current version.
2026-10-13 16:40:41 +02:00
mesh-admin 5f491e0c09 Merge pull request 'lab: name the package registry by the address it names itself by, so npm 12 installs locked @novox packages (issue 624)' (#275) from fix/624-lab-names-the-registry-as-it-names-itself into main 2026-10-13 14:35:04 +00:00
jochen 73404a3c3f betterbird: lock the missed-reminder pass off, so a passed meeting's reminder does not come back (hq issue 640)
mesh/merge-gate head 73404a3c3f21 merged into trunk 5f491e0c097d — pass: builds betterbird, sent nowhere; no bus step; 2 wait(s) for a person; every machin…
mesh/repo-check head 73404a3c3f21 merged into trunk 5f491e0c097d — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
With calendar.alarms.showmissed on, Betterbird's alarm service fires every
reminder of the past month that carries no later acknowledgement at each
load of a calendar. Owl never sends a dismissal to Exchange and keeps it only
in Betterbird's local cache, so on g14 September's Exchange meetings came
back about every ten minutes, Dismiss or not. The policy now locks the pass
off whatever the setting or profile says, and betterbird_check says when the
running Betterbird still has it on beside an Owl calendar.
2026-10-13 16:32:17 +02:00
mesh-admin 305c2ba247 Merge pull request 'claude-code: a session's memory follows the operator to every node (issue 367, ADR 0343)' (#276) from fix/367-memory-follows-the-operator into main 2026-10-13 14:26:59 +00:00
jochen 8ad121d5c3 lab: take the registry's address only from the forge's own host, built from the parsed URL (hq issue 624, review of #275)
mesh/merge-gate head 8ad121d5c369 merged into trunk 305c2ba24733 — pass: builds lab → shanks; no bus step; every machine composes with the change as it did…
mesh/repo-check head 8ad121d5c369 merged into trunk 305c2ba24733 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
What the registry answers may come over plain HTTP, so it must not choose
where packages come from, nor reach the run's npm configuration as a raw
string.
2026-10-13 16:26:48 +02:00
jschoubben 305601d25e claude-code: memory carrying never leaves a memory folder, never takes the unreadable for gone, and screens every write (issue 367, review of #276)
mesh/merge-gate head 305601d25ef4 merged into trunk a84d0094a0a3 — pass: builds claude-code → ace, g14, novox, shanks; no bus step; every machine composes …
mesh/repo-check head 305601d25ef4 merged into trunk a84d0094a0a3 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
A value in the state could name a project such as ../../.. and be written outside
the memory folder; a file or folder that could not be read was carried as a
removal; text kept over a removal or merged in a conflict reached the state
unscreened; and a conflict whose file could not be written was merged into the
state again on every look. Names are judged before any file is touched, what
cannot be read is kept and listed, every state write is screened, a conflict is
written to the file before the state and warns once when it cannot be, a file
changed between reading and writing is not written over, non-UTF-8 text is not
carried, and a node that was away takes a removal it holds no change against
(ADR 0343 §9, as clarified).
2026-10-13 16:20:02 +02:00
jschoubben 910bc58fe0 claude-code: keep two memory versions too large for one file in two, and say a text that cannot fit one value (issue 367)
mesh/merge-gate head 910bc58fe01d merged into trunk a84d0094a0a3 — pass: builds claude-code → ace, g14, novox, shanks; no bus step; every machine composes …
mesh/repo-check head 910bc58fe01d merged into trunk a84d0094a0a3 — 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 merged file could exceed the state's 256 KiB value; the newer now stays in the
file and the older is written beside it, each carried. A node that was away takes
the newest version when its own file holds no uncarried change (tested).
2026-10-13 16:03:37 +02:00
jschoubben 0096beeff9 claude-code: say a memory conflict in the operator's plain words, and skip a look the state does not answer (issue 367)
mesh/merge-gate head 0096beeff999 merged into trunk a84d0094a0a3 — pass: builds claude-code → ace, g14, novox, shanks; no bus step; every machine composes …
mesh/repo-check head 0096beeff999 merged into trunk a84d0094a0a3 — 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 warning now opens with what the operator is needed for and names no path,
agent or session (ADR 0253's plain rule); clause references follow ADR 0343 as
reviewed.
2026-10-13 16:01:16 +02:00
jschoubben 6aa30eae66 claude-code: carry each project's memory folder between nodes (issue 367, ADR 0343)
mesh/merge-gate head 6aa30eae66be merged into trunk a84d0094a0a3 — pass: builds claude-code → ace, g14, novox, shanks; no bus step; every machine composes …
mesh/repo-check head 6aa30eae66be merged into trunk a84d0094a0a3 — 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 session started on another node began without what the sessions elsewhere had
learned, because Claude Code keeps project memory on one node. The module's own
process now carries the memory folders through its kept memory state: a change
is written elsewhere only over the version it replaced, a file changed on two
nodes keeps both (newer first) with a warning, and every replaced or removed
version is kept. No tool writes memory.
2026-10-13 15:57:44 +02:00
jochen bb60de6d61 lab: name the package registry by the address it names itself by, so npm 12 installs locked @novox packages (hq issue 624)
mesh/merge-gate head bb60de6d616a merged into trunk a84d0094a0a3 — pass: builds lab → shanks; no bus step; every machine composes with the change as it did…
mesh/repo-check head bb60de6d616a merged into trunk a84d0094a0a3 — 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
npm 12 installs a locked tarball only from its configured registry; the forge
writes every tarball URL, and so every lock file, at its public address, and
the lab named the registry by its forge setting's address. Every run stopped
at mesh-tools' install with EALLOWREMOTE.
2026-10-13 15:25:41 +02:00
mesh-admin a84d0094a0 Merge pull request 'lab: read the bound forge credential through the tools bundle's env, and check in a fresh directory each time (issue 624)' (#274) from fix/624-lab-check-in-a-fresh-directory into main 2026-10-13 12:58:15 +00:00
jochen dd39d4ace7 lab: name the bound credential in the tools bundle's env, and skip the locked-tree test as root (hq issue 624, reviews 198, 199)
mesh/merge-gate head dd39d4ace74b merged into trunk 0d723484a84a — pass: builds lab → shanks; no bus step; every machine composes with the change as it did…
mesh/repo-check head dd39d4ace74b merged into trunk 0d723484a84a — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
The lab's tools read their bound npm-package-registry credential, root's at 0600 on the machine. The
controller gives the runtime's account exactly the files a tools bundle names in its env (ADR 0192), as
gitea and umami are given theirs, so the bundle now names the credential and its binding there, and no
other credential changes hands.

The merge check runs as root, where no directory mode stops a removal: the test that makes an
unremovable tree now skips there before it makes anything, and a second test clears leftovers whoever
runs it.
2026-10-13 14:51:18 +02:00
jochen 5bd40fa406 lab_check: work in a fresh directory each time, and name what an earlier check left (hq issue 624)
mesh/merge-gate head 5bd40fa4061d merged into trunk 0d723484a84a — pass: builds lab → shanks; no bus step; every machine composes with the change as it did…
mesh/repo-check head 5bd40fa4061d merged into trunk 0d723484a84a — fail: merge-check.sh (go) passed; its merge-check-typescript.sh failed: TypeScript modul…
mesh/delivery superseded: a newer head of the same pull request
lab_check cloned into one fixed path, <work>/check, after an rm -rf whose result it ignored; a tree an
earlier check left that the lab's account could not remove made every check fail on "destination path
already exists". Each check now works in a fresh check-* directory it removes when done, and clears
what earlier checks left, naming any path it could not remove.
2026-10-13 14:40:06 +02: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 covered after its failure: a later walk that contains it was delivered: plan-1791883115921573192 (novox/mesh-catalog at 45d01e77 into main,…
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 merged: every member is merged
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 merged: every member is merged
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 merged: every member is merged
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
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
1090 changed files with 87300 additions and 9515 deletions
+5
View File
@@ -21,3 +21,8 @@ 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
modules/mesh-files/mesh-files
modules/mesh-files/cmd/mesh-files/mesh-files
# The provisioner client built in place (issue 671).
modules/mesh-provisioner-client/cmd/mesh-provisioner-client/mesh-provisioner-client
+6
View File
@@ -0,0 +1,6 @@
# Release 57: config and provisioning
This branch carries the work of hq design 57 (ADR 0348, ADR 0349) in this repository. Each package
merges here through its own reviewed pull request; the branch reaches main as one pull request, in one
delivery group with the branch of the same name in mesh-controller, mesh-host, mesh-sdk, mesh-catalog
and hq, after a rehearsal in the lab. This file is deleted by the last commit before that pull request.
+3
View File
@@ -0,0 +1,3 @@
module git.novox.be/novox/mesh-catalog/checks/config
go 1.22
+201
View File
@@ -0,0 +1,201 @@
// Command config refuses the settings form's names in a module that has moved to config (novox/hq ADR 0348
// clauses 1 and 11, design 57 package K6, issue 665).
//
// "Config" replaces "settings": a manifest declares its values under `config`, a mergeable file names the keys it
// takes under `takes-config`, and a file asks for a value with `${config:<key>}`. A mergeable file naming no keys at
// all is the settings form's last shape: it takes every key of its module's layer and declares none, so mesh-config
// could set none of them (ADR 0348 §10, ADR 0326 §3); it is found here like an old name (issue 735). The old names — `settings`,
// `takes-settings` and `${setting:<key>}` — stay accepted by the controller as aliases until every module has
// moved, and are then refused (ADR 0348 §1). The modules not yet moved are listed in the file `unmoved` beside this
// check, one per line: the catalogue's list ADR 0348 §11 names. Every other module says no old name, in its
// manifest or in a template file its manifest names; a module on the list that says none is taken off it; and a
// line the trunk's copy (the merge base's, given with -base) does not hold is refused, so the list only shrinks;
// while the trunk holds no copy, the modules in seed are the trunk's.
// When it is empty, the controller refuses the old names itself (design 57 package R1).
//
// Prose is not judged: a comment in a file's text that says "settings" composes into the machine's file, and
// rewording it would change what a node is sent.
//
// go run . [-base <the trunk's unmoved>] <catalogue root>
//
// Exits 1 on a finding.
package main
import (
"bufio"
"encoding/json"
"flag"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
)
// The settings form's names (ADR 0348 §1). A key of a JSON object is a quoted string followed by a colon; a
// string value is never followed by one.
var (
settingRef = regexp.MustCompile(`\$\{setting:[a-z0-9][a-z0-9_.-]*\}`)
takesSettingsKey = regexp.MustCompile(`"takes-settings"\s*:`)
oldPlaceholder = "${setting:…}"
oldTakes = "takes-settings"
oldDeclaration = "settings"
wholeLayer = "taking its whole layer"
listFile = "unmoved"
listPathInCatalog = "checks/config/" + listFile
)
// Check answers, per module that says any, the settings form's names its manifest and its template files say.
func Check(root string) (map[string][]string, error) {
manifests, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
if err != nil {
return nil, err
}
sort.Strings(manifests)
out := map[string][]string{}
for _, path := range manifests {
raw, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var m struct {
Module string `json:"module"`
Settings json.RawMessage `json:"settings"`
Resources []map[string]any `json:"resources"`
}
if err := json.Unmarshal(raw, &m); err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
var said []string
if len(m.Settings) > 0 && string(m.Settings) != "null" {
said = append(said, oldDeclaration)
}
if takesSettingsKey.Match(raw) {
said = append(said, oldTakes)
}
if settingRef.Match(raw) {
said = append(said, oldPlaceholder)
}
for _, r := range m.Resources {
// A mergeable file naming no keys takes every key of the layer (ADR 0326 §3), declaring none of them.
if how, _ := r["merge"].(string); how != "" {
_, oldList := r["takes-settings"]
_, newList := r["takes-config"]
if !oldList && !newList {
said = append(said, fmt.Sprintf("%v %s", r["id"], wholeLayer))
}
}
name, _ := r["template"].(string)
if name == "" {
continue
}
text, err := os.ReadFile(filepath.Join(filepath.Dir(path), filepath.FromSlash(name)))
if err != nil {
return nil, fmt.Errorf("%s names the template %s: %w", m.Module, name, err)
}
if settingRef.Match(text) {
said = append(said, oldPlaceholder+" in "+name)
}
}
if len(said) > 0 {
sort.Strings(said)
out[m.Module] = said
}
}
return out, nil
}
// seed is the list as it was first written, standing for the trunk's copy until the trunk holds one.
// The modules taking a whole layer were found after the list was first written and before the trunk held a copy
// (issue 735), so they stand in it too.
var seed = map[string]bool{
"betterbird": true, "fail2ban": true, "influxdb": true, "keycloak": true, "mailu": true, "searxng": true,
"baserow": true, "confluence": true, "gitea": true, "gitlab": true, "grafana": true, "home-assistant": true,
"icecast": true, "jira": true, "letta": true, "matrix": true, "nodered": true, "supabase": true, "unifi": true,
}
// Unmoved is the modules a list names, one per line; a line starting with # and a blank line are not modules.
func Unmoved(path string) (map[string]bool, error) {
f, err := os.Open(path)
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[line] = true
}
return out, s.Err()
}
// Judge answers the modules that say an old name and are not on the list (refused), the modules on the list that
// say none (stale), and the modules on the list that the trunk's list does not hold (added).
func Judge(found map[string][]string, unmoved, base map[string]bool) (refused, stale, added []string) {
for m := range found {
if !unmoved[m] {
refused = append(refused, m)
}
}
for m := range unmoved {
if _, says := found[m]; !says {
stale = append(stale, m)
}
if !base[m] {
added = append(added, m)
}
}
sort.Strings(refused)
sort.Strings(stale)
sort.Strings(added)
return refused, stale, added
}
func main() {
basePath := flag.String("base", "", "the trunk's copy of unmoved (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)
}
unmoved, err := Unmoved(listFile)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
base := seed
if *basePath != "" {
if base, err = Unmoved(*basePath); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
refused, stale, added := Judge(found, unmoved, base)
for _, m := range refused {
fmt.Printf("%s says %s, the settings form's names, and is not on %s: write config, takes-config and "+
"${config:<key>} throughout, and name the keys each mergeable file takes (novox/hq ADR 0348 §1, §11)\n", m, strings.Join(found[m], ", "), listPathInCatalog)
}
for _, m := range stale {
fmt.Printf("%s names %s, which says no old name: take it off the list\n", listPathInCatalog, m)
}
for _, m := range added {
fmt.Printf("%s gains %s, which the trunk's copy does not hold: the list only shrinks — move the module to "+
"config instead (novox/hq ADR 0348 §11)\n", listPathInCatalog, m)
}
if len(refused) > 0 || len(stale) > 0 || len(added) > 0 {
os.Exit(1)
}
fmt.Printf("config: no moved module says settings, takes-settings or ${setting:<key>}, or takes a whole layer "+
"(%d not yet moved)\n",
len(unmoved))
}
+131
View File
@@ -0,0 +1,131 @@
package main
import (
"os"
"path/filepath"
"reflect"
"strings"
"testing"
)
func module(t *testing.T, root, name, body string, files map[string]string) {
t.Helper()
dir := filepath.Join(root, "modules", name)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "module.json"), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
for path, text := range files {
full := filepath.Join(dir, filepath.FromSlash(path))
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(full, []byte(text), 0o644); err != nil {
t.Fatal(err)
}
}
}
// Each of the settings form's names is found where it stands: the declaration, a mergeable file's keys, a
// placeholder in the manifest, and a placeholder in a template file the manifest names (novox/hq ADR 0348 §1).
func TestEveryOldNameIsFound(t *testing.T) {
root := t.TempDir()
module(t, root, "declares", `{"module":"declares","settings":{"size":{"kind":"preference","default":1,"why":"w"}},`+
`"resources":[{"id":"f","type":"file","content":"${setting:size}"}]}`, nil)
module(t, root, "takes", `{"module":"takes","resources":[{"id":"f","type":"file","merge":"json",`+
`"takes-settings":["a"],"content":"{}"}]}`, nil)
module(t, root, "asks", `{"module":"asks","serves":{"x":{"d":"${setting:domain}"}}}`, nil)
module(t, root, "templated", `{"module":"templated","config":{"size":{"type":"number","description":"d",`+
`"default":1}},"resources":[{"id":"f","type":"file","template":"files/conf"}]}`,
map[string]string{"files/conf": "size = ${setting:size}\n"})
module(t, root, "moved", `{"module":"moved","config":{"size":{"type":"number","description":"d","default":1}},`+
`"resources":[{"id":"f","type":"file","template":"files/conf"},{"id":"g","type":"file","merge":"json",`+
`"takes-config":[],"content":"{\"settings\": 1}"}]}`,
map[string]string{"files/conf": "size = ${config:size}\n# change the settings, never this file\n"})
module(t, root, "none", `{"module":"none","resources":[{"id":"f","type":"file","content":"no settings here"}]}`, nil)
got, err := Check(root)
if err != nil {
t.Fatal(err)
}
want := map[string][]string{
"asks": {"${setting:…}"},
"declares": {"${setting:…}", "settings"},
"takes": {"takes-settings"},
"templated": {"${setting:…} in files/conf"},
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("found %v, want %v", got, want)
}
}
// A module not on the list says no old name; one on the list that says none is taken off it; a line the trunk's
// list does not hold is refused, so the list only shrinks until it is empty (novox/hq ADR 0348 §11).
func TestTheListOfModulesNotYetMovedOnlyShrinks(t *testing.T) {
found := map[string][]string{"old": {"settings"}, "forgotten": {"takes-settings"}}
unmoved := map[string]bool{"old": true, "done": true, "new": true}
base := map[string]bool{"old": true, "done": true, "forgotten": true}
refused, stale, added := Judge(found, unmoved, base)
if !reflect.DeepEqual(refused, []string{"forgotten"}) || !reflect.DeepEqual(stale, []string{"done", "new"}) ||
!reflect.DeepEqual(added, []string{"new"}) {
t.Fatalf("refused %v, stale %v, added %v", refused, stale, added)
}
}
func TestTheListIsReadWithoutItsComments(t *testing.T) {
path := filepath.Join(t.TempDir(), "unmoved")
if err := os.WriteFile(path, []byte("# why\n\nmailu\n searxng \n"), 0o644); err != nil {
t.Fatal(err)
}
got, err := Unmoved(path)
if err != nil || !reflect.DeepEqual(got, map[string]bool{"mailu": true, "searxng": true}) {
t.Fatalf("%v %v", got, err)
}
}
// The catalogue itself: every moved module says config alone, and the list names exactly the modules that
// still say settings.
func TestTheCatalogueSaysConfigInEveryMovedModule(t *testing.T) {
found, err := Check(filepath.Join("..", ".."))
if err != nil {
t.Fatal(err)
}
unmoved, err := Unmoved("unmoved")
if err != nil {
t.Fatal(err)
}
refused, stale, added := Judge(found, unmoved, seed)
for _, m := range refused {
t.Errorf("%s is not on checks/config/unmoved and says %s", m, strings.Join(found[m], ", "))
}
for _, m := range stale {
t.Errorf("checks/config/unmoved names %s, which says no old name", m)
}
for _, m := range added {
t.Errorf("checks/config/unmoved gains %s, which its first copy did not hold", m)
}
}
// A mergeable file that names no keys takes every key of its module's layer (novox/hq ADR 0326 §3): the settings
// form's last shape, which declares none of what it takes, so mesh-config could set none of it (ADR 0348 §10). It
// is found like an old name, in a module that says no other (issue 735); one naming its keys, an empty list
// included, is not.
func TestAMergeableFileTakingTheWholeLayerIsFound(t *testing.T) {
root := t.TempDir()
module(t, root, "whole", `{"module":"whole","resources":[{"id":"runtime-config","type":"file","merge":"json",`+
`"template":"files/config.json"}]}`, map[string]string{"files/config.json": "{}\n"})
module(t, root, "names", `{"module":"names","config":{"url":{"type":"string","description":"d","default":""}},`+
`"resources":[{"id":"runtime-config","type":"file","merge":"json","takes-config":["url"],"content":"{}"}]}`, nil)
module(t, root, "none", `{"module":"none","resources":[{"id":"c","type":"file","merge":"json",`+
`"takes-config":[],"content":"{}"}]}`, nil)
module(t, root, "plain", `{"module":"plain","resources":[{"id":"c","type":"file","content":"{}"}]}`, nil)
got, err := Check(root)
if err != nil {
t.Fatal(err)
}
want := map[string][]string{"whole": {"runtime-config taking its whole layer"}}
if !reflect.DeepEqual(got, want) {
t.Fatalf("found %v, want %v", got, want)
}
}
+5
View File
@@ -0,0 +1,5 @@
# The modules still written in the settings form (`settings`, `takes-settings`, `${setting:<key>}`, or a mergeable
# file naming no keys, which takes its whole layer), one per line: the catalogue's list of modules not yet moved to
# config (novox/hq ADR 0348 §11). Every other module says config alone. This list only shrinks: a module that says no
# old name is taken off, and a line the trunk's copy does not hold is refused. When it is empty, the controller
# refuses the old names (design 57 package R1).
+3
View File
@@ -0,0 +1,3 @@
module git.novox.be/novox/mesh-catalog/checks/mailstore
go 1.22
+81
View File
@@ -0,0 +1,81 @@
// Command mailstore refuses any manifest but modules/mesh-mail claiming the mail-store seat (novox/hq ADR 0378
// §3, issue 800 part A): the operator's mail is read, searched and sent through this one seat, and a second
// claimant would be a second path to the same mail that ADR 0378 never accounted for.
//
// go run . <catalogue root>
//
// Exits 1 on a finding.
package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
)
// MailStoreSeat is the seat only mesh-mail may claim.
const MailStoreSeat = "mail-store"
// MailStoreModule is the one module allowed to claim it.
const MailStoreModule = "mesh-mail"
// Finding is one manifest refused: a module other than mesh-mail claiming mail-store.
type Finding struct {
Module string
}
// Check reads every manifest under root/modules and answers which ones it refuses.
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"`
Claims []struct {
Name string `json:"name"`
} `json:"claims"`
}
if err := json.Unmarshal(raw, &m); err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
if m.Module == MailStoreModule {
continue
}
for _, c := range m.Claims {
if c.Name == MailStoreSeat {
out = append(out, Finding{Module: m.Module})
break
}
}
}
return out, nil
}
func main() {
root := "."
if len(os.Args) > 1 {
root = os.Args[1]
}
found, err := Check(root)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
for _, f := range found {
fmt.Printf("%s claims the mail-store seat: only %s may (novox/hq ADR 0378 §3)\n", f.Module, MailStoreModule)
}
if len(found) > 0 {
os.Exit(1)
}
fmt.Println("mail-store: claimed only by mesh-mail")
}
+43
View File
@@ -0,0 +1,43 @@
package main
import (
"os"
"path/filepath"
"testing"
)
func manifest(t *testing.T, root, module, claims string) {
t.Helper()
dir := filepath.Join(root, "modules", module)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
body := `{"module":"` + module + `","claims":[` + claims + `]}`
if err := os.WriteFile(filepath.Join(dir, "module.json"), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
func TestAnotherModuleClaimingMailStoreIsRefused(t *testing.T) {
root := t.TempDir()
manifest(t, root, "mesh-mail", `{"name":"mail-store","scope":"mesh"}`)
manifest(t, root, "betterbird", `{"name":"mail-store","scope":"mesh"}`)
manifest(t, root, "dunst", `{"name":"node-notifier","scope":"node"}`)
found, err := Check(root)
if err != nil {
t.Fatal(err)
}
if len(found) != 1 || found[0].Module != "betterbird" {
t.Fatalf("refused %+v", found)
}
}
func TestTheCatalogueHasNone(t *testing.T) {
found, err := Check("../..")
if err != nil {
t.Fatal(err)
}
if len(found) != 0 {
t.Fatalf("%+v", found)
}
}
+3
View File
@@ -0,0 +1,3 @@
module git.novox.be/novox/mesh-catalog/checks/owned
go 1.22
+236
View File
@@ -0,0 +1,236 @@
// Command owned judges how the catalogue's directories and archives treat files they did not write (novox/hq
// ADR 0422 §8, issue 1024). It refuses, naming the module, the resource and the reason:
//
// 1. a directory or archive without `unknown`, or with a value but move-out, module or program;
//
// 2. `move-out` on a path not under ${machine:account-home} or ${machine:agent-home};
//
// 3. `knows` with an empty, absolute, `.`/`..`/empty-component or repeated path;
//
// 4. a path one module declares (a resource's path, or a fact with "home": true) lying inside a directory of
// another module, after ${dir:<id>} is resolved within each module and both homes are read as one root.
// Archives are not containers for this rule. This holds for ~/.ssh what checks/sshdir held (ADR 0415 §6).
//
// go run . <catalogue root>
//
// Exits 1 on a finding.
package main
import (
"encoding/json"
"fmt"
"os"
"path"
"path/filepath"
"regexp"
"sort"
"strings"
)
// Home is the one root both machine homes are read as.
const Home = "${home}"
var (
ofDir = regexp.MustCompile(`\$\{dir:([a-z0-9][a-z0-9_-]*)\}`)
homeVar = strings.NewReplacer("${machine:account-home}", Home, "${machine:agent-home}", Home)
values = map[string]bool{"move-out": true, "module": true, "program": true}
)
// Finding is one refusal.
type Finding struct {
Rule int
Module, ID, Why string
}
type resource struct {
ID, Type, Path, Unknown string
Knows []string
HasKnows bool
}
type parsed struct {
Module string
Resources []resource
Facts []string // paths of facts with "home": true, under the home
}
func load(file string) (*parsed, error) {
raw, err := os.ReadFile(file)
if err != nil {
return nil, err
}
var m struct {
Module string `json:"module"`
Resources []struct {
ID string `json:"id"`
Type string `json:"type"`
Path string `json:"path"`
Unknown *string `json:"unknown"`
Knows []string `json:"knows"`
} `json:"resources"`
Facts map[string]struct {
Path string `json:"path"`
Home bool `json:"home"`
} `json:"facts"`
}
if err := json.Unmarshal(raw, &m); err != nil {
return nil, fmt.Errorf("%s: %w", file, err)
}
out := &parsed{Module: m.Module}
if out.Module == "" {
out.Module = filepath.Base(filepath.Dir(file))
}
for _, r := range m.Resources {
x := resource{ID: r.ID, Type: r.Type, Path: r.Path, Knows: r.Knows, HasKnows: r.Knows != nil}
if r.Unknown != nil {
x.Unknown = *r.Unknown
if x.Unknown == "" {
x.Unknown = "(empty)"
}
}
out.Resources = append(out.Resources, x)
}
keys := make([]string, 0, len(m.Facts))
for k := range m.Facts {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
if f := m.Facts[k]; f.Home && f.Path != "" {
out.Facts = append(out.Facts, f.Path)
}
}
return out, nil
}
// norm resolves ${dir:<id>} from the module's own resources, reads both homes as one root and cleans the path.
func norm(p string, dirs map[string]string) string {
for i := 0; i < 8 && ofDir.MatchString(p); i++ {
p = ofDir.ReplaceAllStringFunc(p, func(w string) string {
if to, ok := dirs[ofDir.FindStringSubmatch(w)[1]]; ok {
return to
}
return w
})
}
p = homeVar.Replace(p)
if p == "" {
return ""
}
return path.Clean(p)
}
func underHome(p string) bool { return strings.HasPrefix(p, Home+"/") }
// badKnows says why a knows entry is refused, or "".
func badKnows(k string) string {
if k == "" {
return "is empty"
}
if strings.HasPrefix(k, "/") {
return "is absolute"
}
for _, c := range strings.Split(strings.TrimSuffix(k, "/"), "/") {
if c == "" || c == "." || c == ".." {
return "has an empty, . or .. component"
}
}
return ""
}
type declared struct {
module, id, path string
}
// Check reads every manifest under root/modules and answers what it refuses.
func Check(root string) ([]Finding, error) {
files, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
if err != nil {
return nil, err
}
sort.Strings(files)
var out []Finding
var paths []declared
type container struct{ module, path string }
var containers []container
for _, file := range files {
m, err := load(file)
if err != nil {
return nil, err
}
dirs := map[string]string{}
for _, r := range m.Resources {
if r.ID != "" && r.Path != "" {
dirs[r.ID] = r.Path
}
}
for _, r := range m.Resources {
p := norm(r.Path, dirs)
if p != "" {
paths = append(paths, declared{m.Module, r.ID, p})
}
if r.Type != "directory" && r.Type != "archive" {
continue
}
if r.Type == "directory" && p != "" {
containers = append(containers, container{m.Module, p})
}
add := func(rule int, why string) { out = append(out, Finding{rule, m.Module, r.ID, why}) }
switch {
case r.Unknown == "":
add(1, "a "+r.Type+" without `unknown` (move-out, module or program)")
case !values[r.Unknown]:
add(1, fmt.Sprintf("`unknown` is %q, not move-out, module or program", r.Unknown))
case r.Unknown == "move-out" && !underHome(p):
add(2, fmt.Sprintf("`move-out` on %q, which is not under ${machine:account-home} or ${machine:agent-home}", r.Path))
}
seen := map[string]bool{}
for _, k := range r.Knows {
if why := badKnows(k); why != "" {
add(3, fmt.Sprintf("`knows` entry %q %s", k, why))
continue
}
c := strings.TrimSuffix(k, "/")
if seen[c] {
add(3, fmt.Sprintf("`knows` entry %q is repeated", k))
}
seen[c] = true
}
if r.HasKnows && len(r.Knows) == 0 {
add(3, "`knows` is empty")
}
}
for _, f := range m.Facts {
if p := norm(Home+"/"+f, nil); p != "" {
paths = append(paths, declared{m.Module, "fact " + f, p})
}
}
}
for _, d := range paths {
for _, c := range containers {
if c.module != d.module && (d.path == c.path || strings.HasPrefix(d.path, c.path+"/")) {
out = append(out, Finding{4, d.module, d.id, fmt.Sprintf("declares %q, inside a directory of %s (%q)", d.path, c.module, c.path)})
}
}
}
return out, nil
}
func main() {
root := "."
if len(os.Args) > 1 {
root = os.Args[1]
}
found, err := Check(root)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
for _, f := range found {
fmt.Printf("%s: %s: %s (rule %d, novox/hq ADR 0422 §8, issue 1024)\n", f.Module, f.ID, f.Why, f.Rule)
}
if len(found) > 0 {
os.Exit(1)
}
fmt.Println("owned paths: every directory and archive says how it treats unknown files; no module declares inside another's directory")
}
+132
View File
@@ -0,0 +1,132 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
)
func manifest(t *testing.T, root, module, body string) {
t.Helper()
dir := filepath.Join(root, "modules", module)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "module.json"), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
func check(t *testing.T, root string) []Finding {
t.Helper()
found, err := Check(root)
if err != nil {
t.Fatal(err)
}
return found
}
// only asserts exactly one finding, of the rule, naming the module and the resource (issue 1024).
func only(t *testing.T, found []Finding, rule int, module, id string) {
t.Helper()
if len(found) != 1 || found[0].Rule != rule || found[0].Module != module || found[0].ID != id || found[0].Why == "" {
t.Fatalf("found %+v; want one finding of rule %d for %s/%s with a reason", found, rule, module, id)
}
}
func dir(id, path, rest string) string {
return `{"id":"` + id + `","type":"directory","path":"` + path + `"` + rest + `}`
}
func mod(name string, resources ...string) string {
return `{"module":"` + name + `","resources":[` + strings.Join(resources, ",") + `]}`
}
func TestMissingOrUnknownValueIsRefused(t *testing.T) {
root := t.TempDir()
manifest(t, root, "a", mod("a", dir("d", "/var/lib/a", "")))
only(t, check(t, root), 1, "a", "d")
root = t.TempDir()
manifest(t, root, "a", mod("a", `{"id":"z","type":"archive","path":"/opt/a","unknown":"delete"}`))
only(t, check(t, root), 1, "a", "z")
}
func TestMoveOutOutsideAHomeIsRefused(t *testing.T) {
root := t.TempDir()
manifest(t, root, "a", mod("a", dir("d", "/etc/a.d", `,"unknown":"move-out"`)))
only(t, check(t, root), 2, "a", "d")
}
func TestMoveOutUnderAHomeIsAllowed(t *testing.T) {
root := t.TempDir()
manifest(t, root, "a", mod("a",
dir("d", "${machine:account-home}/.config/a", `,"unknown":"move-out"`),
dir("e", "${dir:d}/sub", `,"unknown":"move-out"`),
dir("f", "${machine:agent-home}/x", `,"unknown":"move-out"`),
dir("g", "/var/lib/a", `,"unknown":"program"`),
dir("h", "${machine:account-home}/.ssh", `,"unknown":"module"`)))
if found := check(t, root); len(found) != 0 {
t.Fatalf("refused %+v", found)
}
}
func TestBadKnowsIsRefused(t *testing.T) {
for _, knows := range []string{`[""]`, `["/abs"]`, `["a/../b"]`, `["./a"]`, `["a//b"]`, `["a","a"]`, `["a","a/"]`} {
root := t.TempDir()
manifest(t, root, "a", mod("a", dir("d", "${machine:account-home}/.config/a", `,"unknown":"move-out","knows":`+knows)))
only(t, check(t, root), 3, "a", "d")
}
root := t.TempDir()
manifest(t, root, "a", mod("a", dir("d", "${machine:account-home}/.config/a", `,"unknown":"move-out","knows":["forge.credentials","kept/"]`)))
if found := check(t, root); len(found) != 0 {
t.Fatalf("refused %+v", found)
}
}
func TestAnotherModulesPathInsideADirectoryIsRefused(t *testing.T) {
root := t.TempDir()
manifest(t, root, "owner", mod("owner", dir("ssh", "${machine:account-home}/.ssh", `,"unknown":"module"`)))
manifest(t, root, "intruder", mod("intruder", `{"id":"f","type":"file","path":"${machine:account-home}/.ssh/config.d/20"}`))
manifest(t, root, "viahome", mod("viahome", `{"id":"g","type":"file","path":"${machine:agent-home}/.ssh/x"}`))
manifest(t, root, "facty", `{"module":"facty","facts":{"hosts":{"path":".ssh/config.d/00","home":true}}}`)
manifest(t, root, "outside", mod("outside", `{"id":"o","type":"file","path":"${machine:account-home}/.sshrc"}`))
got := map[string]int{}
for _, f := range check(t, root) {
if f.Rule != 4 || f.Why == "" {
t.Fatalf("finding %+v", f)
}
got[f.Module]++
}
if len(got) != 3 || got["intruder"] != 1 || got["viahome"] != 1 || got["facty"] != 1 {
t.Fatalf("refused %v; want intruder, viahome, facty", got)
}
}
func TestDirReferencesAreResolvedPerModule(t *testing.T) {
root := t.TempDir()
manifest(t, root, "owner", mod("owner", dir("base", "${machine:account-home}/.config/o", `,"unknown":"move-out"`)))
manifest(t, root, "other", mod("other",
dir("base", "${machine:account-home}/.config/p", `,"unknown":"move-out"`),
`{"id":"f","type":"file","path":"${dir:base}/x"}`))
if found := check(t, root); len(found) != 0 {
t.Fatalf("refused %+v", found)
}
manifest(t, root, "third", mod("third",
dir("d", "${machine:account-home}/.config/t", `,"unknown":"move-out"`),
`{"id":"f","type":"file","path":"${dir:d}/../o/x"}`))
only(t, check(t, root), 4, "third", "f")
}
func TestAnArchiveIsNotAContainerAndSameModuleIsFine(t *testing.T) {
root := t.TempDir()
manifest(t, root, "a", mod("a",
`{"id":"arc","type":"archive","path":"/opt/a","unknown":"program"}`,
dir("d", "/opt/a2", `,"unknown":"program"`),
`{"id":"f","type":"file","path":"/opt/a2/x"}`))
manifest(t, root, "b", mod("b", `{"id":"f","type":"file","path":"/opt/a/x"}`))
if found := check(t, root); len(found) != 0 {
t.Fatalf("refused %+v", found)
}
}
+3
View File
@@ -0,0 +1,3 @@
module git.novox.be/novox/mesh-catalog/checks/predefined
go 1.22
+153
View File
@@ -0,0 +1,153 @@
// Command predefined refuses a change to predefined.json that removes a key a module still names (novox/hq ADR
// 0348 clause 7, design 57 package K2, issue 661).
//
// Mesh config and node config hold values only for the keys on the catalogue's list of predefined keys, at the
// root of the repository; a module's config value may fall back to one, `"predefined": "<key>"`. A key removed
// while a manifest still names it would leave that module falling back to nothing: the controller refuses its
// build then, at the next merge that touches it. So the removal is refused here, in the pull request that makes
// it, naming each module that names the key.
//
// **What it cannot know.** Whether a key still holds a value at the mesh or a node is mesh-config's to say (design
// 57 package K3): it holds the values, and the catalogue's check reads no store. Until mesh-config refuses that
// itself, removing a key nobody names is let through here, and said.
//
// The list it compares with is the trunk's at the merge base, given with -base; without one, nothing was removed.
// The controller the mesh runs reads the list itself (type, validation, every manifest against it) in the
// module check; this check reads only its keys.
//
// go run . [-base <the trunk's predefined.json>] <catalogue root>
//
// Exits 1 on a finding.
package main
import (
"encoding/json"
"errors"
"flag"
"fmt"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
)
// File is the list's file at the root of the catalogue.
const File = "predefined.json"
// Keys is the keys of a list, read as a JSON object; an absent file has none.
func Keys(path string) (map[string]bool, error) {
raw, err := os.ReadFile(path)
if errors.Is(err, fs.ErrNotExist) {
return map[string]bool{}, nil
}
if err != nil {
return nil, err
}
var keys map[string]json.RawMessage
if err := json.Unmarshal(raw, &keys); err != nil {
return nil, fmt.Errorf("%s is not a JSON object of keys: %w", path, err)
}
out := map[string]bool{}
for k := range keys {
out[k] = true
}
return out, nil
}
// Named is, per predefined key, the modules whose manifests name it: under `config`, or its alias `settings`.
func Named(root string) (map[string][]string, error) {
manifests, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
if err != nil {
return nil, err
}
sort.Strings(manifests)
named := map[string][]string{}
for _, path := range manifests {
raw, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var m struct {
Module string `json:"module"`
Config map[string]json.RawMessage `json:"config"`
Settings map[string]json.RawMessage `json:"settings"`
}
if err := json.Unmarshal(raw, &m); err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
for _, declared := range []map[string]json.RawMessage{m.Config, m.Settings} {
for _, d := range declared {
var value struct {
Predefined string `json:"predefined"`
}
if json.Unmarshal(d, &value) == nil && value.Predefined != "" {
named[value.Predefined] = append(named[value.Predefined], m.Module)
}
}
}
}
return named, nil
}
// Removed is each key the base list holds and the tree's does not, with the modules still naming it; a removed
// key nobody names is answered with none.
func Removed(base, tree map[string]bool, named map[string][]string) map[string][]string {
out := map[string][]string{}
for k := range base {
if !tree[k] {
out[k] = named[k]
}
}
return out
}
func main() {
basePath := flag.String("base", "", "the trunk's predefined.json at the merge base")
flag.Parse()
if flag.NArg() != 1 {
fmt.Fprintln(os.Stderr, "usage: predefined [-base <the trunk's predefined.json>] <catalogue root>")
os.Exit(2)
}
root := flag.Arg(0)
tree, err := Keys(filepath.Join(root, File))
if err != nil {
fmt.Println(err)
os.Exit(1)
}
if *basePath == "" {
fmt.Printf("%s: %d key(s); no trunk list given, so none was removed\n", File, len(tree))
return
}
base, err := Keys(*basePath)
if err != nil {
fmt.Println(err)
os.Exit(1)
}
named, err := Named(root)
if err != nil {
fmt.Println(err)
os.Exit(1)
}
removed := Removed(base, tree, named)
keys := make([]string, 0, len(removed))
for k := range removed {
keys = append(keys, k)
}
sort.Strings(keys)
failed := false
for _, k := range keys {
if modules := removed[k]; len(modules) > 0 {
failed = true
fmt.Printf("%s: the key %q is removed and still named by %s: move each off it first (novox/hq ADR 0348 "+
"clause 7)\n", File, k, strings.Join(modules, ", "))
} else {
fmt.Printf("%s: the key %q is removed and no module names it; whether the mesh or a node still holds a "+
"value for it is mesh-config's to refuse, not this check's\n", File, k)
}
}
if failed {
os.Exit(1)
}
fmt.Printf("%s: %d key(s), none removed that a module names\n", File, len(tree))
}
+100
View File
@@ -0,0 +1,100 @@
package main
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
// A catalogue whose dunst falls back to font-size, and the trunk's list holding font-size and
// agent-managed-settings.
func aCatalogue(t *testing.T, list string) (root, base string) {
t.Helper()
root = t.TempDir()
write := func(path, text string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(text), 0o644); err != nil {
t.Fatal(err)
}
}
write(filepath.Join(root, "modules", "dunst", "module.json"), `{"module": "dunst", "config": {"font-size":
{"type": "number", "description": "points", "default": 10, "predefined": "font-size"}}}`)
write(filepath.Join(root, "modules", "claude-code", "module.json"), `{"module": "claude-code", "settings": {
"managed_settings": {"kind": "preference", "default": "{}", "why": "none"}}}`)
write(filepath.Join(root, "modules", "plain", "module.json"), `{"module": "plain"}`)
if list != "" {
write(filepath.Join(root, File), list)
}
base = filepath.Join(t.TempDir(), "base.json")
write(base, `{"font-size": {"type": "number"}, "agent-managed-settings": {"type": "object"}}`)
return root, base
}
func run(t *testing.T, args ...string) (string, error) {
t.Helper()
out, err := exec.Command("go", append([]string{"run", "."}, args...)...).CombinedOutput()
return string(out), err
}
func TestARemovalOfAKeyAModuleStillNamesIsRefused(t *testing.T) {
// The planted removal: font-size goes while dunst still falls back to it.
root, base := aCatalogue(t, `{"agent-managed-settings": {"type": "object"}}`)
out, err := run(t, "-base", base, root)
if err == nil || !strings.Contains(out, `the key "font-size" is removed and still named by dunst`) {
t.Fatalf("a removal of a named key passed:\n%s", out)
}
}
func TestARemovalNobodyNamesPassesAndSaysWhatItCannotKnow(t *testing.T) {
root, base := aCatalogue(t, `{"font-size": {"type": "number"}}`)
out, err := run(t, "-base", base, root)
if err != nil || !strings.Contains(out, `"agent-managed-settings" is removed and no module names it`) ||
!strings.Contains(out, "mesh-config's to refuse") {
t.Fatalf("a removal nobody names: %v\n%s", err, out)
}
}
func TestAListUnchangedOrGrownPasses(t *testing.T) {
for _, list := range []string{
`{"font-size": {"type": "number"}, "agent-managed-settings": {"type": "object"}}`,
`{"font-size": {"type": "number"}, "agent-managed-settings": {"type": "object"}, "accent": {"type": "string"}}`,
} {
root, base := aCatalogue(t, list)
if out, err := run(t, "-base", base, root); err != nil || !strings.Contains(out, "none removed") {
t.Fatalf("%s: %v\n%s", list, err, out)
}
}
// No trunk list given: nothing was removed.
root, _ := aCatalogue(t, `{}`)
if out, err := run(t, root); err != nil || !strings.Contains(out, "none was removed") {
t.Fatalf("without a base: %v\n%s", err, out)
}
}
func TestTheFileRemovedWholeIsARemovalOfEveryKey(t *testing.T) {
root, base := aCatalogue(t, "")
out, err := run(t, "-base", base, root)
if err == nil || !strings.Contains(out, `"font-size" is removed and still named by dunst`) {
t.Fatalf("the list removed whole passed:\n%s", out)
}
}
func TestNamedReadsBothForms(t *testing.T) {
root, _ := aCatalogue(t, "")
if err := os.WriteFile(filepath.Join(root, "modules", "plain", "module.json"), []byte(`{"module": "plain",
"settings": {"size": {"predefined": "font-size"}}}`), 0o644); err != nil {
t.Fatal(err)
}
named, err := Named(root)
if err != nil {
t.Fatal(err)
}
if got := strings.Join(named["font-size"], ","); got != "dunst,plain" {
t.Fatalf("font-size named by %s", got)
}
}
+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
+254
View File
@@ -0,0 +1,254 @@
// 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 file whose text is a template
// in files/ is judged by the template's text.
//
// 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)
// A file whose text is a template in its module's files/ (novox/hq ADR 0349 §2) is judged by that text,
// exactly as it would be inline.
if name, _ := r["template"].(string); name != "" {
text, err := os.ReadFile(filepath.Join(filepath.Dir(path), filepath.FromSlash(name)))
if err != nil {
return nil, fmt.Errorf("%s: %s names the template %s: %w", m.Module, id, name, err)
}
content = string(text)
}
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))
}
+154
View File
@@ -0,0 +1,154 @@
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)
}
}
}
// A mergeable file whose text is a template in files/ (novox/hq ADR 0349 §2) is judged by the template's text,
// exactly as the same text inline; a template the module's directory lacks is an error, never a silent pass.
func TestAMergeableFilesTemplateIsJudgedAsItsText(t *testing.T) {
root := t.TempDir()
manifest(t, root, "a", `{"id":"cfg","type":"file","merge":"json","template":"files/cfg.json"}`)
manifest(t, root, "c", `{"id":"cfg","type":"file","merge":"json","protected":["password"],"template":"files/cfg.json"}`)
for _, m := range []string{"a", "c"} {
dir := filepath.Join(root, "modules", m, "files")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "cfg.json"), []byte(`{"password": "${secret:admin}"}`), 0o644); err != nil {
t.Fatal(err)
}
}
found, err := Check(root)
if err != nil {
t.Fatal(err)
}
if len(found) != 1 || found[0].String() != "a cfg password" {
t.Fatalf("found %v, want [a cfg password]", found)
}
manifest(t, root, "g", `{"id":"cfg","type":"file","merge":"json","template":"files/gone.json"}`)
if _, err := Check(root); err == nil {
t.Fatal("a template the module's directory lacks passed the check")
}
}
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)
}
}
+3
View File
@@ -0,0 +1,3 @@
module git.novox.be/novox/mesh-catalog/checks/sshdir
go 1.22
+112
View File
@@ -0,0 +1,112 @@
// Command sshdir refuses any manifest but modules/ssh-client naming a path under ~/.ssh (novox/hq ADR 0415 §6,
// issue 1020): ssh-client owns ~/.ssh and sweeps what it does not know, so a path another module placed there
// would be moved out at the next sweep. A module that needs something there provides it to ssh-client instead.
//
// go run . <catalogue root>
//
// Every string of a manifest is read, wherever it stands, except prose and a file's text (Prose): a path, a
// place, a volume. Exits 1 on a finding.
package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
)
// SSHModule is the one module allowed to name a path under ~/.ssh.
const SSHModule = "ssh-client"
// underSSH is a path under ~/.ssh, or ~/.ssh itself, however the home is spelled: ${machine:account-home}/.ssh,
// ~/.ssh, .ssh/config, or a volume's host side (…/.ssh:/root/.ssh).
var underSSH = regexp.MustCompile(`(^|[/~])\.ssh($|[/:])`)
// Prose are the keys whose strings are words or a file's text, not a path: a template may say ~/.ssh in a comment.
var Prose = map[string]bool{"description": true, "why": true, "does": true, "template": true, "content": true, "title": true}
// Finding is one path under ~/.ssh in a manifest other than ssh-client's.
type Finding struct {
Module string
Key string
Value string
}
// Check reads every manifest under root/modules and answers which paths it refuses.
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 any
if err := json.Unmarshal(raw, &m); err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
module := filepath.Base(filepath.Dir(path))
if obj, ok := m.(map[string]any); ok {
if name, ok := obj["module"].(string); ok && name != "" {
module = name
}
}
if module == SSHModule {
continue
}
walk(m, "", func(key, value string) {
out = append(out, Finding{Module: module, Key: key, Value: value})
})
}
return out, nil
}
// walk calls found for every string under v, outside Prose, that names a path under ~/.ssh.
func walk(v any, key string, found func(key, value string)) {
switch x := v.(type) {
case map[string]any:
keys := make([]string, 0, len(x))
for k := range x {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
if !Prose[k] {
walk(x[k], k, found)
}
}
case []any:
for _, e := range x {
walk(e, key, found)
}
case string:
if underSSH.MatchString(x) {
found(key, x)
}
}
}
func main() {
root := "."
if len(os.Args) > 1 {
root = os.Args[1]
}
found, err := Check(root)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
for _, f := range found {
fmt.Printf("%s names %q (%s) under ~/.ssh: only %s may, and the sweep moves anything else out (novox/hq ADR 0415 §6)\n", f.Module, f.Value, f.Key, SSHModule)
}
if len(found) > 0 {
os.Exit(1)
}
fmt.Println("~/.ssh: named only by ssh-client")
}
+48
View File
@@ -0,0 +1,48 @@
package main
import (
"os"
"path/filepath"
"testing"
)
func manifest(t *testing.T, root, module, body string) {
t.Helper()
dir := filepath.Join(root, "modules", module)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "module.json"), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
func TestAnotherModuleNamingSSHIsRefused(t *testing.T) {
root := t.TempDir()
manifest(t, root, "ssh-client", `{"module":"ssh-client","resources":[{"path":"${machine:account-home}/.ssh/config"}]}`)
manifest(t, root, "forge-account", `{"module":"forge-account","resources":[{"path":"${machine:account-home}/.ssh/config.d/20-forge"}]}`)
manifest(t, root, "roster", `{"module":"roster","facts":{"hosts":{"path":".ssh/known_hosts"}}}`)
manifest(t, root, "box", `{"module":"box","containers":[{"volumes":["~/.ssh:/root/.ssh:ro"]}]}`)
manifest(t, root, "quiet", `{"module":"quiet","description":"reads ~/.ssh/config","resources":[{"path":"${machine:account-home}/.sshrc-not","template":"# see ~/.ssh/config"},{"path":"/etc/ssh/sshd_config"}]}`)
found, err := Check(root)
if err != nil {
t.Fatal(err)
}
got := map[string]bool{}
for _, f := range found {
got[f.Module] = true
}
if len(found) != 3 || !got["forge-account"] || !got["roster"] || !got["box"] {
t.Fatalf("refused %+v; want forge-account, roster and box", found)
}
}
func TestTheCatalogueHasNone(t *testing.T) {
found, err := Check("../..")
if err != nil {
t.Fatal(err)
}
if len(found) != 0 {
t.Fatalf("the catalogue names paths under ~/.ssh outside ssh-client: %+v", found)
}
}
+3
View File
@@ -0,0 +1,3 @@
module git.novox.be/novox/mesh-catalog/checks/templates
go 1.22
+9
View File
@@ -0,0 +1,9 @@
# The modules that still keep a file's text inline in module.json (`content`), one per line: the catalogue's list of
# modules whose file text has not yet moved to a template in files/ (novox/hq ADR 0349 §2, design 57), or in the
# templates directory its manifest names (`templates`, issue 731). Every other module's file resources name their
# template. This list only shrinks: a module that keeps no file text inline is taken off, and a line the trunk's copy
# does not hold is refused. When it is empty, no manifest in the catalogue carries file text (design 57 package T5,
# issue 658).
#
# A window-manager addition (`contributions`, such as files/i3/*.conf) is not a file resource and takes no template;
# it moves with the collections (design 57 package L2, issue 681) and is not judged here.
+178
View File
@@ -0,0 +1,178 @@
// Command templates refuses a file's text kept inline in a manifest, outside the modules still to move (novox/hq
// ADR 0349 §2, design 57 package T5, issue 658).
//
// A file resource names its text as a template in its module's templates directory (`"template": "<dir>/<name>"`;
// the directory is the manifest's `"templates"`, files/ when it says none), which the module's build packs into an
// archive; the controller reads each template back into the file's content, so the
// mesh holds and composes what it would for the same text inline. A module whose file text has not yet moved is
// listed in the file `inline` beside this check, one per line. Every other module's file resources say no
// `content`; a module on the list that keeps none inline is taken off it; and a line the trunk's copy (the merge
// base's, given with -base) does not hold is refused, so the list only shrinks; while the trunk holds no copy, the
// modules in seed are the trunk's. When it is empty, no manifest in the catalogue carries file text.
//
// Only file resources are judged: a window-manager addition (`contributions`) takes no template and moves with the
// collections (design 57 package L2, issue 681).
//
// go run . [-base <the trunk's inline>] <catalogue root>
//
// Every refusal names the refused module's own templates directory. Exits 1 on a finding.
package main
import (
"bufio"
"encoding/json"
"flag"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
)
const (
listFile = "inline"
listPathInCatalog = "checks/templates/" + listFile
)
// Check answers, per module that keeps any, the ids of its file resources that say their text inline.
func Check(root string) (map[string][]string, error) {
manifests, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
if err != nil {
return nil, err
}
sort.Strings(manifests)
out := map[string][]string{}
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 _, inline := r["content"]; inline && r["type"] == "file" {
out[m.Module] = append(out[m.Module], fmt.Sprint(r["id"]))
}
}
}
return out, nil
}
// TemplatesDir answers the directory a module keeps its templates in, with a trailing slash: its manifest's
// `templates`, files/ when the manifest says none or cannot be read here.
func TemplatesDir(root, module string) string {
dir := "files/"
raw, err := os.ReadFile(filepath.Join(root, "modules", module, "module.json"))
if err != nil {
return dir
}
var m struct {
Templates string `json:"templates"`
}
if json.Unmarshal(raw, &m) == nil && m.Templates != "" {
dir = strings.TrimSuffix(m.Templates, "/") + "/"
}
return dir
}
// Refusal is the finding for a module that keeps file text inline and is not on the list.
func Refusal(module string, ids []string, dir string) string {
return fmt.Sprintf("%s keeps the text of %s inline and is not on %s: keep each file's text in %s and name it as "+
"the file's template, packed by the build's archive from %s (novox/hq ADR 0349 §2)",
module, strings.Join(ids, ", "), listPathInCatalog, dir, dir)
}
// Gained is the finding for a module the list gains against the trunk's copy.
func Gained(module, dir string) string {
return fmt.Sprintf("%s gains %s, which the trunk's copy does not hold: the list only shrinks — move its file text "+
"to %s instead (novox/hq ADR 0349 §2)", listPathInCatalog, module, dir)
}
// seed is the list as it was first written, standing for the trunk's copy until the trunk holds one.
var seed = map[string]bool{"asus-zephyrus-g14": true, "power": true, "showcase": true}
// Listed is the modules a list names, one per line; a line starting with # and a blank line are not modules.
func Listed(path string) (map[string]bool, error) {
f, err := os.Open(path)
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[line] = true
}
return out, s.Err()
}
// Judge answers the modules that keep file text inline and are not on the list (refused), the modules on the list
// that keep none (stale), and the modules on the list that the trunk's list does not hold (added).
func Judge(found map[string][]string, listed, base map[string]bool) (refused, stale, added []string) {
for m := range found {
if !listed[m] {
refused = append(refused, m)
}
}
for m := range listed {
if _, keeps := found[m]; !keeps {
stale = append(stale, m)
}
if !base[m] {
added = append(added, m)
}
}
sort.Strings(refused)
sort.Strings(stale)
sort.Strings(added)
return refused, stale, added
}
func main() {
basePath := flag.String("base", "", "the trunk's copy of inline (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)
}
listed, err := Listed(listFile)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
base := seed
if *basePath != "" {
if base, err = Listed(*basePath); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
refused, stale, added := Judge(found, listed, base)
for _, m := range refused {
fmt.Println(Refusal(m, found[m], TemplatesDir(root, m)))
}
for _, m := range stale {
fmt.Printf("%s names %s, which keeps no file text inline: take it off the list\n", listPathInCatalog, m)
}
for _, m := range added {
fmt.Println(Gained(m, TemplatesDir(root, m)))
}
if len(refused) > 0 || len(stale) > 0 || len(added) > 0 {
os.Exit(1)
}
fmt.Printf("templates: no module keeps a file's text inline but the %d listed\n", len(listed))
}
+102
View File
@@ -0,0 +1,102 @@
package main
import (
"os"
"path/filepath"
"reflect"
"strings"
"testing"
)
func module(t *testing.T, root, name, body string) {
t.Helper()
dir := filepath.Join(root, "modules", name)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "module.json"), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
// A file resource that says its text inline is found, by module and resource; one naming its template, a
// resource that is not a file and a window-manager addition are not (novox/hq ADR 0349 §2).
func TestEveryInlineFileTextIsFound(t *testing.T) {
root := t.TempDir()
module(t, root, "inline", `{"module":"inline","resources":[{"id":"a","type":"file","content":"x"},`+
`{"id":"b","type":"file","template":"files/b"},{"id":"c","type":"file","content":""}]}`)
module(t, root, "templated", `{"module":"templated","resources":[{"id":"a","type":"file","template":"files/a"},`+
`{"id":"d","type":"directory","path":"/x"}],"contributions":{"i3":[{"content":"bindsym x"}]}}`)
got, err := Check(root)
if err != nil {
t.Fatal(err)
}
want := map[string][]string{"inline": {"a", "c"}}
if !reflect.DeepEqual(got, want) {
t.Fatalf("found %v, want %v", got, want)
}
}
// A module not on the list keeps no file text inline; one on the list that keeps none is taken off it; a line the
// trunk's list does not hold is refused, so the list only shrinks until it is empty.
func TestTheListOfModulesKeepingTextInlineOnlyShrinks(t *testing.T) {
found := map[string][]string{"old": {"a"}, "forgotten": {"b"}}
inline := map[string]bool{"old": true, "done": true, "new": true}
base := map[string]bool{"old": true, "done": true, "forgotten": true}
refused, stale, added := Judge(found, inline, base)
if !reflect.DeepEqual(refused, []string{"forgotten"}) || !reflect.DeepEqual(stale, []string{"done", "new"}) ||
!reflect.DeepEqual(added, []string{"new"}) {
t.Fatalf("refused %v, stale %v, added %v", refused, stale, added)
}
}
func TestTheListIsReadWithoutItsComments(t *testing.T) {
path := filepath.Join(t.TempDir(), "inline")
if err := os.WriteFile(path, []byte("# why\n\npower\n showcase \n"), 0o644); err != nil {
t.Fatal(err)
}
got, err := Listed(path)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(got, map[string]bool{"power": true, "showcase": true}) {
t.Fatalf("read %v", got)
}
}
// The catalogue itself: no module keeps a file's text inline but those the list names, and the list names none
// that keeps it no longer (design 57 package T5, issue 658: the ten modules without a build moved).
func TestTheCatalogueKeepsFileTextInlineOnlyWhereListed(t *testing.T) {
found, err := Check("../..")
if err != nil {
t.Fatal(err)
}
inline, err := Listed(listFile)
if err != nil {
t.Fatal(err)
}
refused, stale, _ := Judge(found, inline, inline)
if len(refused) > 0 || len(stale) > 0 {
t.Fatalf("keeping file text inline and not listed: %v; listed and keeping none: %v", refused, stale)
}
}
// A refusal names the refused module's own templates directory, not files/ when the module names another (issue 809).
func TestARefusalNamesTheModulesOwnTemplatesDirectory(t *testing.T) {
root := t.TempDir()
module(t, root, "own", `{"module":"own","templates":"t/","resources":[{"id":"a","type":"file","content":"x"}]}`)
module(t, root, "plain", `{"module":"plain","resources":[{"id":"a","type":"file","content":"x"}]}`)
module(t, root, "bare", `{"module":"bare","templates":"templates","resources":[]}`)
for module, dir := range map[string]string{"own": "t/", "plain": "files/", "bare": "templates/"} {
if got := TemplatesDir(root, module); got != dir {
t.Fatalf("%s: directory %q, want %q", module, got, dir)
}
}
msg := Refusal("own", []string{"a"}, TemplatesDir(root, "own"))
if !strings.Contains(msg, "t/") || strings.Contains(msg, "files/") {
t.Fatalf("refusal %q", msg)
}
if msg := Gained("own", TemplatesDir(root, "own")); !strings.Contains(msg, "t/") || strings.Contains(msg, "files/") {
t.Fatalf("gained %q", msg)
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
67
61
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
+42 -2
View File
@@ -23,9 +23,51 @@
#
# 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.
#
# **In an agent's session, outside the toolchain image** (novox/hq issue 742), the same node_modules is
# assembled first, in a throwaway directory under the check's own workspace, the way the image's `deps` stage
# assembles it: the toolchain's own package.json and lock (mesh-tools' node-tools/, read from the forge this
# clone came from, so the list is never copied here) installed with `npm ci`, then the SDK the mesh last
# published (`@novox/mesh-sdk@latest`), the @novox scope from the forge's package registry, read without a
# credential. Node and npm are the dev-tools module's; nothing is installed on the machine, no npm
# configuration or cache of the account's is read or written. Only when neither TOOLCHAIN_MODULES nor
# /app/node_modules is there: the forge's check, in the image, fetches nothing, as before. The forge is
# MESH_FORGE (default: the origin remote's), the owner MESH_FORGE_OWNER (default: the origin's owner).
set -eu
work=$(mktemp -d)
trap 'rm -rf "$work"' EXIT
deps="${TOOLCHAIN_MODULES:-/app/node_modules}"
if [ -z "${TOOLCHAIN_MODULES:-}" ] && [ ! -f "$deps/typescript/bin/tsc" ]; then
origin=$(git remote get-url origin 2>/dev/null || true)
forge="${MESH_FORGE:-$(printf '%s\n' "$origin" | sed -n 's#^\(https\{0,1\}://[^/]*\)/.*#\1#p')}"
owner="${MESH_FORGE_OWNER:-$(printf '%s\n' "$origin" | sed -n 's#^https\{0,1\}://[^/]*/\([^/]*\)/.*#\1#p')}"
if [ -z "$forge" ] || [ -z "$owner" ]; then
echo "NOT CHECKED: no TypeScript toolchain at $deps, and no forge to assemble one from (origin is '$origin'; set MESH_FORGE and MESH_FORGE_OWNER, or TOOLCHAIN_MODULES)"
exit 1
fi
echo "no TypeScript toolchain at $deps: assembling one from $forge/$owner/mesh-tools node-tools/ and $owner's package registry (novox/hq issue 742)"
tc="$work/toolchain"
mkdir -p "$tc"
printf '@%s:registry=%s/api/packages/%s/npm/\n' "$owner" "$forge" "$owner" >"$work/npmrc"
# Read with Node's own fetch (dev-tools declares Node, not curl); a status that is not 2xx fails.
if ! { node -e '
const [base, dir] = process.argv.slice(1);
Promise.all(["package.json", "package-lock.json"].map(async (f) => {
const r = await fetch(base + f);
if (!r.ok) throw new Error(base + f + ": " + r.status);
require("fs").writeFileSync(dir + "/" + f, await r.text());
})).catch((e) => { console.error(e.message); process.exit(1); });' \
"$forge/$owner/mesh-tools/raw/branch/main/node-tools/" "$tc" &&
(cd "$tc" && export npm_config_userconfig="$work/npmrc" npm_config_cache="$work/npm-cache" &&
npm ci --ignore-scripts --no-audit --no-fund --loglevel=error &&
npm install --ignore-scripts --no-audit --no-fund --no-save --loglevel=error "@$owner/mesh-sdk@latest"); }; then
echo "NOT CHECKED: the TypeScript toolchain could not be assembled from $forge (novox/hq issue 742)"
exit 1
fi
deps="$tc/node_modules"
fi
tsc="$deps/typescript/bin/tsc"
if [ ! -f "$tsc" ]; then
echo "NOT CHECKED: no TypeScript compiler at $tsc — this part runs in the TypeScript toolchain"
@@ -39,8 +81,6 @@ touched=$({
printf '%s\n' "${MESH_CHECK_CHANGED:-}" | tr ',' '\n' | sed -n 's#^modules/\([^/]*\)/.*#\1#p'
} | sed '/^$/d' | sort -u)
work=$(mktemp -d)
trap 'rm -rf "$work"' EXIT
failed=""
for m in $touched; do
d="modules/$m"
+81 -3
View File
@@ -17,7 +17,8 @@
# 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
# test that could not run is a failure, never a note (novox/hq issue 385). The integration tests (`//go:build
# integration`) run here, with -tags integration, and only here. 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
@@ -30,11 +31,86 @@
# 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.
# 7. the list of predefined keys (novox/hq ADR 0348 clause 7): a change to predefined.json removes no key a
# module still names, judged against the trunk's copy at the merge base. Whether a removed key still holds a
# value at the mesh or a node is mesh-config's to refuse; this check reads no store. The list itself and
# every manifest against it are judged by the controller's module check in step 1.
# 8. the config form (novox/hq ADR 0348 clauses 1 and 11): no module says `settings`, `takes-settings` or
# `${setting:<key>}`, in its manifest or a template it names, or has a mergeable file naming no keys (which
# takes its whole layer), unless checks/config/unmoved lists it as not yet moved; that list only shrinks,
# judged against the trunk's copy at the merge base.
# 9. the file text (novox/hq ADR 0349 §2): no module keeps a file's text inline in its manifest (`content`)
# unless checks/templates/inline lists it as not yet moved; each names its template in files/. That list
# only shrinks, judged against the trunk's copy at the merge base.
# 10. the mail-store seat (novox/hq ADR 0378 §3, issue 800): no manifest but modules/mesh-mail claims it.
# 11. owned paths (novox/hq ADR 0422 §8, issue 1024; it holds ~/.ssh for ssh-client as ADR 0415 §6 asked):
# every directory and archive says `unknown` (move-out, module or program) and a valid `knows`; `move-out`
# only under a home; and no module declares a path inside a directory of another module.
# checks/sshdir (ADR 0415 §6) stays while main runs it; it goes under issue 1035 once main runs checks/owned.
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 mail-store seat (novox/hq ADR 0378 §3): only mesh-mail may claim it.
(cd checks/mailstore && go test -count=1 . && go run . ../..)
# ~/.ssh (novox/hq ADR 0415 §6): only ssh-client may name a path under it. Main's merge check runs this line, so
# checks/sshdir stays until main runs checks/owned; it goes under issue 1035.
(cd checks/sshdir && go test -count=1 . && go run . ../..)
# Owned paths (novox/hq ADR 0422 §8): unknown files named per directory; no path inside another module's directory.
(cd checks/owned && 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 list of predefined keys (novox/hq ADR 0348 clause 7): no key removed that a module still names. The trunk's
# list at the merge base is what it is judged against; a trunk without the file had none.
predefined_base=""
if merge_base=$(git merge-base HEAD "origin/${MESH_CHECK_BASE:-main}" 2>/dev/null); then
predefined_base=$(mktemp)
if ! git show "$merge_base:predefined.json" >"$predefined_base" 2>/dev/null; then
echo '{}' >"$predefined_base"
fi
else
echo "NOT COMPARED WITH THE TRUNK: no merge base with origin/${MESH_CHECK_BASE:-main}; no removal from predefined.json can be seen"
fi
(cd checks/predefined && go test -count=1 . && if [ -n "$predefined_base" ]; then go run . -base "$predefined_base" ../..; else go run . ../..; fi)
# The config form (novox/hq ADR 0348 §1, §11): a moved module says no old name, and the list of modules not yet moved
# only shrinks, judged against the trunk's copy at the merge base; a trunk without one is the list's first copy.
unmoved_base=""
if merge_base=$(git merge-base HEAD "origin/${MESH_CHECK_BASE:-main}" 2>/dev/null); then
if git cat-file -e "$merge_base:checks/config/unmoved" 2>/dev/null; then
unmoved_base=$(mktemp)
git show "$merge_base:checks/config/unmoved" >"$unmoved_base"
fi
else
echo "NOT COMPARED WITH THE TRUNK: no merge base with origin/${MESH_CHECK_BASE:-main}; checks/config/unmoved is held to the list it was first written with"
fi
(cd checks/config && go test -count=1 . && if [ -n "$unmoved_base" ]; then go run . -base "$unmoved_base" ../..; else go run . ../..; fi)
# The file text (novox/hq ADR 0349 §2): a file's text is a template in files/, outside the modules not yet moved; the
# list of those only shrinks, judged against the trunk's copy at the merge base, a trunk without one being its first.
inline_base=""
if merge_base=$(git merge-base HEAD "origin/${MESH_CHECK_BASE:-main}" 2>/dev/null); then
if git cat-file -e "$merge_base:checks/templates/inline" 2>/dev/null; then
inline_base=$(mktemp)
git show "$merge_base:checks/templates/inline" >"$inline_base"
fi
else
echo "NOT COMPARED WITH THE TRUNK: no merge base with origin/${MESH_CHECK_BASE:-main}; checks/templates/inline is held to the list it was first written with"
fi
(cd checks/templates && go test -count=1 . && if [ -n "$inline_base" ]; then go run . -base "$inline_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=""
@@ -108,8 +184,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 ${modfile:+"-modfile=$modfile"} ./... &&
CGO_ENABLED=$cgo GOPRIVATE=git.novox.be go test ${modfile:+"-modfile=$modfile"} $race -count=1 ./...)
# The integration tests (a file whose first line is `//go:build integration`: a test that starts a bus, a store or
# another process) run here and nowhere else: the unit suite stays under a second (novox/hq playbook 07).
(cd "modules/$m" && CGO_ENABLED=$cgo GOPRIVATE=git.novox.be go vet -tags integration ${modfile:+"-modfile=$modfile"} ./... &&
CGO_ENABLED=$cgo GOPRIVATE=git.novox.be go test -tags integration ${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)"
+4 -4
View File
@@ -22,11 +22,11 @@ for GTK, Qt, the portal and the cursor, dark by default. It claims no seat, beca
| path | class | from |
|---|---|---|
| `~/.config/gtk-3.0/settings.ini` | owned (the found file kept once) | [`config/gtk-settings.ini`](config/gtk-settings.ini) |
| `~/.config/gtk-3.0/settings.ini` | owned (the found file kept once) | [`files/gtk-settings.ini`](files/gtk-settings.ini) |
| `~/.config/gtk-4.0/settings.ini` | owned | the same file |
| `~/.config/qt6ct/qt6ct.conf` | owned | [`config/qt6ct.conf`](config/qt6ct.conf) |
| `~/.config/xdg-desktop-portal/portals.conf` | owned | [`config/portals.conf`](config/portals.conf) |
| `~/.icons/default/index.theme` | owned | [`config/cursor-index.theme`](config/cursor-index.theme): the cursor for programs that read neither `XCURSOR_THEME` nor the resources |
| `~/.config/qt6ct/qt6ct.conf` | owned | [`files/qt6ct.conf`](files/qt6ct.conf) |
| `~/.config/xdg-desktop-portal/portals.conf` | owned | [`files/portals.conf`](files/portals.conf) |
| `~/.icons/default/index.theme` | owned | [`files/cursor-index.theme`](files/cursor-index.theme): the cursor for programs that read neither `XCURSOR_THEME` nor the resources |
**`qt6ct.conf` is the mesh's now.** A change made in qt6ct's own window is replaced at the next push,
and the window's saved geometry goes with it. The theme is this module's to say. Settings will make it
@@ -18,10 +18,18 @@ type manifest struct {
Environment struct {
Variables map[string]string `json:"variables"`
} `json:"environment"`
Items []struct {
Collection string `json:"collection"`
Attributes map[string]string `json:"attributes"`
Template string `json:"template"`
} `json:"items"`
Shell []struct {
For, Slot, Code string
} `json:"shell"`
Resources []map[string]any `json:"resources"`
Build struct {
Artifacts []map[string]any `json:"artifacts"`
} `json:"build"`
}
func readManifest(t *testing.T) manifest {
@@ -33,6 +41,23 @@ func readManifest(t *testing.T) manifest {
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
// The module's code and variables are the templates of its items (design 57 L3, issues 795 and 807).
for _, it := range m.Items {
text, err := os.ReadFile(filepath.Join("..", "..", it.Template))
if err != nil {
t.Fatal(err)
}
parts := strings.Split(it.Collection, ":")
switch {
case len(parts) == 3 && parts[0] == "shell":
m.Shell = append(m.Shell, struct{ For, Slot, Code string }{parts[1], parts[2], string(text)})
case it.Collection == "environment" && it.Attributes["kind"] == "variable":
if m.Environment.Variables == nil {
m.Environment.Variables = map[string]string{}
}
m.Environment.Variables[it.Attributes["name"]] = strings.TrimSuffix(string(text), "\n")
}
}
return m
}
@@ -96,9 +121,10 @@ func TestItOwnsTheFilesItsSourcesHoldAndNoQt5Duplicate(t *testing.T) {
pkgs = append(pkgs, r["package"].(string))
continue
}
raw, _ := os.ReadFile(filepath.Join("..", "..", "config", sources[id]))
if r["content"] != string(raw) || r["into"] != nil || r["owner"] != "${machine:account}" {
t.Errorf("%s is not config/%s, whole and the account's", id, sources[id])
_, inline := r["content"]
if _, err := os.Stat(filepath.Join("..", "..", "files", sources[id])); err != nil || inline ||
r["template"] != "files/"+sources[id] || r["into"] != nil || r["owner"] != "${machine:account}" {
t.Errorf("%s is not files/%s, whole, as its template, and the account's", id, sources[id])
}
if strings.Contains(r["path"].(string), "qt5ct") {
t.Error("qt5ct: both workstations run Qt 6 programs through qt6ct; a second tool is a duplicate")
@@ -107,7 +133,7 @@ func TestItOwnsTheFilesItsSourcesHoldAndNoQt5Duplicate(t *testing.T) {
if strings.Join(pkgs, ",") != "gnome-themes-extra,adwaita-icon-theme,adwaita-cursors,qt6ct,xdg-desktop-portal-gtk" {
t.Fatalf("%v", pkgs)
}
qt := readINI(filepath.Join("..", "..", "config", "qt6ct.conf"))
qt := readINI(filepath.Join("..", "..", "files", "qt6ct.conf"))
if qt["Appearance"]["style"] != "Fusion" || qt["Appearance"]["custom_palette"] != "true" || !strings.Contains(qt["Fonts"]["general"], "Inter,11") {
t.Fatalf("%v", qt)
}
@@ -116,7 +142,7 @@ func TestItOwnsTheFilesItsSourcesHoldAndNoQt5Duplicate(t *testing.T) {
t.Fatalf("qt6ct ships no %s", qt["Appearance"]["color_scheme_path"])
}
}
gtk := readINI(filepath.Join("..", "..", "config", "gtk-settings.ini"))["Settings"]
gtk := readINI(filepath.Join("..", "..", "files", "gtk-settings.ini"))["Settings"]
if gtk["gtk-theme-name"] != "Adwaita" || gtk["gtk-application-prefer-dark-theme"] != "1" || gtk["gtk-font-name"] != "Inter 11" {
t.Fatalf("%v", gtk)
}
@@ -244,3 +270,31 @@ func asJSON(v any) string {
b, _ := json.Marshal(v)
return string(b)
}
// No file resource carries its text inline: each names its template in files/, and the build packs files/
// into the one archive the controller reads the templates back from (novox/hq ADR 0349 §2).
func TestEveryFileTakesItsTextFromATemplate(t *testing.T) {
m := readManifest(t)
for _, r := range m.Resources {
if _, inline := r["content"]; inline {
t.Errorf("resource %v keeps its text inline: keep it in files/ and name it as the template", r["id"])
}
if name, ok := r["template"].(string); ok {
if _, err := os.Stat(filepath.Join("..", "..", name)); err != nil {
t.Errorf("resource %v names %s: %v", r["id"], name, err)
}
}
}
packs := 0
for _, a := range m.Build.Artifacts {
if a["kind"] == "archive" && a["from"] == "files" {
packs++
if a["name"] != "files" {
t.Errorf("the templates archive is named %v, not files", a["name"])
}
}
}
if packs != 1 {
t.Errorf("the build packs files/ into %d archives, not one", packs)
}
}
+1
View File
@@ -0,0 +1 @@
Adwaita:dark
+1
View File
@@ -0,0 +1 @@
/usr/share/themes/Adwaita-dark/gtk-2.0/gtkrc
@@ -0,0 +1 @@
qt6ct
+1
View File
@@ -0,0 +1 @@
6
@@ -0,0 +1 @@
Fusion
+1
View File
@@ -0,0 +1 @@
24
+1
View File
@@ -0,0 +1 @@
Adwaita
+10
View File
@@ -0,0 +1,10 @@
# The appearance (module adwaita): GSettings is where the portal reads dark or light, and the portal is
# the only way it reaches Electron, Chromium, Firefox and flatpaks. Set at every session start to the
# module's default; adwaita_appearance switches it for a session.
gsettings set org.gnome.desktop.interface color-scheme 'prefer-dark' || true
gsettings set org.gnome.desktop.interface gtk-theme 'Adwaita' || true
gsettings set org.gnome.desktop.interface icon-theme 'Adwaita' || true
gsettings set org.gnome.desktop.interface cursor-theme 'Adwaita' || true
gsettings set org.gnome.desktop.interface cursor-size 24 || true
gsettings set org.gnome.desktop.interface font-name 'Inter 11' || true
gsettings set org.gnome.desktop.interface monospace-font-name 'JetBrainsMono Nerd Font 11' || true
+3
View File
@@ -0,0 +1,3 @@
! adwaita: the cursor, for X programs that take it from the resources.
Xcursor.theme: Adwaita
Xcursor.size: 24
+81 -23
View File
@@ -1,6 +1,7 @@
{
"module": "adwaita",
"version": "1",
"moved": true,
"capabilities": [
"package-manager"
],
@@ -10,27 +11,79 @@
"adwaita_icons",
"adwaita_portal_check"
],
"environment": {
"variables": {
"GTK_THEME": "Adwaita:dark",
"GTK2_RC_FILES": "/usr/share/themes/Adwaita-dark/gtk-2.0/gtkrc",
"QT_QPA_PLATFORMTHEME": "qt6ct",
"QT_STYLE_OVERRIDE": "Fusion",
"QT_SELECT": "6",
"XCURSOR_THEME": "Adwaita",
"XCURSOR_SIZE": "24"
}
},
"shell": [
"items": [
{
"for": "xresources",
"slot": "normal",
"code": "! adwaita: the cursor, for X programs that take it from the resources.\nXcursor.theme: Adwaita\nXcursor.size: 24\n"
"collection": "environment",
"name": "gtk-theme",
"attributes": {
"kind": "variable",
"name": "GTK_THEME"
},
"template": "files/env-gtk-theme"
},
{
"for": "xinitrc",
"slot": "normal",
"code": "# The appearance (module adwaita): GSettings is where the portal reads dark or light, and the portal is\n# the only way it reaches Electron, Chromium, Firefox and flatpaks. Set at every session start to the\n# module's default; adwaita_appearance switches it for a session.\ngsettings set org.gnome.desktop.interface color-scheme 'prefer-dark' || true\ngsettings set org.gnome.desktop.interface gtk-theme 'Adwaita' || true\ngsettings set org.gnome.desktop.interface icon-theme 'Adwaita' || true\ngsettings set org.gnome.desktop.interface cursor-theme 'Adwaita' || true\ngsettings set org.gnome.desktop.interface cursor-size 24 || true\ngsettings set org.gnome.desktop.interface font-name 'Inter 11' || true\ngsettings set org.gnome.desktop.interface monospace-font-name 'JetBrainsMono Nerd Font 11' || true\n"
"collection": "environment",
"name": "gtk2-rc-files",
"attributes": {
"kind": "variable",
"name": "GTK2_RC_FILES"
},
"template": "files/env-gtk2-rc-files"
},
{
"collection": "environment",
"name": "qt-qpa-platformtheme",
"attributes": {
"kind": "variable",
"name": "QT_QPA_PLATFORMTHEME"
},
"template": "files/env-qt-qpa-platformtheme"
},
{
"collection": "environment",
"name": "qt-style-override",
"attributes": {
"kind": "variable",
"name": "QT_STYLE_OVERRIDE"
},
"template": "files/env-qt-style-override"
},
{
"collection": "environment",
"name": "qt-select",
"attributes": {
"kind": "variable",
"name": "QT_SELECT"
},
"template": "files/env-qt-select"
},
{
"collection": "environment",
"name": "xcursor-theme",
"attributes": {
"kind": "variable",
"name": "XCURSOR_THEME"
},
"template": "files/env-xcursor-theme"
},
{
"collection": "environment",
"name": "xcursor-size",
"attributes": {
"kind": "variable",
"name": "XCURSOR_SIZE"
},
"template": "files/env-xcursor-size"
},
{
"collection": "shell:xresources:normal",
"name": "xresources-normal",
"template": "files/xresources-normal"
},
{
"collection": "shell:xinitrc:normal",
"name": "xinitrc-normal",
"template": "files/xinitrc-normal"
}
],
"resources": [
@@ -65,7 +118,7 @@
"path": "${machine:account-home}/.config/gtk-3.0/settings.ini",
"owner": "${machine:account}",
"mode": "0644",
"content": "# Written by the mesh (module adwaita, novox/hq ADR 0208), for GTK 3 and GTK 4 alike. Replaced at\n# every push; adwaita_appearance switches dark and light for the running session.\n[Settings]\ngtk-theme-name=Adwaita\ngtk-icon-theme-name=Adwaita\ngtk-cursor-theme-name=Adwaita\ngtk-cursor-theme-size=24\ngtk-font-name=Inter 11\ngtk-application-prefer-dark-theme=1\n"
"template": "files/gtk-settings.ini"
},
{
"id": "gtk4",
@@ -73,7 +126,7 @@
"path": "${machine:account-home}/.config/gtk-4.0/settings.ini",
"owner": "${machine:account}",
"mode": "0644",
"content": "# Written by the mesh (module adwaita, novox/hq ADR 0208), for GTK 3 and GTK 4 alike. Replaced at\n# every push; adwaita_appearance switches dark and light for the running session.\n[Settings]\ngtk-theme-name=Adwaita\ngtk-icon-theme-name=Adwaita\ngtk-cursor-theme-name=Adwaita\ngtk-cursor-theme-size=24\ngtk-font-name=Inter 11\ngtk-application-prefer-dark-theme=1\n"
"template": "files/gtk-settings.ini"
},
{
"id": "qt6ct",
@@ -81,7 +134,7 @@
"path": "${machine:account-home}/.config/qt6ct/qt6ct.conf",
"owner": "${machine:account}",
"mode": "0644",
"content": "[Appearance]\ncolor_scheme_path=/usr/share/qt6ct/colors/darker.conf\ncustom_palette=true\nicon_theme=Adwaita\nstandard_dialogs=default\nstyle=Fusion\n\n[Fonts]\nfixed=\"JetBrainsMono Nerd Font,11,-1,5,50,0,0,0,0,0\"\ngeneral=\"Inter,11,-1,5,50,0,0,0,0,0\"\n\n[Interface]\nactivate_item_on_single_click=1\nbuttonbox_layout=0\ncursor_flash_time=1000\ndialog_buttons_have_icons=1\ndouble_click_interval=400\ngui_effects=@Invalid()\nkeyboard_scheme=2\nmenus_have_icons=true\nshow_shortcuts_in_context_menus=true\nstylesheets=@Invalid()\ntoolbutton_style=4\nunderline_shortcut=1\nwheel_scroll_lines=3\n\n[Troubleshooting]\nforce_raster_widgets=1\nignored_applications=@Invalid()\n"
"template": "files/qt6ct.conf"
},
{
"id": "portals",
@@ -89,7 +142,7 @@
"path": "${machine:account-home}/.config/xdg-desktop-portal/portals.conf",
"owner": "${machine:account}",
"mode": "0644",
"content": "# Written by the mesh (module adwaita, novox/hq ADR 0208). Replaced at every push.\n#\n# Which portal backend answers each interface. i3 is not a desktop xdg-desktop-portal knows, so with\n# no preference it uses whichever backend happens to be installed: fine while gtk is the only one,\n# wrong the day another arrives as somebody else's dependency. Named instead. gtk also serves\n# org.freedesktop.appearance (dark or light) from GSettings, which the session's start sets.\n[preferred]\ndefault=gtk\n# Secrets for sandboxed programs come from the keyring's backend. Without this line no backend answers\n# the interface: gtk does not implement it, and gnome-keyring's names only GNOME as its desktop.\norg.freedesktop.impl.portal.Secret=gnome-keyring\n"
"template": "files/portals.conf"
},
{
"id": "cursor",
@@ -97,7 +150,7 @@
"path": "${machine:account-home}/.icons/default/index.theme",
"owner": "${machine:account}",
"mode": "0644",
"content": "# Written by the mesh (module adwaita, novox/hq ADR 0208): the default cursor theme, for programs that\n# read neither XCURSOR_THEME nor the X resources.\n[Icon Theme]\nName=Default\nInherits=Adwaita\n"
"template": "files/cursor-index.theme"
}
],
"build": {
@@ -112,6 +165,11 @@
"loads": [
"adwaita-tools"
]
},
{
"name": "files",
"kind": "archive",
"from": "files"
}
]
}
+6
View File
@@ -109,3 +109,9 @@ store's current or an older name, a definition not read said first, escalation t
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.
## Files it does not know
Each directory or archive this module declares says how it treats a file it did not write (ADR 0422, ADR 0416).
- `${dir:state}`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
@@ -0,0 +1,64 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"testing"
)
// The module's directory, where its manifest and its files/ are.
const moduleDir = "../.."
// templateText is the text a file resource places on the machine: its template's, in the module's files/
// (novox/hq ADR 0349 §2). A resource that names no template, or a template that is not there, fails the test.
func templateText(t *testing.T, r map[string]any) string {
t.Helper()
name, _ := r["template"].(string)
if name == "" {
t.Fatalf("resource %v names no template", r["id"])
}
raw, err := os.ReadFile(filepath.Join(moduleDir, filepath.FromSlash(name)))
if err != nil {
t.Fatalf("resource %v names %s: %v", r["id"], name, err)
}
return string(raw)
}
// No file resource carries its text inline: each names its template in files/, and the build packs files/
// into the one archive the controller reads the templates back from (novox/hq ADR 0349 §2).
func TestEveryFileTakesItsTextFromATemplate(t *testing.T) {
raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json"))
if err != nil {
t.Fatal(err)
}
var m struct {
Resources []map[string]any `json:"resources"`
Build struct {
Artifacts []map[string]any `json:"artifacts"`
} `json:"build"`
}
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
for _, r := range m.Resources {
if _, inline := r["content"]; inline {
t.Errorf("resource %v keeps its text inline: keep it in files/ and name it as the template", r["id"])
}
if r["type"] == "file" {
templateText(t, r)
}
}
packs := 0
for _, a := range m.Build.Artifacts {
if a["kind"] == "archive" && a["from"] == "files" {
packs++
if a["name"] != "files" {
t.Errorf("the templates archive is named %v, not files", a["name"])
}
}
}
if packs != 1 {
t.Errorf("the build packs files/ into %d archives, not one", packs)
}
}
@@ -0,0 +1,4 @@
# The names the artifact store had before, from the setting older-store-names, written by the mesh.
# artifact_store_references reads every definition under these too (novox/hq issue 323).
# Replaced on every push; change the setting, never this file.
older=${config:older-store-names}
+16 -9
View File
@@ -13,19 +13,21 @@
"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)"
}
},
"config": {
"older-store-names": {
"type": "string",
"description": "The names the artifact store had before, as host names or host:port separated by spaces, or 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).",
"pattern": "none|[A-Za-z0-9.:-]+( [A-Za-z0-9.:-]+)*",
"default": "none"
}
},
"resources": [
{
"id": "state",
"type": "directory",
"mode": "0700",
"place": "."
"place": ".",
"unknown": "program"
},
{
"id": "store-names",
@@ -33,7 +35,7 @@
"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"
"template": "files/store-names"
}
],
"build": {
@@ -52,6 +54,11 @@
"MESH_ARTIFACT_STORE_CONTAINER": "mesh-registry",
"MESH_ARTIFACT_STORE_NAMES_FILE": "${dir:state}/store-names"
}
},
{
"name": "files",
"kind": "archive",
"from": "files"
}
]
}
+12
View File
@@ -385,3 +385,15 @@ The module no longer writes a file into i3's `config.d`. Its window-manager line
under `files/i3/` where it had one) are a contribution to `node-display-session`. The i3 module places
them in its own configuration under a `# <module>` line, so this module depends on a window manager
being assigned beside it.
## Files it does not know
Each directory or archive this module declares says how it treats a file it did not write (ADR 0422, ADR 0416).
- `/usr/local/lib/asus-zephyrus-g14`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
- `/etc/systemd/system/systemd-suspend.service.d`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
- `/etc/systemd/system/systemd-hibernate.service.d`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
- `/etc/systemd/system/systemd-suspend-then-hibernate.service.d`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
- `/etc/systemd/system/nvidia-powerd.service.d`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
- `/etc/UPower/UPower.conf.d`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
- `/etc/X11/xorg.conf.d`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
@@ -34,11 +34,9 @@ func TestKeysListsTriggersI3LinesAndFirmwareKeys(t *testing.T) {
home := "/home/operator"
t.Setenv("MESH_OPERATOR_HOME", home)
// The i3 module's file as the controller composes it: other modules' lines around this one's.
var own string
for _, c := range m.Contributions {
if c.Seat == "node-display-session" {
own += c.Content
}
own := m.i3Lines()
if own == "" || len(m.Contributions) != 0 {
t.Fatalf("the lines for the window manager are one item, and no contribution is left: %q %v", own, m.Contributions)
}
put(t, f.root, home+"/"+I3Config, "set $mod Mod4\n# rofi\nbindsym $mod+d exec rofi\n# "+ModuleName+"\n"+own+
"# triggerhappy-not-here\nbindsym $mod+Shift+z exec other\n#########################################\ninclude x\n")
@@ -15,19 +15,41 @@ type manifest struct {
Tools []string `json:"tools"`
Emits []string `json:"emits"`
Resources []map[string]any `json:"resources"`
// Contributions to other modules' seats (novox/hq ADR 0212): the vendor keys go to node-hotkeys.
Contributions []struct {
Seat string `json:"seat"`
Kind string `json:"kind"`
Content string `json:"content"`
} `json:"contributions"`
// Items of collections (novox/hq ADR 0349 clause 13): the vendor keys are an item of the node-hotkeys
// triggers, its text the template file beside the manifest.
Items []struct {
Collection string `json:"collection"`
Template string `json:"template"`
} `json:"items"`
// Old-form contributions to other modules' seats (novox/hq ADR 0212): none is left.
Contributions []any `json:"contributions"`
}
// triggers is the module's contribution to node-hotkeys: its vendor keys.
// i3Lines is the module's lines for the window manager: the text of the template of its item of the collection
// contribution:node-display-session:config (design 57 L3, issue 848).
func (m manifest) i3Lines() string {
for _, it := range m.Items {
if it.Collection == "contribution:node-display-session:config" {
raw, err := os.ReadFile(filepath.Join("..", "..", it.Template))
if err != nil {
return ""
}
return string(raw)
}
}
return ""
}
// triggers is the module's contribution to node-hotkeys: its vendor keys, the text of the template of its
// item of the collection contribution:node-hotkeys:trigger.
func (m manifest) triggers() string {
for _, c := range m.Contributions {
if c.Seat == "node-hotkeys" && c.Kind == "trigger" {
return c.Content
for _, it := range m.Items {
if it.Collection == "contribution:node-hotkeys:trigger" {
raw, err := os.ReadFile(filepath.Join("..", "..", it.Template))
if err != nil {
return ""
}
return string(raw)
}
}
return ""
@@ -0,0 +1,83 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
// Each file's text is a template in templates/, the directory the manifest names (novox/hq ADR 0349 §2, issue 731),
// never in files/: files/ is the archive the machine unpacks, and stays exactly what the machine is given.
func TestEveryFileTakesItsTextFromATemplateOutsideTheUnpackedFiles(t *testing.T) {
raw, err := os.ReadFile("../../module.json")
if err != nil {
t.Fatal(err)
}
var m struct {
Templates string `json:"templates"`
Resources []map[string]any `json:"resources"`
Build struct {
Artifacts []map[string]any `json:"artifacts"`
} `json:"build"`
}
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
if m.Templates != "templates" {
t.Fatalf("the manifest names the templates directory %q, not templates", m.Templates)
}
files := 0
for _, r := range m.Resources {
if _, inline := r["content"]; inline {
t.Errorf("resource %v keeps its text inline: keep it in templates/ and name it as the template", r["id"])
}
if r["type"] != "file" {
continue
}
files++
name, _ := r["template"].(string)
if !strings.HasPrefix(name, "templates/") {
t.Errorf("resource %v names the template %q, not one in templates/", r["id"], name)
continue
}
if st, err := os.Lstat(filepath.Join("../..", name)); err != nil || !st.Mode().IsRegular() {
t.Errorf("resource %v names the template %s, which is not a plain file: %v", r["id"], name, err)
}
}
if files == 0 {
t.Fatal("no file resource")
}
packs := 0
for _, a := range m.Build.Artifacts {
if a["kind"] == "archive" && a["from"] == "templates" {
packs++
if a["name"] != "templates" {
t.Errorf("the templates archive is named %v, not templates", a["name"])
}
}
}
if packs != 1 {
t.Fatalf("the build packs templates/ into %d archives, not one", packs)
}
// The templates archive is read by the controller, never unpacked onto the machine.
for _, r := range m.Resources {
if r["artifact"] == "templates" {
t.Errorf("resource %v places the templates archive on the machine", r["id"])
}
}
// And files/ holds what the machine runs, no template.
err = filepath.WalkDir("../../files", func(at string, d os.DirEntry, err error) error {
if err != nil {
return err
}
if !d.IsDir() && !strings.HasPrefix(filepath.ToSlash(at), "../../files/bin/") {
t.Errorf("files/ holds %s, which is not one of the scripts the machine unpacks", at)
}
return nil
})
if err != nil {
t.Fatal(err)
}
}
+46 -41
View File
@@ -1,6 +1,8 @@
{
"module": "asus-zephyrus-g14",
"version": "1",
"moved": true,
"templates": "templates",
"capabilities": [
"package-manager",
"service-manager"
@@ -52,80 +54,85 @@
"id": "scripts",
"type": "archive",
"path": "/usr/local/lib/asus-zephyrus-g14",
"artifact": "scripts"
"artifact": "scripts",
"unknown": "program"
},
{
"id": "nvidia-options",
"type": "file",
"path": "/etc/modprobe.d/g14-nvidia-power.conf",
"mode": "0644",
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The discrete GPU's driver options on the ROG Zephyrus G14 (GA403, RTX 40 series, hybrid graphics).\n#\n# NVreg_DynamicPowerManagement=0x00 turns runtime D3 off. With it on, a change of power source sends\n# the driver an ACPI notification it fails to handle on this model (\"RmHandleDNotifierEvent: Failed to\n# handle ACPI D-Notifier event, status=0x62\"), and the GPU stops making progress until the machine is\n# powered off. Off costs a few idle watts in hybrid mode and keeps the machine up.\n#\n# NVreg_PreserveVideoMemoryAllocations=1 saves video memory across suspend, so what used the GPU still\n# works after waking. It needs nvidia-suspend, -hibernate and -resume to run around a sleep, which this\n# module's drop-ins on the sleep services ask for.\n#\n# A change here applies when the driver next loads: at the next boot.\noptions nvidia NVreg_PreserveVideoMemoryAllocations=1\noptions nvidia NVreg_DynamicPowerManagement=0x00\n"
"template": "templates/modprobe.d/g14-nvidia-power.conf"
},
{
"id": "video-options",
"type": "file",
"path": "/etc/modprobe.d/video-brightness-switch.conf",
"mode": "0644",
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The ACPI video driver does not change the backlight itself on the brightness keys: on this model it\n# moves the wrong one. The keys are triggerhappy's (see /etc/triggerhappy/triggers.d/asus-g14.conf).\n# Applies when the module next loads: at the next boot.\noptions video brightness_switch_enabled=0\n"
"template": "templates/modprobe.d/video-brightness-switch.conf"
},
{
"id": "suspend-drop-ins",
"type": "directory",
"path": "/etc/systemd/system/systemd-suspend.service.d",
"mode": "0755"
"mode": "0755",
"unknown": "program"
},
{
"id": "nvidia-on-suspend",
"type": "file",
"path": "/etc/systemd/system/systemd-suspend.service.d/asus-zephyrus-g14-nvidia.conf",
"mode": "0644",
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The NVIDIA driver's own sleep actions, asked for by the sleep itself rather than enabled as\n# links: the mesh declares files and never makes links (novox/hq ADR 0012), and the host's service\n# shape must not start these units by hand, which would put the GPU to sleep with the machine awake.\n[Unit]\nWants=nvidia-suspend.service nvidia-resume.service\n"
"template": "templates/systemd/system/systemd-suspend.service.d/asus-zephyrus-g14-nvidia.conf"
},
{
"id": "hibernate-drop-ins",
"type": "directory",
"path": "/etc/systemd/system/systemd-hibernate.service.d",
"mode": "0755"
"mode": "0755",
"unknown": "program"
},
{
"id": "nvidia-on-hibernate",
"type": "file",
"path": "/etc/systemd/system/systemd-hibernate.service.d/asus-zephyrus-g14-nvidia.conf",
"mode": "0644",
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The NVIDIA driver's own sleep actions, asked for by the sleep itself rather than enabled as\n# links: the mesh declares files and never makes links (novox/hq ADR 0012), and the host's service\n# shape must not start these units by hand, which would put the GPU to sleep with the machine awake.\n[Unit]\nWants=nvidia-hibernate.service nvidia-resume.service\n"
"template": "templates/systemd/system/systemd-hibernate.service.d/asus-zephyrus-g14-nvidia.conf"
},
{
"id": "suspend-then-hibernate-drop-ins",
"type": "directory",
"path": "/etc/systemd/system/systemd-suspend-then-hibernate.service.d",
"mode": "0755"
"mode": "0755",
"unknown": "program"
},
{
"id": "nvidia-on-suspend-then-hibernate",
"type": "file",
"path": "/etc/systemd/system/systemd-suspend-then-hibernate.service.d/asus-zephyrus-g14-nvidia.conf",
"mode": "0644",
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The NVIDIA driver's own sleep actions, asked for by the sleep itself rather than enabled as\n# links: the mesh declares files and never makes links (novox/hq ADR 0012), and the host's service\n# shape must not start these units by hand, which would put the GPU to sleep with the machine awake.\n[Unit]\nWants=nvidia-suspend-then-hibernate.service nvidia-resume.service\n"
"template": "templates/systemd/system/systemd-suspend-then-hibernate.service.d/asus-zephyrus-g14-nvidia.conf"
},
{
"id": "powerd-drop-ins",
"type": "directory",
"path": "/etc/systemd/system/nvidia-powerd.service.d",
"mode": "0755"
"mode": "0755",
"unknown": "program"
},
{
"id": "powerd-opt-in",
"type": "file",
"path": "/etc/systemd/system/nvidia-powerd.service.d/asus-zephyrus-g14.conf",
"mode": "0644",
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# nvidia-powerd (Dynamic Boost) was the first error in the chain that hung this model's GPU on a change\n# of power source, and asusd starts it on mains. It runs only when the kernel command line says\n# zephyrus.nvidia-powerd — an explicit opt-in, at boot.\n[Unit]\nConditionKernelCommandLine=zephyrus.nvidia-powerd\n"
"template": "templates/systemd/system/nvidia-powerd.service.d/asus-zephyrus-g14.conf"
},
{
"id": "backlight-rule",
"type": "file",
"path": "/etc/udev/rules.d/90-backlight.rules",
"mode": "0644",
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The backlights are writable by the video group, so the brightness keys and the module's brightness tool\n# move the panel without root.\nACTION==\"add\", SUBSYSTEM==\"backlight\", RUN+=\"/usr/bin/chgrp video /sys/class/backlight/%k/brightness\", RUN+=\"/usr/bin/chmod g+w /sys/class/backlight/%k/brightness\"\n"
"template": "templates/udev/rules.d/90-backlight.rules"
},
{
"id": "udev",
@@ -144,14 +151,15 @@
"id": "upower-drop-ins",
"type": "directory",
"path": "/etc/UPower/UPower.conf.d",
"mode": "0755"
"mode": "0755",
"unknown": "program"
},
{
"id": "low-battery",
"type": "file",
"path": "/etc/UPower/UPower.conf.d/50-asus-zephyrus-g14.conf",
"mode": "0644",
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# On low battery the machine suspends rather than powering off, at 7 % — s2idle still draws a little,\n# so it leaves headroom. A drop-in over the package's own UPower.conf, which stays the package's.\n[UPower]\nUsePercentageForPolicy=true\nPercentageLow=15.0\nPercentageCritical=10.0\nPercentageAction=7.0\nCriticalPowerAction=Suspend\nAllowRiskyCriticalPowerAction=true\n"
"template": "templates/UPower/UPower.conf.d/50-asus-zephyrus-g14.conf"
},
{
"id": "upower",
@@ -167,14 +175,30 @@
"id": "xorg-drop-ins",
"type": "directory",
"path": "/etc/X11/xorg.conf.d",
"mode": "0755"
"mode": "0755",
"unknown": "program"
},
{
"id": "touchpad",
"type": "file",
"path": "/etc/X11/xorg.conf.d/30-asus-zephyrus-g14-touchpad.conf",
"mode": "0644",
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The touchpad's settings, applied by X every time the device appears — at login and after every\n# resume, when the device is initialised again. This replaces the predecessor's sleep hook, which ran\n# xinput after a resume as a named person on a guessed display.\nSection \"InputClass\"\n Identifier \"asus-zephyrus-g14 touchpad\"\n MatchIsTouchpad \"on\"\n Option \"Tapping\" \"on\"\n Option \"NaturalScrolling\" \"true\"\n Option \"AccelSpeed\" \"0.15\"\nEndSection\n"
"template": "templates/X11/xorg.conf.d/30-asus-zephyrus-g14-touchpad.conf"
}
],
"items": [
{
"collection": "shell:after-wake:normal",
"template": "templates/after-wake"
},
{
"collection": "contribution:node-hotkeys:trigger",
"template": "templates/hotkeys-triggers"
},
{
"collection": "contribution:node-display-session:config",
"name": "display-session",
"template": "templates/display-session"
}
],
"build": {
@@ -194,31 +218,12 @@
"name": "scripts",
"kind": "archive",
"from": "files"
},
{
"name": "templates",
"kind": "archive",
"from": "templates"
}
]
},
"contributions": [
{
"seat": "node-hotkeys",
"kind": "trigger",
"content": "# The ROG Zephyrus G14's vendor keys, which reach no X client: media (the M-keys), panel brightness,\n# and the touchpad key. Each runs this module's own script, as the operator's account.\nKEY_PROG1\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media play-pause\nKEY_PROG3\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media previous\nKEY_PROG4\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media next\nKEY_BRIGHTNESSDOWN\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight -\nKEY_BRIGHTNESSDOWN\t2\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight -\nKEY_BRIGHTNESSUP\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight +\nKEY_BRIGHTNESSUP\t2\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight +\nKEY_F21\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-touchpad reset\n"
},
{
"seat": "node-display-session",
"kind": "config",
"content": "# The laptop's own lines in i3 (module asus-zephyrus-g14, novox/hq ADR 0208, ADR 0210). Owned by the\n# mesh: replaced at every push. Once the controller places contributions to node-display-session\n# (ADR 0210), these become the module's contribution instead of a file in i3's directory.\n#\n# The keys the firmware turns into ordinary key presses. The vendor keys that reach no X client are\n# triggerhappy's (/etc/triggerhappy/triggers.d/asus-g14.conf): M4 and Fn+F4/F5 for media, Fn+F7/F8 for\n# the panel, Fn+F10 for the touchpad. The keyboard backlight (Fn+F2/F3) is the firmware's and asusd's.\n\n# Fn+F6, the screenshot key: the firmware sends Super+Shift+S. Released before it runs, because the\n# screenshot grabs the pointer to select a region, which fails while the key is still held.\nbindsym --release $mod+Shift+s exec --no-startup-id $XDG_CONFIG_HOME/i3/scripts/screenshot.sh\n\n# Fn+F9, the display key: the firmware sends Super+P. Odd workspaces to the panel, even ones to the\n# external output.\nbindsym $mod+p exec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-display order\n\n# The keyboard backlight's level, shown when it changes.\nexec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-kbd-notify\n"
},
{
"seat": "node-display-session",
"kind": "config",
"content": "# The laptop's own lines in i3 (module asus-zephyrus-g14, novox/hq ADR 0208, ADR 0210). Owned by the\n# mesh: replaced at every push. Once the controller places contributions to node-display-session\n# (ADR 0210), these become the module's contribution instead of a file in i3's directory.\n#\n# The model's panel and touchpad.\n\n# The internal panel is the primary output, where the bars' tray goes. Which monitors are on and where\n# is the display server's (autorandr, run at every session start).\nexec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-display primary\n\n# The touchpad's settings again, by hand. X applies them itself whenever the device appears.\nbindsym $mod+Shift+x exec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-touchpad reset\n"
}
],
"shell": [
{
"for": "after-wake",
"slot": "normal",
"code": "# The touchpad's settings again after waking, in the operator's session: X applies the module's\n# input class when the device appears, and this covers a wake that does not initialise it again.\n# Runs as root from the power module; the reset itself runs as the session's owner.\nsleep 2\nowner=$(ps -o user= -C i3 | head -n 1)\n[ -n \"$owner\" ] && runuser -u \"$owner\" -- /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-touchpad reset\ntrue\n"
}
]
}
}
@@ -0,0 +1,11 @@
# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.
#
# On low battery the machine suspends rather than powering off, at 7 % — s2idle still draws a little,
# so it leaves headroom. A drop-in over the package's own UPower.conf, which stays the package's.
[UPower]
UsePercentageForPolicy=true
PercentageLow=15.0
PercentageCritical=10.0
PercentageAction=7.0
CriticalPowerAction=Suspend
AllowRiskyCriticalPowerAction=true
@@ -0,0 +1,12 @@
# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.
#
# The touchpad's settings, applied by X every time the device appears — at login and after every
# resume, when the device is initialised again. This replaces the predecessor's sleep hook, which ran
# xinput after a resume as a named person on a guessed display.
Section "InputClass"
Identifier "asus-zephyrus-g14 touchpad"
MatchIsTouchpad "on"
Option "Tapping" "on"
Option "NaturalScrolling" "true"
Option "AccelSpeed" "0.15"
EndSection
@@ -0,0 +1,7 @@
# The touchpad's settings again after waking, in the operator's session: X applies the module's
# input class when the device appears, and this covers a wake that does not initialise it again.
# Runs as root from the power module; the reset itself runs as the session's owner.
sleep 2
owner=$(ps -o user= -C i3 | head -n 1)
[ -n "$owner" ] && runuser -u "$owner" -- /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-touchpad reset
true
@@ -0,0 +1,30 @@
# The laptop's own lines in i3 (module asus-zephyrus-g14, novox/hq ADR 0208, ADR 0210). Owned by the
# mesh: replaced at every push. Once the controller places contributions to node-display-session
# (ADR 0210), these become the module's contribution instead of a file in i3's directory.
#
# The keys the firmware turns into ordinary key presses. The vendor keys that reach no X client are
# triggerhappy's (/etc/triggerhappy/triggers.d/asus-g14.conf): M4 and Fn+F4/F5 for media, Fn+F7/F8 for
# the panel, Fn+F10 for the touchpad. The keyboard backlight (Fn+F2/F3) is the firmware's and asusd's.
# Fn+F6, the screenshot key: the firmware sends Super+Shift+S. Released before it runs, because the
# screenshot grabs the pointer to select a region, which fails while the key is still held.
bindsym --release $mod+Shift+s exec --no-startup-id $XDG_CONFIG_HOME/i3/scripts/screenshot.sh
# Fn+F9, the display key: the firmware sends Super+P. Odd workspaces to the panel, even ones to the
# external output.
bindsym $mod+p exec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-display order
# The keyboard backlight's level, shown when it changes.
exec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-kbd-notify
# The laptop's own lines in i3 (module asus-zephyrus-g14, novox/hq ADR 0208, ADR 0210). Owned by the
# mesh: replaced at every push. Once the controller places contributions to node-display-session
# (ADR 0210), these become the module's contribution instead of a file in i3's directory.
#
# The model's panel and touchpad.
# The internal panel is the primary output, where the bars' tray goes. Which monitors are on and where
# is the display server's (autorandr, run at every session start).
exec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-display primary
# The touchpad's settings again, by hand. X applies them itself whenever the device appears.
bindsym $mod+Shift+x exec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-touchpad reset
@@ -0,0 +1,10 @@
# The ROG Zephyrus G14's vendor keys, which reach no X client: media (the M-keys), panel brightness,
# and the touchpad key. Each runs this module's own script, as the operator's account.
KEY_PROG1 1 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media play-pause
KEY_PROG3 1 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media previous
KEY_PROG4 1 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media next
KEY_BRIGHTNESSDOWN 1 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight -
KEY_BRIGHTNESSDOWN 2 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight -
KEY_BRIGHTNESSUP 1 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight +
KEY_BRIGHTNESSUP 2 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight +
KEY_F21 1 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-touchpad reset
@@ -0,0 +1,16 @@
# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.
#
# The discrete GPU's driver options on the ROG Zephyrus G14 (GA403, RTX 40 series, hybrid graphics).
#
# NVreg_DynamicPowerManagement=0x00 turns runtime D3 off. With it on, a change of power source sends
# the driver an ACPI notification it fails to handle on this model ("RmHandleDNotifierEvent: Failed to
# handle ACPI D-Notifier event, status=0x62"), and the GPU stops making progress until the machine is
# powered off. Off costs a few idle watts in hybrid mode and keeps the machine up.
#
# NVreg_PreserveVideoMemoryAllocations=1 saves video memory across suspend, so what used the GPU still
# works after waking. It needs nvidia-suspend, -hibernate and -resume to run around a sleep, which this
# module's drop-ins on the sleep services ask for.
#
# A change here applies when the driver next loads: at the next boot.
options nvidia NVreg_PreserveVideoMemoryAllocations=1
options nvidia NVreg_DynamicPowerManagement=0x00
@@ -0,0 +1,6 @@
# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.
#
# The ACPI video driver does not change the backlight itself on the brightness keys: on this model it
# moves the wrong one. The keys are triggerhappy's (see /etc/triggerhappy/triggers.d/asus-g14.conf).
# Applies when the module next loads: at the next boot.
options video brightness_switch_enabled=0
@@ -0,0 +1,7 @@
# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.
#
# nvidia-powerd (Dynamic Boost) was the first error in the chain that hung this model's GPU on a change
# of power source, and asusd starts it on mains. It runs only when the kernel command line says
# zephyrus.nvidia-powerd — an explicit opt-in, at boot.
[Unit]
ConditionKernelCommandLine=zephyrus.nvidia-powerd
@@ -0,0 +1,7 @@
# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.
#
# The NVIDIA driver's own sleep actions, asked for by the sleep itself rather than enabled as
# links: the mesh declares files and never makes links (novox/hq ADR 0012), and the host's service
# shape must not start these units by hand, which would put the GPU to sleep with the machine awake.
[Unit]
Wants=nvidia-hibernate.service nvidia-resume.service
@@ -0,0 +1,7 @@
# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.
#
# The NVIDIA driver's own sleep actions, asked for by the sleep itself rather than enabled as
# links: the mesh declares files and never makes links (novox/hq ADR 0012), and the host's service
# shape must not start these units by hand, which would put the GPU to sleep with the machine awake.
[Unit]
Wants=nvidia-suspend-then-hibernate.service nvidia-resume.service
@@ -0,0 +1,7 @@
# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.
#
# The NVIDIA driver's own sleep actions, asked for by the sleep itself rather than enabled as
# links: the mesh declares files and never makes links (novox/hq ADR 0012), and the host's service
# shape must not start these units by hand, which would put the GPU to sleep with the machine awake.
[Unit]
Wants=nvidia-suspend.service nvidia-resume.service
@@ -0,0 +1,5 @@
# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.
#
# The backlights are writable by the video group, so the brightness keys and the module's brightness tool
# move the panel without root.
ACTION=="add", SUBSYSTEM=="backlight", RUN+="/usr/bin/chgrp video /sys/class/backlight/%k/brightness", RUN+="/usr/bin/chmod g+w /sys/class/backlight/%k/brightness"
+11
View File
@@ -0,0 +1,11 @@
# audit-logger
The `audit-logger` module of the mesh catalogue: declares directories on the node.
## Files it does not know
Each directory or archive this module declares says how it treats a file it did not write (ADR 0422, ADR 0416).
- `${dir:state}`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
- `${dir:trail}`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
- `${dir:spool}`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
+6 -3
View File
@@ -47,17 +47,20 @@
"id": "state",
"type": "directory",
"mode": "0700",
"place": "."
"place": ".",
"unknown": "program"
},
{
"id": "trail",
"type": "directory",
"mode": "0700"
"mode": "0700",
"unknown": "program"
},
{
"id": "spool",
"type": "directory",
"mode": "0700"
"mode": "0700",
"unknown": "program"
}
],
"capabilities": [
+11
View File
@@ -0,0 +1,11 @@
# baserow
The `baserow` module of the mesh catalogue: runs the container image `baserow/baserow`.
## Files it does not know
Each directory or archive this module declares says how it treats a file it did not write (ADR 0422, ADR 0416).
- `${dir:mesh-state}`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
- `${dir:state}`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
- `${dir:data}`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
+4
View File
@@ -0,0 +1,4 @@
{
"password": "${secret:admin}",
"host": "${bound:route:name}"
}
+7
View File
@@ -0,0 +1,7 @@
DATABASE_HOST=${bound:postgres-database:at}
DATABASE_PORT=${bound:postgres-database:port}
DATABASE_NAME=${bound:postgres-database:as}
DATABASE_USER=${bound:postgres-database:as}
DATABASE_PASSWORD_FILE=/run/secrets/database
DISABLE_EMBEDDED_PSQL=true
BASEROW_PUBLIC_URL=https://${bound:route:name}
+36 -6
View File
@@ -46,31 +46,54 @@
}
]
},
"config": {
"host": {
"type": "string",
"description": "The Host header the module's tools send to Baserow, a host name; the file's default is the name the module's route was given.",
"trusted": true,
"default": "${bound:route:name}"
},
"email": {
"type": "string",
"description": "The email address of the Baserow account the module's tools sign in as; unset, the code falls back to its environment.",
"trusted": true,
"default": ""
},
"url": {
"type": "string",
"description": "The address the module's tools reach Baserow at, as a URL; unset, the code falls back to its environment, else this machine's Baserow on port 80.",
"trusted": true,
"default": ""
}
},
"resources": [
{
"id": "mesh-state",
"type": "directory",
"mode": "0700",
"place": "mesh"
"place": "mesh",
"unknown": "program"
},
{
"id": "state",
"type": "directory",
"mode": "0700",
"place": "."
"place": ".",
"unknown": "program"
},
{
"id": "data",
"type": "directory",
"mode": "0755",
"owner": "9999:9999"
"owner": "9999:9999",
"unknown": "program"
},
{
"id": "server-env",
"type": "file",
"path": "${dir:state}/server.env",
"mode": "0600",
"content": "DATABASE_HOST=${bound:postgres-database:at}\nDATABASE_PORT=${bound:postgres-database:port}\nDATABASE_NAME=${bound:postgres-database:as}\nDATABASE_USER=${bound:postgres-database:as}\nDATABASE_PASSWORD_FILE=/run/secrets/database\nDISABLE_EMBEDDED_PSQL=true\nBASEROW_PUBLIC_URL=https://${bound:route:name}\n"
"template": "files/server.env"
},
{
"id": "net",
@@ -102,8 +125,10 @@
"type": "file",
"path": "${dir:mesh-state}/config.json",
"mode": "0600",
"content": "{\n \"password\": \"${secret:admin}\",\n \"host\": \"${bound:route:name}\"\n}\n",
"merge": "json"
"template": "files/config.json",
"merge": "json",
"takes-config": ["host", "email", "url"],
"protected": ["password"]
}
],
"build": {
@@ -122,6 +147,11 @@
"MESH_BASEROW_URL": "http://127.0.0.1:${port:80}",
"MESH_BASEROW_CONFIG_FILE": "${dir:mesh-state}/config.json"
}
},
{
"name": "files",
"kind": "archive",
"from": "files"
}
]
}
+140 -88
View File
@@ -1,10 +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, ADR 0316, ADR 0317,
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 declares the operator's mail accounts without their credentials.
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
@@ -18,13 +20,14 @@ issue 345.
| 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`) and the spelling dictionaries' default (`vendor-prefs.js`) | `/opt/betterbird/defaults/pref` | owned: its two files |
| 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 its `user.js` (setting `prefs`), `accounts.json` (setting `accounts`) and the record of adopted and created accounts | `~/.config/betterbird/` | 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 account lists in the profile's `prefs.js` | the profile | written into: the module's keys only |
| 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) |
@@ -69,97 +72,85 @@ box out in the settings. `betterbird_check` says when Betterbird would ask (novo
**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 the created accounts into the account lists; then it
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 configuration this module ships
## The preferences: locks, the setting prefs, and Betterbird's own defaults
Betterbird's configuration comes in three layers (novox/hq issue 345). The operator approved the set on
2026-10-12. It is the laptop's setup, with the start page locked off, reminders missed while asleep shown at the
next start, and chat off.
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 | file | who changes it | holds |
| 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 |
| **default** | `/opt/betterbird/defaults/pref/mesh-defaults.js` (`install/defaults-pref/`) | a catalogue change; or the operator in Betterbird's settings, where the change lands in the profile's `prefs.js` | the operator's setup: what a fresh node gets without anyone touching it |
| **node** | the setting `prefs`, written to `~/.config/betterbird/user.js` and placed in the profile's `user.js` | the operator, per node, through the settings layers | a node's exceptions, such as a font size for a larger screen |
| **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 node layer (`user.js`) wins over the profile's `prefs.js`, and
`prefs.js` wins over a 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). The `Preferences` policy accepts only some prefixes. `privacy.` and `toolkit.` are not
among them, so such a preference can only be a default.
vendor at every start).
**Default** (`mesh-defaults.js`, each line commented with why):
**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.
- display: compact density (`mail.uidensity` 0), 14-point interface text, and message bodies not darkened;
- layout: the vertical view with multi-line rows, the list as cards, threads grouped;
- no confirmation on Shift+Delete;
- new mail: no pop-up, no sound, no tray icon, and alerts through the desktop's notifier if a node turns them on;
- calendar: the day from 09:00 to 20:00, deleting without asking, the node's time zone, reminders shown, and
reminders missed while asleep shown at the next start;
- chat off;
- remote content blocked, and Do Not Track sent.
**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.
A line `// keeps Betterbird's default: <name>` names a preference the module manages without a value of its own.
For these the operator's setup is Betterbird's own default: the theme, system colours, always-underlined
links, manual spam marking, and the composer's attachment pane.
**Drift.** A default does not beat a value the profile saved in `prefs.js`. That value may be a choice made in
the settings window, or one the profile already held. `betterbird_config` shows each managed preference through
the layers, and flags **drift**: a saved value that beats the module's default without a node layer saying so.
`betterbird_check` reports it. There are two remedies:
- `betterbird_config_apply` with `apply` takes the drifting values out of `prefs.js`, so the module's value
holds at the next start. It acts only while Betterbird is quit (no main process, and no `lock` in the profile),
because Betterbird rewrites `prefs.js` when it quits, and it says what it cleared. Without `apply` it lists
what it would clear.
- `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. So the tools read the theme from the add-on database, and the
module's value is `default-theme@mozilla.org`. Another theme is drift whose remedy is to enable the default
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.
**Never an account.** A name the tools withhold (a credential's, an account's, a server's), or an account's own
preference, is never managed, even when the defaults file lists it. `betterbird_config` leaves it out and names it
in `refused`, `betterbird_config_apply` never clears it, and `betterbird_check` says to take it out of the file.
- `betterbird_config` with `keep` proposes, through the controller's settings verb, a node layer of `prefs` that
holds the profile's value for this machine. The operator approves it on a channel that proves who answers.
The tool never writes `user.js` itself. The setting is one string, so the layer carries what `prefs` says on
this machine today, with the kept values added.
**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: how a preference is declared
## 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:
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.
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** (`"trusted": true`): a preference can weaken how the mail client checks a server,
so the setting is changed only at the controller's terminal or proposed and set on the operator's warrant
(novox/hq issue 339, ADR 0277). An account's preferences (`mail.account.`, `mail.identity.`, `mail.server.`,
`mail.smtpserver.` and the account lists) are refused in `prefs`: an account is declared in `accounts`, so
it has one owner (ADR 0316 §11).
## 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
@@ -205,32 +196,80 @@ lines and its record go, the account stays the person's) and takes a created acc
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 through the controller's settings verb;
the operator reads it whole at their desk (`settings proposals <id>`) and approves on a channel that proves
who answers. Its answer to its caller holds counts and kinds only.
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`, trusted too since issue 751 (the operator's decision), so a change to it is the operator's to approve.
**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 address, a host, an account's name or a credential**: accounts are said by count, place
and key.
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); what is installed and from where (the mesh's release, or the community package before the switch), its version and build beside the newest published build (Betterbird's own `versions.txt`); 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); the declared accounts by count and, per place, adopted, created or refused with their keys; the add-ons in the profile (ids, versions); 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 (the policy's lock, user.js, prefs.js or the default), and the default itself with the file that gives it. Betterbird's own defaults are read from `omni.ja` (`greprefs.js`, `defaults/pref/*.js`) and the installation's `defaults/pref`, the module's from its files there. Of several files, the one first by name holds, as Gecko reads them. A preference compiled into Betterbird is said as built in. 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 of `mailnews.*` only a name that holds a server, account, identity or tag); 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_config` (r; a with `keep`) | each managed preference: its lock, the module's default (or Betterbird's, where the module keeps that), the node layer, the profile's saved value, the value in effect, and drift; with `keep`, a node layer holding the profile's values proposed for this machine |
| `betterbird_config_apply` (r; a with `apply`) | the drifting values listed; with `apply`, while Betterbird is quit, taken out of `prefs.js`, each said with the value it had and the one that now holds |
| `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 from the mesh's release and not behind the newest published build; the policy placed, and the module's defaults with it; no managed preference drifting from them; no ask at the start to be the default mail client; Owl at its pin and active; one running; profile found; the user.js on the machine, valid, placed, in effect; the declared accounts in the list, none refused, each signed in once; its output read |
| `betterbird_accounts` (r; a with `propose`) | the profile's accounts by count and kind (declarable, Owl's, others); with `propose`, the setting `accounts` built from them and proposed to the operator (ADR 0317 §9) |
| `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
@@ -293,9 +332,9 @@ outside the mesh. With this version, a push to a node (each waits for the operat
| `/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/` | `user.js`, `accounts.json` (no account until the setting `accounts` is approved) |
| `~/.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 module's defaults wherever the profile saved no value of its own; a saved value is drift, said by `betterbird_check`, until `betterbird_config_apply` or a node layer |
| 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
@@ -305,4 +344,17 @@ outside the mesh. With this version, a push to a node (each waits for the operat
- `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 `betterbird_accounts` with `propose` (`invokes`).
- 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`).
## Files it does not know
Each directory or archive this module declares says how it treats a file it did not write (ADR 0422, ADR 0416).
- `~/.config/betterbird`: holds only what this module declares and its own tools' records: `accounts-record.json` (`cmd/betterbird-tools/adoption.go:36`), `configuration-record.json` (`owned.go:33`) and the `kept/` folder of replaced filter files (`filters.go:261`); anything else is moved out by the node-engine to `~/.local/state/mesh-moved/betterbird/` with a list, never deleted (ADR 0422 §4).
- `/opt/betterbird`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
- `/opt/betterbird/owl`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
- `/opt/betterbird/distribution`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
- `/opt/betterbird/defaults/pref`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
- `/usr/local/lib/mesh-betterbird`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
@@ -398,10 +398,6 @@ func unquote(v string) string {
// (§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) {
if ps := m.mainProcs(); len(ps) > 0 {
return "", fmt.Errorf("Betterbird is running (pid %d), and it rewrites prefs.js when it quits: the account "+
"list is written at its next start from the launcher, or by betterbird_restart", ps[0].PID)
}
path := filepath.Join(profile, "prefs.js")
raw, err := readBounded(path)
if err != nil && !errors.Is(err, os.ErrNotExist) {
@@ -418,16 +414,15 @@ func (m *Machine) writeAccountLists(profile string, resolved []resolvedAccount)
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()
}
tmp := path + ".mesh-new"
if err := os.WriteFile(tmp, []byte(out), mode); err != nil {
return "", err
}
if err := os.Rename(tmp, path); err != nil {
os.Remove(tmp)
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
@@ -608,11 +603,15 @@ func withoutSignIn(accounts map[string]Account, kept map[string]bool) []string {
return out
}
// AccountsAnswer is what betterbird_accounts answers its caller: counts and kinds, never an address, a host or
// a name (novox/hq ADR 0317 §9). The value itself goes only to the operator, in a proposal they read at
// their own desk.
// 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).
@@ -626,6 +625,20 @@ type AccountsAnswer struct {
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) {
@@ -634,13 +647,28 @@ func (m *Machine) profileDeclarable() (map[string]Account, AccountsAnswer, error
return nil, AccountsAnswer{}, err
}
prefs := mergedPrefs(p.path)
a := AccountsAnswer{Profile: p.Dir, Declarable: map[string]int{}, NotDeclarable: map[string]int{}}
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
@@ -648,6 +676,8 @@ func (m *Machine) profileDeclarable() (map[string]Account, AccountsAnswer, error
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]++
@@ -659,8 +689,14 @@ func (m *Machine) profileDeclarable() (map[string]Account, AccountsAnswer, error
address = pa.Addresses[0]
}
name := nameFor(pa, address, used)
if declaredAs[pa.Key] != "" {
name = declaredAs[pa.Key]
}
used[name] = true
value[name] = readAccount(prefs, pa)
acc := readAccount(prefs, pa)
value[name] = acc
listed.Value = &acc
a.Accounts = append(a.Accounts, listed)
}
if len(a.NotDeclarable) == 0 {
a.NotDeclarable = nil
@@ -681,9 +717,9 @@ func (m *Machine) ProfileAccounts(propose bool) (AccountsAnswer, error) {
if err != nil {
return a, err
}
a.Note = "counts and kinds only: no address, host or name leaves through this answer. With propose, the " +
"setting accounts is built from these accounts (never Owl's, never a password or token) and proposed to " +
"the operator, who reads it whole at their desk before approving (novox/hq ADR 0317 §9)"
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
}
@@ -701,21 +737,16 @@ func (m *Machine) ProfileAccounts(propose bool) (AccountsAnswer, error) {
return a, fmt.Errorf("nothing was proposed: %s; betterbird_prefs shows the account's preferences by name",
strings.Join(problems, "; "))
}
body, err := json.Marshal(map[string]any{"accounts": value})
// 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, err
return a, fmt.Errorf("nothing was proposed: %v", err)
}
raw, err := asker("seat:mesh-controller.settings", map[string]any{"module": "betterbird", "values": string(body),
"propose": "true"})
if err != nil {
return a, fmt.Errorf("the controller's settings verb did not answer: %v", err)
}
said, ok := verbSaid(raw)
layer["accounts"] = value
a.Derived = value
said, err := proposeLayer("", layer, false)
a.Proposed = m.maskValue(said)
if !ok {
return a, fmt.Errorf("the controller did not take the proposal: %s", a.Proposed)
}
return a, nil
return a, err
}
// verbSaid is a verb's words and whether it went ahead, whatever wraps them: the controller's {ok, output},
@@ -357,9 +357,12 @@ func TestDeclaredAccountsWithoutASignInAreCountedFromTheServerNamesAlone(t *test
}
}
func TestBetterbirdAccountsAnswersCountsAndProposesTheValueWithoutOwlOrACredential(t *testing.T) {
// 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 {
@@ -368,19 +371,33 @@ func TestBetterbirdAccountsAnswersCountsAndProposesTheValueWithoutOwlOrACredenti
if a.Declarable["imap"] != 1 || a.KeptByOwl != 1 || a.NotDeclarable["local folders"] != 1 || a.Proposed != "" {
t.Fatalf("%+v", a)
}
raw, _ := json.Marshal(a)
for _, never := range []string{"example", "other", "Other", "work@", "smtp1"} {
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 to the caller names %q: %s", never, raw)
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
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)
}
asked = body.(map[string]any)
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 })
@@ -388,10 +405,15 @@ func TestBetterbirdAccountsAnswersCountsAndProposesTheValueWithoutOwlOrACredenti
if err != nil {
t.Fatal(err)
}
if asked["module"] != "betterbird" || asked["propose"] != "true" || asked["node"] != nil {
t.Fatalf("proposed for the whole mesh, never set: %v", asked)
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)
}
value, err := parseAccounts([]byte(asked["values"].(string)))
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)
}
@@ -400,6 +422,9 @@ func TestBetterbirdAccountsAnswersCountsAndProposesTheValueWithoutOwlOrACredenti
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)
}
@@ -516,3 +541,27 @@ func TestAnAccountsOwnAliasesAndNonMailIdentitiesDoNotShareItsOutgoingServer(t *
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)
}
}
@@ -171,7 +171,8 @@ func refuseSharedOutgoing(resolved []resolvedAccount, prefs map[string]string, h
users := map[string][]string{}
seen := map[string]bool{}
use := func(id string) {
if seen[id] || ours[id] {
// 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
@@ -9,20 +9,20 @@ 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`, and its declared accounts (~/.config/betterbird/accounts.json, the
// setting `accounts`, accounts.go);
// - 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, a user name or password in logins.json, key4.db,
// cert9.db, cookies, the calendar's caches. Nothing of the operator's mail or calendar is answered, and of
// their accounts only counts and kinds.
// 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 files a tool writes in the profile are user.js, and only the module's own (the declared file and
// the declared accounts' preferences, betterbird_user_js with apply, betterbird_restart, and the start
// from the launcher), and the module's own keys in the account lists of prefs.js, only while Betterbird is
// not running (accounts.go). 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"
@@ -51,8 +51,6 @@ const (
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"
)
@@ -440,6 +438,12 @@ func validUserJS(text string) error {
"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
@@ -489,6 +493,16 @@ type UserJS struct {
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"`
}
// refusedAccounts are the reasons of the declared accounts that were refused.
@@ -508,9 +522,12 @@ func (u UserJS) refusedAccounts() []string {
// 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}
declaredText, 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
@@ -518,19 +535,27 @@ func (m *Machine) userJS(apply bool) (UserJS, error) {
target := filepath.Join(p.path, "user.js")
a.Profile = m.tilde(strings.TrimPrefix(target, m.Root))
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, mergedPrefs(p.path), m.readRecord(p.Dir))
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 {
if verr := validUserJS(string(declaredText)); verr != nil {
if a.Present && a.Valid == "" {
if verr := validUserJS(declaredText); verr != nil {
a.Valid = verr.Error()
}
want = []byte(composeUserJS(string(declaredText), accounts, a.Each))
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)) {
@@ -554,11 +579,13 @@ 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)
@@ -590,6 +617,16 @@ func (m *Machine) userJS(apply bool) (UserJS, 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 {
@@ -640,6 +677,20 @@ func (m *Machine) Prepare() error {
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, "; "))
}
@@ -843,7 +894,7 @@ func (m *Machine) Restart(discard bool) (RestartAnswer, error) {
a.UserJS = "not placed: " + err.Error()
} else {
a.UserJS = fmt.Sprintf("placed: %d preference(s) from ~/%s, %d declared account(s); account lists %s",
u.Statements, declared, u.Accounts, u.Lists)
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.
@@ -962,6 +1013,23 @@ func (m *Machine) Check() (CheckAnswer, error) {
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 {
@@ -975,7 +1043,7 @@ 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 != "":
@@ -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)
@@ -1,18 +1,20 @@
package main
// The configuration the module ships (novox/hq issue 345), in three layers:
// 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.
// - DEFAULT: the module's defaults file (/opt/betterbird/defaults/pref/mesh-defaults.js), the operator's
// setup. Betterbird reads it as a default, so a value the profile saved in prefs.js wins over it: that is
// drift, unless a node layer says so.
// - NODE: the setting prefs, which the node-engine writes to ~/.config/betterbird/user.js and the tools place
// in the profile's user.js, where it wins over prefs.js at every start.
// - 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 that holds the
// profile's value for this node instead, through the controller's settings verb, never by writing user.js.
// 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"
@@ -320,14 +322,14 @@ func statementsOf(raw []byte) map[string]string {
// ConfigPref is one managed preference through the layers.
type ConfigPref struct {
Name string `json:"name"`
// Layer is locked (the policy), default (the module's defaults file) or "Betterbird's default" (a managed
// preference whose module value is Betterbird's own).
// 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"`
// NodeLayer is what the setting prefs on this machine says (the module's user.js).
NodeLayer string `json:"node_layer,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"`
@@ -349,6 +351,8 @@ type ConfigAnswer struct {
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"`
@@ -372,8 +376,10 @@ func (m *Machine) config() (ConfigAnswer, Profile, error) {
}
locked := m.locks()
own, _ := m.defaults(false)
declaredRaw, _ := readBounded(m.home(declared))
node := statementsOf(declaredRaw)
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"))
@@ -384,8 +390,13 @@ func (m *Machine) config() (ConfigAnswer, Profile, error) {
names = append(names, n)
}
sort.Strings(names)
for _, n := range sh.order {
if _, isLocked := locked[n]; !isLocked {
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)
}
}
@@ -395,7 +406,7 @@ func (m *Machine) config() (ConfigAnswer, Profile, error) {
a.Refused = append(a.Refused, n+": "+why)
continue
}
c := ConfigPref{Name: n, NodeLayer: node[n], Profile: saved[n]}
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)
@@ -410,6 +421,8 @@ func (m *Machine) config() (ConfigAnswer, Profile, error) {
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
@@ -430,23 +443,37 @@ func (m *Machine) config() (ConfigAnswer, Profile, error) {
default:
c.From = "Betterbird's built-in default (in no defaults file)"
}
if why := drift(c); why != "" {
if why := drift(c, placed[n]); why != "" {
c.Drift = why
if n == themePref {
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.NodeLayer, c.Profile, c.Effective = m.answer(n, c.NodeLayer), m.answer(n, c.Profile), m.answer(n, c.Effective)
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's saved value beats the module's, or "": a value in prefs.js that is not the
// module's, where no lock holds and no node layer names the preference.
func drift(c ConfigPref) string {
if c.Locked != "" || c.NodeLayer != "" || c.Profile == "" || c.Profile == c.module {
// 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
@@ -457,7 +484,7 @@ func drift(c ConfigPref) string {
if c.Name == themePref {
where = "the profile's active theme is"
}
return fmt.Sprintf("%s %s, which beats the module's %s, and no node layer says so", where, c.Profile, want)
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
@@ -468,8 +495,9 @@ func (m *Machine) Config(keep []string) (ConfigAnswer, error) {
if err != nil {
return a, err
}
a.Note = "drift is a value the profile saved that beats the module's default without a node layer saying so: " +
"betterbird_config_apply clears it (Betterbird quit), or keep proposes a node layer that holds it for this machine"
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
}
@@ -494,50 +522,36 @@ func (m *Machine) Config(keep []string) (ConfigAnswer, error) {
}
kept[n] = saved[n]
}
declaredRaw, err := readBounded(m.home(declared))
if err != nil {
return a, fmt.Errorf("nothing was proposed: the module's user.js is not on this machine (~/%s)", declared)
}
node := nodeName()
if node == "" {
return a, errors.New("nothing was proposed: this runtime does not say which machine it serves (MESH_NODE)")
}
// The setting is one string, so a node layer replaces it whole: it carries what the setting says on this
// machine today, with the kept values in place of any the same names had.
var parts []string
for _, line := range strings.Split(string(declaredRaw), "\n") {
for _, st := range splitStatements(strings.TrimSpace(line)) {
if mm := prefLine.FindStringSubmatch(st); mm != nil {
if _, replaced := kept[mm[1]]; !replaced {
parts = append(parts, fmt.Sprintf("user_pref(%s, %s);", quoted(mm[1]), mm[2]))
}
}
}
// 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 {
if v, ok := kept[n]; ok {
parts = append(parts, fmt.Sprintf("user_pref(%s, %s);", quoted(n), v))
delete(kept, n)
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
}
value := strings.Join(parts, " ")
if err := validUserJS(mark + ")\n" + value + "\n"); err != nil {
return a, fmt.Errorf("nothing was proposed: the layer would be refused: %v", err)
}
body, _ := json.Marshal(map[string]string{"prefs": value})
raw, err := asker("seat:mesh-controller.settings", map[string]any{"module": "betterbird", "node": node,
"values": string(body), "propose": "true"})
if err != nil {
return a, fmt.Errorf("the controller's settings verb did not answer: %v", err)
}
said, ok := verbSaid(raw)
layer["prefs"] = prefs
said, err := proposeLayer(node, layer, false)
a.Proposed = m.maskValue(said)
if !ok {
return a, fmt.Errorf("the controller did not take the proposal: %s", a.Proposed)
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 the whole setting prefs as it stands on this machine, since a layer replaces it whole"
"The layer carries this node's layer as it stands, with the kept preferences in the setting prefs"
return a, nil
}
@@ -13,8 +13,9 @@ import (
// 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 DEFAULT layer the operator approved on 2026-10-12: the laptop's setup, with
// reminders missed while asleep shown at the next start and chat off.
// 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",
@@ -147,8 +148,13 @@ func TestTheModuleShipsTheApprovedLocksAndDefaults(t *testing.T) {
text := string(repoFile(t, "install/defaults-pref/mesh-defaults.js"))
sh := parseShipped([]byte(text))
if !reflect.DeepEqual(sh.values, shippedDefaultsWant) {
t.Errorf("the defaults are the approved ones:\n got %v\nwant %v", sh.values, shippedDefaultsWant)
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 {
@@ -198,8 +204,8 @@ func TestPrefsSayTheDefaultAndTheFileThatGivesIt(t *testing.T) {
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 != "0" || !strings.Contains(p.From, "the module's default") {
t.Errorf("the module's default is over Betterbird's: %+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)
@@ -234,8 +240,8 @@ func TestMailnewsIsWithheldOnlyWhereItNamesAnAccount(t *testing.T) {
}
}
// T1: every managed preference through the layers, and drift where the profile's own value beats the module's
// default without a node layer saying so.
// 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()
@@ -243,13 +249,14 @@ func TestTheConfigShowsTheLayersAndTheDrift(t *testing.T) {
f.withDefaults()
f.betterbirdsOwn()
links(t, nil)
f.declare(`user_pref("mail.uifontsize", 16);`)
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", 16);`+"\n"+
`user_pref("mail.uidensity", 0);`+"\n"+
`user_pref("mail.uifontsize", 12);`+"\n"+
`user_pref("mail.biff.show_alert", true);`+"\n"+
`user_pref("mail.spam.manualMark", false);`+"\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 {
@@ -259,30 +266,34 @@ func TestTheConfigShowsTheLayersAndTheDrift(t *testing.T) {
for _, p := range c.Prefs {
by[p.Name] = p
}
if len(c.Prefs) != 3+len(shippedDefaultsWant)+len(shippedKeepsWant) || !strings.HasPrefix(c.Defaults, "placed") {
t.Fatalf("every lock, default and kept default: %d, %s", len(c.Prefs), c.Defaults)
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.biff.show_alert"}) {
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 != "default" || p.ModuleDefault != "false" || p.BetterbirdDefault != "true" ||
p.Profile != "true" || p.Effective != "true" || p.From != "the profile's prefs.js" || !strings.Contains(p.Drift, "beats the module's false") {
t.Errorf("%+v", p)
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["mail.spam.manualMark"]; p.Drift != "" || p.BetterbirdDefault != "false" {
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.NodeLayer != "16" {
t.Errorf("a node layer that says so 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.From != "the module's default" {
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.
@@ -292,7 +303,7 @@ func TestTheConfigShowsTheLayersAndTheDrift(t *testing.T) {
}
found := false
for _, x := range ch.Findings {
found = found || (strings.Contains(x.What, "1 managed preference(s) drift") && strings.Contains(x.What, "mail.biff.show_alert") &&
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
@@ -300,7 +311,11 @@ func TestTheConfigShowsTheLayersAndTheDrift(t *testing.T) {
theme = theme || (strings.HasPrefix(x.What, "extensions.activeThemeID drifts") && strings.Contains(x.Do, "add-on manager") &&
!strings.Contains(x.Do, "betterbird_config_apply"))
}
if !found || !theme {
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))
@@ -326,7 +341,7 @@ func TestConfigApplyClearsTheDriftOnlyWhileBetterbirdIsQuit(t *testing.T) {
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.biff.show_alert", true);` + "\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)
@@ -365,7 +380,7 @@ func TestConfigApplyClearsTheDriftOnlyWhileBetterbirdIsQuit(t *testing.T) {
for _, c := range a.Clears {
said[c.Name] = c
}
if c := said["mail.biff.show_alert"]; c.Was != "true" || c.Now != "false" {
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") {
@@ -384,46 +399,52 @@ func TestConfigApplyClearsTheDriftOnlyWhileBetterbirdIsQuit(t *testing.T) {
}
}
// T6: keeping a value on one node is a node layer proposed through the controller, never a write to user.js.
// 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.biff.show_alert", true);`+"\n")
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 verb string
var args map[string]any
var asked []map[string]any
asker = func(v string, a any) (json.RawMessage, error) {
verb, args = v, a.(map[string]any)
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 + "/.config/betterbird/user.js")
c, err := f.Config([]string{"mail.uifontsize", "mail.biff.show_alert"})
before := f.read(testHome + "/" + prefsFile)
c, err := f.Config([]string{"mail.uifontsize", "mail.spam.manualMark"})
if err != nil {
t.Fatal(err)
}
if verb != "seat:mesh-controller.settings" || args["node"] != "shanks" || args["propose"] != "true" || args["module"] != "betterbird" {
t.Fatalf("%s %v", verb, args)
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]string
if err := json.Unmarshal([]byte(args["values"].(string)), &values); err != nil {
var values map[string]any
if err := json.Unmarshal([]byte(asked[1]["values"].(string)), &values); err != nil {
t.Fatal(err)
}
want := `user_pref("calendar.alarms.playsound", false); user_pref("mail.uifontsize", 16); user_pref("mail.biff.show_alert", true);`
if values["prefs"] != want {
t.Fatalf("the layer keeps what the setting says and the kept values:\n got %s\nwant %s", values["prefs"], want)
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+"/.config/betterbird/user.js") != before {
t.Fatalf("proposed, and user.js untouched: %+v", c)
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"}, {"mail.uidensity"}} {
verb = ""
if _, err := f.Config(bad); err == nil || verb != "" {
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)
}
}
@@ -0,0 +1,472 @@
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; trusted too since issue 751,
// so a change to it is the operator's to approve.
//
// 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,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
}
+131 -31
View File
@@ -2,14 +2,17 @@
// 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"
@@ -34,6 +37,31 @@ 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",
@@ -42,9 +70,9 @@ func tools() []stdio.Tool {
"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); the declared accounts by count (in the profile's list, other accounts, not signed " +
"in yet), never an address, host or name; the add-ons in the profile; and the preferences that decide " +
"how its windows and notifications look. (r)",
"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() },
},
{
@@ -69,13 +97,13 @@ func tools() []stdio.Tool {
},
{
Name: "betterbird_config",
Description: "The configuration the module ships, preference by preference: the value its policy locks, " +
"its default (mesh-defaults.js, or Betterbird's own where the module keeps that), the node layer (the " +
"setting prefs on this machine), the profile's own saved value and the value in effect. It flags drift: " +
"a value the profile saved that beats the module's default without a node layer saying so. With keep " +
"(names), it proposes a node layer of the setting prefs for this machine holding the profile's values of " +
"those preferences, through the controller's settings verb, for the operator's Approve; it never writes " +
"user.js. (r; a with keep)",
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\"]"},
@@ -108,15 +136,16 @@ func tools() []stdio.Tool {
},
{
Name: "betterbird_user_js",
Description: "The module's user.js — the preferences the module declares (setting prefs), then the declared " +
"accounts' preferences under the module's own keys (setting accounts) — 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 account the profile already holds at its address is adopted under its own keys; " +
"one it does not hold is created under the module's keys, which are added to the account lists when " +
"Betterbird is not running; an ambiguous one is refused alone, with the remedy (novox/hq ADR 0317). " +
"Accounts' values are withheld in the answer. 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)"},
},
@@ -179,19 +208,20 @@ func tools() []stdio.Tool {
"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; its output " +
"read. Answers ok and each finding with what to do, never an address, host or name. (r)",
"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, by count and kind: the IMAP and POP3 accounts a " +
"declaration can hold, the accounts Owl keeps (never declared), and the rest. Never an address, a host " +
"or a name, and never anything of the login store. With propose, it builds the module's setting " +
"accounts from them (every account but Owl's, without any password or token) and proposes it through " +
"the controller's settings verb for the operator's Approve on a channel that proves who answers " +
"(novox/hq ADR 0317, ADR 0277): nothing changes until then, and the operator reads the value whole at " +
"their desk. (r; a with propose)",
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)"},
},
@@ -203,6 +233,76 @@ func tools() []stdio.Tool {
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: "OPENS WINDOWS ON THE OPERATOR'S SCREEN: tell the operator before calling it. Shows a calendar " +
@@ -13,8 +13,8 @@ import (
)
// 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 user.js the setting prefs fills and the accounts file the setting accounts fills, both
// trusted; the community package declared absent before anything is placed in /opt/betterbird; Betterbird from
// 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.
@@ -25,7 +25,7 @@ type manifest struct {
Upgrade map[string]string `json:"upgrade"`
Requires []string `json:"requires"`
Invokes []string `json:"invokes"`
Settings map[string]any `json:"settings"`
Config map[string]any `json:"config"`
Tools []string `json:"tools"`
Resources []map[string]any `json:"resources"`
Build struct {
@@ -84,7 +84,7 @@ func TestItPlacesBetterbirdWhereTheCommunityPackageDidAndOnlyAfterItIsGone(t *te
for _, r := range m.Resources {
ids = append(ids, r["id"].(string))
}
want := "configuration-dir,user-js,accounts,community-package,installation,owl,policies,spelling,preparation,start,launcher"
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)
}
@@ -102,7 +102,7 @@ func TestItPlacesBetterbirdWhereTheCommunityPackageDidAndOnlyAfterItIsGone(t *te
}
}
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)
}
}
@@ -114,29 +114,66 @@ func TestItPlacesBetterbirdWhereTheCommunityPackageDidAndOnlyAfterItIsGone(t *te
}
}
func TestTheUserJSAndTheAccountsAreTrustedFilesOfTheModulesOwn(t *testing.T) {
// ADR 0329 §4–§6, ADR 0326, issue 586, ADR 0348: every value reaches the machine as a mergeable file of the
// account's that names the keys it takes, and every one is trusted, tags too since issue 751 (the operator's
// decision); each is an object whose default is the file's own, empty, never required, since a node without the
// operator's setup still runs Betterbird; and each file's default declares nothing.
func TestTheValuesAreTrustedMergeableFilesThatNameTheirKeys(t *testing.T) {
m, _ := readManifest(t)
file := resource(t, m, "user-js")
content := file["content"].(string)
if !strings.HasPrefix(content, mark) || !strings.Contains(content, "${setting:prefs}") || file["trusted"] != true {
t.Fatalf("the user.js begins with the mark, holds the setting, and is trusted (only the terminal or the operator's warrant sets it): %v", file)
if _, said := m.Config["tags"]; !said || len(m.Config) != 7 {
t.Fatalf("the seven values are declared under config: %v", m.Config)
}
// 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 {
t.Fatal(err)
for key, raw := range m.Config {
d, _ := raw.(map[string]any)
def, _ := d["default"].(map[string]any)
if d["type"] != "object" || d["trusted"] != true || d["required"] != nil || def == nil || len(def) != 0 {
t.Errorf("%s is a trusted object whose default is empty and which is not required: %v", key, d)
}
}
acc := resource(t, m, "accounts")
if acc["path"] != "${machine:account-home}/"+accountsFile || acc["merge"] != "json" || acc["trusted"] != true ||
acc["mode"] != "0600" || acc["owner"] != "${machine:account}" {
t.Fatalf("the setting accounts reaches the machine as a trusted mergeable file of the account's (ADR 0316 §10, §15): %v", acc)
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"}, true},
}
got, err := parseAccounts([]byte(acc["content"].(string)))
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-config"], want.takes) {
t.Errorf("%s: %v", id, r)
}
var content map[string]any
if err := json.Unmarshal([]byte(templateText(t, r)), &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(templateText(t, resource(t, m, "accounts"))))
if err != nil || len(got) != 0 {
t.Fatalf("its default declares no account: %v %v", got, err)
}
if _, declared := m.Settings["accounts"]; declared {
t.Fatal("accounts is the operator's value and has no default in the definition (ADR 0262)")
prefs, err := parseDeclaredPrefs([]byte(templateText(t, resource(t, m, "prefs"))))
if err != nil || len(prefs) != 0 {
t.Fatalf("its default declares no preference: %v %v", prefs, err)
}
var c Configuration
if err := decodeInto([]byte(templateText(t, resource(t, m, "configuration"))), configurationFile, &c); err != nil {
t.Fatal(err)
}
if err := decodeInto([]byte(templateText(t, resource(t, m, "tags"))), tagsFile, &c); err != nil {
t.Fatal(err)
}
}
@@ -239,13 +276,13 @@ func TestThePolicyTurnsTheUpdaterOffAndInstallsTheMeshsOwlEverywhere(t *testing.
func TestTheStartPreparesThenStartsBetterbirdAndTheLauncherUsesIt(t *testing.T) {
m, _ := readManifest(t)
start := resource(t, m, "start")
content := start["content"].(string)
content := templateText(t, start)
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)
lc := templateText(t, launcher)
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") {
@@ -278,8 +315,10 @@ func TestTheToolsAgreeWithTheManifest(t *testing.T) {
if b["kind"] != "bundle" || b["language"] != "go" || b["from"] != "cmd/betterbird-tools" || b["binary"] != "betterbird-tools" {
t.Errorf("the Go tools bundle: %v", b)
}
// betterbird_accounts proposes the setting accounts through the controller's settings verb (ADR 0317 §9).
if !reflect.DeepEqual(m.Invokes, []string{"seat:mesh-controller.settings"}) {
t.Errorf("the module invokes the controller's settings verb and nothing else: %v", m.Invokes)
// 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) }
@@ -165,8 +165,8 @@ func (m *Machine) ReminderTest(after time.Duration, carry []string, stop bool) (
// 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, err := readBounded(m.home(declared)); err == nil && validUserJS(string(mine)) == nil {
user = string(mine) + "\n"
if mine, present, err := m.declaredText(); err == nil && present && validUserJS(mine) == nil {
user = mine + "\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 {
@@ -0,0 +1,64 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"testing"
)
// The module's directory, where its manifest and its files/ are.
const moduleDir = "../.."
// templateText is the text a file resource places on the machine: its template's, in the module's files/
// (novox/hq ADR 0349 §2). A resource that names no template, or a template that is not there, fails the test.
func templateText(t *testing.T, r map[string]any) string {
t.Helper()
name, _ := r["template"].(string)
if name == "" {
t.Fatalf("resource %v names no template", r["id"])
}
raw, err := os.ReadFile(filepath.Join(moduleDir, filepath.FromSlash(name)))
if err != nil {
t.Fatalf("resource %v names %s: %v", r["id"], name, err)
}
return string(raw)
}
// No file resource carries its text inline: each names its template in files/, and the build packs files/
// into the one archive the controller reads the templates back from (novox/hq ADR 0349 §2).
func TestEveryFileTakesItsTextFromATemplate(t *testing.T) {
raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json"))
if err != nil {
t.Fatal(err)
}
var m struct {
Resources []map[string]any `json:"resources"`
Build struct {
Artifacts []map[string]any `json:"artifacts"`
} `json:"build"`
}
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
for _, r := range m.Resources {
if _, inline := r["content"]; inline {
t.Errorf("resource %v keeps its text inline: keep it in files/ and name it as the template", r["id"])
}
if r["type"] == "file" {
templateText(t, r)
}
}
packs := 0
for _, a := range m.Build.Artifacts {
if a["kind"] == "archive" && a["from"] == "files" {
packs++
if a["name"] != "files" {
t.Errorf("the templates archive is named %v, not files", a["name"])
}
}
}
if packs != 1 {
t.Errorf("the build packs files/ into %d archives, not one", packs)
}
}
+3
View File
@@ -0,0 +1,3 @@
{
"accounts": {}
}
+10
View File
@@ -0,0 +1,10 @@
#!/bin/sh
# Betterbird's start, written by the mesh (module betterbird, novox/hq ADR 0316). Replaced at every push.
#
# Before Betterbird starts, the module's tools place its user.js in the profile (the settings prefs, accounts, identities, calendars,
# address-books and tags) and write what Betterbird rewrites itself: the account lists and each declared account's filters. They do
# it only while no Betterbird of this account runs, because Betterbird rewrites its saved preferences when it quits; a
# second start while one runs only hands over to it. What the preparation could not do it says here, and
# betterbird_check says it too. Betterbird starts either way: its configuration never holds the mail back.
/usr/local/lib/mesh-betterbird/betterbird-tools prepare || echo "betterbird: the mesh's preparation did not complete; betterbird_check says why" >&2
exec /opt/betterbird/betterbird "$@"
@@ -0,0 +1,6 @@
{
"identities": {},
"calendars": {},
"address-books": {},
"filters": {}
}
@@ -0,0 +1,25 @@
[Desktop Entry]
Version=1.0
Type=Application
Name=Betterbird
GenericName=Mail Client
Comment=Send and receive mail with Betterbird
Exec=/usr/local/bin/betterbird %u
Icon=/opt/betterbird/chrome/icons/default/default128.png
Terminal=false
Categories=Network;Email;
MimeType=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;
StartupNotify=true
StartupWMClass=eu.betterbird.Betterbird
Keywords=Email;E-mail;Calendar;Thunderbird;
Actions=ComposeMessage;OpenAddressBook;
# Written by the mesh (module betterbird, novox/hq ADR 0316 §3), in place of the community package's entry:
# it starts Betterbird through the module's start, and names the icon in the installation, with no link.
[Desktop Action ComposeMessage]
Name=Write new message
Exec=/usr/local/bin/betterbird -compose
[Desktop Action OpenAddressBook]
Name=Open address book
Exec=/usr/local/bin/betterbird -addressbook
+3
View File
@@ -0,0 +1,3 @@
{
"prefs": {}
}
+3
View File
@@ -0,0 +1,3 @@
{
"tags": {}
}
@@ -1,85 +1,20 @@
// Written by the mesh (module betterbird, novox/hq issue 345): the operator's setup of Betterbird, taken from
// the laptop's profile and approved by the operator on 2026-10-12. Betterbird reads this file at its start as
// a default: a value the profile saved in its prefs.js (a choice made in Betterbird's settings window) wins
// over it, a node's layer of the setting prefs (user.js) wins over both, and the policy's locks
// (distribution/policies.json) win over everything. betterbird_config shows the layers and the drift;
// betterbird_config_apply brings a profile back to these values. Change it in the catalogue, never here.
// 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: the
// operator's setup is Betterbird's own default there, and a value the profile saved is drift.
// --- Theme and display ---
// 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; compactness comes from
// mail.uidensity, not a theme. 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.
// 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.
// Compact density: more of the message list fits on a laptop screen (Betterbird's default is 1, normal).
pref("mail.uidensity", 0);
// 14-point interface text, the operator's choice; a larger desk screen sets its own size in a node layer.
pref("mail.uifontsize", 14);
// Message bodies are shown as their sender wrote them, not darkened.
pref("mail.dark-reader.enabled", false);
// --- Layout ---
// Vertical view: folders, the message list and the message side by side (Betterbird's default is 0, classic).
pref("mail.pane_config.dynamic", 2);
// Each row of the message list on several lines, so sender, subject and date all show in the narrow column.
pref("mail.pane_config.multiline_all", true);
// The message list as cards (Betterbird's default is 1, the table).
pref("mail.threadpane.listview", 0);
// Threads grouped in every folder by default (Betterbird's default is 0, unthreaded).
pref("mailnews.default_view_flags", 1);
// --- Mail handling and compose ---
// No confirmation before Shift+Delete removes a message for good.
pref("mail.warn_on_shift_delete", false);
// 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.
// --- Notifications ---
// No pop-up for new mail: the operator reads mail when they choose to.
pref("mail.biff.show_alert", false);
// No sound for new mail.
pref("mail.biff.play_sound", false);
// No tray icon for new mail.
pref("mail.biff.show_tray_icon", false);
// If a node's layer turns alerts back on, they go through the desktop's notification daemon.
pref("mail.biff.use_system_alert", true);
// --- Calendar ---
// The calendar's working day starts at 09:00 (Betterbird's default is 08:00).
pref("calendar.view.daystarthour", 9);
// and ends at 20:00 (Betterbird's default is 17:00).
pref("calendar.view.dayendhour", 20);
// A calendar item is deleted without asking.
pref("calendar.item.promptDelete", false);
// The calendar follows the node's time zone, because the laptop travels.
pref("calendar.timezone.useSystemTimezone", true);
// Reminders show in their window (Betterbird's default, made explicit: the reminder is why this module exists).
pref("calendar.alarms.show", true);
// Reminders missed while the laptop slept show at the next start (Betterbird's default is false).
pref("calendar.alarms.showmissed", true);
// --- Chat ---
// No chat button: the operator uses no chat account in Betterbird (Betterbird's default is true).
pref("mail.chat.enabled", false);
// --- Privacy ---
// No remote content in a message unless its sender is allowed (Betterbird's default, made explicit).
pref("mailnews.message_display.disable_remote_image", true);
// Ask sites not to track. A default only: the policy cannot lock a privacy. preference.
pref("privacy.donottrackheader.enabled", true);

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