Compare commits

..
Author SHA1 Message Date
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 delivered: every member is delivered
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 delivered: every member is delivered
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 delivered: every member is delivered
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 delivered: every member is delivered
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 delivered: every member is delivered
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 delivered: every member is delivered
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 delivered: every member is delivered
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 delivered: every member is delivered
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 delivered: every member is delivered
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
jschoubben 12a5e7be73 build-agent: name mesh-builder by its path in busy, as its health does (review of #260)
mesh/merge-gate head 12a5e7be7321 merged into trunk 23a2e1e20467 — fail: builds build-agent → ace, g14, novox, shanks; no bus step; head 12a5e7be7321 does …
mesh/repo-check head 12a5e7be7321 merged into trunk 23a2e1e20467 — fail: head 12a5e7be7321 does not merge cleanly into trunk 23a2e1e20467: in conflict modu…
mesh/delivery superseded: a newer head of the same pull request
2026-10-14 00:07:42 +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 delivered: every member is delivered
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 delivered: every member is delivered
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
jschoubben 1906081937 build-agent: declare busy, so a replaced agent finishes the check it runs
mesh/delivery-group group feat/335-a-busy-container-is-replaced-when-idle rejected: its order contradicts itself: novox/mesh-catalog@190608193764, novox/mesh-c…
mesh/merge-gate out of date: judged against trunk 8f80cd471634, the trunk is now 39e7a06e6293; judged again on a recheck or when it is merged
mesh/repo-check out of date: judged against trunk 8f80cd471634, the trunk is now 39e7a06e6293; judged again on a recheck or when it is merged
mesh/delivery stopped: the pull request closed unmerged
Every merge into mesh-controller replaced the agents and the queue started
their checks again elsewhere (issue 594). Its server now declares busy as
mesh-builder busy (novox/hq ADR 0335 §5).
2026-10-13 00:53:40 +02:00
869 changed files with 67258 additions and 2661 deletions
+5
View File
@@ -21,3 +21,8 @@ modules/mesh-knowledge/cmd/mesh-knowledge/mesh-knowledge
modules/mesh-knowledge/mesh-knowledge modules/mesh-knowledge/mesh-knowledge
modules/mesh-reads/mesh-reads modules/mesh-reads/mesh-reads
modules/mesh-reads/cmd/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/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)
}
}
+11 -1
View File
@@ -7,7 +7,8 @@
// until somebody removed it by hand. The controller refuses a setting of a key the file keeps as `protected`, // 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 // 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 // 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. // 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 // 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` // be protected alone today: the controller protects top-level keys. Such a file is named in the file `known`
@@ -64,6 +65,15 @@ func Check(root string) ([]Finding, error) {
} }
id, _ := r["id"].(string) id, _ := r["id"].(string)
content, _ := r["content"].(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 var doc map[string]any
if strings.TrimSpace(content) == "" { if strings.TrimSpace(content) == "" {
continue continue
+29
View File
@@ -51,6 +51,35 @@ func TestASecretASettingCouldReplaceIsRefused(t *testing.T) {
} }
} }
// 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) { func TestTheKnownListOnlyShrinks(t *testing.T) {
found := []Finding{{"b", "cfg", "server.secret_key"}, {"a", "cfg", "password"}} 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}) refused, stale := Judge(found, map[string]bool{"b cfg server.secret_key": true, "gone cfg token": true})
+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 @@
66 61
The long-running resources of this catalogue that do not say how they are ready (`health`, novox/hq ADR 0240 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 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 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 # 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. # /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 set -eu
work=$(mktemp -d)
trap 'rm -rf "$work"' EXIT
deps="${TOOLCHAIN_MODULES:-/app/node_modules}" 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" tsc="$deps/typescript/bin/tsc"
if [ ! -f "$tsc" ]; then if [ ! -f "$tsc" ]; then
echo "NOT CHECKED: no TypeScript compiler at $tsc — this part runs in the TypeScript toolchain" 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' printf '%s\n' "${MESH_CHECK_CHANGED:-}" | tr ',' '\n' | sed -n 's#^modules/\([^/]*\)/.*#\1#p'
} | sed '/^$/d' | sort -u) } | sed '/^$/d' | sort -u)
work=$(mktemp -d)
trap 'rm -rf "$work"' EXIT
failed="" failed=""
for m in $touched; do for m in $touched; do
d="modules/$m" d="modules/$m"
+50
View File
@@ -33,11 +33,25 @@
# 6. the secrets (novox/hq issue 342): no mergeable file fills a key with a vault secret that a setting could # 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 # 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. # 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.
set -eu set -eu
(cd checks/words && go test -count=1 . && go run . ../..) (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. # 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 . ../..) (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 . ../..)
# The secrets (novox/hq issue 342): a password is not a setting, so no setting can replace the vault's. The # 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. # 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_base=""
@@ -51,6 +65,42 @@ 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" 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 fi
(cd checks/secrets && go test -count=1 . && if [ -n "$secrets_base" ]; then go run . -base "$secrets_base" ../..; else go run . ../..; 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 # 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. # judged against (MESH_CHECK_BASE, main when unset), read from the clone the check runs in.
base_lists="" base_lists=""
+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 | | 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/gtk-4.0/settings.ini` | owned | the same file |
| `~/.config/qt6ct/qt6ct.conf` | owned | [`config/qt6ct.conf`](config/qt6ct.conf) | | `~/.config/qt6ct/qt6ct.conf` | owned | [`files/qt6ct.conf`](files/qt6ct.conf) |
| `~/.config/xdg-desktop-portal/portals.conf` | owned | [`config/portals.conf`](config/portals.conf) | | `~/.config/xdg-desktop-portal/portals.conf` | owned | [`files/portals.conf`](files/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 | | `~/.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, **`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 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 { Environment struct {
Variables map[string]string `json:"variables"` Variables map[string]string `json:"variables"`
} `json:"environment"` } `json:"environment"`
Items []struct {
Collection string `json:"collection"`
Attributes map[string]string `json:"attributes"`
Template string `json:"template"`
} `json:"items"`
Shell []struct { Shell []struct {
For, Slot, Code string For, Slot, Code string
} `json:"shell"` } `json:"shell"`
Resources []map[string]any `json:"resources"` Resources []map[string]any `json:"resources"`
Build struct {
Artifacts []map[string]any `json:"artifacts"`
} `json:"build"`
} }
func readManifest(t *testing.T) manifest { func readManifest(t *testing.T) manifest {
@@ -33,6 +41,23 @@ func readManifest(t *testing.T) manifest {
if err := json.Unmarshal(raw, &m); err != nil { if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err) 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 return m
} }
@@ -96,9 +121,10 @@ func TestItOwnsTheFilesItsSourcesHoldAndNoQt5Duplicate(t *testing.T) {
pkgs = append(pkgs, r["package"].(string)) pkgs = append(pkgs, r["package"].(string))
continue continue
} }
raw, _ := os.ReadFile(filepath.Join("..", "..", "config", sources[id])) _, inline := r["content"]
if r["content"] != string(raw) || r["into"] != nil || r["owner"] != "${machine:account}" { if _, err := os.Stat(filepath.Join("..", "..", "files", sources[id])); err != nil || inline ||
t.Errorf("%s is not config/%s, whole and the account's", id, sources[id]) 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") { if strings.Contains(r["path"].(string), "qt5ct") {
t.Error("qt5ct: both workstations run Qt 6 programs through qt6ct; a second tool is a duplicate") 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" { if strings.Join(pkgs, ",") != "gnome-themes-extra,adwaita-icon-theme,adwaita-cursors,qt6ct,xdg-desktop-portal-gtk" {
t.Fatalf("%v", pkgs) 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") { if qt["Appearance"]["style"] != "Fusion" || qt["Appearance"]["custom_palette"] != "true" || !strings.Contains(qt["Fonts"]["general"], "Inter,11") {
t.Fatalf("%v", qt) t.Fatalf("%v", qt)
} }
@@ -116,7 +142,7 @@ func TestItOwnsTheFilesItsSourcesHoldAndNoQt5Duplicate(t *testing.T) {
t.Fatalf("qt6ct ships no %s", qt["Appearance"]["color_scheme_path"]) 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" { if gtk["gtk-theme-name"] != "Adwaita" || gtk["gtk-application-prefer-dark-theme"] != "1" || gtk["gtk-font-name"] != "Inter 11" {
t.Fatalf("%v", gtk) t.Fatalf("%v", gtk)
} }
@@ -244,3 +270,31 @@ func asJSON(v any) string {
b, _ := json.Marshal(v) b, _ := json.Marshal(v)
return string(b) 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", "module": "adwaita",
"version": "1", "version": "1",
"moved": true,
"capabilities": [ "capabilities": [
"package-manager" "package-manager"
], ],
@@ -10,27 +11,79 @@
"adwaita_icons", "adwaita_icons",
"adwaita_portal_check" "adwaita_portal_check"
], ],
"environment": { "items": [
"variables": {
"GTK_THEME": "Adwaita:dark",
"GTK2_RC_FILES": "/usr/share/themes/Adwaita-dark/gtk-2.0/gtkrc",
"QT_QPA_PLATFORMTHEME": "qt6ct",
"QT_STYLE_OVERRIDE": "Fusion",
"QT_SELECT": "6",
"XCURSOR_THEME": "Adwaita",
"XCURSOR_SIZE": "24"
}
},
"shell": [
{ {
"for": "xresources", "collection": "environment",
"slot": "normal", "name": "gtk-theme",
"code": "! adwaita: the cursor, for X programs that take it from the resources.\nXcursor.theme: Adwaita\nXcursor.size: 24\n" "attributes": {
"kind": "variable",
"name": "GTK_THEME"
},
"template": "files/env-gtk-theme"
}, },
{ {
"for": "xinitrc", "collection": "environment",
"slot": "normal", "name": "gtk2-rc-files",
"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" "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": [ "resources": [
@@ -65,7 +118,7 @@
"path": "${machine:account-home}/.config/gtk-3.0/settings.ini", "path": "${machine:account-home}/.config/gtk-3.0/settings.ini",
"owner": "${machine:account}", "owner": "${machine:account}",
"mode": "0644", "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", "id": "gtk4",
@@ -73,7 +126,7 @@
"path": "${machine:account-home}/.config/gtk-4.0/settings.ini", "path": "${machine:account-home}/.config/gtk-4.0/settings.ini",
"owner": "${machine:account}", "owner": "${machine:account}",
"mode": "0644", "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", "id": "qt6ct",
@@ -81,7 +134,7 @@
"path": "${machine:account-home}/.config/qt6ct/qt6ct.conf", "path": "${machine:account-home}/.config/qt6ct/qt6ct.conf",
"owner": "${machine:account}", "owner": "${machine:account}",
"mode": "0644", "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", "id": "portals",
@@ -89,7 +142,7 @@
"path": "${machine:account-home}/.config/xdg-desktop-portal/portals.conf", "path": "${machine:account-home}/.config/xdg-desktop-portal/portals.conf",
"owner": "${machine:account}", "owner": "${machine:account}",
"mode": "0644", "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", "id": "cursor",
@@ -97,7 +150,7 @@
"path": "${machine:account-home}/.icons/default/index.theme", "path": "${machine:account-home}/.icons/default/index.theme",
"owner": "${machine:account}", "owner": "${machine:account}",
"mode": "0644", "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": { "build": {
@@ -112,6 +165,11 @@
"loads": [ "loads": [
"adwaita-tools" "adwaita-tools"
] ]
},
{
"name": "files",
"kind": "archive",
"from": "files"
} }
] ]
} }
@@ -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}
+14 -8
View File
@@ -13,13 +13,14 @@
"artifact_store_references", "artifact_store_references",
"artifact_store_collect" "artifact_store_collect"
], ],
"settings": { "config": {
"older-store-names": { "older-store-names": {
"kind": "preference", "type": "string",
"default": "none", "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).",
"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)" "pattern": "none|[A-Za-z0-9.:-]+( [A-Za-z0-9.:-]+)*",
} "default": "none"
}, }
},
"resources": [ "resources": [
{ {
"id": "state", "id": "state",
@@ -33,7 +34,7 @@
"path": "${dir:state}/store-names", "path": "${dir:state}/store-names",
"mode": "0600", "mode": "0600",
"trusted": false, "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": { "build": {
@@ -52,6 +53,11 @@
"MESH_ARTIFACT_STORE_CONTAINER": "mesh-registry", "MESH_ARTIFACT_STORE_CONTAINER": "mesh-registry",
"MESH_ARTIFACT_STORE_NAMES_FILE": "${dir:state}/store-names" "MESH_ARTIFACT_STORE_NAMES_FILE": "${dir:state}/store-names"
} }
},
{
"name": "files",
"kind": "archive",
"from": "files"
} }
] ]
} }
@@ -34,11 +34,9 @@ func TestKeysListsTriggersI3LinesAndFirmwareKeys(t *testing.T) {
home := "/home/operator" home := "/home/operator"
t.Setenv("MESH_OPERATOR_HOME", home) t.Setenv("MESH_OPERATOR_HOME", home)
// The i3 module's file as the controller composes it: other modules' lines around this one's. // The i3 module's file as the controller composes it: other modules' lines around this one's.
var own string own := m.i3Lines()
for _, c := range m.Contributions { if own == "" || len(m.Contributions) != 0 {
if c.Seat == "node-display-session" { t.Fatalf("the lines for the window manager are one item, and no contribution is left: %q %v", own, m.Contributions)
own += c.Content
}
} }
put(t, f.root, home+"/"+I3Config, "set $mod Mod4\n# rofi\nbindsym $mod+d exec rofi\n# "+ModuleName+"\n"+own+ 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") "# triggerhappy-not-here\nbindsym $mod+Shift+z exec other\n#########################################\ninclude x\n")
@@ -15,19 +15,41 @@ type manifest struct {
Tools []string `json:"tools"` Tools []string `json:"tools"`
Emits []string `json:"emits"` Emits []string `json:"emits"`
Resources []map[string]any `json:"resources"` Resources []map[string]any `json:"resources"`
// Contributions to other modules' seats (novox/hq ADR 0212): the vendor keys go to node-hotkeys. // Items of collections (novox/hq ADR 0349 clause 13): the vendor keys are an item of the node-hotkeys
Contributions []struct { // triggers, its text the template file beside the manifest.
Seat string `json:"seat"` Items []struct {
Kind string `json:"kind"` Collection string `json:"collection"`
Content string `json:"content"` Template string `json:"template"`
} `json:"contributions"` } `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 { func (m manifest) triggers() string {
for _, c := range m.Contributions { for _, it := range m.Items {
if c.Seat == "node-hotkeys" && c.Kind == "trigger" { if it.Collection == "contribution:node-hotkeys:trigger" {
return c.Content raw, err := os.ReadFile(filepath.Join("..", "..", it.Template))
if err != nil {
return ""
}
return string(raw)
} }
} }
return "" 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)
}
}
+32 -34
View File
@@ -1,6 +1,8 @@
{ {
"module": "asus-zephyrus-g14", "module": "asus-zephyrus-g14",
"version": "1", "version": "1",
"moved": true,
"templates": "templates",
"capabilities": [ "capabilities": [
"package-manager", "package-manager",
"service-manager" "service-manager"
@@ -59,14 +61,14 @@
"type": "file", "type": "file",
"path": "/etc/modprobe.d/g14-nvidia-power.conf", "path": "/etc/modprobe.d/g14-nvidia-power.conf",
"mode": "0644", "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", "id": "video-options",
"type": "file", "type": "file",
"path": "/etc/modprobe.d/video-brightness-switch.conf", "path": "/etc/modprobe.d/video-brightness-switch.conf",
"mode": "0644", "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", "id": "suspend-drop-ins",
@@ -79,7 +81,7 @@
"type": "file", "type": "file",
"path": "/etc/systemd/system/systemd-suspend.service.d/asus-zephyrus-g14-nvidia.conf", "path": "/etc/systemd/system/systemd-suspend.service.d/asus-zephyrus-g14-nvidia.conf",
"mode": "0644", "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", "id": "hibernate-drop-ins",
@@ -92,7 +94,7 @@
"type": "file", "type": "file",
"path": "/etc/systemd/system/systemd-hibernate.service.d/asus-zephyrus-g14-nvidia.conf", "path": "/etc/systemd/system/systemd-hibernate.service.d/asus-zephyrus-g14-nvidia.conf",
"mode": "0644", "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", "id": "suspend-then-hibernate-drop-ins",
@@ -105,7 +107,7 @@
"type": "file", "type": "file",
"path": "/etc/systemd/system/systemd-suspend-then-hibernate.service.d/asus-zephyrus-g14-nvidia.conf", "path": "/etc/systemd/system/systemd-suspend-then-hibernate.service.d/asus-zephyrus-g14-nvidia.conf",
"mode": "0644", "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", "id": "powerd-drop-ins",
@@ -118,14 +120,14 @@
"type": "file", "type": "file",
"path": "/etc/systemd/system/nvidia-powerd.service.d/asus-zephyrus-g14.conf", "path": "/etc/systemd/system/nvidia-powerd.service.d/asus-zephyrus-g14.conf",
"mode": "0644", "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", "id": "backlight-rule",
"type": "file", "type": "file",
"path": "/etc/udev/rules.d/90-backlight.rules", "path": "/etc/udev/rules.d/90-backlight.rules",
"mode": "0644", "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", "id": "udev",
@@ -151,7 +153,7 @@
"type": "file", "type": "file",
"path": "/etc/UPower/UPower.conf.d/50-asus-zephyrus-g14.conf", "path": "/etc/UPower/UPower.conf.d/50-asus-zephyrus-g14.conf",
"mode": "0644", "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", "id": "upower",
@@ -174,7 +176,22 @@
"type": "file", "type": "file",
"path": "/etc/X11/xorg.conf.d/30-asus-zephyrus-g14-touchpad.conf", "path": "/etc/X11/xorg.conf.d/30-asus-zephyrus-g14-touchpad.conf",
"mode": "0644", "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": { "build": {
@@ -194,31 +211,12 @@
"name": "scripts", "name": "scripts",
"kind": "archive", "kind": "archive",
"from": "files" "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"
+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}
+28 -2
View File
@@ -46,6 +46,26 @@
} }
] ]
}, },
"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": [ "resources": [
{ {
"id": "mesh-state", "id": "mesh-state",
@@ -70,7 +90,7 @@
"type": "file", "type": "file",
"path": "${dir:state}/server.env", "path": "${dir:state}/server.env",
"mode": "0600", "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", "id": "net",
@@ -102,8 +122,9 @@
"type": "file", "type": "file",
"path": "${dir:mesh-state}/config.json", "path": "${dir:mesh-state}/config.json",
"mode": "0600", "mode": "0600",
"content": "{\n \"password\": \"${secret:admin}\",\n \"host\": \"${bound:route:name}\"\n}\n", "template": "files/config.json",
"merge": "json", "merge": "json",
"takes-config": ["host", "email", "url"],
"protected": ["password"] "protected": ["password"]
} }
], ],
@@ -123,6 +144,11 @@
"MESH_BASEROW_URL": "http://127.0.0.1:${port:80}", "MESH_BASEROW_URL": "http://127.0.0.1:${port:80}",
"MESH_BASEROW_CONFIG_FILE": "${dir:mesh-state}/config.json" "MESH_BASEROW_CONFIG_FILE": "${dir:mesh-state}/config.json"
} }
},
{
"name": "files",
"kind": "archive",
"from": "files"
} }
] ]
} }
+1 -1
View File
@@ -208,7 +208,7 @@ node set up from nothing, the declared accounts are written at the second start
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 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 that node). `identities`, `calendars`, `address-books` and `filters` reach the machine as
`~/.config/betterbird/configuration.json`, trusted as `accounts` is; `tags` as `tags.json`, not trusted (§5). `~/.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 **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. user part carries a password (§2). A setting holds no line break, so a signature is a list of its lines.
@@ -7,7 +7,8 @@ package main
// //
// - ~/.config/betterbird/configuration.json: identities, calendars, address-books and filters, trusted as // - ~/.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; // accounts is (§5), because each names where a password, a reply or forwarded mail goes;
// - ~/.config/betterbird/tags.json: tags, which names no server and sends no mail, and is not trusted. // - ~/.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 // 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 // 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
@@ -25,7 +25,7 @@ type manifest struct {
Upgrade map[string]string `json:"upgrade"` Upgrade map[string]string `json:"upgrade"`
Requires []string `json:"requires"` Requires []string `json:"requires"`
Invokes []string `json:"invokes"` Invokes []string `json:"invokes"`
Settings map[string]any `json:"settings"` Config map[string]any `json:"config"`
Tools []string `json:"tools"` Tools []string `json:"tools"`
Resources []map[string]any `json:"resources"` Resources []map[string]any `json:"resources"`
Build struct { Build struct {
@@ -114,13 +114,21 @@ func TestItPlacesBetterbirdWhereTheCommunityPackageDidAndOnlyAfterItIsGone(t *te
} }
} }
// ADR 0329 §4–§6, ADR 0326, issue 586: every setting reaches the machine as a mergeable file of the account's that // ADR 0329 §4–§6, ADR 0326, issue 586, ADR 0348: every value reaches the machine as a mergeable file of the
// names the keys it takes; four kinds and accounts and prefs are trusted, tags is not; no setting has a default in the // account's that names the keys it takes, and every one is trusted, tags too since issue 751 (the operator's
// definition, since each is the operator's own value; and each file's default declares nothing. // decision); each is an object whose default is the file's own, empty, never required, since a node without the
func TestTheSettingsAreMergeableFilesThatNameTheirKeysAndTagsAloneIsUntrusted(t *testing.T) { // operator's setup still runs Betterbird; and each file's default declares nothing.
func TestTheValuesAreTrustedMergeableFilesThatNameTheirKeys(t *testing.T) {
m, _ := readManifest(t) m, _ := readManifest(t)
if len(m.Settings) != 0 { if _, said := m.Config["tags"]; !said || len(m.Config) != 7 {
t.Fatalf("no setting has a default in the definition: the operator's setup is the settings' mesh-wide layer (issue 586, ADR 0262): %v", m.Settings) t.Fatalf("the seven values are declared under config: %v", m.Config)
}
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)
}
} }
files := map[string]struct { files := map[string]struct {
path, mode string path, mode string
@@ -130,7 +138,7 @@ func TestTheSettingsAreMergeableFilesThatNameTheirKeysAndTagsAloneIsUntrusted(t
"prefs": {prefsFile, "0644", []any{"prefs"}, true}, "prefs": {prefsFile, "0644", []any{"prefs"}, true},
"accounts": {accountsFile, "0600", []any{"accounts"}, true}, "accounts": {accountsFile, "0600", []any{"accounts"}, true},
"configuration": {configurationFile, "0600", []any{"identities", "calendars", "address-books", "filters"}, true}, "configuration": {configurationFile, "0600", []any{"identities", "calendars", "address-books", "filters"}, true},
"tags": {tagsFile, "0644", []any{"tags"}, false}, "tags": {tagsFile, "0644", []any{"tags"}, true},
} }
merging := 0 merging := 0
for _, r := range m.Resources { for _, r := range m.Resources {
@@ -144,27 +152,27 @@ func TestTheSettingsAreMergeableFilesThatNameTheirKeysAndTagsAloneIsUntrusted(t
for id, want := range files { for id, want := range files {
r := resource(t, m, id) r := resource(t, m, id)
if r["path"] != "${machine:account-home}/"+want.path || r["merge"] != "json" || r["trusted"] != want.trusted || if r["path"] != "${machine:account-home}/"+want.path || r["merge"] != "json" || r["trusted"] != want.trusted ||
r["mode"] != want.mode || r["owner"] != "${machine:account}" || !reflect.DeepEqual(r["takes-settings"], want.takes) { r["mode"] != want.mode || r["owner"] != "${machine:account}" || !reflect.DeepEqual(r["takes-config"], want.takes) {
t.Errorf("%s: %v", id, r) t.Errorf("%s: %v", id, r)
} }
var content map[string]any var content map[string]any
if err := json.Unmarshal([]byte(r["content"].(string)), &content); err != nil || len(content) != len(want.takes) { 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) t.Errorf("%s's default holds each key it takes, empty: %v %v", id, content, err)
} }
} }
got, err := parseAccounts([]byte(resource(t, m, "accounts")["content"].(string))) got, err := parseAccounts([]byte(templateText(t, resource(t, m, "accounts"))))
if err != nil || len(got) != 0 { if err != nil || len(got) != 0 {
t.Fatalf("its default declares no account: %v %v", got, err) t.Fatalf("its default declares no account: %v %v", got, err)
} }
prefs, err := parseDeclaredPrefs([]byte(resource(t, m, "prefs")["content"].(string))) prefs, err := parseDeclaredPrefs([]byte(templateText(t, resource(t, m, "prefs"))))
if err != nil || len(prefs) != 0 { if err != nil || len(prefs) != 0 {
t.Fatalf("its default declares no preference: %v %v", prefs, err) t.Fatalf("its default declares no preference: %v %v", prefs, err)
} }
var c Configuration var c Configuration
if err := decodeInto([]byte(resource(t, m, "configuration")["content"].(string)), configurationFile, &c); err != nil { if err := decodeInto([]byte(templateText(t, resource(t, m, "configuration"))), configurationFile, &c); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := decodeInto([]byte(resource(t, m, "tags")["content"].(string)), tagsFile, &c); err != nil { if err := decodeInto([]byte(templateText(t, resource(t, m, "tags"))), tagsFile, &c); err != nil {
t.Fatal(err) t.Fatal(err)
} }
} }
@@ -268,13 +276,13 @@ func TestThePolicyTurnsTheUpdaterOffAndInstallsTheMeshsOwlEverywhere(t *testing.
func TestTheStartPreparesThenStartsBetterbirdAndTheLauncherUsesIt(t *testing.T) { func TestTheStartPreparesThenStartsBetterbirdAndTheLauncherUsesIt(t *testing.T) {
m, _ := readManifest(t) m, _ := readManifest(t)
start := resource(t, m, "start") start := resource(t, m, "start")
content := start["content"].(string) content := templateText(t, start)
if start["path"] != "/usr/local/bin/betterbird" || start["mode"] != "0755" || if start["path"] != "/usr/local/bin/betterbird" || start["mode"] != "0755" ||
!strings.Contains(content, "\n"+preparation+" prepare ||") || !strings.HasSuffix(content, "\nexec "+startBin+" \"$@\"\n") { !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) 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") launcher := resource(t, m, "launcher")
lc := launcher["content"].(string) lc := templateText(t, launcher)
if launcher["path"] != "/usr/local/share/applications/eu.betterbird.Betterbird.desktop" || 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, "\nExec=/usr/local/bin/betterbird %u\n") || !strings.Contains(lc, "\nIcon="+installDir+"/") ||
!strings.Contains(lc, "\nStartupWMClass=eu.betterbird.Betterbird\n") { !strings.Contains(lc, "\nStartupWMClass=eu.betterbird.Betterbird\n") {
@@ -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": {}
}
+60 -11
View File
@@ -33,6 +33,50 @@
"betterbird_drift", "betterbird_drift",
"betterbird_reminder_test" "betterbird_reminder_test"
], ],
"config": {
"prefs": {
"type": "object",
"description": "Betterbird's preferences the operator sets, by their own names (mailnews.*, calendar.* and the like), laid into prefs.json; an account's, a server's and a tag's are refused, as accounts, the four kinds and tags own them (novox/hq ADR 0329 §6).",
"trusted": true,
"default": {}
},
"accounts": {
"type": "object",
"description": "The mail accounts Betterbird is set up with, each by a name of the operator's: its address, its sender's name and its incoming and outgoing servers; no password, token or cookie (novox/hq ADR 0329 §2).",
"trusted": true,
"default": {}
},
"identities": {
"type": "object",
"description": "The identities beside an account's own, each by a name of the operator's: the address and name it sends as, its signature as a list of lines, and the profile's key to adopt on a node.",
"trusted": true,
"default": {}
},
"calendars": {
"type": "object",
"description": "The calendars Betterbird shows, each by a name of the operator's: where it is and how it is reached, and the profile's key to adopt on a node.",
"trusted": true,
"default": {}
},
"address-books": {
"type": "object",
"description": "The address books Betterbird uses, each by a name of the operator's: where it is and how it is reached, and the profile's key to adopt on a node.",
"trusted": true,
"default": {}
},
"filters": {
"type": "object",
"description": "The message filters of each account, by account: what each matches and what it does with the mail.",
"trusted": true,
"default": {}
},
"tags": {
"type": "object",
"description": "The tags Betterbird offers, each by its own key: its name, colour and order; trusted since issue 751, so a change to it is the operator's to approve.",
"trusted": true,
"default": {}
}
},
"resources": [ "resources": [
{ {
"id": "configuration-dir", "id": "configuration-dir",
@@ -48,10 +92,10 @@
"owner": "${machine:account}", "owner": "${machine:account}",
"mode": "0644", "mode": "0644",
"merge": "json", "merge": "json",
"takes-settings": [ "takes-config": [
"prefs" "prefs"
], ],
"content": "{\n \"prefs\": {}\n}\n", "template": "files/prefs.json",
"trusted": true "trusted": true
}, },
{ {
@@ -61,10 +105,10 @@
"owner": "${machine:account}", "owner": "${machine:account}",
"mode": "0600", "mode": "0600",
"merge": "json", "merge": "json",
"takes-settings": [ "takes-config": [
"accounts" "accounts"
], ],
"content": "{\n \"accounts\": {}\n}\n", "template": "files/accounts.json",
"trusted": true "trusted": true
}, },
{ {
@@ -74,13 +118,13 @@
"owner": "${machine:account}", "owner": "${machine:account}",
"mode": "0600", "mode": "0600",
"merge": "json", "merge": "json",
"takes-settings": [ "takes-config": [
"identities", "identities",
"calendars", "calendars",
"address-books", "address-books",
"filters" "filters"
], ],
"content": "{\n \"identities\": {},\n \"calendars\": {},\n \"address-books\": {},\n \"filters\": {}\n}\n", "template": "files/configuration.json",
"trusted": true "trusted": true
}, },
{ {
@@ -90,11 +134,11 @@
"owner": "${machine:account}", "owner": "${machine:account}",
"mode": "0644", "mode": "0644",
"merge": "json", "merge": "json",
"takes-settings": [ "takes-config": [
"tags" "tags"
], ],
"content": "{\n \"tags\": {}\n}\n", "template": "files/tags.json",
"trusted": false "trusted": true
}, },
{ {
"id": "community-package", "id": "community-package",
@@ -137,14 +181,14 @@
"type": "file", "type": "file",
"path": "/usr/local/bin/betterbird", "path": "/usr/local/bin/betterbird",
"mode": "0755", "mode": "0755",
"content": "#!/bin/sh\n# Betterbird's start, written by the mesh (module betterbird, novox/hq ADR 0316). Replaced at every push.\n#\n# Before Betterbird starts, the module's tools place its user.js in the profile (the settings prefs, accounts, identities, calendars,\n# address-books and tags) and write what Betterbird rewrites itself: the account lists and each declared account's filters. They do\n# it only while no Betterbird of this account runs, because Betterbird rewrites its saved preferences when it quits; a\n# second start while one runs only hands over to it. What the preparation could not do it says here, and\n# betterbird_check says it too. Betterbird starts either way: its configuration never holds the mail back.\n/usr/local/lib/mesh-betterbird/betterbird-tools prepare || echo \"betterbird: the mesh's preparation did not complete; betterbird_check says why\" >&2\nexec /opt/betterbird/betterbird \"$@\"\n" "template": "files/betterbird"
}, },
{ {
"id": "launcher", "id": "launcher",
"type": "file", "type": "file",
"path": "/usr/local/share/applications/eu.betterbird.Betterbird.desktop", "path": "/usr/local/share/applications/eu.betterbird.Betterbird.desktop",
"mode": "0644", "mode": "0644",
"content": "[Desktop Entry]\nVersion=1.0\nType=Application\nName=Betterbird\nGenericName=Mail Client\nComment=Send and receive mail with Betterbird\nExec=/usr/local/bin/betterbird %u\nIcon=/opt/betterbird/chrome/icons/default/default128.png\nTerminal=false\nCategories=Network;Email;\nMimeType=message/rfc822;x-scheme-handler/mailto;application/x-xpinstall;application/x-extension-ics;text/calendar;text/vcard;text/x-vcard;x-scheme-handler/webcal;x-scheme-handler/webcals;x-scheme-handler/mid;\nStartupNotify=true\nStartupWMClass=eu.betterbird.Betterbird\nKeywords=Email;E-mail;Calendar;Thunderbird;\nActions=ComposeMessage;OpenAddressBook;\n# Written by the mesh (module betterbird, novox/hq ADR 0316 §3), in place of the community package's entry:\n# it starts Betterbird through the module's start, and names the icon in the installation, with no link.\n\n[Desktop Action ComposeMessage]\nName=Write new message\nExec=/usr/local/bin/betterbird -compose\n\n[Desktop Action OpenAddressBook]\nName=Open address book\nExec=/usr/local/bin/betterbird -addressbook\n" "template": "files/eu.betterbird.Betterbird.desktop"
} }
], ],
"build": { "build": {
@@ -191,6 +235,11 @@
"name": "defaults", "name": "defaults",
"kind": "archive", "kind": "archive",
"from": "install/defaults-pref" "from": "install/defaults-pref"
},
{
"name": "files",
"kind": "archive",
"from": "files"
} }
] ]
} }
+1 -1
View File
@@ -14,7 +14,7 @@ WORKDIR /src
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -o /mesh-builder ./cmd/mesh-builder RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -buildvcs=false -ldflags=-buildid= -o /mesh-builder ./cmd/mesh-builder
# Unlike mesh-controller's own FROM scratch (ADR 0006: nothing to audit but one binary), the build # Unlike mesh-controller's own FROM scratch (ADR 0006: nothing to audit but one binary), the build
# machine's whole job is shelling out to git and docker — it needs a real userland to do that in, # machine's whole job is shelling out to git and docker — it needs a real userland to do that in,
+5
View File
@@ -13,6 +13,11 @@ that repository's main (`build.artifacts[].context`); this module ships the pack
Assign it to every machine with a container runtime. It replaces `builder`, the one-holder form of the Assign it to every machine with a container runtime. It replaces `builder`, the one-holder form of the
same thing; retire that once this is assigned where it was. same thing; retire that once this is assigned where it was.
A send that replaces the agent's container does not throw away the build it runs: the container declares
`busy` as `/usr/local/bin/mesh-builder busy` (novox/hq ADR 0335). The node-engine asks it before replacing it; from that
ask the agent takes no new build, the node-engine leaves it running while it says a build is in hand, and
replaces it once it says idle, or after 15 minutes at the latest.
## The package registry (novox/hq ADR 0336) ## The package registry (novox/hq ADR 0336)
The package registry (`npm-package-registry`, provided by the gitea module) is an optional requirement. The package registry (`npm-package-registry`, provided by the gitea module) is an optional requirement.
@@ -0,0 +1,8 @@
MESH_BROKER_FILE=/run/mesh/broker
MESH_NODE=${machine:name}
MESH_REGISTRY=${bound:artifact-store:at}:${bound:artifact-store:port}
MESH_PACKAGE_BINDING=/run/mesh/package-registry.json
MESH_NPM_TOKEN_FILE=/run/mesh/package-registry.secret
MESH_GENESIS_PACKAGE_REGISTRY=/run/mesh/genesis-package-registry.json
MESH_WORKSPACE=${dir:workspace}
MESH_JUDGE_CACHE=${dir:judge-cache}
+59 -2
View File
@@ -75,7 +75,10 @@ func TestTheRegistrysBindsAndSecretsAreOnlyWhenBound(t *testing.T) {
t.Fatalf("the agent is not told where the registry is once it binds: binds %v, secrets %v", m.Binds, m.Secrets) t.Fatalf("the agent is not told where the registry is once it binds: binds %v, secrets %v", m.Binds, m.Secrets)
} }
for _, r := range m.Resources { for _, r := range m.Resources {
content, _ := r["content"].(string) if r["type"] != "file" {
continue
}
content := templateText(t, r)
if strings.Contains(content, "${bound:npm-package-registry:") || strings.Contains(content, "${secret:npm-package-registry}") { if strings.Contains(content, "${bound:npm-package-registry:") || strings.Contains(content, "${secret:npm-package-registry}") {
if r["only-when-bound"] != "npm-package-registry" { if r["only-when-bound"] != "npm-package-registry" {
t.Errorf("%v names the registry's binding and is delivered while it is absent", r["id"]) t.Errorf("%v names the registry's binding and is delivered while it is absent", r["id"])
@@ -106,7 +109,7 @@ func TestTheGenesisDocumentIsDeclaredAndIssuedOutside(t *testing.T) {
inside := "/run/mesh/" + strings.TrimPrefix(own.Path, "${dir:mesh-state}/") inside := "/run/mesh/" + strings.TrimPrefix(own.Path, "${dir:mesh-state}/")
for _, r := range m.Resources { for _, r := range m.Resources {
if r["id"] == "agent-env" { if r["id"] == "agent-env" {
content, _ := r["content"].(string) content := templateText(t, r)
if !strings.Contains(content, "MESH_GENESIS_PACKAGE_REGISTRY="+inside+"\n") { if !strings.Contains(content, "MESH_GENESIS_PACKAGE_REGISTRY="+inside+"\n") {
t.Errorf("the agent is not told where the genesis document is (%s):\n%s", inside, content) t.Errorf("the agent is not told where the genesis document is (%s):\n%s", inside, content)
} }
@@ -115,3 +118,57 @@ func TestTheGenesisDocumentIsDeclaredAndIssuedOutside(t *testing.T) {
} }
t.Fatal("the build agent has no environment file") t.Fatal("the build agent has no environment file")
} }
// **The judge cache is the agent's own, beside its workspace and never in it** (novox/hq ADR 0358 §6, issue 704): a
// directory resource of its own, kept as the agent's data, mounted into the agent at the same path as on the host —
// the container runtime resolves a judge build's mounts on the host — and handed to the agent as MESH_JUDGE_CACHE.
func TestTheJudgeCacheIsADirectoryOfItsOwnMountedAtTheSamePath(t *testing.T) {
raw, err := os.ReadFile("module.json")
if err != nil {
t.Fatal(err)
}
var m struct {
Data struct {
Own []struct {
ID string `json:"id"`
Path string `json:"path"`
Class string `json:"class"`
} `json:"own"`
} `json:"data"`
Resources []struct {
ID string `json:"id"`
Type string `json:"type"`
Template string `json:"template"`
Volumes []string `json:"volumes"`
} `json:"resources"`
}
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
dir, data, env, mounted := false, false, false, false
for _, d := range m.Data.Own {
if d.ID == "judge-cache" && d.Path == "${dir:judge-cache}" && d.Class == "cache" {
data = true
}
}
for _, r := range m.Resources {
switch {
case r.ID == "judge-cache" && r.Type == "directory":
dir = true
case r.ID == "agent-env":
env = strings.Contains(templateText(t, map[string]any{"id": r.ID, "template": r.Template}), "MESH_JUDGE_CACHE=${dir:judge-cache}\n")
case r.ID == "server":
for _, v := range r.Volumes {
if v == "${dir:judge-cache}:${dir:judge-cache}" {
mounted = true
}
if strings.HasPrefix(v, "${dir:workspace}") && strings.Contains(v, "judge-cache") {
t.Errorf("the judge cache is mounted inside the workspace: %s", v)
}
}
}
}
if !dir || !data || !env || !mounted {
t.Fatalf("the judge cache: directory %v, data %v, env %v, mounted at the same path %v", dir, data, env, mounted)
}
}
+21 -1
View File
@@ -39,6 +39,12 @@
"path": "${dir:workspace}", "path": "${dir:workspace}",
"class": "cache", "class": "cache",
"why": "a build's working copy, cloned again for every build" "why": "a build's working copy, cloned again for every build"
},
{
"id": "judge-cache",
"path": "${dir:judge-cache}",
"class": "cache",
"why": "the Go cache every merge check's judge is built with and the release-branch judges kept per head; mounted into judge builds only, never into a check's containers (novox/hq ADR 0358 §6)"
} }
] ]
}, },
@@ -54,12 +60,17 @@
"type": "directory", "type": "directory",
"mode": "0700" "mode": "0700"
}, },
{
"id": "judge-cache",
"type": "directory",
"mode": "0700"
},
{ {
"id": "agent-env", "id": "agent-env",
"type": "file", "type": "file",
"path": "${dir:mesh-state}/build-agent.env", "path": "${dir:mesh-state}/build-agent.env",
"mode": "0600", "mode": "0600",
"content": "MESH_BROKER_FILE=/run/mesh/broker\nMESH_NODE=${machine:name}\nMESH_REGISTRY=${bound:artifact-store:at}:${bound:artifact-store:port}\nMESH_PACKAGE_BINDING=/run/mesh/package-registry.json\nMESH_NPM_TOKEN_FILE=/run/mesh/package-registry.secret\nMESH_GENESIS_PACKAGE_REGISTRY=/run/mesh/genesis-package-registry.json\nMESH_WORKSPACE=${dir:workspace}\n" "template": "files/build-agent.env"
}, },
{ {
"id": "server", "id": "server",
@@ -72,6 +83,7 @@
"volumes": [ "volumes": [
"${dir:mesh-state}:/run/mesh:ro", "${dir:mesh-state}:/run/mesh:ro",
"${dir:workspace}:${dir:workspace}", "${dir:workspace}:${dir:workspace}",
"${dir:judge-cache}:${dir:judge-cache}",
"/var/run/docker.sock:/var/run/docker.sock" "/var/run/docker.sock:/var/run/docker.sock"
], ],
"restart-on": [ "restart-on": [
@@ -85,6 +97,9 @@
"timeout": "5s", "timeout": "5s",
"looks": 3, "looks": 3,
"grace": "60s" "grace": "60s"
},
"busy": {
"command": "/usr/local/bin/mesh-builder busy"
} }
} }
], ],
@@ -100,6 +115,11 @@
"repository": "novox/mesh-controller", "repository": "novox/mesh-controller",
"ref": "main" "ref": "main"
} }
},
{
"name": "files",
"kind": "archive",
"from": "files"
} }
], ],
"on": [ "on": [
+64
View File
@@ -0,0 +1,64 @@
package buildagent
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)
}
}
+58
View File
@@ -0,0 +1,58 @@
#!/bin/sh
# The mesh's internal certificate authority, trusted by this machine.
#
# Written by the mesh from the ca-trust module's manifest (novox/hq ADR 0147).
# Editing it here lasts until the next apply.
#
# There is no prior trust to verify the fetch against — this is the thing that
# establishes it — so it is made over the mesh's own private network, which is
# what authenticates it (novox/hq ADR 0098, the same reasoning that lets the
# route proxy fetch this root for itself). What comes back is checked here: a
# body that is not a certificate is refused now, rather than believed and then
# failed by whatever reads the trust store next.
set -eu
ROOTS='https://${bound:internal-acme-ca:at}:${bound:internal-acme-ca:port}${bound:internal-acme-ca:roots}'
ANCHORS=/etc/ca-certificates/trust-source/anchors
ANCHOR="$ANCHORS/mesh-internal-ca.crt"
# Arch's layout, said out loud rather than assumed: a machine that keeps its
# anchors elsewhere fails here, visibly, instead of writing a file nothing
# reads. That failure is the signal that this belongs in the host, where one
# operating system's difference lives (novox/hq ADR 0147, option 2).
[ -d "$ANCHORS" ] || {
echo "this machine keeps no trust anchors in $ANCHORS; ca-trust is written for that layout" >&2
exit 1
}
case "${1:-}" in
install)
tmp=$(mktemp)
trap 'rm -f "$tmp"' EXIT
# The authority may still be starting, or this machine may have come up
# before it: two minutes of asking, then an honest failure.
n=0
while [ "$n" -lt 60 ]; do
if curl --fail --silent --show-error --insecure --max-time 10 \
--output "$tmp" "$ROOTS" &&
grep -q 'BEGIN CERTIFICATE' "$tmp"; then
install -m 0644 "$tmp" "$ANCHOR"
update-ca-trust
exit 0
fi
n=$((n + 1))
sleep 2
done
echo "the authority at $ROOTS did not serve a certificate within two minutes" >&2
exit 1
;;
remove)
# What stopping the unit does, and therefore what being unassigned does.
rm -f "$ANCHOR"
update-ca-trust
;;
*)
echo "usage: $(basename "$0") install|remove" >&2
exit 2
;;
esac
@@ -0,0 +1,16 @@
[Unit]
Description=The mesh's internal certificate authority, trusted by this machine
# novox/hq ADR 0147. Starting this unit places the mesh's root among this
# machine's trust anchors; stopping it takes the root away again, which is what
# the host does when the module is no longer assigned here.
Wants=network-online.target
After=network-online.target
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=${dir:state}/anchor install
ExecStop=${dir:state}/anchor remove
[Install]
WantedBy=multi-user.target
+12 -3
View File
@@ -32,14 +32,14 @@
"type": "file", "type": "file",
"path": "${dir:state}/anchor", "path": "${dir:state}/anchor",
"mode": "0755", "mode": "0755",
"content": "#!/bin/sh\n# The mesh's internal certificate authority, trusted by this machine.\n#\n# Written by the mesh from the ca-trust module's manifest (novox/hq ADR 0147).\n# Editing it here lasts until the next apply.\n#\n# There is no prior trust to verify the fetch against \u2014 this is the thing that\n# establishes it \u2014 so it is made over the mesh's own private network, which is\n# what authenticates it (novox/hq ADR 0098, the same reasoning that lets the\n# route proxy fetch this root for itself). What comes back is checked here: a\n# body that is not a certificate is refused now, rather than believed and then\n# failed by whatever reads the trust store next.\nset -eu\n\nROOTS='https://${bound:internal-acme-ca:at}:${bound:internal-acme-ca:port}${bound:internal-acme-ca:roots}'\nANCHORS=/etc/ca-certificates/trust-source/anchors\nANCHOR=\"$ANCHORS/mesh-internal-ca.crt\"\n\n# Arch's layout, said out loud rather than assumed: a machine that keeps its\n# anchors elsewhere fails here, visibly, instead of writing a file nothing\n# reads. That failure is the signal that this belongs in the host, where one\n# operating system's difference lives (novox/hq ADR 0147, option 2).\n[ -d \"$ANCHORS\" ] || {\n\techo \"this machine keeps no trust anchors in $ANCHORS; ca-trust is written for that layout\" >&2\n\texit 1\n}\n\ncase \"${1:-}\" in\ninstall)\n\ttmp=$(mktemp)\n\ttrap 'rm -f \"$tmp\"' EXIT\n\t# The authority may still be starting, or this machine may have come up\n\t# before it: two minutes of asking, then an honest failure.\n\tn=0\n\twhile [ \"$n\" -lt 60 ]; do\n\t\tif curl --fail --silent --show-error --insecure --max-time 10 \\\n\t\t\t--output \"$tmp\" \"$ROOTS\" &&\n\t\t\tgrep -q 'BEGIN CERTIFICATE' \"$tmp\"; then\n\t\t\tinstall -m 0644 \"$tmp\" \"$ANCHOR\"\n\t\t\tupdate-ca-trust\n\t\t\texit 0\n\t\tfi\n\t\tn=$((n + 1))\n\t\tsleep 2\n\tdone\n\techo \"the authority at $ROOTS did not serve a certificate within two minutes\" >&2\n\texit 1\n\t;;\nremove)\n\t# What stopping the unit does, and therefore what being unassigned does.\n\trm -f \"$ANCHOR\"\n\tupdate-ca-trust\n\t;;\n*)\n\techo \"usage: $(basename \"$0\") install|remove\" >&2\n\texit 2\n\t;;\nesac\n" "template": "files/anchor"
}, },
{ {
"id": "unit", "id": "unit",
"type": "file", "type": "file",
"path": "/etc/systemd/system/mesh-ca-trust.service", "path": "/etc/systemd/system/mesh-ca-trust.service",
"mode": "0644", "mode": "0644",
"content": "[Unit]\nDescription=The mesh's internal certificate authority, trusted by this machine\n# novox/hq ADR 0147. Starting this unit places the mesh's root among this\n# machine's trust anchors; stopping it takes the root away again, which is what\n# the host does when the module is no longer assigned here.\nWants=network-online.target\nAfter=network-online.target\n\n[Service]\nType=oneshot\nRemainAfterExit=yes\nExecStart=${dir:state}/anchor install\nExecStop=${dir:state}/anchor remove\n\n[Install]\nWantedBy=multi-user.target\n" "template": "files/mesh-ca-trust.service"
}, },
{ {
"id": "trust", "id": "trust",
@@ -52,5 +52,14 @@
"unit" "unit"
] ]
} }
] ],
"build": {
"artifacts": [
{
"name": "files",
"kind": "archive",
"from": "files"
}
]
}
} }
+50 -6
View File
@@ -11,8 +11,9 @@ Two directories, declared, so the mesh refuses a second module owning either:
- `~/.claude` under the home of the account agents run as (the operator's, unless the machine names an agent - `~/.claude` under the home of the account agents run as (the operator's, unless the machine names an agent
account of its own), that account's, `0700`. The module owns the directory — account of its own), that account's, `0700`. The module owns the directory —
that it exists, who owns it, its mode — and of what is inside only what it writes. Everything else that it exists, who owns it, its mode — and of what is inside only what it writes. Everything else
in it (memory, history, projects, local settings, a person's own rules and skills) is the person's in it (history, projects, local settings, a person's own rules and skills) is the person's
and is never read or written (hq ADR 0182). Unassigned, the module leaves the directory: the host and is never read or written (hq ADR 0182) — but for each project's memory folder, which the module
carries between nodes (hq ADR 0343, below). Unassigned, the module leaves the directory: the host
removes a directory only when it is empty. removes a directory only when it is empty.
And, for where the agent runs as an account of its own (hq ADR 0266): And, for where the agent runs as an account of its own (hq ADR 0266):
@@ -42,7 +43,7 @@ whenever the node's tool runtime collects the module's tools:
| file | holds | | file | holds |
|---|---| |---|---|
| `managed-mcp.json` | the tool servers every session loads: the mesh's console as `mesh`, and the servers set in this module's `mcp_servers` setting. **Exclusive**: a server not listed here does not load — not one added with `claude mcp add`, not a project's `.mcp.json`, not a plugin's | | `managed-mcp.json` | the tool servers every session loads: the mesh's console as `mesh`, and the servers set in this module's `mcp_servers` setting. **Exclusive**: a server not listed here does not load — not one added with `claude mcp add`, not a project's `.mcp.json`, not a plugin's |
| `managed-settings.json` | the keys set in this module's `managed_settings` setting — at the controller's terminal alone (hq issue 340) — under the mesh's own keys: the repositories' attribution convention, the claude.ai connectors kept beside the managed servers, the key-helper while the node holds an API-key licence, and the two that name the `nox-mesh` marketplace and enable its plugin | | `managed-settings.json` | the keys set in this module's `managed_settings` setting — at the controller's terminal alone (hq issue 340) — under the mesh's own keys: the repositories' attribution convention, the claude.ai connectors kept beside the managed servers, the key-helper while the node holds an API-key licence, git's credential file while the node holds the forge's read credential (below), and the two that name the `nox-mesh` marketplace and enable its plugin |
| `CLAUDE.md` | how a session on this mesh works, this node's name and role, the conventions, the **"instead of" table** — then the instruction sections registered for every node and for this one | | `CLAUDE.md` | how a session on this mesh works, this node's name and role, the conventions, the **"instead of" table** — then the instruction sections registered for every node and for this one |
| `marketplace/` | the `nox-mesh` plugin (hq ADR 0216): the skills, subagents, commands and output styles registered for every node and for this one, offered in a session as `nox-mesh:<name>` — and the **guard on the agent's shell** (`guard/`), the mesh's own hook and the plugin's only one (hq issue 340). Replaced whole, staged beside and swapped in | | `marketplace/` | the `nox-mesh` plugin (hq ADR 0216): the skills, subagents, commands and output styles registered for every node and for this one, offered in a session as `nox-mesh:<name>` — and the **guard on the agent's shell** (`guard/`), the mesh's own hook and the plugin's only one (hq issue 340). Replaced whole, staged beside and swapped in |
@@ -54,6 +55,17 @@ same content, and never through a directory that is a symbolic link. A placed fi
alone, and one the person deleted stays deleted until the item is unregistered (hq ADR 0182). The status tool reads the rest of the home's alone, and one the person deleted stays deleted until the item is unregistered (hq ADR 0182). The status tool reads the rest of the home's
items to report them; nothing else is read or written. items to report them; nothing else is read or written.
**The forge's read credential** (hq ADR 0365 §4, issue 737). The module requires `gitea-read`, optionally: the forge
mints this node's agent a token of its own account, `agent-<node>`, scoped to read repositories alone, and the vault
keeps it for this module on this machine alone (issue 749); it arrives in the state as `gitea-read.secret`, git's
credential-store lines. Every few seconds the module places them at `~/.config/mesh/forge.credentials` in the agent's
home, mode 0600, written as the agent's account; puts the file back when git's store helper or a person changed it;
and takes it back when none is held. A value that is not credential lines (one the mesh made before the forge was
asked) is never placed. While one is held, `managed-settings.json` gives every session's environment
`GIT_CONFIG_COUNT`, `GIT_CONFIG_KEY_0=credential.helper` and `GIT_CONFIG_VALUE_0=store --file='<that path>'`: the
path, never the token. So `git clone` and `git fetch` of what the agent's account may read work with nothing typed,
printed or put on a command line; pushing stays `gitea_commit`'s, and the forge refuses the token a push.
## Over NATS ## Over NATS
Everything between this module and the rest of the mesh is NATS, in three kinds: an **event** says that Everything between this module and the rest of the mesh is NATS, in three kinds: an **event** says that
@@ -69,8 +81,37 @@ must see, a node that joins later included — kept, so it carries no secret eit
| an MCP server registered through this module before registering was refused (hq issue 340) | a key in the module's `servers` state — `all.<server>` for every node, `<node>.<server>` for one; every node watches it and renders what applies to it, a node's own entry over the one for every node. A node that joins later, or was off, reads the whole current set at start; unregistering is a delete. Nothing adds a key any more: a new tool server is the operator's, in the `mcp_servers` setting at the controller's terminal | | an MCP server registered through this module before registering was refused (hq issue 340) | a key in the module's `servers` state — `all.<server>` for every node, `<node>.<server>` for one; every node watches it and renders what applies to it, a node's own entry over the one for every node. A node that joins later, or was off, reads the whole current set at start; unregistering is a delete. Nothing adds a key any more: a new tool server is the operator's, in the `mcp_servers` setting at the controller's terminal |
| a proposed instruction section (hq ADR 0274) | a key in the module's `proposals` state per proposal, the ask published on `operator-channel` under this module's name, and the router's record of it read under that name alone; what a node approved is its own file, never the bus | | a proposed instruction section (hq ADR 0274) | a key in the module's `proposals` state per proposal, the ask published on `operator-channel` under this module's name, and the router's record of it read under that name alone; what a node approved is its own file, never the bus |
| a session's question to the operator (hq ADR 0332) | a key `q-…` in the module's `questions` state per question, the ask published on `operator-channel` under this module's name, and the router's record of it read under that name alone; the router's word that it ended without a choice is kept on the question; two slot keys `slot-1`, `slot-2` name the questions open | | a session's question to the operator (hq ADR 0332) | a key `q-…` in the module's `questions` state per question, the ask published on `operator-channel` under this module's name, and the router's record of it read under that name alone; the router's word that it ended without a choice is kept on the question; two slot keys `slot-1`, `slot-2` name the questions open |
| a project's memory (hq ADR 0343) | a key in the module's `memory` state per file — `<kind>.<project and path, base64url>`, `operator` for the operator's own homes and `agent` for an agent account's — holding the text, its digest, the digest of the version it replaced, the node and when it changed; a removal is a value marked removed, never a deleted key. Written at the revision read, so of two nodes writing one file only one wins. Every node of the same kind watches it. No event and no tool answer carries memory text |
| the agent's configuration (hq ADR 0216) | a key in the module's `config` state per registration — `mesh.<kind>.<name>` for every node, `node.<node>.<kind>.<name>` for one, `home.<node>.<kind>.<name>` for one account's own directory — the item and its files in one value, at most 256 KiB. Every node watches it and renders what applies to it, a node item over a mesh item of the same kind and name. A hook, settings, or an item that would run something by itself is never taken, whenever it was put there (hq issue 340) | | the agent's configuration (hq ADR 0216) | a key in the module's `config` state per registration — `mesh.<kind>.<name>` for every node, `node.<node>.<kind>.<name>` for one, `home.<node>.<kind>.<name>` for one account's own directory — the item and its files in one value, at most 256 KiB. Every node watches it and renders what applies to it, a node item over a mesh item of the same kind and name. A hook, settings, or an item that would run something by itself is never taken, whenever it was put there (hq issue 340) |
## A session's memory follows the operator (hq ADR 0343)
A session started on another node starts with what the sessions on every other node learned (hq issue 367). The
module's long-running process carries each project's memory folder, `~/.claude/projects/<project>/memory/`, and
nothing else of the home. A project is one entry of the projects directory and a path is relative, no part empty,
`.` or `..`: a value in the state naming anything else is never taken, and nothing is touched for it.
- **Picked up** every ten seconds: a file whose text differs from the version last carried goes into the `memory`
state. Not carried, and listed with why: a symbolic link, a file over 128 KiB or too large for one value once
encoded, and a file holding a key block, a
signed token, a known token prefix or a password in an address.
- **Written** on every other node of the same home kind, only where its file is still the version the change
replaced, or the version it last carried (a node that was away), or there is none; staged in the folder and renamed in, never through a symbolic link. The version
replaced is kept first in `memory-copies/` in the module's state directory, 30 days.
- **A file changed on two nodes** becomes one file holding both, the newer first, then a line naming each version's
node and time, then the older (two versions too large for one file: the newer in it, the older beside it as
`<name>.older-from-<node>`); what was written is carried, so every node ends on it, and the operator is warned through
the operator channel's `notify` (a warning, cleared when the file next changes without a conflict).
- **A removal** of a carried file, from a folder that still exists, is a marker in the state for 30 days: a node still
holding the removed version moves it into the copies; a node that changed it keeps it and carries it again. A
missing folder, a key the state lost and a deleted key remove nothing, and neither does a file or folder that
cannot be read on a look: it is kept everywhere and listed as not carried.
- **The two home kinds never mix**: the operator's own homes carry among themselves, an agent account's among
themselves (hq ADR 0266), so a confined agent's notes never reach a session run as the operator.
- **No tool writes memory** (hq issue 340): `claude_code_memory` only lists what is carried — fingerprints, never the
text — the files not carried, the open conflicts and the copies kept. A value put straight into the state by a
holder of a tool runner's credential is not yet told apart (hq issue 638).
## The mesh's tools first (hq ADR 0245) ## The mesh's tools first (hq ADR 0245)
The agent kept reaching for `ssh <machine> journalctl` while the service manager's `journal` verb existed. So: The agent kept reaching for `ssh <machine> journalctl` while the service manager's `journal` verb existed. So:
@@ -149,9 +190,12 @@ said in the render's answer. What was registered before is still rendered and ma
hand-over, the context a later session needs — or with `remove` for a section approved before. Nothing becomes hand-over, the context a later session needs — or with `remove` for a section approved before. Nothing becomes
active: the proposal is kept in the module's `proposals` state and the operator is asked through the operator active: the proposal is kept in the module's `proposals` state and the operator is asked through the operator
channel (`operator-channel`, hq ADR 0259) with Approve and Decline, both at the level approve, so only a channel that channel (`operator-channel`, hq ADR 0259) with Approve and Decline, both at the level approve, so only a channel that
proves who answered (Telegram) carries either. The phone shows the whole text when it is short and every line may proves who answered (Telegram) carries either. The ask reads at a glance (hq issue 639): its explanation is one
leave the mesh, else its first lines and a fingerprint; the whole text is read at the controller's terminal with line saying what the proposal does — the proposer's `summary`, at most 300 characters, or the module's own words —
`mesh-cli ask claude-code claude_code_proposals '{"id": "<id>"}'`. Decline, or no answer within a day, discards it. and its Details, which the phone shows collapsed after the answers, carry the text (whole when it is short and every
line may leave the mesh, else its first lines), where it came from and its fingerprint; the whole text is read at the
controller's terminal with `mesh-cli ask claude-code claude_code_proposals '{"id": "<id>"}'`. A proposal made before
that change is asked, and its warrant held, in the form it was made in (`form`). Decline, or no answer within a day, discards it.
**Every node proves the approval itself**: it reads the router's own record of the ask under this module's name, **Every node proves the approval itself**: it reads the router's own record of the ask under this module's name,
judges the record as this module would have made it, composes the ask again from the proposal and holds the warrant to it (the same ask digest, the option's bound act, judges the record as this module would have made it, composes the ask again from the proposal and holds the warrant to it (the same ask digest, the option's bound act,
the text's digest, in time), and only then writes the section into its own `approved.json`, which the managed the text's digest, in time), and only then writes the section into its own `approved.json`, which the managed
@@ -285,6 +285,25 @@ func TestANewerGenerationFetchesTheTokenOnceByTheSeatsVerb(t *testing.T) {
} }
} }
// The node's line names the generation it applied and why the manager moved it, and who asked (novox/hq issue 625).
func TestAnAppliedBindingSaysWhyAndWho(t *testing.T) {
p, w := node(t, "laptop")
var asked []string
done, err := OnBinding(p, &BindingState{Licence: "personal", Kind: "subscription", Generation: 7,
Why: "the switch verb", By: "g14.node-tools"}, seat(t, "personal", "at-7", 7, &asked), writer(w))
if err != nil {
t.Fatal(err)
}
for _, want := range []string{`"generation":7`, `"why":"the switch verb"`, `"by":"g14.node-tools"`} {
if !strings.Contains(done, want) {
t.Fatalf("the line %s lacks %s", done, want)
}
}
if strings.Contains(done, "at-7") {
t.Fatal("the line carries the token")
}
}
func TestTheTokenANodeIsHandedReplacesALoginsGrantAndLeavesNoRefreshToken(t *testing.T) { func TestTheTokenANodeIsHandedReplacesALoginsGrantAndLeavesNoRefreshToken(t *testing.T) {
p, w := node(t, "laptop") p, w := node(t, "laptop")
writeFile(t, p.credentials(), `{"claudeAiOauth":{"accessToken":"at-old","refreshToken":"rt-spent","expiresAt":`+ writeFile(t, p.credentials(), `{"claudeAiOauth":{"accessToken":"at-old","refreshToken":"rt-spent","expiresAt":`+
@@ -0,0 +1,142 @@
package main
// The forge's read credential in the agent's home (novox/hq ADR 0365 §4, issue 737).
//
// The forge's module mints each node's agent a token of its own account, `agent-<node>`, scoped to read
// repositories alone, and the vault keeps it for this module on this machine alone (provision `gitea-read`, issue
// 749). What arrives in this module's state is git's credential-store lines for it, one per origin the forge is
// reached at. This module places them in the agent's home, a file of mode 0600 written as the agent's account (ADR
// 0266: no other module writes there), and points every session's git at it through the managed settings'
// environment: the file's path, never its content. So an agent clones and fetches what its account may read, and no
// token is printed, typed on a command line or put in an environment a process listing shows.
//
// Git's own `store` helper reads the file; on a token the forge rejects it erases that line, and on a password a
// person typed elsewhere it adds one. Both are undone at the next look (every few seconds): the file holds what the
// forge issued, and nothing else. A value that is not credential lines — one the mesh made while the forge had not
// been asked yet — is not placed, and what was placed before is taken back.
import (
"bytes"
"errors"
"fmt"
"io/fs"
"net/url"
"os"
"path/filepath"
"strings"
)
// ForgeProvision is the provision whose credential this is, and the file this module's manifest has it delivered to.
const (
ForgeProvision = "gitea-read"
forgeSecret = "gitea-read.secret"
)
func (p Paths) forgeSecret() string { return filepath.Join(p.State, forgeSecret) }
// forgeCredentials is where the agent's git reads the credential: under the agent's home, in a directory of the
// mesh's own.
func (p Paths) forgeCredentials() string {
return filepath.Join(p.Home, ".config", "mesh", "forge.credentials")
}
// ForgeCredentialLines are the credential lines held, checked: each an http(s) origin with an account and a token,
// and nothing else. Anything else is refused, said without the value.
func ForgeCredentialLines(raw string) (string, error) {
var lines []string
for _, line := range strings.Split(raw, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
u, err := url.Parse(line)
if err != nil || (u.Scheme != "https" && u.Scheme != "http") || u.Host == "" || u.User == nil ||
u.User.Username() == "" || u.Path != "" || u.RawQuery != "" || u.Fragment != "" {
return "", errors.New("what the mesh holds for the forge's read credential is not git credential lines " +
"(the forge has not issued it yet, or something else was given)")
}
if pw, ok := u.User.Password(); !ok || pw == "" {
return "", errors.New("a forge credential line names no token")
}
lines = append(lines, line)
}
if len(lines) == 0 {
return "", errors.New("the forge's read credential is empty")
}
return strings.Join(lines, "\n") + "\n", nil
}
// ForgeCredentialHeld says whether this machine holds a forge read credential to place.
func ForgeCredentialHeld(p Paths) bool {
raw, err := os.ReadFile(p.forgeSecret())
if err != nil {
return false
}
_, err = ForgeCredentialLines(string(raw))
return err == nil
}
// PlaceForgeCredential puts the forge's read credential in the agent's home as it is held — written, rewritten when
// it differs, taken back when none is held — and says what it did, or "" when nothing changed. Never the value.
func PlaceForgeCredential(p Paths) (string, error) {
path := p.forgeCredentials()
raw, err := os.ReadFile(p.forgeSecret())
var lines string
var why error
switch {
case errors.Is(err, fs.ErrNotExist):
why = errors.New("this machine holds no forge read credential")
case err != nil:
return "", fmt.Errorf("the forge's read credential cannot be read: %w", err)
default:
lines, why = ForgeCredentialLines(string(raw))
}
if why != nil {
if _, err := fsys.Stat(path); errors.Is(err, fs.ErrNotExist) {
return "", nil
}
if err := fsys.Remove(path); err != nil && !errors.Is(err, fs.ErrNotExist) {
return "", fmt.Errorf("the forge credential at %s could not be taken back: %w", path, err)
}
return fmt.Sprintf("forge credential: taken back from %s: %v", path, why), nil
}
if held, err := fsys.ReadFile(path); err == nil && bytes.Equal(held, []byte(lines)) {
if info, err := fsys.Stat(path); err == nil && info.Mode().Perm() == 0o600 {
return "", nil
}
}
if err := fsys.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return "", fmt.Errorf("the directory for the forge credential could not be made: %w", err)
}
tmp := path + ".mesh-tmp"
if err := fsys.WriteFile(tmp, []byte(lines), 0o600); err != nil {
return "", fmt.Errorf("the forge credential could not be written: %w", err)
}
if err := fsys.Chmod(tmp, 0o600); err != nil {
_ = fsys.Remove(tmp)
return "", fmt.Errorf("the forge credential could not be made 0600: %w", err)
}
if err := fsys.Rename(tmp, path); err != nil {
_ = fsys.Remove(tmp)
return "", fmt.Errorf("the forge credential could not be put in place: %w", err)
}
return fmt.Sprintf("forge credential: placed at %s for %d origin(s)", path, strings.Count(lines, "\n")), nil
}
// forgeEnv is what every session's environment carries for git to read the credential: the file's path in git's
// configuration from the environment, never the credential. Laid over the operator's `env`, the mesh's keys winning.
func forgeEnv(managed map[string]any, credentials string) {
if credentials == "" {
return
}
env := map[string]any{}
if held, ok := managed["env"].(map[string]any); ok {
for k, v := range held {
env[k] = v
}
}
env["GIT_CONFIG_COUNT"] = "1"
env["GIT_CONFIG_KEY_0"] = "credential.helper"
env["GIT_CONFIG_VALUE_0"] = "store --file=" + shellQuote(credentials)
managed["env"] = env
}
@@ -0,0 +1,262 @@
package main
import (
"encoding/json"
"net/http"
"net/http/cgi"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
const agentToken = "0123456789abcdef0123456789abcdef01234567"
// privateForge is a forge serving one private repository over git's smart http, to agent-anchor with its token
// alone, as the forge does for an account a team lets read it.
func privateForge(t *testing.T) (origin, bare string) {
t.Helper()
backend := filepath.Join(gitExecPath(t), "git-http-backend")
if _, err := os.Stat(backend); err != nil {
t.Skipf("git-http-backend is not installed: %v", err)
}
root := t.TempDir()
bare = filepath.Join(root, "private.git")
work := t.TempDir()
runGit(t, work, nil, "git", "init", "-q", "-b", "main", ".")
commit(t, work, "one")
runGit(t, root, nil, "git", "clone", "-q", "--bare", work, bare)
cgiHandler := &cgi.Handler{Path: backend, Env: []string{"GIT_PROJECT_ROOT=" + root, "GIT_HTTP_EXPORT_ALL=1"}}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, pass, ok := r.BasicAuth()
if !ok || user != "agent-anchor" || pass != agentToken {
w.Header().Set("WWW-Authenticate", `Basic realm="Gitea"`)
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "git-receive-pack") {
http.Error(w, "read only", http.StatusForbidden)
return
}
cgiHandler.ServeHTTP(w, r)
}))
t.Cleanup(srv.Close)
return srv.URL, bare
}
func gitExecPath(t *testing.T) string {
t.Helper()
out, err := exec.Command("git", "--exec-path").Output()
if err != nil {
t.Skipf("git is not installed: %v", err)
}
return strings.TrimSpace(string(out))
}
func runGit(t *testing.T, dir string, env []string, name string, args ...string) string {
t.Helper()
out, err := runErr(dir, env, name, args...)
if err != nil {
t.Fatalf("%s %v: %v\n%s", name, args, err, out)
}
return out
}
func runErr(dir string, env []string, name string, args ...string) (string, error) {
cmd := exec.Command(name, args...)
cmd.Dir = dir
cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t", "GIT_COMMITTER_NAME=t",
"GIT_COMMITTER_EMAIL=t@t", "GIT_TERMINAL_PROMPT=0", "GIT_ASKPASS=", "SSH_ASKPASS=")
cmd.Env = append(cmd.Env, env...)
out, err := cmd.CombinedOutput()
return string(out), err
}
func commit(t *testing.T, dir, text string) {
t.Helper()
if err := os.WriteFile(filepath.Join(dir, "file"), []byte(text+"\n"), 0o644); err != nil {
t.Fatal(err)
}
runGit(t, dir, nil, "git", "add", "file")
runGit(t, dir, nil, "git", "commit", "-q", "-m", text)
}
// sessionEnv is the environment a session starts with: the managed settings' `env`, in a home with no git
// configuration of its own.
func sessionEnv(t *testing.T, home, managedSettings string) []string {
t.Helper()
var settings struct {
Env map[string]string `json:"env"`
}
if err := json.Unmarshal([]byte(managedSettings), &settings); err != nil {
t.Fatal(err)
}
env := []string{"HOME=" + home, "XDG_CONFIG_HOME=" + filepath.Join(home, ".config"), "GIT_CONFIG_NOSYSTEM=1"}
for k, v := range settings.Env {
env = append(env, k+"="+v)
}
return env
}
func forgePaths(t *testing.T) Paths {
t.Helper()
return Paths{State: t.TempDir(), Home: t.TempDir(), Node: "anchor"}
}
func managedFor(p Paths) string {
facts := Facts{Node: p.Node, Console: "http://console"}
if ForgeCredentialHeld(p) {
facts.ForgeCredentials = p.forgeCredentials()
}
return Render(facts, Settings{}, nil, "", nil, Config{})["managed-settings.json"]
}
// Issue 737's done-when, as far as a machine of its own proves it: an agent session clones a private repository and
// fetches it again, and no credential appears in a log, a command line or the session.
func TestAnAgentClonesAPrivateRepositoryAndFetchesItAgainWithTheCredentialPlaced(t *testing.T) {
origin, bare := privateForge(t)
p := forgePaths(t)
repo := origin + "/private.git"
// Before the forge issued anything, the session's git has no credential: the clone is refused, as today.
before := sessionEnv(t, p.Home, managedFor(p))
if out, err := runErr(t.TempDir(), before, "git", "clone", "-q", repo, "x"); err == nil {
t.Fatalf("a private repository was cloned with no credential:\n%s", out)
}
// The vault hands this module what the forge issued for this machine's agent.
lines := strings.Replace(origin, "http://", "http://agent-anchor:"+agentToken+"@", 1) + "\n" +
"https://agent-anchor:" + agentToken + "@git.example.test\n"
if err := os.WriteFile(p.forgeSecret(), []byte(lines), 0o600); err != nil {
t.Fatal(err)
}
said, err := PlaceForgeCredential(p)
if err != nil {
t.Fatal(err)
}
info, err := os.Stat(p.forgeCredentials())
if err != nil || info.Mode().Perm() != 0o600 {
t.Fatalf("the credential file: %v %v, want mode 0600", info, err)
}
if again, err := PlaceForgeCredential(p); err != nil || again != "" {
t.Fatalf("placed again with nothing changed: %q %v", again, err)
}
managed := managedFor(p)
env := sessionEnv(t, p.Home, managed)
work := t.TempDir()
cloneOut := runGit(t, work, env, "git", "clone", repo, "clone")
clone := filepath.Join(work, "clone")
if got := runGit(t, clone, env, "git", "log", "--format=%s"); strings.TrimSpace(got) != "one" {
t.Fatalf("cloned %q", got)
}
// The forge's repository moves on; the agent fetches it again.
other := t.TempDir()
runGit(t, other, nil, "git", "clone", "-q", bare, "w")
commit(t, filepath.Join(other, "w"), "two")
runGit(t, filepath.Join(other, "w"), nil, "git", "push", "-q", "origin", "main")
fetchOut := runGit(t, clone, env, "git", "fetch", "origin")
if got := runGit(t, clone, env, "git", "log", "--format=%s", "origin/main"); !strings.HasPrefix(got, "two") {
t.Fatalf("fetched %q", got)
}
// A push is the forge's to refuse (ADR 0365 §5): the token reads, and nothing writes with it.
commit(t, clone, "three")
if out, err := runErr(clone, env, "git", "push", "origin", "main"); err == nil {
t.Fatalf("a push with the agent's credential went through:\n%s", out)
}
// No credential in what the session saw, its command lines, its environment or the module's words.
remote := runGit(t, clone, env, "git", "remote", "get-url", "origin")
for what, text := range map[string]string{"the clone's output": cloneOut, "the fetch's output": fetchOut,
"the remote's url": remote, "the managed settings": managed, "the session's environment": strings.Join(env, "\n"),
"what the module said": said, "the clone's configuration": readOrEmpty(filepath.Join(clone, ".git", "config"))} {
if strings.Contains(text, agentToken) {
t.Errorf("the token appears in %s", what)
}
}
}
func readOrEmpty(path string) string {
raw, _ := os.ReadFile(path)
return string(raw)
}
func TestWhatGitChangedInTheFileIsPutBackAndAValueNotIssuedIsNeverPlaced(t *testing.T) {
p := forgePaths(t)
// A value the mesh made while the forge had not been asked: not credential lines, never placed.
if err := os.WriteFile(p.forgeSecret(), []byte("Zm9yZ2VkLWJ5LXRoZS1tZXNo\n"), 0o600); err != nil {
t.Fatal(err)
}
if said, err := PlaceForgeCredential(p); err != nil || said != "" {
t.Fatalf("%q %v", said, err)
}
if _, err := os.Stat(p.forgeCredentials()); err == nil {
t.Fatal("a value the forge did not issue was placed")
}
if ForgeCredentialHeld(p) || strings.Contains(managedFor(p), "GIT_CONFIG") {
t.Fatal("the sessions' git is pointed at a credential that is not held")
}
issued := "https://agent-anchor:" + agentToken + "@git.example.test\n"
if err := os.WriteFile(p.forgeSecret(), []byte(issued), 0o600); err != nil {
t.Fatal(err)
}
if _, err := PlaceForgeCredential(p); err != nil {
t.Fatal(err)
}
// git's store helper erased the line after a rejection, and a person's password for another host was stored.
if err := os.WriteFile(p.forgeCredentials(), []byte("https://me:mine@example.org\n"), 0o644); err != nil {
t.Fatal(err)
}
if said, err := PlaceForgeCredential(p); err != nil || !strings.Contains(said, "placed") {
t.Fatalf("%q %v", said, err)
}
if got := readOrEmpty(p.forgeCredentials()); got != issued {
t.Fatalf("the file holds %q", strings.ReplaceAll(got, agentToken, "<token>"))
}
if info, _ := os.Stat(p.forgeCredentials()); info.Mode().Perm() != 0o600 {
t.Fatalf("mode %v", info.Mode().Perm())
}
// The credential withdrawn: taken back, and the sessions' git no longer pointed at it.
if err := os.Remove(p.forgeSecret()); err != nil {
t.Fatal(err)
}
if said, err := PlaceForgeCredential(p); err != nil || !strings.Contains(said, "taken back") || strings.Contains(said, agentToken) {
t.Fatalf("%q %v", said, err)
}
if _, err := os.Stat(p.forgeCredentials()); err == nil {
t.Fatal("a withdrawn credential stayed in the agent's home")
}
}
func TestTheSessionsGitIsPointedAtTheFileUnderTheOperatorsOwnEnvironment(t *testing.T) {
managed := map[string]any{"env": map[string]any{"FOO": "bar", "GIT_CONFIG_COUNT": "7"}}
forgeEnv(managed, "/home/agent/.config/mesh/forge.credentials")
env := managed["env"].(map[string]any)
if env["FOO"] != "bar" || env["GIT_CONFIG_COUNT"] != "1" || env["GIT_CONFIG_KEY_0"] != "credential.helper" ||
env["GIT_CONFIG_VALUE_0"] != "store --file='/home/agent/.config/mesh/forge.credentials'" {
t.Fatalf("%v", env)
}
none := map[string]any{}
forgeEnv(none, "")
if _, set := none["env"]; set {
t.Fatal("an environment was set with no credential held")
}
}
func TestForgeCredentialLinesAreReadStrictly(t *testing.T) {
for _, bad := range []string{"", "\n", "token", "https://git.example.test", "https://agent-anchor@git.example.test",
"https://agent-anchor:t@git.example.test/novox/hq.git", "ssh://agent-anchor:t@git.example.test",
"https://agent-anchor:t@git.example.test\nnot a line"} {
if _, err := ForgeCredentialLines(bad); err == nil {
t.Errorf("%q was taken", bad)
} else if strings.Contains(err.Error(), "t@git") {
t.Errorf("the refusal said the value: %v", err)
}
}
}
@@ -152,6 +152,36 @@ func (s stateOf) Create(key string, value any) error {
return err return err
} }
func (s stateOf) Keys() ([]string, error) { return s.s.Keys() } func (s stateOf) Keys() ([]string, error) { return s.s.Keys() }
// memoryStateOf adapts the SDK's state to the memory carrier: every write at the revision read (ADR 0343).
type memoryStateOf struct{ s stdio.KeptState }
func (s memoryStateOf) Get(key string) (json.RawMessage, uint64, bool, error) {
e, err := s.s.Get(key)
if err != nil || e == nil {
return nil, 0, false, err
}
return e.Value, e.Revision, true, nil
}
func (s memoryStateOf) Create(key string, value any) error {
_, err := s.s.Create(key, value)
return err
}
func (s memoryStateOf) Update(key string, value any, revision uint64) error {
_, err := s.s.Update(key, value, revision)
return err
}
func (s memoryStateOf) Keys() ([]string, error) { return s.s.Keys() }
func (s memoryStateOf) Delete(key string) error { return s.s.Delete(key) }
// memoryCarrier carries this node's memory folders (ADR 0343); nil until the long-running half starts it.
var memoryCarrier *MemoryCarrier
// notifyOperator tells the operator through the operator channel's notify verb (ADR 0253).
func notifyOperator(args map[string]any) error {
_, err := ask("seat:operator-channel.notify", args)
return err
}
func (s stateOf) Get(key string) (json.RawMessage, bool, error) { func (s stateOf) Get(key string) (json.RawMessage, bool, error) {
e, err := s.s.Get(key) e, err := s.s.Get(key)
if err != nil || e == nil { if err != nil || e == nil {
@@ -354,6 +384,9 @@ func tools(p Paths, servers ServerState, view *ServerView, config ConfigState, c
out = append(out, stdio.Tool{Name: "claude_code_registrations", out = append(out, stdio.Tool{Name: "claude_code_registrations",
Description: "Read-only: everything registered through this module that a Claude Code session runs or obeys — the tool servers, the hooks, the settings and the skills, subagents, commands, output styles and instructions — by key, with who registered it, when, and whether it is still rendered (novox/hq issue 340), beside what the operator set for this node at the controller's terminal (managed_settings, mcp_servers). `by` is the node whose tool runner served the call, never the caller.", Description: "Read-only: everything registered through this module that a Claude Code session runs or obeys — the tool servers, the hooks, the settings and the skills, subagents, commands, output styles and instructions — by key, with who registered it, when, and whether it is still rendered (novox/hq issue 340), beside what the operator set for this node at the controller's terminal (managed_settings, mcp_servers). `by` is the node whose tool runner served the call, never the caller.",
Run: func(map[string]any) (any, error) { return Registrations(p, servers, config) }}) Run: func(map[string]any) (any, error) { return Registrations(p, servers, config) }})
out = append(out, stdio.Tool{Name: "claude_code_memory",
Description: "Read-only: what this node carries of the agent's memory between nodes (novox/hq ADR 0343) — the home kind it carries for (the operator's own homes, or an agent account's), each project's memory files with their fingerprint and the node and time of their last version, the files not carried and why, the conflicts still open (a file changed on two nodes, kept twice in one file, the newer first), and how many replaced or removed versions are kept. Fingerprints only, never the text; it changes nothing.",
Run: func(map[string]any) (any, error) { return memoryCarrier.Status(), nil }})
out = append(out, proposalTools(p)...) out = append(out, proposalTools(p)...)
judge := NewJudge(p) judge := NewJudge(p)
out = append(out, judgeTool(judge), judgedTool(judge)) out = append(out, judgeTool(judge), judgedTool(judge))
@@ -430,6 +463,12 @@ func main() {
proposing = &Proposing{Store: stateOf{stdio.State(ProposalsState)}, Bus: sdkAskBus{}, Record: recordWarrant, proposing = &Proposing{Store: stateOf{stdio.State(ProposalsState)}, Bus: sdkAskBus{}, Record: recordWarrant,
Now: time.Now, Write: writeManaged} Now: time.Now, Write: writeManaged}
go proposing.watch(p) go proposing.watch(p)
memoryCarrier = NewMemoryCarrier(p, memoryStateOf{stdio.State(MemoryState)}, notifyOperator)
go memoryCarrier.Run(func(handle func(key, op string, value json.RawMessage, revision uint64) error) error {
return stdio.State(MemoryState).Watch("", func(c stdio.StateChange) error {
return handle(c.Key, c.Op, c.Value, c.Revision)
})
})
go run(p, view, configView) go run(p, view, configView)
if err := stdio.Serve("", tools(p, servers, view, config, configView)); err != nil { if err := stdio.Serve("", tools(p, servers, view, config, configView)); err != nil {
say("%v", err) say("%v", err)
@@ -546,7 +585,25 @@ func run(p Paths, view *ServerView, configView *ConfigView) {
report() report()
var last string var last string
// The forge's read credential (forge.go, ADR 0365 §4): placed as held, and put back when git or a person changed
// it; the sessions' environment names it only while one is held.
forgeHeld := ForgeCredentialHeld(p)
placeForge := func() {
if line, err := PlaceForgeCredential(p); err != nil {
say("%v", err)
} else if line != "" {
say("%s", line)
}
if held := ForgeCredentialHeld(p); held != forgeHeld {
forgeHeld = held
if _, err := RenderNow(p, writeManaged); err != nil {
say("rendering after the forge's read credential changed: %v", err)
}
}
}
placeForge()
for range time.Tick(5 * time.Second) { for range time.Tick(5 * time.Second) {
placeForge()
info, err := fsys.Stat(p.credentials()) info, err := fsys.Stat(p.credentials())
now := "absent" now := "absent"
if err == nil { if err == nil {
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,968 @@
package main
// A session's memory follows the operator (novox/hq ADR 0343, issue 367). Every test runs against temporary homes
// and a state in memory with the bus's watch semantics: never the live memory folders.
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"testing"
"time"
)
// memState is the `memory` state in memory: revisions, create and compare-and-set, and every change handed to every
// watcher, the writer included, as the bus does.
type memState struct {
mu sync.Mutex
values map[string]json.RawMessage
revs map[string]uint64
rev uint64
pending []change
watchers []*MemoryCarrier
}
type change struct {
key, op string
value json.RawMessage
rev uint64
}
func newMemState() *memState {
return &memState{values: map[string]json.RawMessage{}, revs: map[string]uint64{}}
}
var errChanged = errors.New("wrong last revision")
func (s *memState) Get(key string) (json.RawMessage, uint64, bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
v, ok := s.values[key]
return v, s.revs[key], ok, nil
}
func (s *memState) write(key string, value any) {
raw, _ := json.Marshal(value)
s.rev++
s.values[key], s.revs[key] = raw, s.rev
s.pending = append(s.pending, change{key, "put", raw, s.rev})
}
func (s *memState) Create(key string, value any) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.values[key]; ok {
return errChanged
}
s.write(key, value)
return nil
}
func (s *memState) Update(key string, value any, revision uint64) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.revs[key] != revision {
return errChanged
}
s.write(key, value)
return nil
}
func (s *memState) Keys() ([]string, error) {
s.mu.Lock()
defer s.mu.Unlock()
var keys []string
for k := range s.values {
keys = append(keys, k)
}
sort.Strings(keys)
return keys, nil
}
func (s *memState) Delete(key string) error {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.values, key)
delete(s.revs, key)
s.rev++
s.pending = append(s.pending, change{key, "delete", nil, s.rev})
return nil
}
// pump hands every change so far to every watcher, until none is left.
func (s *memState) pump(t *testing.T) {
t.Helper()
for i := 0; i < 50; i++ {
s.mu.Lock()
todo := s.pending
s.pending = nil
s.mu.Unlock()
if len(todo) == 0 {
return
}
for _, c := range todo {
for _, w := range s.watchers {
if err := w.Take(c.key, c.op, c.value, c.rev); err != nil {
t.Logf("%s taking %s: %v", w.Node, c.key, err)
}
}
}
for _, w := range s.watchers {
if err := w.Drain(); err != nil && !errors.Is(err, errChanged) {
t.Logf("%s applying: %v", w.Node, err)
}
}
}
t.Fatal("the state never settled")
}
// clock is a clock a test moves.
type clock struct{ now time.Time }
func (c *clock) Now() time.Time { return c.now }
// memNode is one node's carrier over its own temporary home and state directory.
type memNode struct {
*MemoryCarrier
notices []map[string]any
}
func newMemNode(t *testing.T, root, name, kind string, state *memState, c *clock) *memNode {
t.Helper()
n := &memNode{}
home := filepath.Join(root, name, "home")
stateDir := filepath.Join(root, name, "state")
for _, d := range []string{filepath.Join(home, ".claude", "projects"), stateDir} {
if err := os.MkdirAll(d, 0o700); err != nil {
t.Fatal(err)
}
}
n.MemoryCarrier = &MemoryCarrier{Home: home, Node: name, State: stateDir, Kind: kind, Store: state, Now: c.Now,
wake: make(chan struct{}, 1),
Notify: func(a map[string]any) error { n.notices = append(n.notices, a); return nil }}
state.watchers = append(state.watchers, n.MemoryCarrier)
return n
}
func (n *memNode) path(project, file string) string {
return filepath.Join(n.Home, ".claude", "projects", project, "memory", filepath.FromSlash(file))
}
func (n *memNode) write(t *testing.T, project, file, text string, at time.Time) {
t.Helper()
p := n.path(project, file)
if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte(text), 0o644); err != nil {
t.Fatal(err)
}
_ = os.Chtimes(p, at, at)
}
func (n *memNode) read(t *testing.T, project, file string) (string, bool) {
t.Helper()
raw, err := os.ReadFile(n.path(project, file))
if os.IsNotExist(err) {
return "", false
}
if err != nil {
t.Fatal(err)
}
return string(raw), true
}
func (n *memNode) look(t *testing.T) {
t.Helper()
if err := n.Look(); err != nil && !errors.Is(err, errChanged) {
t.Fatalf("%s's look: %v", n.Node, err)
}
}
// copies is every text kept in the node's copies.
func (n *memNode) copies(t *testing.T) []string {
t.Helper()
var out []string
_ = filepath.WalkDir(n.MemoryCarrier.copies(), func(p string, d os.DirEntry, err error) error {
if err == nil && !d.IsDir() {
raw, _ := os.ReadFile(p)
out = append(out, string(raw))
}
return nil
})
return out
}
func holdsText(list []string, s string) bool {
for _, x := range list {
if x == s {
return true
}
}
return false
}
const project = "-home-op-projects-novox-hq"
func twoNodes(t *testing.T) (*memState, *clock, *memNode, *memNode) {
state, c := newMemState(), &clock{now: time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC)}
root := t.TempDir()
return state, c, newMemNode(t, root, "g14", HomeOperator, state, c), newMemNode(t, root, "shanks", HomeOperator, state, c)
}
// carried writes a file on g14 and carries it to shanks.
func carriedOnBoth(t *testing.T, state *memState, c *clock, a, b *memNode, file, text string) {
t.Helper()
a.write(t, project, file, text, c.now)
a.look(t)
state.pump(t)
b.look(t)
state.pump(t)
if got, _ := b.read(t, project, file); got != text {
t.Fatalf("not carried to %s: %q", b.Node, got)
}
}
// §5, §6: a memory file written on one node appears unchanged on another, at any depth, and is carried once.
func TestAMemoryFileWrittenOnOneNodeAppearsUnchangedOnAnother(t *testing.T) {
state, c, g14, shanks := twoNodes(t)
g14.write(t, project, "MEMORY.md", "- [state](state.md) — where things stand\n", c.now)
g14.write(t, project, "notes/state.md", "ADR 0343 proposed.\n", c.now)
g14.look(t)
state.pump(t)
for file, want := range map[string]string{"MEMORY.md": "- [state](state.md) — where things stand\n", "notes/state.md": "ADR 0343 proposed.\n"} {
if got, ok := shanks.read(t, project, file); !ok || got != want {
t.Fatalf("%s on shanks: %q, %v", file, got, ok)
}
}
before := state.rev
shanks.look(t)
g14.look(t)
state.pump(t)
if state.rev != before {
t.Fatalf("a carried file was carried again: %d changes", state.rev-before)
}
// A change on shanks comes back to g14, over the version it replaced, which g14 keeps.
c.now = c.now.Add(time.Minute)
shanks.write(t, project, "notes/state.md", "ADR 0343 accepted.\n", c.now)
shanks.look(t)
state.pump(t)
if got, _ := g14.read(t, project, "notes/state.md"); got != "ADR 0343 accepted.\n" {
t.Fatalf("the change did not reach g14: %q", got)
}
if !holdsText(g14.copies(t), "ADR 0343 proposed.\n") {
t.Fatal("the version replaced on g14 was not kept")
}
if len(g14.notices)+len(shanks.notices) != 0 {
t.Fatalf("a plain change warned: %v %v", g14.notices, shanks.notices)
}
// No value in the state is anything but a version of a memory file, and no tool answer carries the text.
raw, _ := json.Marshal(g14.Status())
if strings.Contains(string(raw), "accepted") {
t.Fatalf("the memory tool answered the text: %s", raw)
}
}
// §1, §2: only the memory folders are carried; a symbolic link, an oversized file and a secret's shape are not, and are
// listed with why.
func TestOnlyTheMemoryFoldersAreCarried(t *testing.T) {
state, c, g14, shanks := twoNodes(t)
projectDir := filepath.Join(g14.Home, ".claude", "projects", project)
g14.write(t, project, "MEMORY.md", "index\n", c.now)
for path, text := range map[string]string{
filepath.Join(projectDir, "session.jsonl"): "{}\n",
filepath.Join(g14.Home, ".claude", "settings.json"): "{}\n",
filepath.Join(g14.Home, ".claude", "history.jsonl"): "{}\n",
} {
if err := os.WriteFile(path, []byte(text), 0o600); err != nil {
t.Fatal(err)
}
}
g14.write(t, project, "key.md", "-----BEGIN OPENSSH PRIVATE KEY-----\nabc\n", c.now)
g14.write(t, project, "url.md", "https://op:hunter2@git.example.org/x\n", c.now)
g14.write(t, project, "big.md", strings.Repeat("x", MemoryMostBytes+1), c.now)
g14.write(t, project, "hash.md", "head 1fe6ea4ee47379007598e163784dbd7ac50b7f79\n", c.now)
outside := filepath.Join(t.TempDir(), "elsewhere.md")
_ = os.WriteFile(outside, []byte("not memory\n"), 0o600)
if err := os.Symlink(outside, g14.path(project, "link.md")); err != nil {
t.Fatal(err)
}
g14.look(t)
state.pump(t)
for _, file := range []string{"key.md", "url.md", "big.md", "link.md"} {
if _, ok := shanks.read(t, project, file); ok {
t.Errorf("%s was carried", file)
}
}
for _, file := range []string{"MEMORY.md", "hash.md"} {
if _, ok := shanks.read(t, project, file); !ok {
t.Errorf("%s was not carried", file)
}
}
if _, err := os.Stat(filepath.Join(shanks.Home, ".claude", "projects", project, "session.jsonl")); err == nil {
t.Error("a session was carried")
}
for _, f := range []string{"settings.json", "history.jsonl"} {
if _, err := os.Stat(filepath.Join(shanks.Home, ".claude", f)); err == nil {
t.Errorf("%s was carried", f)
}
}
keys, _ := state.Keys()
if len(keys) != 2 {
t.Fatalf("the state holds %d keys, not the two memory files: %v", len(keys), keys)
}
not, _ := g14.Status()["not carried"].(map[string]string)
for _, file := range []string{"key.md", "url.md", "big.md", "link.md"} {
if not[project+"/"+file] == "" {
t.Errorf("%s is not listed as not carried: %v", file, not)
}
}
}
// §4: the operator's homes and an agent account's homes never take each other's memory.
func TestHomesOfTheTwoKindsNeverTakeEachOthersMemory(t *testing.T) {
state, c := newMemState(), &clock{now: time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC)}
root := t.TempDir()
laptop := newMemNode(t, root, "g14", HomeOperator, state, c)
control := newMemNode(t, root, "novox", HomeAgent, state, c)
control.write(t, project, "MEMORY.md", "from a confined agent\n", c.now)
control.look(t)
state.pump(t)
if _, ok := laptop.read(t, project, "MEMORY.md"); ok {
t.Fatal("a confined agent's memory reached the operator's home")
}
laptop.write(t, project, "MEMORY.md", "the operator's\n", c.now)
laptop.look(t)
state.pump(t)
if got, _ := control.read(t, project, "MEMORY.md"); got != "from a confined agent\n" {
t.Fatalf("the operator's memory reached the agent account's home: %q", got)
}
// A value of the other kind put under this kind's key is refused too.
key := MemoryKey(HomeOperator, project, "x.md")
raw, _ := json.Marshal(MemoryVersion{Kind: HomeAgent, Project: project, Path: "x.md", Text: "x", Digest: digestOf([]byte("x")), Node: "novox"})
if err := laptop.Take(key, "put", raw, 99); err == nil {
t.Fatal("a value of another kind was taken")
}
// Which kind a node carries for.
if k, _ := HomeKind(Paths{Agent: "jochen", Operator: "jochen", Home: "/home/jochen"}); k != HomeOperator {
t.Fatalf("the operator's own home is %q", k)
}
if k, _ := HomeKind(Paths{Agent: "agent", Operator: "jochen", Home: "/home/agent", AgentHome: "/home/agent", OperatorHome: "/home/jochen"}); k != HomeAgent {
t.Fatalf("an agent account's home is %q", k)
}
if k, why := HomeKind(Paths{Agent: "agent", Operator: "jochen", Home: "/home/jochen", AgentHome: "/home/agent", OperatorHome: "/home/jochen"}); k != "" || why == "" {
t.Fatalf("a confined agent in the operator's home carries %q", k)
}
}
// §7, §8: a file changed on two nodes since it was carried is kept twice in one file on both, the newer first, with
// one warning; it clears once the file is tidied.
func TestAFileChangedOnTwoNodesIsKeptTwiceTheNewerFirstWithAWarning(t *testing.T) {
state, c, g14, shanks := twoNodes(t)
carriedOnBoth(t, state, c, g14, shanks, "MEMORY.md", "- base\n")
g14.write(t, project, "MEMORY.md", "- base\n- learned on g14\n", c.now.Add(time.Minute))
shanks.write(t, project, "MEMORY.md", "- base\n- learned on shanks\n", c.now.Add(2*time.Minute))
c.now = c.now.Add(3 * time.Minute)
g14.look(t)
shanks.look(t)
state.pump(t)
g14.look(t)
shanks.look(t)
state.pump(t)
a, _ := g14.read(t, project, "MEMORY.md")
b, _ := shanks.read(t, project, "MEMORY.md")
if a != b {
t.Fatalf("the nodes did not end on one text:\n%s\n----\n%s", a, b)
}
newer, older := strings.Index(a, "learned on shanks"), strings.Index(a, "learned on g14")
marker := strings.Index(a, "<!-- novox mesh: this file was changed on two nodes")
if newer < 0 || older < 0 || marker < 0 || !(newer < marker && marker < older) {
t.Fatalf("not both versions, the newer first, with the line between:\n%s", a)
}
if !strings.Contains(a[marker:], "changed on shanks") || !strings.Contains(a[marker:], "changed on g14") {
t.Fatalf("the line does not name both nodes:\n%s", a)
}
notices := append(append([]map[string]any{}, g14.notices...), shanks.notices...)
if len(notices) != 1 || notices[0]["severity"] != "warning" || notices[0]["clear"] != nil {
t.Fatalf("not one warning: %v", notices)
}
for _, field := range []string{"headline", "explanation", "summary", "resolved"} {
s, _ := notices[0][field].(string)
if s == "" || strings.ContainsAny(s, "/~:") && field != "explanation" || strings.Contains(s, ".md") {
t.Errorf("the warning's %s is empty or names a path: %q", field, s)
}
// The plain rule (ADR 0253 §3): it never sends the operator elsewhere.
if field != "summary" && (strings.Contains(strings.ToLower(s), "agent") || strings.Contains(strings.ToLower(s), "session")) {
t.Errorf("the warning's %s sends the operator elsewhere: %q", field, s)
}
}
if e, _ := notices[0]["explanation"].(string); !strings.HasPrefix(e, "Needs you: ") {
t.Errorf("the explanation does not open with its verdict: %q", e)
}
if h, _ := notices[0]["headline"].(string); len([]rune(h)) > 60 {
t.Errorf("the headline is longer than sixty characters: %q", h)
}
all := append(g14.copies(t), shanks.copies(t)...)
for _, v := range []string{"- base\n- learned on g14\n", "- base\n- learned on shanks\n"} {
if !holdsText(all, v) {
t.Errorf("the version %q was not kept", v)
}
}
if st := g14.Status(); len(st["conflicts"].([]conflict))+len(shanks.Status()["conflicts"].([]conflict)) != 1 {
t.Fatalf("the conflict is not listed: %v", st)
}
// Tidied on either node, the warning clears.
c.now = c.now.Add(time.Minute)
g14.write(t, project, "MEMORY.md", "- base\n- learned on g14\n- learned on shanks\n", c.now)
g14.look(t)
state.pump(t)
cleared := false
for _, n := range append(g14.notices, shanks.notices...) {
if n["clear"] == true {
cleared = true
}
}
if !cleared {
t.Fatal("the warning did not clear once the file was tidied")
}
if got, _ := shanks.read(t, project, "MEMORY.md"); got != "- base\n- learned on g14\n- learned on shanks\n" {
t.Fatalf("the tidied file did not reach shanks: %q", got)
}
}
// The order of the two versions: the later change first, equal times by node name.
func TestMergeVersionsPutsTheNewerFirst(t *testing.T) {
at := time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC)
m := string(MergeVersions([]byte("old\n"), "g14", at, []byte("new\n"), "shanks", at.Add(time.Second)))
if strings.Index(m, "new") > strings.Index(m, "old") {
t.Fatalf("the newer is not first: %s", m)
}
m = string(MergeVersions([]byte("from shanks\n"), "shanks", at, []byte("from ace\n"), "ace", at))
if strings.Index(m, "from ace") > strings.Index(m, "from shanks") {
t.Fatalf("equal times are not ordered by node name: %s", m)
}
}
// §9: a removal moves the other node's unchanged file into the copies.
func TestARemovalMovesTheUnchangedFileIntoTheCopies(t *testing.T) {
state, c, g14, shanks := twoNodes(t)
carriedOnBoth(t, state, c, g14, shanks, "old.md", "an old note\n")
carriedOnBoth(t, state, c, g14, shanks, "MEMORY.md", "index\n")
if err := os.Remove(g14.path(project, "old.md")); err != nil {
t.Fatal(err)
}
g14.look(t)
state.pump(t)
if _, ok := shanks.read(t, project, "old.md"); ok {
t.Fatal("the removal did not reach shanks")
}
if !holdsText(shanks.copies(t), "an old note\n") {
t.Fatal("the removed version was not kept on shanks")
}
raw, _, ok, _ := state.Get(MemoryKey(HomeOperator, project, "old.md"))
var v MemoryVersion
_ = json.Unmarshal(raw, &v)
if !ok || !v.Removed || v.Text != "" {
t.Fatalf("the removal is not a marker in the state: %s", raw)
}
if got, _ := shanks.read(t, project, "MEMORY.md"); got != "index\n" {
t.Fatal("another file was touched")
}
}
// §9: a change outlives a removal, whichever reaches the state first.
func TestAChangeOutlivesARemoval(t *testing.T) {
for _, removalFirst := range []bool{true, false} {
state, c, g14, shanks := twoNodes(t)
carriedOnBoth(t, state, c, g14, shanks, "note.md", "v1\n")
_ = os.Remove(g14.path(project, "note.md"))
shanks.write(t, project, "note.md", "v2\n", c.now.Add(time.Minute))
if removalFirst {
g14.look(t)
state.pump(t)
shanks.look(t)
} else {
shanks.look(t)
g14.look(t)
}
state.pump(t)
g14.look(t)
shanks.look(t)
state.pump(t)
for _, n := range []*memNode{g14, shanks} {
if got, _ := n.read(t, project, "note.md"); got != "v2\n" {
t.Fatalf("removal first %v: the change did not outlive the removal on %s: %q", removalFirst, n.Node, got)
}
}
}
}
// §9: a missing folder, a state that lost its keys and a deleted key remove nothing.
func TestAMissingFolderOrAnAbsentKeyRemovesNothing(t *testing.T) {
state, c, g14, shanks := twoNodes(t)
carriedOnBoth(t, state, c, g14, shanks, "MEMORY.md", "index\n")
key := MemoryKey(HomeOperator, project, "MEMORY.md")
// The folder gone on g14.
if err := os.RemoveAll(filepath.Join(g14.Home, ".claude", "projects", project, "memory")); err != nil {
t.Fatal(err)
}
g14.look(t)
state.pump(t)
if got, _ := shanks.read(t, project, "MEMORY.md"); got != "index\n" {
t.Fatal("a missing folder removed the file elsewhere")
}
// A deleted key.
_ = state.Delete(key)
state.pump(t)
if got, _ := shanks.read(t, project, "MEMORY.md"); got != "index\n" {
t.Fatal("a deleted key removed the file")
}
// The state lost: the file is carried again, not removed.
shanks.look(t)
state.pump(t)
if _, _, ok, _ := state.Get(key); !ok {
t.Fatal("a file whose key the state lost was not carried again")
}
if got, _ := shanks.read(t, project, "MEMORY.md"); got != "index\n" {
t.Fatalf("the file was touched on shanks: %q", got)
}
}
// §10: on a first carrying, two nodes' different versions are both kept, and each node's own files reach the other.
func TestTheFirstCarryingKeepsEveryVersion(t *testing.T) {
state, c, g14, shanks := twoNodes(t)
g14.write(t, project, "MEMORY.md", "- laptop's index\n", c.now)
g14.write(t, project, "laptop.md", "only on the laptop\n", c.now)
shanks.write(t, project, "MEMORY.md", "- workstation's index\n", c.now.Add(time.Hour))
shanks.write(t, project, "workstation.md", "only on the workstation\n", c.now)
shanks.write(t, project, "same.md", "same\n", c.now)
g14.write(t, project, "same.md", "same\n", c.now)
g14.look(t)
state.pump(t)
shanks.look(t)
state.pump(t)
g14.look(t)
state.pump(t)
for _, n := range []*memNode{g14, shanks} {
index, _ := n.read(t, project, "MEMORY.md")
if !strings.Contains(index, "laptop's index") || !strings.Contains(index, "workstation's index") ||
strings.Index(index, "workstation's index") > strings.Index(index, "laptop's index") {
t.Fatalf("%s's index does not hold both, the newer first:\n%s", n.Node, index)
}
for file, want := range map[string]string{"laptop.md": "only on the laptop\n", "workstation.md": "only on the workstation\n", "same.md": "same\n"} {
if got, _ := n.read(t, project, file); got != want {
t.Fatalf("%s's %s: %q", n.Node, file, got)
}
}
}
if n := len(g14.notices) + len(shanks.notices); n != 1 {
t.Fatalf("%d warnings for one conflict", n)
}
}
// Nothing is written through a symbolic link: a memory folder that is one is neither read nor written.
func TestNothingIsWrittenThroughASymbolicLink(t *testing.T) {
state, c, g14, shanks := twoNodes(t)
elsewhere := t.TempDir()
if err := os.MkdirAll(filepath.Join(shanks.Home, ".claude", "projects", project), 0o700); err != nil {
t.Fatal(err)
}
if err := os.Symlink(elsewhere, filepath.Join(shanks.Home, ".claude", "projects", project, "memory")); err != nil {
t.Fatal(err)
}
g14.write(t, project, "MEMORY.md", "index\n", c.now)
g14.look(t)
state.pump(t)
if entries, _ := os.ReadDir(elsewhere); len(entries) != 0 {
t.Fatalf("written through a symbolic link: %v", entries)
}
}
// The manifest declares the state and the tool; the tool reads and never writes.
func TestTheManifestDeclaresTheMemoryState(t *testing.T) {
raw, err := os.ReadFile("../../module.json")
if err != nil {
t.Fatal(err)
}
var m struct {
State []string `json:"state"`
}
_ = json.Unmarshal(raw, &m)
if !holdsText(m.State, MemoryState) {
t.Fatalf("the manifest does not declare the %q state: %v", MemoryState, m.State)
}
var carrier *MemoryCarrier
if st := carrier.Status(); st["carrying"] != false {
t.Fatalf("a carrier not started says it carries: %v", st)
}
}
// replay hands one node the whole current set, as a watch begins when the node comes back.
func (s *memState) replay(t *testing.T, n *memNode) {
t.Helper()
keys, _ := s.Keys()
for _, k := range keys {
raw, rev, _, _ := s.Get(k)
if err := n.Take(k, "put", raw, rev); err != nil {
t.Logf("%s taking %s: %v", n.Node, k, err)
}
}
if err := n.Drain(); err != nil && !errors.Is(err, errChanged) {
t.Logf("%s applying: %v", n.Node, err)
}
}
// §6: a node that was away while a file changed twice takes the newest version, whose base it never saw, because
// its own file holds no change it did not carry; the version replaced is kept.
func TestANodeThatWasAwayTakesTheNewestVersion(t *testing.T) {
state, c, g14, shanks := twoNodes(t)
carriedOnBoth(t, state, c, g14, shanks, "MEMORY.md", "v1\n")
state.watchers = []*MemoryCarrier{g14.MemoryCarrier}
for _, v := range []string{"v2\n", "v3\n"} {
c.now = c.now.Add(time.Minute)
g14.write(t, project, "MEMORY.md", v, c.now)
g14.look(t)
state.pump(t)
}
state.watchers = append(state.watchers, shanks.MemoryCarrier)
state.replay(t, shanks)
if got, _ := shanks.read(t, project, "MEMORY.md"); got != "v3\n" {
t.Fatalf("the node that was away holds %q", got)
}
if !holdsText(shanks.copies(t), "v1\n") {
t.Fatal("the version replaced was not kept")
}
if len(g14.notices)+len(shanks.notices) != 0 {
t.Fatal("taking a newer version warned")
}
}
// §2, §7: two versions too large to share one file are kept in two: the newer in the file, the older beside it,
// both carried; and a text that would not fit in one value of the state is not carried.
func TestTwoVersionsTooLargeForOneFileAreKeptInTwo(t *testing.T) {
state, c, g14, shanks := twoNodes(t)
carriedOnBoth(t, state, c, g14, shanks, "big.md", "base\n")
older := strings.Repeat("from g14 ", MemoryMostBytes/9-100) + "\n"
newer := strings.Repeat("from shanks ", MemoryMostBytes/12-100) + "\n"
g14.write(t, project, "big.md", older, c.now.Add(time.Minute))
shanks.write(t, project, "big.md", newer, c.now.Add(2*time.Minute))
c.now = c.now.Add(3 * time.Minute)
g14.look(t)
shanks.look(t)
state.pump(t)
g14.look(t)
shanks.look(t)
state.pump(t)
beside := OlderBeside("big.md", "g14")
if beside != "big.older-from-g14.md" {
t.Fatalf("the older is named %q", beside)
}
for _, n := range []*memNode{g14, shanks} {
if got, _ := n.read(t, project, "big.md"); got != newer {
t.Fatalf("%s's file does not hold the newer version", n.Node)
}
if got, _ := n.read(t, project, beside); got != older {
t.Fatalf("%s does not hold the older version beside it", n.Node)
}
}
notices := append(append([]map[string]any{}, g14.notices...), shanks.notices...)
if len(notices) != 1 || !strings.Contains(notices[0]["explanation"].(string), "second file beside it") {
t.Fatalf("not one warning saying the older is beside it: %v", notices)
}
// Under the size limit, but too large once encoded: not carried, and said.
g14.write(t, project, "angles.md", strings.Repeat("<", MemoryMostBytes-10), c.now)
g14.look(t)
state.pump(t)
if _, ok := shanks.read(t, project, "angles.md"); ok {
t.Fatal("a text too large for one value was carried")
}
not, _ := g14.Status()["not carried"].(map[string]string)
if not[project+"/angles.md"] == "" {
t.Fatalf("it is not listed as not carried: %v", not)
}
}
// stateHolds says whether any value of the state holds s.
func (s *memState) holds(sub string) bool {
s.mu.Lock()
defer s.mu.Unlock()
for _, v := range s.values {
var mv MemoryVersion
_ = json.Unmarshal(v, &mv)
if strings.Contains(mv.Text, sub) {
return true
}
}
return false
}
// putRaw puts a value straight into the state, as a holder of a tool runner's credential could.
func (s *memState) putRaw(t *testing.T, v MemoryVersion) string {
t.Helper()
key := MemoryKey(v.Kind, v.Project, v.Path)
s.mu.Lock()
s.write(key, v)
s.mu.Unlock()
return key
}
// Review of #276, 1: a value whose project or path leads out of a memory folder is never taken, and nothing is
// written for it.
func TestAValueLeadingOutOfAMemoryFolderIsNeverTaken(t *testing.T) {
state, c, g14, shanks := twoNodes(t)
root := filepath.Dir(filepath.Dir(shanks.Home))
for _, bad := range []struct{ project, path string }{
{"../../../escaped", "x.md"}, {"..", "x.md"}, {"a/b", "x.md"}, {"", "x.md"},
{project, "../x.md"}, {project, "/abs.md"}, {project, "a//x.md"}, {project, "./x.md"}, {project, "a/../../x.md"},
} {
text := "planted\n"
state.putRaw(t, MemoryVersion{Kind: HomeOperator, Project: bad.project, Path: bad.path, Text: text,
Digest: digestOf([]byte(text)), Node: "elsewhere", Changed: c.now})
if NameProblem(bad.project, bad.path) == "" {
t.Errorf("%q %q is taken as a name", bad.project, bad.path)
}
}
state.pump(t)
g14.look(t)
shanks.look(t)
_ = filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error {
if err == nil && !d.IsDir() {
if raw, _ := os.ReadFile(p); string(raw) == "planted\n" {
t.Errorf("a planted value was written at %s", p)
}
}
return nil
})
if NameProblem(project, "notes/a.md") != "" || NameProblem(project, "MEMORY.md") != "" {
t.Fatal("a memory file's own name is refused")
}
}
// Review of #276, 2: a file or a folder that cannot be read is kept everywhere, never carried as a removal, and is
// said as not carried.
func TestWhatCannotBeReadIsNeverTakenForGone(t *testing.T) {
if os.Geteuid() == 0 {
t.Skip("root reads a file whatever its mode")
}
state, c, g14, shanks := twoNodes(t)
carriedOnBoth(t, state, c, g14, shanks, "MEMORY.md", "index\n")
carriedOnBoth(t, state, c, g14, shanks, "notes/deep.md", "deep\n")
file, folder := g14.path(project, "MEMORY.md"), filepath.Dir(g14.path(project, "notes/deep.md"))
_ = os.Chmod(file, 0)
_ = os.Chmod(folder, 0)
t.Cleanup(func() { _ = os.Chmod(file, 0o644); _ = os.Chmod(folder, 0o755) })
g14.look(t)
state.pump(t)
for _, f := range []string{"MEMORY.md", "notes/deep.md"} {
if _, ok := shanks.read(t, project, f); !ok {
t.Errorf("%s, unreadable on g14, was removed on shanks", f)
}
}
not, _ := g14.Status()["not carried"].(map[string]string)
if len(not) == 0 {
t.Fatalf("nothing is said as not carried: %v", not)
}
keys, _ := state.Keys()
for _, k := range keys {
raw, _, _, _ := state.Get(k)
var v MemoryVersion
_ = json.Unmarshal(raw, &v)
if v.Removed {
t.Errorf("%s/%s was carried as a removal", v.Project, v.Path)
}
}
}
// Review of #276, 3: no text reaches the state without being screened, on the removal and the conflict paths too.
func TestNoUnscreenedTextReachesTheState(t *testing.T) {
token := "ghp_" + strings.Repeat("A1b2C3d4", 5) + "\n"
big := strings.Repeat("x", MemoryMostBytes+10) + "\n"
for _, text := range []string{token, big} {
// The removal path: removed on g14, changed on shanks to what is never carried.
state, c, g14, shanks := twoNodes(t)
carriedOnBoth(t, state, c, g14, shanks, "note.md", "v1\n")
_ = os.Remove(g14.path(project, "note.md"))
shanks.write(t, project, "note.md", text, c.now.Add(time.Minute))
g14.look(t)
state.pump(t)
if state.holds(text[:20]) {
t.Errorf("the removal path put text that is never carried into the state")
}
if got, _ := shanks.read(t, project, "note.md"); got != text {
t.Errorf("the change on shanks was not kept")
}
// The conflict path: changed on g14, and on shanks to what is never carried.
state, c, g14, shanks = twoNodes(t)
carriedOnBoth(t, state, c, g14, shanks, "note.md", "v1\n")
g14.write(t, project, "note.md", "v2\n", c.now.Add(time.Minute))
shanks.write(t, project, "note.md", text, c.now.Add(2*time.Minute))
g14.look(t)
state.pump(t)
shanks.look(t)
state.pump(t)
if state.holds(text[:20]) {
t.Errorf("the conflict path put text that is never carried into the state")
}
if got, _ := shanks.read(t, project, "note.md"); got != text {
t.Errorf("shanks's own version was not kept as it was")
}
}
}
// Review of #276, 4: a conflict whose file cannot be written leaves the state as it was, says so once, and merges
// once when the folder is writable again.
func TestAConflictThatCannotBeWrittenIsNotMergedAgainAndAgain(t *testing.T) {
if os.Geteuid() == 0 {
t.Skip("root writes a folder whatever its mode")
}
state, c, g14, shanks := twoNodes(t)
carriedOnBoth(t, state, c, g14, shanks, "MEMORY.md", "base\n")
g14.write(t, project, "MEMORY.md", "base\nfrom g14\n", c.now.Add(time.Minute))
shanks.write(t, project, "MEMORY.md", "base\nfrom shanks\n", c.now.Add(2*time.Minute))
folder := filepath.Dir(shanks.path(project, "MEMORY.md"))
_ = os.Chmod(folder, 0o500)
t.Cleanup(func() { _ = os.Chmod(folder, 0o700) })
g14.look(t)
state.pump(t)
for i := 0; i < 5; i++ {
_ = shanks.Look()
state.pump(t)
}
if state.holds("<!-- novox mesh") {
t.Fatal("a merge that could not be written here reached the state")
}
unwritten := 0
for _, n := range shanks.notices {
if strings.HasSuffix(n["key"].(string), ".unwritten") {
unwritten++
}
}
if unwritten != 1 {
t.Fatalf("%d warnings that the file could not be written", unwritten)
}
_ = os.Chmod(folder, 0o700)
shanks.look(t)
state.pump(t)
g14.look(t)
state.pump(t)
for _, n := range []*memNode{g14, shanks} {
got, _ := n.read(t, project, "MEMORY.md")
if strings.Count(got, "<!-- novox mesh") != 1 || !strings.Contains(got, "from g14") || !strings.Contains(got, "from shanks") {
t.Fatalf("%s does not hold both versions once:\n%s", n.Node, got)
}
}
}
// Review of #276, minor: a file that changed between its reading and its writing is not written over; text that is
// not UTF-8 is not carried.
func TestAFileChangedBeforeItsWritingIsNotWrittenOver(t *testing.T) {
state, c, g14, shanks := twoNodes(t)
_ = state
g14.write(t, project, "a.md", "read\n", c.now)
g14.write(t, project, "a.md", "changed meanwhile\n", c.now)
err := g14.writeLocal(project, "a.md", []byte("incoming\n"), "replaced", digestOf([]byte("read\n")))
if !errors.Is(err, errChangedHere) {
t.Fatalf("written over a change: %v", err)
}
if got, _ := g14.read(t, project, "a.md"); got != "changed meanwhile\n" {
t.Fatalf("the change was replaced: %q", got)
}
g14.write(t, project, "latin1.md", "caf\xe9\n", c.now)
g14.look(t)
state.pump(t)
if _, ok := shanks.read(t, project, "latin1.md"); ok {
t.Fatal("text that is not UTF-8 was carried")
}
not, _ := g14.Status()["not carried"].(map[string]string)
if !strings.Contains(not[project+"/latin1.md"], "UTF-8") {
t.Fatalf("it is not said why: %v", not)
}
}
// §9: a node that was away takes a removal of a version newer than the one it holds, when it holds no change of
// its own: the removed file does not come back.
func TestARemovalReachesANodeThatWasAway(t *testing.T) {
state, c, g14, shanks := twoNodes(t)
carriedOnBoth(t, state, c, g14, shanks, "old.md", "v1\n")
state.watchers = []*MemoryCarrier{g14.MemoryCarrier}
c.now = c.now.Add(time.Minute)
g14.write(t, project, "old.md", "v2\n", c.now)
g14.look(t)
state.pump(t)
_ = os.Remove(g14.path(project, "old.md"))
g14.look(t)
state.pump(t)
state.watchers = append(state.watchers, shanks.MemoryCarrier)
state.replay(t, shanks)
shanks.look(t)
state.pump(t)
g14.look(t)
if _, ok := shanks.read(t, project, "old.md"); ok {
t.Fatal("the removal did not reach the node that was away")
}
if _, ok := g14.read(t, project, "old.md"); ok {
t.Fatal("the removed file came back")
}
if !holdsText(shanks.copies(t), "v1\n") {
t.Fatal("the version removed on the node that was away was not kept")
}
}
// slowState answers every read only once it is let go, as a state a whole bus away does under load.
type slowState struct {
*memState
gate chan struct{}
}
func (s slowState) Get(key string) (json.RawMessage, uint64, bool, error) {
<-s.gate
return s.memState.Get(key)
}
// Issue 367, seen live: the runtime hands a watch its current values one at a time and gives them all ten seconds;
// shanks's handler read the state and waited for its look for each of a few hundred values, so the watch never
// started and nothing written on g14 reached it. The handler now only notes a change: it returns at once while a
// look holds the carrier and while the state is slow to answer, and Drain applies what it noted.
func TestTheWatchHandlerReturnsAtOnceAndDrainAppliesWhatItNoted(t *testing.T) {
state, c, g14, shanks := twoNodes(t)
g14.write(t, project, "MEMORY.md", "index\n", c.now)
g14.look(t)
state.mu.Lock()
state.pending = nil
state.mu.Unlock()
slow := slowState{memState: state, gate: make(chan struct{})}
shanks.Store = slow
key := MemoryKey(HomeOperator, project, "MEMORY.md")
raw, rev, _, _ := state.Get(key)
shanks.mu.Lock() // a look running
done := make(chan error, 1)
start := time.Now()
for i := 0; i < 400; i++ {
go func() { done <- shanks.Take(key, "put", raw, rev) }()
}
for i := 0; i < 400; i++ {
select {
case err := <-done:
if err != nil {
t.Fatal(err)
}
case <-time.After(2 * time.Second):
t.Fatalf("the watch handler waited for the look or the state (%d of 400 returned)", i)
}
}
if took := time.Since(start); took > time.Second {
t.Fatalf("400 values took %s to hand over", took)
}
shanks.mu.Unlock()
close(slow.gate)
if err := shanks.Drain(); err != nil {
t.Fatal(err)
}
if got, _ := shanks.read(t, project, "MEMORY.md"); got != "index\n" {
t.Fatalf("what the watch noted was not applied: %q", got)
}
}
@@ -216,6 +216,9 @@ func RenderNow(p Paths, write WriteManaged) ([]string, error) {
if !readJSON(p.Facts, &facts) || facts.Console == "" { if !readJSON(p.Facts, &facts) || facts.Console == "" {
return nil, fmt.Errorf("the mesh has not rendered %s yet; nothing to write", p.Facts) return nil, fmt.Errorf("the mesh has not rendered %s yet; nothing to write", p.Facts)
} }
if ForgeCredentialHeld(p) {
facts.ForgeCredentials = p.forgeCredentials()
}
var settings Settings var settings Settings
readJSON(p.Settings, &settings) readJSON(p.Settings, &settings)
var binding *Binding var binding *Binding
@@ -298,6 +301,9 @@ type BindingState struct {
Licence string `json:"licence"` Licence string `json:"licence"`
Kind string `json:"kind"` Kind string `json:"kind"`
Generation int64 `json:"generation"` Generation int64 `json:"generation"`
// Why and By are what moved the binding and who asked, as the manager recorded it (novox/hq issue 625).
Why string `json:"why,omitempty"`
By string `json:"by,omitempty"`
} }
// Current is what the seat answers to `current`: the licence this node is bound to and its token, sealed. // Current is what the seat answers to `current`: the licence this node is bound to and its token, sealed.
@@ -433,6 +439,15 @@ func OnBinding(p Paths, b *BindingState, ask Ask, write WriteManaged) (string, e
if err != nil { if err != nil {
return "", err return "", err
} }
// The line says which binding this was and why the manager moved it, so a node's journal alone answers
// what changed here and who asked (novox/hq issue 625).
out["generation"] = b.Generation
if b.Why != "" {
out["why"] = b.Why
}
if b.By != "" {
out["by"] = b.By
}
raw, _ := json.Marshal(out) raw, _ := json.Marshal(out)
return string(raw), nil return string(raw), nil
} }
@@ -130,7 +130,7 @@ func proposalTools(p Paths) []stdio.Tool {
text, _ = a["content"].(string) text, _ = a["content"].(string)
} }
return Propose(p, ProposeInput{Scope: scopeOf(a), Nodes: nodesOf(a["nodes"]), Name: strArg(a, "name"), return Propose(p, ProposeInput{Scope: scopeOf(a), Nodes: nodesOf(a["nodes"]), Name: strArg(a, "name"),
Text: text, Remove: boolArg(a, "remove")}, proposing.Store, proposing.Bus, proposing.Now()) Text: text, Remove: boolArg(a, "remove"), Summary: strArg(a, "summary")}, proposing.Store, proposing.Bus, proposing.Now())
}}, }},
{Name: "claude_code_proposals", {Name: "claude_code_proposals",
Description: "The instruction sections proposed for the operator's approval (novox/hq ADR 0274), newest first: what " + Description: "The instruction sections proposed for the operator's approval (novox/hq ADR 0274), newest first: what " +
@@ -164,14 +164,14 @@ func questionTools(p Paths, notLaunched error) []stdio.Tool {
"operator's word to you; it authorises no call that needs the operator's approval.", "operator's word to you; it authorises no call that needs the operator's approval.",
Input: map[string]any{ Input: map[string]any{
"headline": str("what is decided, in a few plain words, at most 60 characters, e.g. 'Merge the resolver fix tonight?'"), "headline": str("what is decided, in a few plain words, at most 60 characters, e.g. 'Merge the resolver fix tonight?'"),
"context": str("what the operator decides and why it matters, in plain words, at most 1500 bytes"), "context": str("what the operator decides and why it matters, in plain words: two or three short sentences, one line, at most 300 characters"),
"options": map[string]any{"type": "array", "description": "two to four answers; exactly one recommended", "options": map[string]any{"type": "array", "description": "two to four answers; exactly one recommended",
"items": map[string]any{"type": "object", "properties": map[string]any{ "items": map[string]any{"type": "object", "properties": map[string]any{
"label": str("the button's words, at most 24 characters"), "label": str("the button's words, at most 24 characters"),
"means": str("what you do when it is chosen, in plain words"), "means": str("what you do when it is chosen, in plain words"),
"recommended": map[string]any{"type": "boolean", "description": "true on the one you recommend"}, "recommended": map[string]any{"type": "boolean", "description": "true on the one you recommend"},
}, "required": []string{"label", "means"}}}, }, "required": []string{"label", "means"}}},
"why": str("why the recommended option is the one you recommend"), "why": str("why the recommended option is the one you recommend, at most 120 bytes"),
"on_expiry": str("what you do if nobody answers before it expires, e.g. 'I leave the pull request open'"), "on_expiry": str("what you do if nobody answers before it expires, e.g. 'I leave the pull request open'"),
"by": str("required: your session's name, as who@node or who, e.g. fix-589@g14"), "by": str("required: your session's name, as who@node or who, e.g. fix-589@g14"),
"expires_minutes": map[string]any{"type": "integer", "expires_minutes": map[string]any{"type": "integer",
@@ -104,6 +104,12 @@ type Proposal struct {
Digest string `json:"digest"` Digest string `json:"digest"`
From string `json:"from"` From string `json:"from"`
At time.Time `json:"at"` At time.Time `json:"at"`
// Summary is what the operator's phone shows of it, at a glance, in its proposer's words; empty, the module
// says what it does (novox/hq issue 639).
Summary string `json:"summary,omitempty"`
// Form is how its ask is composed, so every node composes the same one again to hold the warrant to it: 0 for
// a proposal made before issue 639 (its text in the explanation), AskGlance for one read at a glance.
Form int `json:"form,omitempty"`
// Asked is empty once the ask was published, or says why it was not; nothing composes from it. // Asked is empty once the ask was published, or says why it was not; nothing composes from it.
Asked string `json:"asked,omitempty"` Asked string `json:"asked,omitempty"`
} }
@@ -164,9 +170,109 @@ func (p Proposal) Act(option string) asks.Act {
"at": p.At.UTC().Format(time.RFC3339Nano)} "at": p.At.UTC().Format(time.RFC3339Nano)}
} }
// AskGlance is the form of an ask read at a glance (Proposal.Form, Question.Form; novox/hq issue 639).
const AskGlance = 1
// Ask is the ask for a proposal, composed from it alone: every node composes the same one, and holds the // Ask is the ask for a proposal, composed from it alone: every node composes the same one, and holds the
// router's warrant to it. // router's warrant to it — in the form it was asked in.
func (p Proposal) Ask() asks.Ask { func (p Proposal) Ask() asks.Ask {
if p.Form == AskGlance {
return p.glanceAsk()
}
return p.listedAsk()
}
// glanceAsk is the ask read at a glance on the phone (novox/hq issue 639): its headline, and as its explanation
// the summary of what it does; the text, where it came from, its fingerprint and where to read all of it are its
// Details, which the router notes after the answers, collapsed where the channel can.
func (p Proposal) glanceAsk() asks.Ask {
verb := map[string]string{ActionAdd: "Add", ActionReplace: "Replace", ActionRemove: "Remove"}[p.Action]
shown, whole := excerpt(p.Text)
part := ""
if p.Action != ActionRemove && !whole {
part = " Part shown"
}
headline := fmt.Sprintf("%s Claude instructions '%s'?%s", verb, p.Name, part)
if len([]rune(headline)) > asks.HeadlineLength {
headline = verb + " Claude instructions?" + part
}
var d strings.Builder
if p.Action == ActionRemove {
fmt.Fprintf(&d, "Proposed through the claude-code module on %s at %s UTC. The approved text it removes has the fingerprint %s.",
p.From, p.At.UTC().Format("15:04 on 2 Jan"), p.Fingerprint())
} else {
if whole {
d.WriteString("The text:\n" + shown + "\n")
} else {
d.WriteString("NOT SHOWN IN FULL here. Its first lines:\n" + shown + "\n")
}
fmt.Fprintf(&d, "Proposed through the claude-code module on %s at %s UTC: %d lines, %d bytes, fingerprint %s.",
p.From, p.At.UTC().Format("15:04 on 2 Jan"), p.Lines(), len(p.Text), p.Fingerprint())
}
fmt.Fprintf(&d, "\nRead all of it at the controller's terminal: mesh-cli ask claude-code claude_code_proposals '{\"id\": \"%s\"}'.", p.ID)
does := "the text becomes part of those sessions' instructions"
if p.Action == ActionRemove {
does = "the instructions are removed from those sessions"
}
return p.withOptions(asks.Ask{ID: p.ID, Headline: headline, Explanation: p.summary(), Details: d.String()}, does)
}
// summary is what the proposal does, at a glance: its proposer's words, or the module's.
func (p Proposal) summary() string {
if s := strings.TrimSpace(p.Summary); s != "" {
return s
}
lines := fmt.Sprintf("these %d lines", p.Lines())
if p.Lines() == 1 {
lines = "this line"
}
where := strings.ToUpper(p.where()[:1]) + p.where()[1:]
switch p.Action {
case ActionRemove:
return fmt.Sprintf("%s stops reading the instructions '%s' from its next start.", where, p.Name)
case ActionReplace:
return fmt.Sprintf("%s reads %s in place of the instructions '%s', as the mesh's own word, from its next start until "+
"they are removed the same way.", where, lines, p.Name)
}
return fmt.Sprintf("%s reads %s as the mesh's own word, from its next start until they are removed the same way.", where, lines)
}
// SummaryProblem says why a proposer's summary cannot be shown at a glance, or "".
func SummaryProblem(summary string) string {
switch s := strings.TrimSpace(summary); {
case s == "":
return ""
case strings.Contains(s, "\n"):
return "the summary is more than one line"
case len([]rune(s)) > asks.SummaryLength:
return fmt.Sprintf("the summary is %d characters, more than the %d a phone shows at a glance", len([]rune(s)), asks.SummaryLength)
case !shownLine(s):
return "the summary names a path, an address, a host name or a long word, which a phone is not shown"
}
return ""
}
// withOptions is an ask with what every form of it carries: who answers, its expiry, what it is about, and its two
// options, each binding its act.
func (p Proposal) withOptions(a asks.Ask, does string) asks.Ask {
a.Who, a.Expires, a.OnExpiry = asks.Operator, p.At.Add(ProposalLasts), "the proposal is discarded; nothing changes"
a.About = "instructions." + p.Scope + "." + strings.Join(p.Nodes, "+") + "." + p.Name
if len(p.Nodes) == 0 {
a.About = "instructions." + p.Scope + "." + p.Name
}
for _, o := range []struct{ id, label, does string }{
{OptionApprove, "Approve", does},
{OptionDecline, "Decline", "nothing changes; the proposal is discarded"},
} {
binds, _ := asks.ActDigest(p.Act(o.id))
a.Options = append(a.Options, asks.Option{ID: o.id, Label: o.label, Does: o.does, Level: asks.Approve, Binds: binds})
}
return a
}
// listedAsk is the ask as it was composed before issue 639, the text in its explanation: composed only to hold the
// warrant of a proposal made then.
func (p Proposal) listedAsk() asks.Ask {
verb := map[string]string{ActionAdd: "Add", ActionReplace: "Replace", ActionRemove: "Remove"}[p.Action] verb := map[string]string{ActionAdd: "Add", ActionReplace: "Replace", ActionRemove: "Remove"}[p.Action]
shown, whole := excerpt(p.Text) shown, whole := excerpt(p.Text)
part := "" part := ""
@@ -617,6 +723,8 @@ type ProposeInput struct {
Name string Name string
Text string Text string
Remove bool Remove bool
// Summary is what the operator's phone shows of it, in one line (novox/hq issue 639); empty, the module says it.
Summary string
} }
// AtTheTerminalInstead is the other way, said whenever a proposal is refused for want of a channel. // AtTheTerminalInstead is the other way, said whenever a proposal is refused for want of a channel.
@@ -696,7 +804,11 @@ func Propose(p Paths, in ProposeInput, store ProposalStore, bus AskBus, now time
return nil, err return nil, err
} }
active, isActive := approved.Sections[sectionKey(in.Scope, nodes, in.Name)] active, isActive := approved.Sections[sectionKey(in.Scope, nodes, in.Name)]
prop := Proposal{ID: newProposalID(), Scope: in.Scope, Nodes: nodes, Name: in.Name, From: p.Node, At: now.UTC()} if problem := SummaryProblem(in.Summary); problem != "" {
return refuse(problem)
}
prop := Proposal{ID: newProposalID(), Scope: in.Scope, Nodes: nodes, Name: in.Name, From: p.Node, At: now.UTC(),
Summary: strings.TrimSpace(in.Summary), Form: AskGlance}
switch { switch {
case in.Remove: case in.Remove:
if !isActive { if !isActive {
@@ -147,14 +147,17 @@ func TestAProposalAsksTheOperatorAtTheLevelApproveAndChangesNothing(t *testing.T
if err := a.Check(t0); err != nil { if err := a.Check(t0); err != nil {
t.Fatalf("the router would refuse the ask: %v", err) t.Fatalf("the router would refuse the ask: %v", err)
} }
if !strings.HasPrefix(a.Explanation, "Add instructions 'handoff-from-g14' to every Claude session on the mesh?") { // At a glance (issue 639): what it does is the explanation, one line; the text is the Details, which the
t.Errorf("the phone does not say what is asked first: %q", a.Explanation) // router notes collapsed after the answers.
if a.Explanation != "Every Claude session on the mesh reads these 4 lines as the mesh's own word, from its next start until they are removed the same way." ||
asks.Glance(a.Explanation) != nil {
t.Errorf("the phone does not say what is asked at a glance: %q", a.Explanation)
} }
if a.Headline != "Add Claude instructions 'handoff-from-g14'?" { if a.Headline != "Add Claude instructions 'handoff-from-g14'?" {
t.Errorf("headline: %q", a.Headline) t.Errorf("headline: %q", a.Headline)
} }
if !strings.Contains(a.Explanation, "The whole text:\n\n"+strings.TrimRight(handover, "\n")) { if !strings.HasPrefix(a.Details, "The text:\n"+strings.TrimRight(handover, "\n")+"\nProposed through the claude-code module on g14 at ") {
t.Errorf("a short text is shown whole: %q", a.Explanation) t.Errorf("a short text is not shown whole: %q", a.Details)
} }
if len(a.Options) != 2 { if len(a.Options) != 2 {
t.Fatalf("options: %+v", a.Options) t.Fatalf("options: %+v", a.Options)
@@ -294,7 +297,7 @@ func TestARemovalNeedsTheSameApprovalAndARecordDeletedRemovesNothing(t *testing.
t.Fatal(err) t.Fatal(err)
} }
rid := out["proposed"].(string) rid := out["proposed"].(string)
if !strings.HasPrefix(r.published[rid].Explanation, "Remove the instructions 'handoff-from-g14' from every Claude session on the mesh?") { if r.published[rid].Explanation != "Every Claude session on the mesh stops reading the instructions 'handoff-from-g14' from its next start." {
t.Errorf("removal ask: %q", r.published[rid].Explanation) t.Errorf("removal ask: %q", r.published[rid].Explanation)
} }
if !strings.Contains(renderedInstructions(t, p), "### handoff-from-g14") { if !strings.Contains(renderedInstructions(t, p), "### handoff-from-g14") {
@@ -326,7 +329,7 @@ func TestANodeScopedSectionReachesOnlyItsNodes(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
id := out["proposed"].(string) id := out["proposed"].(string)
if !strings.HasPrefix(r.published[id].Explanation, "Add instructions 'builds' to every Claude session on shanks?") { if !strings.HasPrefix(r.published[id].Explanation, "Every Claude session on shanks reads this line as the mesh's own word") {
t.Errorf("%q", r.published[id].Explanation) t.Errorf("%q", r.published[id].Explanation)
} }
r.close(r.chose(id, OptionApprove, t0.Add(time.Hour))) r.close(r.chose(id, OptionApprove, t0.Add(time.Hour)))
@@ -417,15 +420,16 @@ func TestALongTextIsShownInPartAndReadWholeAtTheTerminal(t *testing.T) {
} }
id := out["proposed"].(string) id := out["proposed"].(string)
a := r.published[id] a := r.published[id]
if strings.Contains(a.Explanation, "/etc/claude-code") || !strings.Contains(a.Explanation, "[1 line(s) not shown here") { if strings.Contains(a.Details, "/etc/claude-code") || !strings.Contains(a.Details, "[1 line(s) not shown here") ||
t.Errorf("a line with a path was shown: %q", a.Explanation) !strings.HasPrefix(a.Details, "NOT SHOWN IN FULL here.") || !strings.HasSuffix(a.Headline, "Part shown") {
t.Errorf("a line with a path was shown, or the cut not said: %q", a.Details)
} }
if !strings.Contains(a.Explanation, "mesh-cli ask claude-code claude_code_proposals") || if !strings.Contains(a.Details, "mesh-cli ask claude-code claude_code_proposals") ||
!strings.Contains(a.Explanation, "fingerprint there must be "+out["fingerprint"].(string)) { !strings.Contains(a.Details, "fingerprint "+out["fingerprint"].(string)) {
t.Errorf("the phone does not say where to read all of it: %q", a.Explanation) t.Errorf("the phone does not say where to read all of it: %q", a.Details)
} }
if len(a.Explanation) > 3000 { if len(a.Details) > 3000 || asks.Glance(a.Explanation) != nil {
t.Errorf("the explanation is %d bytes", len(a.Explanation)) t.Errorf("the details are %d bytes; the explanation %q", len(a.Details), a.Explanation)
} }
list, err := ListProposals(p, store, r, id, t0) list, err := ListProposals(p, store, r, id, t0)
if err != nil { if err != nil {
@@ -739,3 +743,33 @@ func TestAnAskTakenAfterItsRefusalIsStillAnswered(t *testing.T) {
t.Errorf("decided %q", got) t.Errorf("decided %q", got)
} }
} }
// A proposer's summary is the explanation, held to a glance; a proposal made before issue 639 is composed as it was
// asked, so the warrant for it still holds after the change.
func TestAProposalIsAskedAtAGlanceAndAnOlderOneAsItWasAsked(t *testing.T) {
p, _ := node(t, "g14")
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
out, err := Propose(p, ProposeInput{Scope: ScopeMesh, Name: "handoff-from-g14", Text: handover,
Summary: "Every session learns how hand-overs are written, so the next one reads them the same way."}, store, r, t0)
if err != nil {
t.Fatal(err)
}
id := out["proposed"].(string)
a := r.published[id]
if a.Explanation != "Every session learns how hand-overs are written, so the next one reads them the same way." {
t.Errorf("the proposer's summary is not the explanation: %q", a.Explanation)
}
for _, bad := range []string{strings.Repeat("word ", 70), "one\ntwo", "Read it in /etc/claude-code first."} {
if _, err := Propose(p, ProposeInput{Scope: ScopeMesh, Name: "other", Text: handover, Summary: bad}, store, r, t0); err == nil ||
!strings.Contains(err.Error(), "the summary") {
t.Errorf("the summary %q was taken: %v", bad, err)
}
}
prop, _ := readProposal(store, id)
older := prop
older.Form = 0
listed := older.Ask()
if !strings.Contains(listed.Explanation, "The whole text:") || listed.Details != "" || listed.Digest() == prop.Ask().Digest() {
t.Errorf("a proposal made before is not composed as it was asked: %q", listed.Explanation)
}
}
@@ -69,9 +69,9 @@ const (
// The bounds of what a question says. // The bounds of what a question says.
const ( const (
FewestQuestionOptions = 2 FewestQuestionOptions = 2
MostContextBytes = 1500 MostContextBytes = asks.SummaryLength
MostMeansBytes = 200 MostMeansBytes = 200
MostWhyBytes = 300 MostWhyBytes = 120
MostOnExpiryBytes = 200 MostOnExpiryBytes = 200
) )
@@ -131,6 +131,8 @@ type Question struct {
From string `json:"from"` From string `json:"from"`
At time.Time `json:"at"` At time.Time `json:"at"`
Expires time.Time `json:"expires"` Expires time.Time `json:"expires"`
// Form is how its ask is composed (AskGlance since novox/hq issue 639), so the answer is held to the ask asked.
Form int `json:"form,omitempty"`
// Asked is empty once the ask was published, or says why it was not; nothing composes from it. // Asked is empty once the ask was published, or says why it was not; nothing composes from it.
Asked string `json:"asked,omitempty"` Asked string `json:"asked,omitempty"`
// Ended is the router's word that the ask ended without a choice, as heard on the decided subject: a refused ask // Ended is the router's word that the ask ended without a choice, as heard on the decided subject: a refused ask
@@ -160,8 +162,41 @@ func (q Question) Act(option string) asks.Act {
return act return act
} }
// Ask is the ask for a question, composed from it alone. // Ask is the ask for a question, composed from it alone, in the form it was asked in.
func (q Question) Ask() asks.Ask { func (q Question) Ask() asks.Ask {
if q.Form == AskGlance {
return q.glanceAsk()
}
return q.listedAsk()
}
// glanceAsk is the question read at a glance on the phone (novox/hq issue 639): its context, then the recommended
// answer and why as its one point; who asked and what the answer does are its Details.
func (q Question) glanceAsk() asks.Ask {
explanation := strings.TrimSpace(q.Context)
if rec, ok := q.recommended(); ok {
explanation += fmt.Sprintf("\n• Recommended: %s, because %s.", rec.Label, strings.TrimRight(strings.TrimSpace(q.Why), "."))
}
details := fmt.Sprintf("Asked by the agent session %s on %s through the claude-code module, at %s UTC. Your answer is told "+
"to that session; it performs nothing by itself.", q.By, q.From, q.At.UTC().Format("15:04 on 2 Jan"))
return q.withOptions(asks.Ask{ID: q.ID, Headline: q.Headline, Explanation: explanation, Details: details})
}
func (q Question) withOptions(a asks.Ask) asks.Ask {
a.Who, a.Expires, a.OnExpiry = asks.Operator, q.Expires, q.OnExpiry
for _, o := range q.Options {
does := o.Means
if o.Recommended {
does = "Recommended. " + does
}
binds, _ := asks.ActDigest(q.Act(o.ID))
a.Options = append(a.Options, asks.Option{ID: o.ID, Label: o.Label, Does: does, Level: asks.Approve, Binds: binds})
}
return a
}
// listedAsk is the ask as it was composed before issue 639: composed only to hold the answer to a question asked then.
func (q Question) listedAsk() asks.Ask {
var b strings.Builder var b strings.Builder
b.WriteString(strings.TrimSpace(q.Context)) b.WriteString(strings.TrimSpace(q.Context))
if rec, ok := q.recommended(); ok { if rec, ok := q.recommended(); ok {
@@ -199,8 +234,10 @@ func QuestionProblem(q Question) string {
switch { switch {
case strings.TrimSpace(q.Context) == "": case strings.TrimSpace(q.Context) == "":
add("it gives no context: say in plain words what the operator decides and why it matters") add("it gives no context: say in plain words what the operator decides and why it matters")
case len(q.Context) > MostContextBytes: case len([]rune(q.Context)) > MostContextBytes:
add("its context is %d bytes, more than the %d a phone shows in one glance", len(q.Context), MostContextBytes) add("its context is %d characters, more than the %d a phone shows in one glance", len([]rune(q.Context)), MostContextBytes)
case strings.Contains(strings.TrimSpace(q.Context), "\n"):
add("its context is more than one line: a phone shows it at a glance as one short paragraph")
} }
if n := len(q.Options); n < FewestQuestionOptions || n > asks.MostOptions { if n := len(q.Options); n < FewestQuestionOptions || n > asks.MostOptions {
add("it offers %d options; a question offers %d to %d", n, FewestQuestionOptions, asks.MostOptions) add("it offers %d options; a question offers %d to %d", n, FewestQuestionOptions, asks.MostOptions)
@@ -322,7 +359,7 @@ func AskQuestion(p Paths, in QuestionInput, store QuestionStore, bus AskBus, now
} }
q := Question{ID: newQuestionID(), Headline: strings.TrimSpace(in.Headline), Context: strings.TrimSpace(in.Context), q := Question{ID: newQuestionID(), Headline: strings.TrimSpace(in.Headline), Context: strings.TrimSpace(in.Context),
Why: strings.TrimSpace(in.Why), OnExpiry: strings.TrimSpace(in.OnExpiry), By: by, From: p.Node, At: now.UTC(), Why: strings.TrimSpace(in.Why), OnExpiry: strings.TrimSpace(in.OnExpiry), By: by, From: p.Node, At: now.UTC(),
Expires: now.UTC().Add(life)} Expires: now.UTC().Add(life), Form: AskGlance}
for i, o := range in.Options { for i, o := range in.Options {
q.Options = append(q.Options, QuestionOption{ID: fmt.Sprintf("o%d", i+1), Label: strings.TrimSpace(o.Label), q.Options = append(q.Options, QuestionOption{ID: fmt.Sprintf("o%d", i+1), Label: strings.TrimSpace(o.Label),
Means: strings.TrimSpace(o.Means), Recommended: o.Recommended}) Means: strings.TrimSpace(o.Means), Recommended: o.Recommended})
@@ -68,10 +68,13 @@ func TestAQuestionIsAskedUnderTheModulesNameAtTheLevelApprove(t *testing.T) {
if !strings.HasPrefix(a.Options[0].Does, "Recommended. ") || strings.HasPrefix(a.Options[1].Does, "Recommended") { if !strings.HasPrefix(a.Options[0].Does, "Recommended. ") || strings.HasPrefix(a.Options[1].Does, "Recommended") {
t.Errorf("the recommendation is not on the recommended option alone: %+v", a.Options) t.Errorf("the recommendation is not on the recommended option alone: %+v", a.Options)
} }
for _, want := range []string{"Recommended: Merge now, because it is reviewed and the checks are green.", // At a glance (issue 639): the context and the recommendation are the explanation; who asks is the Details.
"the agent session fix-589 on g14", "performs nothing by itself"} { if !strings.HasSuffix(a.Explanation, "\n• Recommended: Merge now, because it is reviewed and the checks are green.") || asks.Glance(a.Explanation) != nil {
if !strings.Contains(a.Explanation, want) { t.Errorf("the explanation does not read at a glance:\n%s", a.Explanation)
t.Errorf("the explanation does not say %q:\n%s", want, a.Explanation) }
for _, want := range []string{"the agent session fix-589 on g14", "performs nothing by itself"} {
if !strings.Contains(a.Details, want) || strings.Contains(a.Explanation, want) {
t.Errorf("the details do not say %q:\n%s", want, a.Details)
} }
} }
if err := a.Check(t0); err != nil { if err := a.Check(t0); err != nil {
@@ -34,6 +34,9 @@ const meshEntry = "mesh"
type Facts struct { type Facts struct {
Node string `json:"node"` Node string `json:"node"`
Console string `json:"console"` Console string `json:"console"`
// ForgeCredentials is the forge's read credential placed in the agent's home, when this machine holds one
// (forge.go): its path, which every session's git is pointed at. Never rendered by the mesh.
ForgeCredentials string `json:"-"`
} }
// Settings are the operator's, for the mesh or this node. // Settings are the operator's, for the mesh or this node.
@@ -139,6 +142,7 @@ func RenderWithMesh(facts Facts, settings Settings, binding *Binding, helperPath
} }
managed[key] = entries managed[key] = entries
} }
forgeEnv(managed, facts.ForgeCredentials)
if binding != nil && binding.Kind == "api-key" { if binding != nil && binding.Kind == "api-key" {
managed["apiKeyHelper"] = helperPath managed["apiKeyHelper"] = helperPath
} }
@@ -19,11 +19,12 @@ const agentAccountFile = "/etc/claude-code/agent-account"
type manifestShell struct { type manifestShell struct {
Resources []map[string]any `json:"resources"` Resources []map[string]any `json:"resources"`
Shell []struct { // The zsh code is an item of the collection shell:zsh:<slot> (novox/hq ADR 0349 clause 13), its text the
For string `json:"for"` // template file beside the manifest.
Slot string `json:"slot"` Items []struct {
Code string `json:"code"` Collection string `json:"collection"`
} `json:"shell"` Template string `json:"template"`
} `json:"items"`
} }
func readManifestShell(t *testing.T) manifestShell { func readManifestShell(t *testing.T) manifestShell {
@@ -54,9 +55,13 @@ func resourceByID(t *testing.T, m manifestShell, id string) map[string]any {
func zshCode(t *testing.T, m manifestShell) string { func zshCode(t *testing.T, m manifestShell) string {
t.Helper() t.Helper()
var code []string var code []string
for _, s := range m.Shell { for _, it := range m.Items {
if s.For == "zsh" { if strings.HasPrefix(it.Collection, "shell:zsh:") {
code = append(code, s.Code) text, err := os.ReadFile(filepath.Join("..", "..", it.Template))
if err != nil {
t.Fatal(err)
}
code = append(code, string(text))
} }
} }
if len(code) != 1 { if len(code) != 1 {
@@ -71,8 +76,8 @@ func TestTheAccountAgentsRunAsIsWrittenForTheShell(t *testing.T) {
if r["type"] != "file" || r["path"] != agentAccountFile || r["mode"] != "0644" { if r["type"] != "file" || r["path"] != agentAccountFile || r["mode"] != "0644" {
t.Fatalf("the account's file is %v", r) t.Fatalf("the account's file is %v", r)
} }
if r["content"] != "${machine:agent-account}\n" { if text := templateText(t, r); text != "${machine:agent-account}\n" {
t.Fatalf("the account's file holds %q", r["content"]) t.Fatalf("the account's file holds %q", text)
} }
if _, has := r["owner"]; has { if _, has := r["owner"]; has {
t.Fatal("the account's file must be root's, as claude-agent is") t.Fatal("the account's file must be root's, as claude-agent is")
@@ -163,7 +168,7 @@ func TestClaudeAgentRunsTheRealClaudeUnderTheAlias(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
start, _ := resourceByID(t, readManifestShell(t), "start")["content"].(string) start := templateText(t, resourceByID(t, readManifestShell(t), "start"))
if !strings.Contains(start, "exec claude \"$@\"") { if !strings.Contains(start, "exec claude \"$@\"") {
t.Fatalf("claude-agent no longer runs claude itself:\n%s", start) t.Fatalf("claude-agent no longer runs claude itself:\n%s", start)
} }
@@ -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)
}
}
+1
View File
@@ -0,0 +1 @@
${machine:agent-account}
+13
View File
@@ -0,0 +1,13 @@
#!/bin/sh
# The mesh's (module claude-code, novox/hq ADR 0266): start the agent as the account agents run as on this
# machine. Written whole at every push: an edit here is overwritten.
agent='${machine:agent-account}'
if [ "$(id -un)" = "$agent" ]; then
exec claude "$@"
fi
# Another account, the operator's, becomes the agent's through its own sudo: the person is the authority. The
# operator's override of the guard travels only when it is set in this shell, as it would reach claude.
if [ -n "$MESH_GUARD_OVERRIDE" ]; then
exec sudo -iu "$agent" env MESH_GUARD_OVERRIDE="$MESH_GUARD_OVERRIDE" claude "$@"
fi
exec sudo -iu "$agent" claude "$@"
+4
View File
@@ -0,0 +1,4 @@
{
"node": "${machine:name}",
"console": "http://127.0.0.1:${bound:mcp-endpoint:port}/mcp"
}
+10
View File
@@ -0,0 +1,10 @@
{
"role": "",
"mcp_servers": {},
"managed_settings": {},
"instructions": {},
"output_styles": {},
"skills": {},
"commands": {},
"agents": {}
}
+12
View File
@@ -0,0 +1,12 @@
# The agent's session (module claude-code, novox/hq ADR 0266): where this machine names an account of the
# agents' own, claude in an interactive zsh is claude-agent, which starts the session as that account. Where
# agents run as the operator's account the file names that account, and nothing is added. claude-agent runs
# the real claude: exec, sudo and its sh see no alias.
if [[ -r /etc/claude-code/agent-account ]]; then
() {
local agent=$(</etc/claude-code/agent-account)
if [[ -n $agent && $agent != $USERNAME ]]; then
alias claude=claude-agent
fi
}
fi

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