Compare commits

...
Author SHA1 Message Date
jschoubben 16bb051418 Merge origin/main into feat/r4d-n8n 2026-10-21 19:14:22 +02:00
mesh-tools 7a7a403485 Merge pull request 'Files go through the store: object-store links, node-files upload/download, a document converter, and brother printing a key, a path on any machine or a web link (design 60 F3, ADR 0468)' (#417) from feat/files-through-the-store into main 2026-10-21 16:31:22 +00:00
jschoubben 6cf51173d5 Give the Service its own fetch client and open-document waits instead of reading package variables from background goroutines, which a test or reload changed meanwhile: data races under -race (issue 1601)
mesh/repo-check head 6cf51173d586 merged into trunk b2f5ebb27dbf beside mesh-controller#424@550cf37d — pass: its merge-check.sh passed, each part in its to…
mesh/merge-gate head 6cf51173d586 merged into trunk b2f5ebb27dbf — pass: group feat/files-through-the-store composed: every machine composes with the chang…
mesh/delivery superseded: its walk was superseded and names no walk that took it over: a walk that took it over answers its merge, and it follows that wa…
mesh/delivery-group group feat/files-through-the-store stopped: superseded, not carried by a later delivery: novox/mesh-catalog@6cf51173d586, novox/mesh-contro…
2026-10-21 18:03:58 +02:00
jschoubben ad85a979e3 Read the upload fake under a lock, since the print runs in the background and the test read it meanwhile: a data race under -race (issue 1598) 2026-10-21 18:03:58 +02:00
jschoubben aef2a1a91f Merge remote-tracking branch 'origin/main' into feat/files-through-the-store
mesh/delivery-group group feat/files-through-the-store rejected: a member's own check failed
mesh/merge-gate waits for its group's composed check
mesh/repo-check head aef2a1a91ffc merged into trunk b2f5ebb27dbf beside mesh-controller#424@550cf37d — fail: its merge-check.sh failed: --- FAIL: TestAScan…
mesh/delivery superseded: a newer head of the same pull request
2026-10-21 17:49:29 +02:00
mesh-tools b2f5ebb27d Merge pull request 'Fill a delivery made from its verdict from its pull request's announcement, and say what it waits for (issue 1726)' (#419) from fix/1726-delivery-from-verdict into main 2026-10-21 15:40:16 +00:00
mesh-tools 3259765d46 Merge pull request 'Read automx's rows in a mode its image's sqlite3 knows (issue 1630)' (#418) from fix/1630-automx-rows-sqlite into main 2026-10-21 15:08:27 +00:00
jschoubben 4b9464f3ce Supersede older heads when an announcement or edit fills a delivery made from its verdict, and say truthfully what fills it (issue 1726)
mesh/merge-gate head 4b9464f3ceeb merged into trunk 3259765d46bc — pass: builds mesh-delivery-gate → novox; no bus step; every machine composes with the ch…
mesh/repo-check head 4b9464f3ceeb merged into trunk 3259765d46bc — 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@6cf51173d586 (merged as 7a7a4034 into main, walk plan-17925972200…
2026-10-21 17:06:51 +02:00
jschoubben 1f7dd702d2 Read automx's rows with a mode its image's sqlite3 knows, since it refuses -json (issue 1630)
mesh/merge-gate head 1f7dd702d283 merged into trunk 90ecba4e50a3 — pass: builds mailu, sent nowhere; no bus step; 1 wait(s) for a person; every machine com…
mesh/repo-check head 1f7dd702d283 merged into trunk 90ecba4e50a3 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
2026-10-21 16:24:58 +02:00
jschoubben dd31d991cd Fill a delivery made from its verdict from its pull request's announcement, and say what it waits for (issue 1726) 2026-10-21 16:18:43 +02:00
jschoubben a2deae03b7 Answer printer.print within 25 s as started with its ask id, and read it with printer.print {ask}, so no call is cut by the tool runner (issue 1598)
mesh/merge-gate checking again: rejected at 16:52 only because the group sibling mesh-controller#424 (227c86eb) no longer merged into main; the sibling's n…
mesh/repo-check checking again: rejected at 16:52 only because the group sibling mesh-controller#424 (227c86eb) no longer merged into main; the sibling's n…
mesh/delivery-group group feat/files-through-the-store checking: 0 of 2 member(s) ready
mesh/delivery superseded: a newer head of the same pull request
2026-10-21 15:25:17 +02:00
jschoubben 72ef20a4e7 Follow a conversion with convert {ask}, each call under 25 s, and leave the SDK's call timeout alone (issue 1600) 2026-10-21 15:23:33 +02:00
jschoubben 778356f10c Answer a conversion not done in 25 s as converting and read it with convert {ask}, so no call is cut by the tool runner (issue 1595) 2026-10-21 15:22:36 +02:00
jschoubben 7c97ce0bf8 Bound an upload and a download to 25 s, so a tool call is never cut while the transfer goes on (issue 1597) 2026-10-21 15:21:56 +02:00
jschoubben 68e1ad80ac Declare the seat document-converter in only-office's seats (issue 1595) 2026-10-21 15:19:03 +02:00
jschoubben 6e2762d943 Declare the seat object-store in minio's seats (issue 1516) 2026-10-21 15:19:03 +02:00
jschoubben dc2b0641bf Merge remote-tracking branch 'origin/main' into feat/files-through-the-store 2026-10-21 15:10:43 +02:00
jschoubben b54640e386 Post conversions to the document server's documented /converter path (issue 1596) 2026-10-21 15:10:35 +02:00
jschoubben 6358041eca Convert a Word, spreadsheet, slide, text or web document to PDF through the converter before printing it (issue 1600) 2026-10-21 15:09:47 +02:00
jschoubben 657b38ccd1 Print a web link: the device process fetches it bounded, never inside the mesh or the machine, stores it under its SHA-256 and prints that (issue 1599) 2026-10-21 15:07:34 +02:00
jschoubben e959cf339b Make printer.print take exactly one of object, path with node, or url, send a machine's file through its node-files, and check the printer's formats first (issue 1598) 2026-10-21 15:05:03 +02:00
jschoubben cb226c6fef File a scan from the store by the link the device process signs, not by parts over the bus (issue 1601) 2026-10-21 15:01:09 +02:00
jschoubben 278014fd79 Add the converter process: take convert-asks, have the document server make the PDF with a token only it holds, and put it to the target link (issue 1596) 2026-10-21 14:58:41 +02:00
jschoubben caae838a2a Claim document-converter and serve convert: queue the ask for the converter and read its answer, holding no credential (issue 1595) 2026-10-21 14:55:47 +02:00
jschoubben 6ee3507af0 Serve node-files.upload and download: a machine's file goes into a bucket and an object comes out of one, by short links, never over the bus (issue 1597) 2026-10-21 14:54:17 +02:00
jschoubben 06ca1b7fb9 Serve object-store.link: a signed link to one object, signed with the bucket's link key, for the consumer and its shares; retire the root-signed minio_presigned_url (issue 1516) 2026-10-21 14:52:14 +02:00
mesh-tools 90ecba4e50 Merge pull request 'Capture automx's live answers as fixtures and add a read-only tool for its database rows, before R4d replaces it (issue 1630)' (#415) from feat/r4d-automx into main 2026-10-21 12:47:49 +00:00
jschoubben 5b3fd1008a Make a link key per bucket, limited to get and put of its objects and kept as minio's own secret, so links are not signed by the root account (issue 1594) 2026-10-21 14:47:14 +02:00
mesh-tools 671ab9a3ba Merge pull request 'Pin gitea, keycloak, mesh-provisioner-client, postgres and route-adapter to a mesh-sdk commit on main, so a fresh go mod download resolves them once the feature branch goes (issue 1663)' (#414) from fix/1663-sdk-pins-on-main into main 2026-10-21 12:30:06 +00:00
jschoubben 0a7c58766c Pin gitea, keycloak, mesh-provisioner-client, postgres and route-adapter to a commit of mesh-sdk main, since the pinned one is on a feature branch only (issue 1663)
mesh/merge-gate head 0a7c58766c20 merged into trunk 4efac366cebb — pass: builds gitea, keycloak, mesh-provisioner-client, postgres, route-adapter → ace, g1…
mesh/repo-check head 0a7c58766c20 merged into trunk 4efac366cebb — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivering: 0 machine step(s) passed
2026-10-21 14:07:01 +02:00
jschoubben 9ff24fa619 n8n's media-gid is a required config value: a machine that does not say it refuses n8n (issue 1639) 2026-10-21 14:02:46 +02:00
jschoubben 454c5662dd Add automx_rows, a read-only tool for the rows automx answers from (issue 1630)
mesh/merge-gate head 454c5662ddcb merged into trunk 671ab9a3ba7b — pass: builds mailu, sent nowhere; no bus step; 1 wait(s) for a person; every machine com…
mesh/repo-check head 454c5662ddcb merged into trunk 671ab9a3ba7b — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivering: 0 machine step(s) passed
2026-10-21 14:02:37 +02:00
jschoubben 8fba5e831b Capture what automx answers today, before a Go program replaces it (issue 1630) 2026-10-21 13:59:58 +02:00
jschoubben 90daee7fec n8n runs its upstream image with uuid mounted and the media group joined, in place of a Dockerfile (issue 1639, ADR 0480 clauses 1, 3, 7, 8) 2026-10-21 13:54:33 +02:00
mesh-tools 4efac366ce Merge pull request 'Pin mesh-provisioner, mesh-vault and rofi to a mesh-sdk commit on main, so a fresh go mod download (mesh-build-go's fetch) resolves them (issue 1659)' (#413) from fix/1659-fetch-pseudo-version into main 2026-10-21 11:40:06 +00:00
mesh-tools 38ac738d84 Merge pull request 'Add netcheck_request: one HTTP request and its whole answer, so a service's live answers can be captured, refusing loopback, unspecified and link-local addresses (issue 1643)' (#412) from fix/1643-http-request into main 2026-10-21 11:27:33 +00:00
jschoubben 92c5e1d9ae Pin mesh-provisioner, mesh-vault and rofi to a commit of mesh-sdk main, since the pinned one is on no branch and no fetch can resolve it (issue 1659)
mesh/merge-gate head 92c5e1d9aea8 merged into trunk 38ac738d8463 — pass: builds mesh-provisioner, mesh-vault, rofi → g14, novox, shanks; no bus step; every…
mesh/repo-check head 92c5e1d9aea8 merged into trunk 38ac738d8463 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
2026-10-21 13:14:57 +02:00
jschoubben a01e1ea7fb Refuse a host in a short or legacy numeric form (127.1, 2130706433, 0x7f.0.0.1) in netcheck_request before any lookup, so the refusal does not depend on the resolver
mesh/merge-gate head a01e1ea7fb64 merged into trunk d74c1cb995a4 — pass: builds netcheck → ace, g14, novox, shanks; no bus step; every machine composes wit…
mesh/repo-check head a01e1ea7fb64 merged into trunk d74c1cb995a4 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
2026-10-21 13:07:08 +02:00
mesh-tools d74c1cb995 Merge pull request 'Read manifests that name shares: mesh-build-go and mesh-build-node take mesh-sdk 465beb4 (issue 1657)' (#411) from feat/files-through-the-store-builders into main 2026-10-21 11:01:03 +00:00
jschoubben 2d30dc2f1e Read manifests that name shares: mesh-build-go and mesh-build-node take mesh-sdk 465beb4 (issue 1657)
mesh/merge-gate head 2d30dc2f1ef7 merged into trunk b5dbfbb78450 — pass: builds mesh-build-go, mesh-build-node → g14, novox, shanks; no bus step; every mac…
mesh/repo-check head 2d30dc2f1ef7 merged into trunk b5dbfbb78450 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
2026-10-21 12:52:28 +02:00
mesh-tools b5dbfbb784 Merge pull request 'Pack archive artifacts in mesh-build-go, a module's directory or a pinned file, and refuse any path that leads out of the module through a link (issue 1589, ADR 0478 §1)' (#408) from fix/1589-archive-artifacts into main 2026-10-21 10:34:00 +00:00
mesh-tools e9e85365b5 Refuse loopback, unspecified and link-local addresses in netcheck_request, on the address dialled, and use no proxy 2026-10-21 10:27:44 +00:00
mesh-tools 710cfbd622 Merge pull request 'Let the build modules answer their own step results and toolchains, so a fetch's workspace and a build's digest can be read (issue 1562, build-module half)' (#410) from fix/1562-read-steps into main 2026-10-21 10:26:13 +00:00
mesh-tools 940594b074 Merge pull request 'Make dev-tools, lab and the build modules need the go and nodejs modules instead of declaring the toolchain packages, step B of issue 1573 (ADR 0477 §4)' (#409) from fix/1573b-toolchain-users into main 2026-10-21 10:21:02 +00:00
mesh-tools ff849d9f62 Add netcheck_request: one HTTP request and its whole answer, so a service's live answers can be captured (issue 1643) 2026-10-21 10:08:59 +00:00
mesh-tools f0c8958dd2 Merge pull request 'Apply a patch in the forge tool itself when the forge's diffpatch answers 500, so a patch whose context holds a non-ASCII character is taken (issue 1604)' (#407) from fix/1604-patch-applied into main 2026-10-21 10:08:48 +00:00
mesh-tools c33b83dce7 Read a build module's steps and toolchains states with its own tools, so a step's result and the reported toolchain can be checked live (issue 1562)
mesh/merge-gate head c33b83dce7d5 merged into trunk 940594b07478 — pass: builds mesh-build-go, mesh-build-node → g14, novox, shanks; no bus step; every mac…
mesh/repo-check head c33b83dce7d5 merged into trunk 940594b07478 — 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@e023f69989ab (merged as b5dbfbb7 into main, walk plan-17925769516…
2026-10-21 09:57:16 +00:00
mesh-tools 36259f3406 Update the build modules' manifest tests for the toolchain provisions (issue 1573)
mesh/merge-gate head 36259f3406f7 merged into trunk f0c8958dd217 — pass: builds dev-tools, lab, mesh-build-go, mesh-build-node → ace, g14, novox, shanks; n…
mesh/repo-check head 36259f3406f7 merged into trunk f0c8958dd217 — pass: THE CHANGE ALTERS ITS OWN CHECK (merge-check.sh): main's version judged it; the ch…
mesh/delivery covered: a later merge that contains it was delivered: novox/mesh-catalog@e023f69989ab (merged as b5dbfbb7 into main, walk plan-17925769516…
2026-10-21 09:53:48 +00:00
mesh-tools e023f69989 Compare an archive's tar stream with the build agent's always and its digest only under the Go it was captured with (issue 1589)
mesh/merge-gate head e023f69989ab merged into trunk 710cfbd622d7 — pass: builds mesh-build-go → g14, novox, shanks; no bus step; every machine composes wit…
mesh/repo-check head e023f69989ab merged into trunk 710cfbd622d7 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
2026-10-21 09:52:22 +00:00
mesh-tools 324e331184 Anchor patch hunks as git apply does, refuse patches touching non-100644 files by their real mode, survive bare-CR context lines (issue 1604 review)
mesh/merge-gate head 324e331184da merged into trunk 0f2cee1bea4e — pass: builds gitea → novox; no bus step; every machine composes with the change as it di…
mesh/repo-check head 324e331184da merged into trunk 0f2cee1bea4e — 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@e023f69989ab (merged as b5dbfbb7 into main, walk plan-17925769516…
2026-10-21 09:52:10 +00:00
mesh-tools d8d153295a Say in the go and nodejs READMEs that they are now the only declarers (issue 1573) 2026-10-21 09:51:22 +00:00
mesh-tools bda75011a1 Say in the build modules' READMEs where the packages come from (issue 1573) 2026-10-21 09:50:55 +00:00
mesh-tools 9d1f152603 Wire the toolchains check into the merge check (issue 1573) 2026-10-21 09:50:46 +00:00
mesh-tools c4cd7a1b16 Check that only the go and nodejs modules declare the toolchain packages (issue 1573) 2026-10-21 09:50:14 +00:00
mesh-tools 7753c27042 Make dev-tools, lab and the two build modules require the toolchain provisions instead of declaring the packages (issue 1573) 2026-10-21 09:49:50 +00:00
mesh-tools ef154705b9 Make an archive's digest independent of the umask of the account that packs it (issue 1589) 2026-10-21 09:49:13 +00:00
mesh-tools d7e6074d97 Refuse a build output directory that is a link out of the module (issue 1589) 2026-10-21 09:46:47 +00:00
mesh-tools 800d6624d5 Refuse an archive of a module directory that is a link out of the workspace's tree (issue 1589) 2026-10-21 09:45:58 +00:00
mesh-tools 0f2cee1bea Merge pull request 'Add the go and nodejs modules that own the toolchain packages on a node, step A of issue 1573 (ADR 0477 §4)' (#406) from fix/1573a-toolchain-owners into main 2026-10-21 09:36:49 +00:00
mesh-tools 5efd84e93e Merge pull request 'Build with the node's installed Go and Node, never a pinned or downloaded toolchain, so builds match what the nodes run (issue 1572, ADR 0477)' (#403) from fix/1572-installed-toolchain into main 2026-10-21 09:27:17 +00:00
mesh-tools ceb42b0e92 Say in the go and nodejs READMEs what holds at this step, and what holds once the users require them (review of issue 1573 step A)
mesh/merge-gate head ceb42b0e920e merged into trunk 5efd84e93e38 — pass: builds new: modules/go, modules/nodejs, sent nowhere; no bus step; every machine c…
mesh/repo-check head ceb42b0e920e merged into trunk 5efd84e93e38 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
2026-10-21 09:25:23 +00:00
mesh-tools e5cfcb316a Fall back to the tool's own patch applier when the forge answers 500, with tests (issue 1604) 2026-10-21 09:21:59 +00:00
mesh-tools 025d62478a Add the go and nodejs modules, which own the toolchain packages and provide go-toolchain and node-toolchain (issue 1573) 2026-10-21 09:21:19 +00:00
mesh-tools e6053262e4 Apply a unified diff in the forge tool itself, for the patches the forge's diffpatch refuses (issue 1604) 2026-10-21 09:20:25 +00:00
mesh-tools 6993ef003c Refuse an archive whose directory leads out of the module through a symlinked parent (review of issue 1589) 2026-10-21 09:19:03 +00:00
mesh-tools 406ee69732 Add the pinned release the archive tests serve (issue 1589) 2026-10-21 09:13:40 +00:00
mesh-tools 8bfa626a8b Test archive artifacts against build-agent's own digests (issue 1589) 2026-10-21 09:13:32 +00:00
mesh-tools bfd6b7c7ce Say the build tool packs archives, give the module a client for pinned files, and record how the digests were captured (issue 1589) 2026-10-21 09:12:55 +00:00
mesh-tools 640c6f9b0c Hand an archive artifact to the packing step (issue 1589, ADR 0478 §1) 2026-10-21 09:12:23 +00:00
mesh-tools d369adf245 Unpack and pack archive artifacts in mesh-build-go: a module's directory or a pinned file (issue 1589, ADR 0478 §1) 2026-10-21 09:11:29 +00:00
mesh-tools d8f87808b5 Add the archive fixture tree for mesh-build-go's archive tests (issue 1589) 2026-10-21 09:10:41 +00:00
mesh-tools 17d2f7fd5d Merge pull request 'End a stranded stop whose modules no merge can carry, so its alert clears instead of asking the operator for ever (issue 1581)' (#405) from fix/1581-stranded-removed into main 2026-10-21 08:40:20 +00:00
mesh-tools 6411eb4614 End a stranded stop whose modules no merge can carry, so stalled stops listing it (issue 1581)
mesh/merge-gate head 6411eb4614a7 merged into trunk 50ef13d29385 — pass: builds mesh-delivery-gate → novox; no bus step; every machine composes with the ch…
mesh/repo-check head 6411eb4614a7 merged into trunk 50ef13d29385 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
A stop for a walk that built none of its modules waited for a later merge to
carry them for ever when the pull request removed the module or its plan sent
it nowhere. It now reads ended, as covered reads covered, and leaves stalled.
2026-10-21 08:28:20 +00:00
mesh-tools 50ef13d293 Merge pull request 'Upgrade every node's packages on a Saturday 04:00 timer and make its outcome the pacman module's health, so the nodes stay on the same versions (issue 1574, ADR 0477)' (#404) from fix/1574-weekly-upgrade into main 2026-10-21 08:15:53 +00:00
mesh-tools bd10ec4f0d Say a weekly upgrade that hangs past two hours, so a stuck pacman lock is loud (issue 1574, ADR 0477)
mesh/merge-gate head bd10ec4f0dbe merged into trunk 4bdacf38d2f0 — pass: builds pacman → ace, g14, novox, shanks; no bus step; every machine composes with …
mesh/repo-check head bd10ec4f0dbe merged into trunk 4bdacf38d2f0 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
2026-10-21 08:08:04 +00:00
mesh-tools 4bdacf38d2 Merge pull request 'Propose mesh-issues' owners: mesh-config takes the SDK that reads names with digits as names (issue 1439)' (#402) from feat/1439-name-runs into main 2026-10-21 08:07:38 +00:00
mesh-tools d699708fb3 Say in mesh-build-go's README that it builds with the installed go and its digest holds no Go version (issue 1572, ADR 0477)
mesh/merge-gate head d699708fb382 merged into trunk 17d2f7fd5df1 — pass: builds mesh-build-go, mesh-build-node → g14, novox, shanks; no bus step; every mac…
mesh/repo-check head d699708fb382 merged into trunk 17d2f7fd5df1 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
2026-10-21 08:07:15 +00:00
mesh-tools e8046fca87 Say in the build modules' READMEs that they build with the installed toolchain, the digest holds no Go version, and the reference is agreement between build nodes (issue 1572, ADR 0477) 2026-10-21 08:05:55 +00:00
mesh-tools edec7063b3 Build TypeScript with the installed node and name no Node version (issue 1572, ADR 0477) 2026-10-21 08:04:41 +00:00
mesh-tools fc4089cde9 Test the Go module's steps and guard against the installed go (issue 1572, ADR 0477) 2026-10-21 08:03:45 +00:00
mesh-tools 049202d373 Build Go with the installed go and name no Go version (issue 1572, ADR 0477) 2026-10-21 08:02:54 +00:00
mesh-tools 7fcb9a743b Replace the downloaded release's tests with tests of the installed toolchain (issue 1572, ADR 0477) 2026-10-21 08:02:02 +00:00
mesh-tools d4d1a1591b Name pacman_upgrade_health in the pacman README's tools (issue 1574) 2026-10-21 08:00:39 +00:00
mesh-tools 5123c1dd57 Say the weekly upgrade and its health in the pacman README (issue 1574, ADR 0477) 2026-10-21 08:00:30 +00:00
mesh-tools e3ac017c28 Test the weekly upgrade's timer and its health (issue 1574, ADR 0477) 2026-10-21 08:00:21 +00:00
mesh-tools 1f5046f47e Declare the weekly upgrade timer with its health, and serve pacman_upgrade_health (issue 1574, ADR 0477) 2026-10-21 07:59:55 +00:00
mesh-tools f12f87f1f9 The weekly upgrade's timer, service and health tool (issue 1574, ADR 0477) 2026-10-21 07:59:31 +00:00
mesh-tools e319f9877f Upgrade every node's packages together each Saturday at 04:00, with the outcome as the pacman module's health, so the nodes keep the same toolchain versions (issue 1574, ADR 0477) 2026-10-21 07:58:01 +00:00
jochen 2379027cec Propose mesh-issues' owners: take the SDK that reads names with digits as names (issue 1439)
mesh/repo-check head 2379027ceca5 merged into trunk f813be275306 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/merge-gate head 2379027ceca5 merged into trunk f813be275306 — pass: group feat/1439-name-runs composed: every machine composes with the change as it d…
mesh/delivery delivered
mesh/delivery-group group feat/1439-name-runs delivered: every member is delivered
mesh-config refused every owners proposal as carrying a secret, because the SDK's CheckSecret took
modules/asus-zephyrus-g14, i3status-rust and powerlevel10k for random strings. The SDK at
feat/1439-name-runs judges such a lowercase name piece by piece; this moves mesh-config to it and
proves the catalogue's own owners list plus okioki is put to the operator.
2026-10-21 09:57:56 +02:00
mesh-tools cf197fee89 Upgrade every node's packages together each Saturday at 04:00, with the outcome as the pacman module's health, so the nodes keep the same toolchain versions (issue 1574, ADR 0477) 2026-10-21 07:57:48 +00:00
mesh-tools f813be2753 Merge pull request 'Declare scanner.scan's apart argument, so several tickets on the glass can be filed apart (issue 1528)' (#401) from fix/1528-apart-argument into main 2026-10-21 07:33:45 +00:00
jschoubben c4913dbbcb Declare scanner.scan's apart argument, so the console lets it through to the tool (issue 1528)
mesh/merge-gate head c4913dbbcb35 merged into trunk 9daeab209ba4 — pass: builds brother → shanks; no bus step; every machine composes with the change as it…
mesh/repo-check head c4913dbbcb35 merged into trunk 9daeab209ba4 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
2026-10-21 09:26:11 +02:00
mesh-tools 9daeab209b Merge pull request 'Build Go modules with Go's own release of the recipe's version, so the distribution's build no longer makes the guard refuse (issue 1560, ADR 0476)' (#400) from fix/1560-go-release-toolchain into main 2026-10-21 06:47:11 +00:00
mesh-tools 3c4deb8bee Name the cause of a failed toolchain or module download, not Go's progress line before it (issue 1560, review round 1)
mesh/merge-gate head 3c4deb8bee8b merged into trunk 76bb22c36270 — pass: builds mesh-build-go → g14, novox, shanks; no bus step; every machine composes wit…
mesh/repo-check head 3c4deb8bee8b merged into trunk 76bb22c36270 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
2026-10-21 00:59:12 +00:00
mesh-tools 4605fcf46d Fill the shared module cache with the release by the probe before the real-go integration tests, and skip loudly when it cannot (issue 1560, ADR 0476) 2026-10-21 00:56:03 +00:00
mesh-tools c5f8f81a07 Test that every step and the probe run Go's own release and the probe fills the module cache (issue 1560, ADR 0476) 2026-10-21 00:51:40 +00:00
mesh-tools 3f60d1ff31 Build Go modules with Go's own release of the recipe's version, put in the module cache by the probe, so the distribution's build no longer makes the guard refuse (issue 1560, ADR 0476) 2026-10-21 00:50:56 +00:00
mesh-tools 76bb22c362 Merge pull request 'Polish the brother module so the scanner and printer just work: long feeder scans, several tickets, joined glass pages, one-call print, certificate health' (#399) from feat/brother-polish into main 2026-10-21 00:22:44 +00:00
mesh-tools 9aa9792614 Merge pull request 'Fill a module's files one by one, so a known secret's file is not held back by a template that waits (issue 1553)' (#398) from fix/1553-fill-file-by-file into main 2026-10-21 00:13:38 +00:00
jschoubben 3757d75d6a Wait for the background scan's outcome in the test instead of reading it once, and say where brother_health sits (issue 1552)
mesh/merge-gate head 3757d75d6acf merged into trunk 9aa97926141f — pass: builds brother → shanks; no bus step; every machine composes with the change as it…
mesh/repo-check head 3757d75d6acf merged into trunk 9aa97926141f — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
2026-10-21 02:08:50 +02:00
mesh-tools f99f11e8a4 Merge pull request 'Mail reading: mesh-mail-reader notes each new mail, tells mail that matters on Telegram, and the mail page shows and corrects it (design 59 milestone 2)' (#397) from feat/mail-reading into main 2026-10-21 00:06:24 +00:00
jschoubben b7243505ca Carry brother's certificate check on a watch process beside the device unit check, as fail2ban does (issue 1510) 2026-10-21 02:05:21 +02:00
jschoubben 7eddb116b1 Answer brother_health's wait for unset settings with the SDK's health.Waiting (issue 1510) 2026-10-21 02:00:54 +02:00
mesh-tools 37035dfc90 Fill a module's files one by one, so a known secret's file is not held back by a template that waits (issue 1553)
mesh/merge-gate head 37035dfc904b merged into trunk f99f11e8a406 — pass: builds mesh-provisioner-client → ace, g14, novox, shanks; no bus step; every machi…
mesh/repo-check head 37035dfc904b merged into trunk f99f11e8a406 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
2026-10-21 00:00:09 +00:00
jschoubben 65e67abb7d Bound the certificate check to 3 s a handshake and 7 s, and answer waits while printer is not given (issue 1510) 2026-10-21 01:58:50 +02:00
jschoubben 95c47a09c9 Print from a path only under the home folder, refused in one fixed way (issue 1551) 2026-10-21 01:57:34 +02:00
jschoubben 43771458f6 Cut several documents apart only when scanner.scan is asked apart: true (issue 1528) 2026-10-21 01:56:52 +02:00
jschoubben ef833e26c7 Drop an ask taken up after its deadline, follow a scan for its run, re-arm open documents and delete their pages (issues 1539, 1552, 1538) 2026-10-21 01:56:24 +02:00
jochen f3007eb242 Fix the try on 8000b637 and review round 2: RunBatches takes its gap as a parameter instead of reading the var BatchGap, and no tell while unhealthy is checked as an exact count, not a before/after that a same-pass tell could hide behind (issue 1520, 1524)
mesh/merge-gate head f3007eb242f8 merged into trunk 6c6d5f681c30 — pass: builds mesh-mail, mesh-mail-web, new: modules/mesh-mail-reader → novox; no bus ste…
mesh/repo-check head f3007eb242f8 merged into trunk 6c6d5f681c30 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
2026-10-21 01:52:58 +02:00
jochen 8000b63785 Review round 1: an unset mail-reading verb answers unavailable not unknown, cache the index instead of leaking a pool per call, a gone mail leaves the queue without going unhealthy, sort the judge's own cause by its words, drive RunBatches itself for its pacing test, and prove no tell while unhealthy against a genuinely untold mail (issues 1430, 1523, 1517, 1518, 1520, 1519, 1524) 2026-10-21 01:48:23 +02:00
jschoubben 1e19e8ee9a Judge the module healthy by the certificates the printer serves against the pinned list (issue 1510) 2026-10-21 01:47:25 +02:00
jschoubben a75e11e814 Scan several glass pages into one PDF with scanner.scan document start, an id and finish (issue 1538) 2026-10-21 01:45:53 +02:00
jschoubben 17f3c45182 Cut each of several documents lying apart on the glass out and file it as its own PDF (issue 1528) 2026-10-21 01:44:05 +02:00
jschoubben a5bc37459b Let printer.print take a file path and upload and print it in one call (issue 1551) 2026-10-21 01:41:21 +02:00
jschoubben 23c3a2396e Answer a long feeder scan as started with an id, file it in the background, and scale the feeder's bound with its pages (issues 1552, 1539) 2026-10-21 01:40:29 +02:00
jschoubben 7bf63f9eff Ask a failed part fetch once more before a scan fails, and name the stored object (issue 1536) 2026-10-21 01:37:26 +02:00
jschoubben 8295bc3894 Refuse a redirect off https or off the printer's host and a re-sent login body in the admin client (issue 1515) 2026-10-21 01:36:59 +02:00
jochen f89b73d936 Fix the try on 63980bf5: keep a test database's name within postgres's 63-byte limit so two calls in one long-named test never collide (issue 1517) 2026-10-21 01:30:52 +02:00
jochen 63980bf5ac Merge remote-tracking branch 'origin/main' into feat/mail-reading 2026-10-21 01:25:15 +02:00
jochen cfea9de530 Fix the try on bb594652: a const batch gap unit tests assert directly, wait for the bundle's own subscribe before publishing, and a migration-0001 database built through the real path (issues 1520, 1518, 1431, 1517) 2026-10-21 01:24:57 +02:00
mesh-tools 6c6d5f681c Merge pull request 'Send the scan request on one line so the Brother honours it, and scan from the feeder, both sides, into one PDF (issue 1452)' (#396) from fix/1452-feeder-duplex into main 2026-10-20 23:19:56 +00:00
jochen bb594652fa Merge remote-tracking branch 'origin/main' into feat/mail-reading 2026-10-21 01:12:06 +02:00
jochen 794de09c4d Fix the try: guard shared test state with mutexes, pgxpool over one conn, a short batch gap for integration builds, unique+forced database drops (issues 1431, 1524, 1517) 2026-10-21 01:11:48 +02:00
mesh-tools d62c2edc35 Merge pull request 'Mark the build modules moved, so a machine assigned them composes before the forge serves their entry (issue 1537)' (#395) from fix/1537-build-modules-moved into main 2026-10-20 23:09:36 +00:00
jschoubben d5f9cd465c Send brother's scan settings on one line and scan from the feeder, both sides, into one PDF (issue 1452)
mesh/merge-gate head d5f9cd465c96 merged into trunk d62c2edc35f3 — pass: builds brother → shanks; no bus step; every machine composes with the change as it…
mesh/repo-check head d5f9cd465c96 merged into trunk d62c2edc35f3 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
The Brother ignores the whole body when it holds a line break, so every setting was ignored and the glass was scanned at its default.
2026-10-21 01:05:55 +02:00
mesh-tools c7e892ca63 Mark the build modules moved, so a machine assigned them composes before the forge serves their entry (issue 1537)
mesh/merge-gate head c7e892ca63b3 merged into trunk 82649bc770d1 — pass: builds mesh-build-go, mesh-build-node, sent nowhere; no bus step; every machine co…
mesh/repo-check head c7e892ca63b3 merged into trunk 82649bc770d1 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
2026-10-20 22:59:36 +00:00
jochen d61fe4e2da Merge remote-tracking branch 'origin/main' into feat/mail-reading 2026-10-21 00:56:07 +02:00
jochen 3d030c9b4f Prove a refused judgement fails loudly and catches up, and the page shows why (issue 1524) 2026-10-21 00:55:23 +02:00
jochen 95704c7410 Start mesh-mail-reader whole against the real bus and store: entries, no body kept, the tell, the cap (issue 1431) 2026-10-21 00:53:37 +02:00
mesh-tools 82649bc770 Merge pull request 'Close every eSCL scan job brother opens, and say a busy scanner as busy (issue 1531)' (#392) from fix/1531-close-scan-jobs into main 2026-10-20 22:53:09 +00:00
mesh-tools a28398c16b Merge pull request 'File a scan of any size: answer its parts in 128 KiB under the state's 256 KiB value limit, and end only a too-large answer at once (issue 1535)' (#394) from fix/1535-scan-answer-size into main 2026-10-20 22:47:14 +00:00
jochen 0bc59c6f4a Correct a mail's mark or group and undo a lesson from the page, with the weekly measure (issue 1523) 2026-10-21 00:46:56 +02:00
jschoubben 66616ed353 Bring fix/1531 up to main, so its check is judged against main again (issue 888)
mesh/merge-gate head 66616ed3538f merged into trunk a28398c16be6 — pass: builds brother → shanks; no bus step; every machine composes with the change as it…
mesh/repo-check head 66616ed3538f merged into trunk a28398c16be6 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
2026-10-21 00:42:56 +02:00
jochen 8ff94287a7 Show mail that matters, mail groups and scam warnings above the classical list (issue 1430) 2026-10-21 00:39:31 +02:00
mesh-tools 3811ac69ed Merge pull request 'Let printer.settings say each setting's current value: the checked radio, a checkbox's state, the selected option (issue 1530)' (#393) from fix/1530-settings-current-values into main 2026-10-20 22:38:06 +00:00
jschoubben db581abb05 Terminate an ask only for an answer the state refuses as too large; retry any other failed answer write (issue 1535)
mesh/merge-gate head db581abb0570 merged into trunk 3811ac69ed3e — pass: builds brother → shanks; no bus step; every machine composes with the change as it…
mesh/repo-check head db581abb0570 merged into trunk 3811ac69ed3e — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
2026-10-21 00:35:26 +02:00
jochen 35496d6381 Send the mail signal after each batch: messenger_check, tell, untold carried over (issue 1522) 2026-10-21 00:34:52 +02:00
jschoubben 61796cd3dd Answer a scan's parts in 128 KiB, under the state's 256 KiB value limit, and end an ask whose answer is refused instead of redelivering it (issue 1535) 2026-10-21 00:33:00 +02:00
jochen 72f4bc52a4 Build the mail signal's own words: headline, nearest due date first (issue 1429) 2026-10-21 00:31:14 +02:00
jochen e87269b0d2 Keep the operator's corrections as reading lessons at once: correct, lessons, undo (issue 1428) 2026-10-21 00:27:47 +02:00
jochen a13a6c5898 Serve entries, groups and status: filtered, sorted, with the weekly reading-lessons measure (issue 1521) 2026-10-21 00:22:37 +02:00
jochen 42bffb9363 Run the reader's batches: paced, judged, written as entries, unhealthy on failure (issue 1520) 2026-10-21 00:18:37 +02:00
mesh-tools 43e0daf694 Merge pull request 'Name the build test's fixture manifest module.json.fixture, so no reader takes it for a module of the catalogue (issue 1527)' (#391) from fix/1527-fixture-manifest-name into main 2026-10-20 22:15:31 +00:00
jschoubben 6d7117a361 Never guess a label from another control's, form's or link's text; test the radio group with nothing checked (issue 1530)
mesh/merge-gate head 6d7117a361b8 merged into trunk 43e0daf6947d — pass: builds brother → shanks; no bus step; every machine composes with the change as it…
mesh/repo-check head 6d7117a361b8 merged into trunk 43e0daf6947d — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
2026-10-21 00:11:48 +02:00
jschoubben 3df52a8ac5 Answer each settings field's current value, and leave the menu links and hidden fields out unless asked (issue 1530) 2026-10-21 00:08:58 +02:00
jochen 83bc01e746 Build the batch's question and read its answer, bounded and out of shape when wrong (issue 1427) 2026-10-21 00:08:28 +02:00
jschoubben eac59c60c8 Say a Platen job's second page as an error instead of dropping it (issue 1531)
mesh/merge-gate out of date: judged against trunk 199f72fed29d, the trunk is now 43e0daf6947d; judged again on a recheck or when it is merged
mesh/repo-check out of date: judged against trunk 199f72fed29d, the trunk is now 43e0daf6947d; judged again on a recheck or when it is merged
mesh/delivery superseded: a newer head of the same pull request
2026-10-21 00:05:34 +02:00
jschoubben a760a4695a Close every eSCL scan job brother opens, so the next scan is not refused with 503 (issue 1531) 2026-10-21 00:03:28 +02:00
jochen 183cc58347 Keep mesh-mail-reader's own health, surviving a restart (issue 1519) 2026-10-20 23:58:37 +02:00
mesh-tools b56670535f Name the build test's fixture manifest module.json.fixture, so no reader takes it for a module of the catalogue (issue 1527)
mesh/merge-gate head b56670535f82 merged into trunk 199f72fed29d — pass: builds fixture-build, sent nowhere; no bus step; every machine composes with the c…
mesh/repo-check head b56670535f82 merged into trunk 199f72fed29d — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery stopped: a merged change never reached the machines: its walk plan-1792534533713829068 ended done before its word without building fixture-…
2026-10-20 21:56:31 +00:00
jochen d7b5c87887 Hear mesh-mail's added events, recorded before acknowledged, in arrival order (issue 1518) 2026-10-20 23:56:17 +02:00
jochen 0d2d19daa0 Keep a mail entry per mail, never the body (issue 1517) 2026-10-20 23:53:48 +02:00
jochen d17b7e832d Declare the seat mail-reading and the module mesh-mail-reader's skeleton (issue 1426) 2026-10-20 23:50:02 +02:00
mesh-tools 199f72fed2 Merge pull request 'The Go and the Node build modules, ssh-client on the SDK's key keeper, and gitea's read-only builder accounts (design 47 R4c-3)' (#389) from feat/r4c3-build-modules into main 2026-10-20 21:43:14 +00:00
mesh-tools 06d5dc9830 Merge pull request 'Start brother's admin login where the printer serves its form, and never leave a session open or post the password off https (issue 1514)' (#390) from fix/1514-admin-login-start-page into main 2026-10-20 21:28:42 +00:00
jschoubben 02076ebe93 Log in to the default page and ask for the page after, so a missing page leaves no session; send the password over https only (issue 1514 review)
mesh/merge-gate head 02076ebe9351 merged into trunk e2e990a0ebdb — pass: builds brother → shanks; no bus step; every machine composes with the change as it…
mesh/repo-check head 02076ebe9351 merged into trunk e2e990a0ebdb — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
2026-10-20 23:21:15 +02:00
mesh-tools bcee61edf7 Skip, loudly, a build module's real-toolchain test where the check's image lacks the tool; it is proven live on a build node (issues 1486, 1490)
mesh/repo-check head bcee61edf709 merged into trunk 06d5dc98303a — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/merge-gate head bcee61edf709 merged into trunk 06d5dc98303a — pass: group feat/r4c3-build-modules composed: every machine composes with the change as …
mesh/delivery failed: its walk failed, carrying its own commit: a gate on a first machine (what it carried put back), a build, a machine
mesh/delivery-group group feat/r4c3-build-modules failed: a member failed
2026-10-20 21:19:15 +00:00
jschoubben 2716c6faed Name the captured root response 2026-10-20 23:18:39 +02:00
jschoubben b1950f6a5c Start the printer's admin login at the root and default to /home/status.html: this firmware has no /general/status.html behind the login (issue 1514) 2026-10-20 23:18:36 +02:00
mesh-tools 983a6c39c8 Ship the Node build module's toolchain lock as an archive, not a template naming hosts (issue 1488) 2026-10-20 21:15:56 +00:00
mesh-tools 00fa5e74d0 Review round 1 for the Node build module: registry for other @novox packages and refused bundles (1489), toolchain.reported stream (1483, 1488), re-pin to mesh-sdk 36d6f774; delta part 4 of 4 2026-10-20 21:12:10 +00:00
mesh-tools 4a1804cda3 Review round 1 for the Node build module: build depth (1490), bin scripts for the test script (1491), toolchain.reported and its stream (1483, 1488); delta part 3 of 4 2026-10-20 21:10:18 +00:00
mesh-tools 7e7cc27c30 Review round 1 for the checks, ssh-client and the Go build module: sshdir (1484), socket path (1486), over_bound (1290, 1486), toolchain.reported and streams (1483), re-pin to mesh-sdk 36d6f774; delta part 2 of 4 2026-10-20 21:09:36 +00:00
mesh-tools bb5d420538 Bring the branch up to main: brother pins several printer certificates (issue 1503), messenger offers unsent asks again (issue 1507), delta part 1 of 4 2026-10-20 21:08:08 +00:00
mesh-tools bda49fb945 Test the Node build module's fetch, forge files, manifest and guard (issues 1489, 1484, 1488), part 19 of 19 2026-10-20 21:05:55 +00:00
mesh-tools e2e990a0eb Merge pull request 'Offer an open ask no channel has sent again on the minute tick: a stopped messenger loses its outbox (issue 1507)' (#388) from fix/1507-offer-unsent-asks into main 2026-10-20 21:05:01 +00:00
mesh-tools 1d6c905407 Add the Node build module's workspace and the tests of its build and test steps (issues 1490, 1491), part 18 of 19 2026-10-20 21:04:12 +00:00
mesh-tools 59e6f53a12 Add the Node build module's fetch, test step, registry and toolchain install (issues 1488, 1489, 1491), part 17 of 19 2026-10-20 21:02:50 +00:00
mesh-tools 97ed21c250 Add the Node build module's toolchain lock, main, recipe, guard and key keeper (issues 1488, 1484), part 16 of 19 2026-10-20 21:01:26 +00:00
mesh-tools 3f6f8909d9 Add the Node build module's manifest, ssh files, toolchain package, build step and toolchain tests (issues 1488, 1484, 1490), part 15 of 19 2026-10-20 20:59:51 +00:00
mesh-tools c27f978bc4 Add the Go build module's fixture repositories, and correct the copied build recipe (issues 1290, 1291, 1486, 1487), part 14 of 20 2026-10-20 20:57:30 +00:00
jschoubben 7bd7a34090 Offer an open ask no channel was sent again on the minute tick: the outbox is lost when the process stops (issue 1507)
mesh/merge-gate head 7bd7a34090b2 merged into trunk f1906d9ffa6c — pass: builds messenger → novox; no bus step; every machine composes with the change as i…
mesh/repo-check head 7bd7a34090b2 merged into trunk f1906d9ffa6c — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
2026-10-20 22:57:13 +02:00
mesh-tools 9436ef8777 Add the Go build module's captured go output and fixture repositories (issues 1290, 1291, 1486, 1487), part 13 of 20 2026-10-20 20:57:00 +00:00
mesh-tools dbc36d3d3c Test the Go build module's integration level against a faked and a real store and bus (issues 1486, 1290, 1487), part 12 of 20 2026-10-20 20:56:12 +00:00
mesh-tools 2462f96f65 Add the Go build module's test step with its unit tests and the guard tests (issues 1290, 1483), part 11 of 20 2026-10-20 20:54:52 +00:00
mesh-tools 86d79f7872 Add the Go build module's digest step, its tests and the level judging (issues 1291, 1290, 1486), part 10 of 20 2026-10-20 20:53:50 +00:00
mesh-tools be7f04dbd4 Test the Go build module's fetch, forge files and manifest (issues 1485, 1484, 1483, 1482), part 9 of 20 2026-10-20 20:52:35 +00:00
mesh-tools 36c933541f Add the Go build module's fetch, build and integration level steps, and the build tests (issues 1485, 1486, 1487), part 8 of 20 2026-10-20 20:51:21 +00:00
mesh-tools 6cf74414e9 Add the Go build module's main, recipe, guard, key keeper, runner, store and bus, and its README (issues 1483, 1484, 1485, 1486), part 7 of 20 2026-10-20 20:50:11 +00:00
mesh-tools f1906d9ffa Merge pull request 'Let brother's printer-certificate pin several fingerprints: the printer serves an ECDSA and an RSA certificate (issue 1503)' (#387) from fix/1503-printer-two-certificates into main 2026-10-20 20:49:33 +00:00
mesh-tools 298a0b8eda Test the forge account provider's builder entries, and add the Go build module's manifest, ssh files and README (issues 1481, 1483), part 6 of 17 2026-10-20 20:48:52 +00:00
mesh-tools fb52442b50 Serve a build module's key from its own state, read-only on nox-builder, in the forge account provider (issue 1482), part 5 of 17 2026-10-20 20:47:25 +00:00
mesh-tools 5702046667 Make the builders team and the read-only nox-builder keys in the forge account library, and watch the build modules' keys (issues 1481, 1482), part 4 of 17 2026-10-20 20:46:23 +00:00
mesh-tools 8dc0bc98bd Move ssh-client's tests and dependencies onto the SDK's forgekey (issue 1480), part 3 of 17 2026-10-20 20:45:35 +00:00
mesh-tools bf8f47ce66 Let ssh-client use the SDK's forgekey, and let the checks accept a module's own .ssh (issues 1480, 1484), part 2 of 17 2026-10-20 20:44:30 +00:00
mesh-tools 1e1def4d32 Remove ssh-client's own key keeper, key parser and runner, now in the SDK's forgekey (issue 1480), part 1 of 17 2026-10-20 20:43:51 +00:00
jschoubben 6312bc4219 Let printer-certificate pin several fingerprints, since the printer serves an ECDSA and an RSA certificate (issue 1503)
mesh/merge-gate head 6312bc421964 merged into trunk c294f7bd16dc — pass: builds brother → shanks; no bus step; every machine composes with the change as it…
mesh/repo-check head 6312bc421964 merged into trunk c294f7bd16dc — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
2026-10-20 22:43:02 +02:00
mesh-tools c294f7bd16 Merge pull request 'Make the messenger's hourly cap a setting, messages-per-hour: 100 by default, 0 turns the pause off (issue 1476)' (#386) from fix/1476-messenger-cap into main 2026-10-20 20:21:56 +00:00
mesh-tools 59f8c9be00 Merge pull request 'Let brother's scan-folder take an absolute path: mesh-config matches a pattern against the whole value (issue 1472)' (#385) from fix/brother-scan-folder-pattern into main 2026-10-20 20:17:25 +00:00
jschoubben a5d3480e7e Let messages-per-hour 0 turn the hourly pause off (issue 1476)
mesh/merge-gate head a5d3480e7e5d merged into trunk 59f8c9be0090 — pass: builds messenger → novox; no bus step; every machine composes with the change as i…
mesh/repo-check head a5d3480e7e5d merged into trunk 59f8c9be0090 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
2026-10-20 21:54:25 +02:00
jschoubben dccae4b0e7 Make the messenger's per-channel hourly cap the setting messages-per-hour, default 100 (issue 1476) 2026-10-20 21:54:01 +02:00
jschoubben 35aac9f358 Let brother's scan-folder take an absolute path: mesh-config matches a pattern against the whole value (issue 1472)
mesh/merge-gate head 35aac9f358cd merged into trunk 1357023bed09 — pass: builds brother → shanks; no bus step; every machine composes with the change as it…
mesh/repo-check head 35aac9f358cd merged into trunk 1357023bed09 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
2026-10-20 21:49:47 +02:00
mesh-tools 1357023bed Merge pull request 'The brother module: one module for the Brother printer, holding the scanner and printer seats, scans kept in its bucket and printing over the broker (issue 1453, ADR 0467, ADR 0468)' (#384) from feat/brother into main 2026-10-20 19:48:06 +00:00
jschoubben 2f6086120c issue 1453: read the fake printer's connection count under its lock, which the race detector requires; say 700 KiB as ADR 0468 does
mesh/merge-gate head 2f6086120c46 merged into trunk 809dd76973fd — pass: builds scan, new: modules/brother, sent nowhere; no bus step; every machine compos…
mesh/repo-check head 2f6086120c46 merged into trunk 809dd76973fd — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery stopped: a merged change never reached the machines: its walk plan-1792525688362824501 ended done before its word without building scan (mo…
2026-10-20 21:39:27 +02:00
jschoubben 02cb424d5c brother: emits declared in the module's own list too, parts of 700 KiB at most, a duplicates window on device-asks, and the built binary ignored (issue 1453) 2026-10-20 21:37:07 +02:00
jschoubben b430b35633 brother: README for the bucket, the documents in parts, printing and the device process (issue 1453) 2026-10-20 21:31:06 +02:00
jschoubben 1d62305001 brother: scanning moves into the device process and its scans are kept in the bucket; scanner.document, printer.upload and printer.print carry documents in parts and print over IPP (issue 1453, ADR 0468) 2026-10-20 21:30:51 +02:00
jschoubben d6e88fac17 brother: the module requires an s3-bucket, with mesh-mail's S3 client and store.json read by the device account alone (issue 1453, ADR 0468 clause 2) 2026-10-20 21:24:19 +02:00
jschoubben feada9fb14 brother: the admin process becomes the device process, brother-device, with device-asks and device-answers (issue 1453, ADR 0468 clause 1) 2026-10-20 21:23:28 +02:00
jschoubben adfb61c053 brother: the admin account, its owned secrets, the process and the printer-asks stream declared as mesh-mail's sender is; an integration test and the README (issue 1453) 2026-10-20 21:21:15 +02:00
jschoubben 29107b0d93 brother: the admin process alone reads the admin password and logs in; printer.settings queues an ask and reads its answer (issue 1453) 2026-10-20 21:20:16 +02:00
jschoubben 58c0c0682c brother: README for the two seats, the pinned certificate, the admin password and the login (issue 1453) 2026-10-20 21:12:56 +02:00
jschoubben 6782816707 brother: the printer seat's status verb, IPP Get-Printer-Attributes by hand and the eSCL state, replayed from captures (issue 1453) 2026-10-20 21:12:45 +02:00
jschoubben 34260d8c29 brother: the printer seat's settings verb, read-only through the login, with the page's links and form fields (issue 1453) 2026-10-20 21:11:15 +02:00
jschoubben 2b62a1819e brother: the printer config and admin-password secret, HTTPS pinned to one certificate, and the login by the form's own field (issue 1453) 2026-10-20 21:10:14 +02:00
jschoubben 78f9731d4a brother: the scanner seat declared and claimed, its verbs scan, status and list; preferences untrusted (issue 1453, 1446) 2026-10-20 21:07:00 +02:00
jschoubben 4a892ee29d Move the scan module to brother, ADR 0467 clause 1: the code unchanged in what it does (issue 1453) 2026-10-20 21:06:24 +02:00
mesh-tools 809dd76973 Merge pull request 'A scan module: scan the glass over eSCL, crop the document, file it as a PDF and announce it (issue 1440, ADR 0464)' (#383) from feat/scan-module into main 2026-10-20 18:23:43 +00:00
jschoubben 0821718d7c issue 1440: the scan module's manifest, settings template and README
mesh/merge-gate head 0821718d7ca9 merged into trunk a67f01963f0d — pass: builds new: modules/scan, sent nowhere; no bus step; every machine composes with t…
mesh/repo-check head 0821718d7ca9 merged into trunk a67f01963f0d — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
2026-10-20 20:13:58 +02:00
jschoubben 27709b07e7 issue 1440: scan_now, scan_status and scan_list, and scan.added said after the rename 2026-10-20 20:13:02 +02:00
jschoubben ae47a6cc10 issue 1440: file the scan as a one-page PDF, written whole and renamed, and list the filed scans 2026-10-20 20:11:39 +02:00
jschoubben 26ae4d2fd8 issue 1440: the plain-lid test's specks lie where counting them would make a box 2026-10-20 20:10:29 +02:00
jschoubben cf407f187d issue 1440: crop the document out of the platen's white 2026-10-20 20:10:20 +02:00
jschoubben 36a34a49b8 issue 1440: the scan module's eSCL client, settings and image density 2026-10-20 20:09:47 +02:00
mesh-tools a67f01963f Merge pull request 'mesh-issues stops listening to the removed module's transition, which every controller merge check now refuses (issue 1406)' (#382) from fix/1406-drop-the-old-transition-name into main 2026-10-20 13:28:29 +00:00
mesh-tools 56ba1c3837 Hear the gate's transition alone: today's module is forgotten (issue 1406)
mesh/merge-gate head 56ba1c3837e5 merged into trunk db32f8ac5632 — pass: builds mesh-delivery-gate, mesh-issues → novox; no bus step; every machine compose…
mesh/repo-check head 56ba1c3837e5 merged into trunk db32f8ac5632 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
2026-10-20 13:02:35 +00:00
mesh-tools db32f8ac56 Merge pull request 'Remove today's delivery module now that the gate holds its seat (design 47 R3e, issue 1351, ADR 0454)' (#381) from fix/1351-remove-todays-delivery-module into main 2026-10-20 12:27:48 +00:00
mesh-tools 1c7024224d Make the catalogue test strict: the gate alone claims the seat, and say the old module is gone (ADR 0454, issue 1351)
mesh/merge-gate head 1c7024224dd7 merged into trunk bb0fe93bbddd — pass: builds mesh-delivery, mesh-issues → novox; no bus step; every machine composes wit…
mesh/repo-check head 1c7024224dd7 merged into trunk bb0fe93bbddd — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
2026-10-20 12:03:46 +00:00
mesh-tools 1ef19030f1 Remove today's mesh-delivery module: the gate holds the seat since the handover (ADR 0454, issue 1351) 2026-10-20 12:02:56 +00:00
mesh-tools bb0fe93bbd Merge pull request 'The gate takes over today's mesh-delivery: mesh-delivery-gate holds the seat, asks and merges through the parts, and today's module leaves (design 47 R3e, issues 1346-1351)' (#380) from feat/r3e-the-gate into main 2026-10-20 06:49:17 +00:00
mesh-tools 1d9c72c476 Compare in the carry bed what the carry copies, not what the gate does after it; keep today's module among mesh-issues' owners
mesh/repo-check head 1d9c72c476f8 merged into trunk a3a9b8749d12 beside mesh-tools#37@06e6721e — pass: its merge-check.sh passed, each part in its toolchai…
mesh/merge-gate head 1d9c72c476f8 merged into trunk a3a9b8749d12 — pass: group feat/r3e-the-gate composed: every machine composes with the change as it did…
mesh/delivery covered after its failure: a later walk that contains it was delivered: plan-1792499270059758752 (novox/mesh-catalog at db32f8ac into main,…
mesh/delivery-group group feat/r3e-the-gate delivered: every member is delivered
2026-10-20 06:43:26 +00:00
mesh-tools 1f193f6d01 Let the catalogue hold the gate and today's module as the two claimants of the seat, and say the removal follows the handover (issue 1351)
mesh/delivery-group group feat/r3e-the-gate checking: 2 of 3 member(s) ready
mesh/delivery superseded: a newer head of the same pull request
mesh/merge-gate waits for its group's composed check
mesh/repo-check head 1f193f6d01d2 merged into trunk a3a9b8749d12 beside mesh-tools#37@06e6721e — fail: its merge-check.sh failed: --- FAIL: TestTheGateCarr…
2026-10-20 06:43:17 +00:00
mesh-tools 41f18b2452 Keep today's module mesh-delivery in the catalogue, unchanged: its removal follows the handover as a pull request of its own
mesh/merge-gate waits for its group's composed check
mesh/repo-check head 41f18b24527f merged into trunk a3a9b8749d12 beside mesh-tools#37@06e6721e — fail: its merge-check.sh failed: --- FAIL: TestTheGateCarr…
mesh/delivery-group group feat/r3e-the-gate rejected: a member's own check failed
mesh/delivery superseded: a newer head of the same pull request
2026-10-20 06:41:48 +00:00
mesh-tools cf5c02737b Pin mesh-sdk to main's merge of #47 (88581d0a), the same tree as ba684e54 the review read
mesh/merge-gate head cf5c02737bdb merged into trunk a3a9b8749d12 — fail: builds mesh-delivery, mesh-issues, new: modules/mesh-delivery-gate → novox; no bus…
mesh/repo-check head cf5c02737bdb merged into trunk a3a9b8749d12 — fail: its merge-check.sh failed: --- FAIL: TestTheGateCarriesALiveCapturesDeliveriesAndG…
mesh/delivery-group group feat/r3e-the-gate rejected: a member's own check failed
mesh/delivery superseded: a newer head of the same pull request
2026-10-20 06:32:31 +00:00
mesh-tools a3a9b8749d Merge pull request 'Let a commit copy files from another repository on the forge, so a large move reaches the forge from any machine (issue 1365)' (#379) from fix/1365-a-commit-copies-from-a-repository into main 2026-10-20 05:57:00 +00:00
mesh-tools 83adc46fe0 Give each asking at unchanged heads its own router id, overwrite the ask instead of taking it away, and announce the answered asking (review round 1, G2; issue 1370) 2026-10-20 01:26:28 +00:00
mesh-tools 67fe9a4dbe Pin mesh-sdk at issue 1370's commit and read the phase removed by its name (review round 1) 2026-10-20 01:25:52 +00:00
mesh-tools 1a985d68fd Test that the carry copies a group, a pull key and an unasked delivery byte for byte (review round 1) 2026-10-20 01:20:37 +00:00
mesh-tools 68117bd74d Hold the seat only when no assignment stands, every have key says removed and both watches are current; fix the two bed tests that failed the try (review round 1, G1) 2026-10-20 01:20:09 +00:00
mesh-tools dc3a177410 Let a commit copy files from another forge repository, so a large move is not typed through the caller
mesh/merge-gate head dc3a177410fa merged into trunk c0880c4dc271 — pass: builds gitea → novox; no bus step; every machine composes with the change as it di…
mesh/repo-check head dc3a177410fa merged into trunk c0880c4dc271 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
2026-10-20 01:07:28 +00:00
mesh-tools 3b03c220ad Check that the catalogue holds no module mesh-delivery but through the names its handover needs, and say how the seat passes (issue 1351) 2026-10-20 00:59:36 +00:00
mesh-tools d16fa1e052 Hear the gate's transitions beside today's delivery module's in mesh-issues, and name the gate among the owners (issue 1350) 2026-10-20 00:58:01 +00:00
mesh-tools 6a39da5485 Owe the forge its statuses, views, notes and comments as keys, read the plan from plans as data, and call an empty check answer an error (issue 1349) 2026-10-20 00:52:42 +00:00
mesh-tools beafa55d97 Ask the Approve through the gate's asks, hand merges to the bridge one member at a time, and move source.main once a group is merged (issue 1348) 2026-10-20 00:45:02 +00:00
mesh-tools dd462a80e2 Keep the gate silent until it holds the seat, and carry today's state over once when it first does (issue 1347) 2026-10-20 00:29:19 +00:00
mesh-tools a390bb40f8 Take a stray key out of the gate's reads (issue 1346) 2026-10-20 00:19:29 +00:00
mesh-tools 09f0b0d5ab Move the delivery module to the gate: mesh-delivery-gate claims the seat with its own buckets and reads (issue 1346) 2026-10-20 00:19:19 +00:00
mesh-tools c0880c4dc2 Merge pull request 'Read the grant file on keycloak's moved path, so a consumer not moved gets its client (issue 1332)' (#378) from fix/1332-keycloak-reads-the-grant-file into main 2026-10-19 22:46:05 +00:00
jochen b9104454f9 Read the grant file on keycloak's moved path, so a consumer not moved is still made (issue 1332)
mesh/merge-gate head b9104454f9a5 merged into trunk 954f84ea19b5 — pass: builds keycloak, sent nowhere; no bus step; 1 wait(s) for a person; every machine …
mesh/repo-check head b9104454f9a5 merged into trunk 954f84ea19b5 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
mesh-mail-web, grafana and car-hunter are not moved, so keycloak wanted nothing for them: it never
made mesh_novox_mail_web, and held mesh_ace_grafana and mesh_ace_carhunt for retirement. Keycloak's
moved spec now asks for the grant file's consumers too (WithGrants: true), and the moved harness
reads MESH_RECEIVES exactly as the grant path does and as postgres's moved harness already did.
2026-10-20 00:30:45 +02:00
jochen 73a10fafc7 Pull keycloak's moved-path harness into movedHarness, and test it reads the grant file (issue 1332)
Adds the failing unit tests for keycloak's provider on the moved path: a grant-file consumer not
moved should still be made and never held for retirement, and the moved harness should read
MESH_RECEIVES as the grant path does. All three fail on an assertion against today's code, which
wants nothing from the grant file on this path.
2026-10-20 00:30:10 +02:00
mesh-tools 954f84ea19 Merge pull request 'The mail web client: mesh-mail-web, and mesh-mail's views for it (design 59 milestone 1)' (#377) from feat/mail-webclient into main 2026-10-19 21:27:33 +00:00
jochen 9f3a8ee391 Follow real links instead of building them, and check the CSP on every answer (issue 1268)
mesh/merge-gate head 9f3a8ee39101 merged into trunk 66aa204f6779 — pass: builds mesh-mail, new: modules/mesh-mail-web → novox; no bus step; every machine c…
mesh/repo-check head 9f3a8ee39101 merged into trunk 66aa204f6779 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
2026-10-19 23:18:53 +02:00
jochen cad6194447 Keep the HTTP endpoint's own state, so status and health stop claiming to serve once it has failed (issue 1283) 2026-10-19 23:18:51 +02:00
jochen 3fd1b47fe7 Bounce once through this origin before sign-in, so a Strict cookie is not lost crossing from Keycloak or another site (issue 1263) 2026-10-19 23:18:48 +02:00
jochen 5b57422523 Report an unknown ask through a small interface, not *testing.T directly (issue 1285)
A failed subtest fails its parent, so TestHarnessFailsTheTestWhenTheBundleAsksSomethingUnknown could never
pass the way it was written (try 2's finding). The player now reports through askFailer (Helper, Errorf);
every other test keeps passing its own *testing.T, which satisfies it already. That one test gives a
recorder instead, and asserts it holds exactly one message naming seat:mail-store.accounts, and that
mail-webclient.status's own answer shows the ask got an error back. Checked: no other integration test uses
the same deliberate-subtest-failure pattern (grepped t.Run).
2026-10-19 22:53:18 +02:00
jochen 523d7846cd Fix mesh-mail-web's manifest: operators is type list, the listen word names its port number (issue 1262)
The controller refused the manifest: config.operators declared type "array" (not one of the controller's
five types, novox/hq ADR 0348 §4 — the word is "list"), and the bundle's env word MESH_MAIL_WEB_LISTEN said
${port:web} (a bundle's word takes the port NUMBER a listens entry gives, novox/hq ADR 0192; mesh-controller
internal/catalogue/port_into.go portsFilledInto matches it against listens[].Port, never an endpoint's
label). Fixed to type "list" and ${port:8099} (the listens entry's own port). main.go and the integration
harness already read/write a plain port number, so neither needed a change. manifest_test.go now asserts
both: config.operators.Type == "list", and the listen word equals ${port:<the listens entry's port>};
confirmed each independently fails against the old values before this fix.
2026-10-19 22:47:56 +02:00
jochen 502cda34cb Add design 59 milestone 1's acceptance tests on the runtime player (issue 1268)
Four scenarios, entered only through HTTP with Host mail.<node>.internal as a browser would, against the
built mesh-mail-web, a stand-in identity provider (auth_test.go's own fakeIssuer, reused) and a real account
bucket holding granted account a and ungranted account c: sign-in redirects, 403s and the session cookie's
attributes; the 404 for another host; browsing and a send followed to sent, only the five mail verbs asked,
account c never shown, paging by date, no database or extra writable directory; a mail's script and remote
image shown as text, an attachment downloaded with nosniff, and the CSP on every answer.

Build tag integration; compiles under go vet -tags integration ./...; never run here.
2026-10-19 22:42:27 +02:00
jochen c996121e9d Add the runtime player and stand-in mail-store for a bundle's integration test (issue 1285)
Plays the SDK's own runtime protocol (initialize, tools/list, tools/call, the bundle's mesh/ask and
mesh/state.<verb>) against mesh-mail-web's real built binary, from stdio.go and state.go's own source. The
account bucket is a real JetStream key-value bucket (mesh-mail_accounts, named as mesh-mail's own
acceptance_integration_test.go names a module's state); its fixture (accountBucketValue/accountBucketFolder)
is accounts.go's own, itself copied from mesh-mail's mirror.go AccountValue (issue 1255, 1264).

Build tag integration; compiles under go vet -tags integration ./...; never run here (go test -tags
integration is this batch's hard rule) — the lead's try runs it.
2026-10-19 22:38:36 +02:00
jochen f863e852c9 Add the write page and a send's state, behind issue 1284's form token (issue 1267)
Wires 1284's per-session token into the real write form and its real POST /send; adds tests for 1284/AC1-AC4
against that real page and handler, beside this issue's own AC1-AC4.
2026-10-19 22:29:41 +02:00
jochen 58b337770d Show a mail as escaped text and serve its attachments as downloads (issue 1266) 2026-10-19 22:26:47 +02:00
jochen ff1886ac62 Add the search page, by mail-store.search's own argument names (issue 1265) 2026-10-19 22:24:52 +02:00
jochen 28fdbbd346 Show every granted account, its state and its folders' counts (issue 1264)
Accounts bucket fixture (accountBucketValue/accountBucketFolder) copied from mesh-mail's own
modules/mesh-mail/cmd/mesh-mail/mirror.go AccountValue/AccountFolderValue (issue 1255); mesh-mail-web is its
own Go module, so this is a copy, not an import.
2026-10-19 22:22:52 +02:00
jochen 3ee80c98ab Bind every changing form's token to its session (issue 1284) 2026-10-19 22:18:03 +02:00
jochen 8e0ef84060 Sign in through Keycloak: code flow with PKCE, verified ID token, 12-hour session (issue 1263)
Library choice: golang.org/x/oauth2 for the code exchange, github.com/coreos/go-oidc/v3 for the ID token's
verification (issuer, audience, expiry, signature from the issuer's JWKS).
2026-10-19 22:16:15 +02:00
jochen 68c141849f Serve mesh-mail-web's HTTP endpoint: internal host check, CSP, open (issue 1283) 2026-10-19 22:09:49 +02:00
jochen a9ad306693 Add the module mesh-mail-web, with its status verb (issue 1262) 2026-10-19 22:07:36 +02:00
jochen a3e9985c15 Keep each account's newest mails in a bucket, bounded past 81 accounts (issue 1282) 2026-10-19 21:56:04 +02:00
jochen 08200bc178 Keep each account's state in a bucket (issue 1255) 2026-10-19 21:50:12 +02:00
jochen a9ff48e200 Answer a send's state only to its caller (issue 1261) 2026-10-19 21:44:12 +02:00
mesh-tools 66aa204f67 Merge pull request 'Say a group member refused only because main moved a warning for ten minutes, urgent after (issue 1275 AC6, ADR 0442)' (#376) from fix/1275-ac6-out-of-date-warning into main 2026-10-19 18:58:21 +00:00
jschoubben a76910a0c0 Say only the merge tool's two out of date refusals a warning, and never step a severity down (issue 1275, ADR 0442's cases)
mesh/merge-gate head a76910a0c036 merged into trunk 9efdd87a73a9 — pass: builds mesh-delivery → novox; no bus step; every machine composes with the change …
mesh/repo-check head a76910a0c036 merged into trunk 9efdd87a73a9 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
2026-10-19 20:51:58 +02:00
jschoubben dc86203d29 Say a refusal for an out of date verdict a warning for ten minutes, urgent after (issue 1275, operator's amendment, AC6) 2026-10-19 20:51:58 +02:00
mesh-tools 9efdd87a73 Merge pull request 'Merge a group whole or not at all, and say loudly when it cannot (issues 1275, 1276)' (#375) from fix/1275-group-merges-whole-or-not into main 2026-10-19 18:45:31 +00:00
jschoubben 301cd2287f Answer gitea_commit_statuses in the bus test's runtime, and say a held, refused member once (issue 1275, review round 1)
mesh/merge-gate head 301cd2287f5f merged into trunk eda55ceb9e1b — pass: builds mesh-delivery → novox; no bus step; every machine composes with the change …
mesh/repo-check head 301cd2287f5f merged into trunk eda55ceb9e1b — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
2026-10-19 20:38:48 +02:00
jschoubben 524e3405e5 Ask a group's Approve only when every member may merge, and withdraw the ask when a required status turns (issue 1275)
mesh/merge-gate head 524e3405e517 merged into trunk eda55ceb9e1b — pass: builds mesh-delivery → novox; no bus step; every machine composes with the change …
mesh/repo-check head 524e3405e517 merged into trunk eda55ceb9e1b — fail: its merge-check.sh failed: --- FAIL: TestAGroupOfTwoWithGreenChecksGetsOneAskAndOn…
mesh/delivery superseded: a newer head of the same pull request
An Approve given hours after its ask must not rest on a check made at the ask: a required status
that turns not a success while the ask waits takes the ask back, and its late answer merges nothing.
2026-10-19 20:32:22 +02:00
jschoubben 6e6d1f3e47 Merge a group whole or not at all: re-read every required status first, stop at the first refusal, say a held or half merged group urgent (issue 1275, 1276)
An approved group was merged member by member, each on its own, so a refusal of the first
did not stop the second. A group now merges in one pass in its order, after every member's
required statuses are read again at its head, and a held or half merged group stays raised.
A refusal naming the member's own repository check as missing asks that check (issue 1276).
2026-10-19 20:32:15 +02:00
mesh-tools eda55ceb9e Merge pull request 'Give the forge bridge a merge commit's trees in one call, and each open pull request's head branch (design 47 R3c, issue 1234)' (#374) from feat/delivery-parts into main 2026-10-19 18:22:09 +00:00
mesh-tools e8c3448797 Merge pull request 'Answer each gmail mail once when no folder is named, keeping archived mail (issue 1248)' (#373) from fix/1248-gmail-mail-once into main 2026-10-19 13:39:15 +00:00
jochen 5c1ff4bb15 mesh-mail: dedup-archive picks the same copy in both stores when a message-id also sits in a second non-archive folder (issue 1248, review round 1 finding 1)
mesh/merge-gate head 5c1ff4bb152b merged into trunk b767aac8e072 — pass: builds mesh-mail → novox; no bus step; every machine composes with the change as i…
mesh/repo-check head 5c1ff4bb152b merged into trunk b767aac8e072 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
2026-10-19 15:33:36 +02:00
jochen 280aeb2f47 Answer each gmail mail once when no folder is named, keeping archived mail (issue 1248) 2026-10-19 15:30:44 +02:00
mesh-tools b767aac8e0 Merge pull request 'mail_search answers the operator's questions: optional words, filters by sender, recipient, cc, date, words, attachment, flags, thread and correspondents, ordered and grouped (issues 1228-1232)' (#372) from feat/mail-search into main 2026-10-19 13:17:13 +00:00
jochen 920199862e mesh-mail: group-by-sender and order-by sender compare addresses case-insensitively in Postgres too, and a sender's shown name and address are its newest mail's, deterministically (issue 1230, review finding 6)
mesh/merge-gate head 920199862e18 merged into trunk 7bb588ca1bfb — pass: builds mesh-mail → novox; no bus step; every machine composes with the change as i…
mesh/repo-check head 920199862e18 merged into trunk 7bb588ca1bfb — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
2026-10-19 15:09:44 +02:00
jochen 8dd2a42448 mesh-mail: mail_search's order defaults to descending when left out, matching its own schema text, and AC1's no-filter test proves ordering, limit and grant isolation together (issue 1232, review findings 5 and 7) 2026-10-19 15:05:23 +02:00
jochen d1a9056a1c mesh-mail: mail_search gains words/words-any, a word found in subject or body, since subject-words and body-words together only ever meant and (issue 1229, review finding 3; also proves AC9's type-or-name independently and AC2+unread combined) 2026-10-19 15:02:40 +02:00
jochen a3476a7bf2 mesh-mail: from-someone-i-wrote-to recognises gmail's Sent folder name too, and stays scoped to one account (issue 1231, review findings 1 and 2) 2026-10-19 14:57:26 +02:00
jochen 1c3158e098 mesh-mail: mail_search's address filter never treats % or _ as a wildcard, so a literal domain filter does not leak every mail in Postgres (issue 1228, review finding 4) 2026-10-19 14:52:36 +02:00
jschoubben 75957fc3c0 Hash the root tree from the root's entries, since the forge names the commit (issue 1234)
mesh/repo-check head 75957fc3c006 merged into trunk e8c3448797c7 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/merge-gate head 75957fc3c006 merged into trunk e8c3448797c7 — pass: group feat/delivery-parts composed: every machine composes with the change as it d…
mesh/delivery covered after its failure: a later walk that contains it was delivered: plan-1792433168676870614 (novox/mesh-catalog at eda55ceb into main,…
mesh/delivery-group group feat/delivery-parts delivered: every member is delivered
The listing's top sha is the commit's, not the root tree's (seen live, novox/mesh-delivery at 7f61817), so
gitea_commit_trees now computes the root tree as git does, from the entries without a slash. The query keys
recursive and per_page stay in the allow-list: read flags only.
2026-10-19 14:52:29 +02:00
jschoubben ea83746297 Give the forge bridge a merge commit's trees in one call, and a pull request's head branch (issue 1234)
gitea_commit_trees reads the commit's recursive tree listing and answers the root tree and the
tree of every directory holding a module.json; a cut listing fails loudly. gitea_open_pulls
entries gain head. The query keys recursive and per_page join the allow-list, read flags only.
mesh-delivery decodes open pulls with json.Unmarshal into a struct (cmd/mesh-delivery/ports.go:405-410),
which ignores the added key.
2026-10-19 14:50:19 +02:00
jochen b60ee706d4 mesh-mail: mail_search's tool makes the query optional and offers every filter and order-by (one schema for mail_search and mail-store.search), validated before the index is ever asked (issue 1232) 2026-10-19 14:46:58 +02:00
jochen 4a60deb4a8 mesh-mail: mail_search finds a thread's replies and mail from someone the operator wrote to, so a reply or a known correspondent's junk mail can be found (issue 1231) 2026-10-19 14:39:59 +02:00
jochen fc10031d22 mesh-mail: mail_search orders by date, sender, subject or size and groups by sender, so the newest, the largest or who writes most can be answered (issue 1230)
Query's From becomes a list, OR'd (1228/AC11's several domains), since 1230's order-by needs it to test the
ascending-order criterion end to end; To and Cc stay single, no criterion asking for more.
2026-10-19 14:36:53 +02:00
jochen 2985122856 mesh-mail: mail_search's Query gains subject/body words, attachment type/name and read/unread, so a word in one field, an attachment or a flag can filter mails (issue 1229) 2026-10-19 14:29:15 +02:00
jochen 20342f2172 mesh-mail: mail_search's Query gains from, to, cc and a date range, so a sender, a recipient or a date can filter mails without query words (issue 1228) 2026-10-19 14:22:14 +02:00
mesh-tools 7bb588ca1b Merge pull request 'mesh-mail sends an approved mail from its own sender process, and tells a failed send once (ADR 0436, ADR 0438; issues 1172, 1193-1199)' (#371) from feat/mail-sender into main 2026-10-19 11:36:51 +00:00
jochen a2c0c58677 mesh-mail: a redelivery of a message whose send already finished (sent, failed, refused or not proven) is a repeat, terminated without recording, emitting or telling anything again (operator-approved change to ADR 0436 clause 10; issue 1196)
mesh/merge-gate head a2c0c586772b merged into trunk c441d83ba753 — pass: builds mesh-mail → novox; no bus step; every machine composes with the change as i…
mesh/repo-check head a2c0c586772b merged into trunk c441d83ba753 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
2026-10-19 13:29:31 +02:00
mesh-tools c441d83ba7 Merge pull request 'Keep two values of each issue in the tracker's bucket, since the log holds them all (issue 1221)' (#370) from fix/1221-tracker-keeps-two-copies into main 2026-10-19 11:08:47 +00:00
jschoubben e601e88e95 Keep two values of each issue in the tracker's bucket, since the log holds them all (issue 1221)
mesh/merge-gate head e601e88e95dd merged into trunk fb8e677cd849 — pass: builds mesh-issues → novox; no bus step; every machine composes with the change as…
mesh/repo-check head e601e88e95dd merged into trunk fb8e677cd849 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
2026-10-19 12:38:55 +02:00
mesh-tools fb8e677cd8 Merge pull request 'The router takes a module's condition on the operator channel's one subject (design 47 R3b: issues 1217, 1218)' (#369) from feat/delivery-seat-by-claim into main 2026-10-19 10:28:28 +00:00
jschoubben 5aca005cb7 Shorten the router's burst window in the test bundle so the desk is sent within seconds (issue 1218, check finding 1)
mesh/repo-check head 5aca005cb741 merged into trunk b4527d9091e9 beside mesh-controller#394@3e7f34d0 — pass: its merge-check.sh passed, each part in its to…
mesh/merge-gate head 5aca005cb741 merged into trunk b4527d9091e9 — pass: group feat/delivery-seat-by-claim composed: every machine composes with the change…
mesh/delivery delivered
mesh/delivery-group group feat/delivery-seat-by-claim delivered: every member is delivered
2026-10-19 12:15:47 +02:00
jschoubben 9f9e9fc9d3 Shorten the router's start grace in the test bundle so the bus tests stay in seconds (issue 1218, review round 1 finding 2) 2026-10-19 12:05:07 +02:00
jschoubben f737418bb3 Keep a module's condition open when the controller's conditions are read (issue 1217, review round 1 finding 1) 2026-10-19 12:04:58 +02:00
jschoubben 5b235a6e80 Prove over the bus that a condition on the one subject reaches the desk (issue 1218) 2026-10-19 11:59:49 +02:00
jschoubben de6741a335 Take a module's condition on the operator channel's one subject (issue 1217) 2026-10-19 11:58:10 +02:00
jochen dd8305d97d mesh-mail: the refused-sign-in acceptance test's wait excluded queued too, so it returned done on the very first look before the sender ever claimed anything (issue 1198) 2026-10-19 11:10:36 +02:00
jochen 06dfce87f5 mesh-mail: decide kept by settleSend's own returned revision, not its error, so a changed revision is actually retried instead of silently accepted as another writer's state; emit mail.send.failed from claimQueuedSend only once its own write lands (review round 2; issue 1198) 2026-10-19 11:07:24 +02:00
jochen 7db4bd215d mesh-mail: fix the work-queue stream's real bus name (MAIL_SENDS, not MAIL-SENDS) and build the integration tests' stream, subject and duplicate window exactly as the controller composes them, exercising the send id's dedup (review round 1 finding 7; issue 1198) 2026-10-19 10:57:45 +02:00
jochen 1639269ad4 mesh-mail: on Approve the tools record the send queued directly, with no separate approved state, then publish; a stuck queued record is republished by the sweep past a short grace, never NakWithDelay'd by the sender (ADR 0436 clause 8, decided to replace the review round 1 item 2 work-around; issues 1194, 1196, 1199) 2026-10-19 10:53:32 +02:00
jochen 7397051771 mesh-mail: tighten tests that proved less than their criterion — attempt 5 and no mail received (1198/AC4), mail_health actually turning unhealthy (1199/AC2), and the words mail_send_status gives, not only its status (1199/AC4) (review round 1 finding 6) 2026-10-19 10:53:12 +02:00
jochen 95089762ef mesh-mail: retry the outcome's own write on a changed revision, and never ack or emit an outcome that could not be kept (review round 1 finding 5; issue 1198) 2026-10-19 10:43:45 +02:00
jochen ce3b12f349 mesh-mail: emit mail.send.failed when the sender's own claim refuses or finds a send already sending, same as a failed send after it (review round 1 finding 4; issue 1198) 2026-10-19 10:40:41 +02:00
jochen acb82992ee mesh-mail: index a send for a tell or mail_health's overdue check before its record carries the outcome, drop the old index entry only after, and report an index write that fails instead of dropping it silently (review round 1 finding 3; issues 1194, 1199) 2026-10-19 10:38:07 +02:00
jochen 0a7669b48f mesh-mail: a send still approved (the tools' own publish-then-record race) is redelivered later, never refused or told (review round 1 finding 2; issue 1196) 2026-10-19 10:31:39 +02:00
jochen f140099a75 mesh-mail: declare the sender's own secret broker-send, so the controller can issue its account (review round 1 finding 1; issue 1172) 2026-10-19 10:29:48 +02:00
jochen f3a59ee63a mesh-mail: tell the operator once for a failed approved send and once more if its sender has not taken it, make an overdue send unhealthy, and give mail_send_status its full vocabulary (ADR 0436 clauses 11, 12; issue 1199) 2026-10-19 10:22:27 +02:00
jochen 33a97e6d13 mesh-mail: the sender records a send's outcome, retries a not-taken failure and emits mail.send.sent or mail.send.failed, acknowledging only once it is kept (ADR 0436 clauses 10, 11; issue 1198) 2026-10-19 10:11:41 +02:00
jochen e7dce9616c mesh-mail: classify an SMTP send's outcome (not taken, refused, not proven), and carry the send id in the Message-ID (ADR 0436 clause 10; issue 1197) 2026-10-19 10:03:44 +02:00
jochen 1ccb617a86 mesh-mail: the sender verifies the operator's warrant again from the router's own record and claims a send once before any connection (ADR 0436 clauses 9, 10; issue 1196) 2026-10-19 09:57:07 +02:00
jochen dd3ed68483 mesh-mail: a send process that connects on its own bus account and consumes the mail-sends queue, stopping cleanly on a signal (ADR 0436 clause 7; issue 1195) 2026-10-19 09:49:24 +02:00
jochen 428b42181d mesh-mail: on the operator's Approve the tools record approved and queue the send for the sender, instead of sending it themselves (ADR 0436 clause 8; issue 1194) 2026-10-19 09:44:42 +02:00
jochen 59c0906c79 mesh-mail: move password reading out of the tools' reach into its own package, so only keep and the send actor import it (ADR 0436 clause 12; issue 1193) 2026-10-19 09:32:54 +02:00
jochen 75a9a45c6f mesh-mail: pin mesh-sdk go to release 2, declare the sender account, bus process and work-queue stream (ADR 0436 clauses 7, 8; issue 1172) 2026-10-19 09:23:45 +02:00
mesh-tools b4527d9091 Merge pull request 'Ask a refused merge again when its checks turn green on the forge (issue 1144)' (#368) from fix/1144-merge-asked-again-on-green-status into main 2026-10-18 22:05:45 +00:00
jochen a32b7af3fd Ask a refused merge again when its checks turn green on the forge (issue 1144)
mesh/merge-gate head a32b7af3fd8a merged into trunk a6295d6b98d5 — pass: builds mesh-delivery → novox; no bus step; every machine composes with the change …
mesh/repo-check head a32b7af3fd8a merged into trunk a6295d6b98d5 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
2026-10-18 23:50:25 +02:00
mesh-tools a6295d6b98 Merge pull request 'Write a provisioner client's files only on a change over the bus, never on a timer (issue 1129, ADR 0430)' (#366) from fix/1129-no-drift-pass into main 2026-10-18 16:56:32 +00:00
mesh-tools afb94d925e Merge pull request 'Register the repository mesh-delivery as an owner, so its issues can be located in it (issue 1133)' (#367) from fix/1133-mesh-delivery-owner into main 2026-10-18 16:52:06 +00:00
jschoubben 26d81b09b5 Register the repository mesh-delivery as an owner, so its issues can be located in it (issue 1133)
mesh/merge-gate head 26d81b09b5e8 merged into trunk 72ad5ad138ce — pass: builds mesh-issues → novox; no bus step; every machine composes with the change as…
mesh/repo-check head 26d81b09b5e8 merged into trunk 72ad5ad138ce — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
The tracker runs with the manifest's owners default; a test now holds the
default and the template's copy to one list.
2026-10-18 18:39:11 +02:00
mesh-tools 72ad5ad138 Merge pull request 'A repository the gitea module makes refuses an administrator's merge past its checks (issue 1141)' (#365) from fix/1141-admin-override into main 2026-10-18 16:24:56 +00:00
jschoubben 54db38be08 gitea: a repository the module makes refuses an administrator's merge past its checks (issue 1141)
mesh/merge-gate head 54db38be0896 merged into trunk 4301da630d82 — pass: builds gitea → novox; no bus step; every machine composes with the change as it di…
mesh/repo-check head 54db38be0896 merged into trunk 4301da630d82 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
narrowed never set block_admin_merge_override, so novox/mesh-delivery's
main let an administrator merge past mesh/merge-gate and mesh/repo-check,
unlike every other repository's main since 2026-10-07. narrowed sets it and
isRight requires it, so the module's next start puts mesh-delivery right.
2026-10-18 18:10:29 +02:00
jochen e7ff7c3045 Write a provisioner client's files only on a change over the bus, never on a timer (issue 1129, ADR 0349 clause 4)
mesh/merge-gate head e7ff7c3045fc merged into trunk afb94d925e94 — pass: builds mesh-provisioner-client → ace, g14, novox, shanks; no bus step; every machi…
mesh/repo-check head e7ff7c3045fc merged into trunk afb94d925e94 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
2026-10-18 18:08:50 +02:00
mesh-tools 4301da630d Merge pull request 'gitea fills repositories.json through the provisioner client, so an approved repositories value lands at once (issue 1128)' (#364) from fix/1128-repositories-template into main 2026-10-18 16:03:58 +00:00
jschoubben e75e793a4b gitea: fill repositories.json through the provisioner client, so an approved value lands at once (issue 1128)
mesh/merge-gate head e75e793a4b3b merged into trunk cfb90d7b2f96 — pass: builds gitea → novox; no bus step; every machine composes with the change as it di…
mesh/repo-check head e75e793a4b3b merged into trunk cfb90d7b2f96 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
The file was merged by the controller (merge, takes-config), which writes a
config change only on the node's next send (ADR 0348 §10): the operator's
Approve of the mesh-delivery repository set the value and nothing made it.
A plain template saying ${config:repositories} is filled by the provisioner
client on config-changed, and the node-engine reruns the run-once program
that names the file under restart-on.
2026-10-18 17:40:59 +02:00
mesh-tools cfb90d7b2f Merge pull request 'The gitea module makes the organisation's repositories from its config, protected before any team reaches them; no tool creates in the organisation (issue 1124, ADR 0428)' (#363) from fix/1124-repositories-config into main 2026-10-18 15:05:04 +00:00
jschoubben 11761637cf Put main's rule right again once a new repository is in the organisation: the forge drops a merger without access, and under the setup account the tools' account has none (issue 1124, ADR 0428 §3, review round 1)
mesh/merge-gate head 11761637cf81 merged into trunk e47d0694c752 — pass: builds gitea → novox; no bus step; every machine composes with the change as it di…
mesh/repo-check head 11761637cf81 merged into trunk e47d0694c752 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
2026-10-18 16:58:17 +02:00
jschoubben 8c587d0f00 Let the repositories pass's failed exit be its gate: a run-once step the node-engine runs again until it passes, its stderr naming each refused repository, step and forge answer, instead of a health check of its own (issue 1124, ADR 0428 §6) 2026-10-18 16:52:29 +02:00
jschoubben df275b3c6e Make the organisation's repositories from the gitea module's config value, protected under the setup account before they join the organisation, and take the owner argument from gitea_create_repo, since a repository's maker becomes its administrator (issue 1124, ADR 0428) 2026-10-18 16:49:12 +02:00
jschoubben 94b49a296f Test that the gitea module makes the organisation's repositories from its config, protected before any team reaches them, and that no tool creates in the organisation (issue 1124, ADR 0428) 2026-10-18 16:45:49 +02:00
mesh-tools e47d0694c7 Merge pull request 'gitea_create_repo creates a repository in an organisation, saying the forge's refusal (issue 1124)' (#362) from fix/1124-create-repo-in-org into main 2026-10-18 13:50:31 +00:00
jschoubben 0d3f0fd3fc Let gitea_create_repo create in an organisation, saying the forge's refusal, so novox repositories can be made through the tools (issue 1124)
mesh/merge-gate head 0d3f0fd3fc89 merged into trunk 7f77ce663a9e — pass: builds gitea → novox; no bus step; every machine composes with the change as it di…
mesh/repo-check head 0d3f0fd3fc89 merged into trunk 7f77ce663a9e — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
2026-10-18 15:45:01 +02:00
jschoubben 50fee22d0a Test that gitea_create_repo creates in an organisation and says the forge's refusal (issue 1124) 2026-10-18 15:44:38 +02:00
mesh-tools 7f77ce663a Merge pull request 'mesh-delivery waits for nothing: a check is asked at once and a standing verdict adopted (issue 1115)' (#361) from fix/zero-delay into main 2026-10-18 10:38:05 +00:00
jschoubben 6954467292 mesh-delivery bus test waits for the group's composed check, not a member's own check asked at once (issue 1115)
mesh/merge-gate head 695446729264 merged into trunk 580fadb63ecb — pass: builds mesh-delivery → novox; no bus step; every machine composes with the change …
mesh/repo-check head 695446729264 merged into trunk 580fadb63ecb — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
2026-10-18 12:33:02 +02:00
jschoubben 5f002441b7 mesh-delivery takes an empty answer to a check ask as a refusal (issue 1115)
mesh/merge-gate head 5f002441b7f0 merged into trunk 580fadb63ecb — pass: builds mesh-delivery → novox; no bus step; every machine composes with the change …
mesh/repo-check head 5f002441b7f0 merged into trunk 580fadb63ecb — fail: its merge-check.sh failed: --- FAIL: TestAGroupOfTwoWithGreenChecksGetsOneAskAndOn…
mesh/delivery superseded: a newer head of the same pull request
2026-10-18 12:28:03 +02:00
jschoubben fc9ddd9185 mesh-delivery adopts a standing verdict only when every required check stands, at every look, and counts an ask with no id as none (issue 1115) 2026-10-18 12:25:53 +02:00
jschoubben 1760b94824 mesh-delivery waits for nothing: a check is asked at once and a standing verdict adopted (issue 1115) 2026-10-18 12:22:31 +02:00
mesh-tools 580fadb63e Merge pull request 'A pull request at the same head as one closed unmerged takes its delivery over (issue 1114)' (#360) from fix/takeover-on-close into main 2026-10-18 09:59:02 +00:00
jschoubben 30d3bb9466 A pull request announced at a live head takes its delivery over when the other one closes or is edited (issue 1114)
mesh/merge-gate head 30d3bb94667f merged into trunk e40ec47058e6 — pass: builds mesh-delivery → novox; no bus step; every machine composes with the change …
mesh/repo-check head 30d3bb94667f merged into trunk e40ec47058e6 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
2026-10-18 11:53:28 +02:00
mesh-tools e40ec47058 Merge pull request 'mesh-delivery hears a pull request closed unmerged from the forge's own event (issue 1107)' (#359) from fix/close-heard-from-forge-event into main 2026-10-18 09:00:18 +00:00
jschoubben bc1acddaa4 mesh-delivery hears the forge's own pull-request closed event as a close unmerged (issue 1107)
mesh/merge-gate head bc1acddaa4e5 merged into trunk 798d6fd68589 — pass: builds mesh-delivery → novox; no bus step; every machine composes with the change …
mesh/repo-check head bc1acddaa4e5 merged into trunk 798d6fd68589 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
2026-10-18 04:55:48 +02:00
mesh-tools 798d6fd685 Merge pull request 'A group member refused for its own pending repo-check is asked its own check, not the composed one (issue 1049)' (#358) from fix/1049-own-check-on-refusal into main 2026-10-18 02:41:25 +00:00
jschoubben b0ec4f6f14 A group member refused for a pending status of its own asks its own check once, not the composed check (issue 1049)
mesh/merge-gate head b0ec4f6f1405 merged into trunk d3dd46a9fdb4 — pass: builds mesh-delivery → novox; no bus step; every machine composes with the change …
mesh/repo-check head b0ec4f6f1405 merged into trunk d3dd46a9fdb4 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
2026-10-18 04:27:42 +02:00
mesh-tools d3dd46a9fd Merge pull request 'Readers of the assignments skip an ended or pending one (design 47 milestone 5, issue 1098)' (#357) from feat/chain-on-the-bus into main 2026-10-18 02:04:16 +00:00
jschoubben 3bc4215d87 Take the bus's backup guard out of modules/nats: it would move the bus (issue 1098, guard follows in issue 1104)
mesh/repo-check head 3bc4215d872c merged into trunk 44e7002d3c84 beside mesh-controller#378@0149a3d7 — pass: its merge-check.sh passed, each part in its to…
mesh/delivery stopped: its walk plan-1792289060772890935 was stopped by a person (mesh-controller): the operator, via telegram (user id verified), chose …
mesh/delivery-group group feat/chain-on-the-bus stopped: a member was stopped
mesh/merge-gate out of date: judged against trunk 798d6fd68589, the trunk is now e40ec47058e6; judged again on a recheck or when it is merged
2026-10-18 03:55:50 +02:00
jschoubben 68e3326d4c Providers and the provisioner client take the SDK that reads an ended assignment as a removed key (issue 1098)
mesh/repo-check head 68e3326d4cbc merged into trunk 44e7002d3c84 beside mesh-controller#378@b56c1d00 — pass: its merge-check.sh passed, each part in its to…
mesh/delivery-group group feat/chain-on-the-bus ready: every member ready, and composed together they pass
mesh/merge-gate head 68e3326d4cbc merged into trunk 44e7002d3c84 — pass: group feat/chain-on-the-bus composed: every machine composes with the change as it…
mesh/delivery superseded: a newer head of the same pull request
2026-10-18 02:40:18 +02:00
jschoubben 84af0ed180 Guard that the bus storage stays declared as data with a backup (issue 1098) 2026-10-18 02:37:28 +02:00
jschoubben 9edd16eaeb mesh-provisioner-client takes a module off the node when its assignment is ended (issue 1098) 2026-10-18 02:37:28 +02:00
jschoubben 60a3823f0e mesh-vault reads an ended assignment as a removed key (issue 1098) 2026-10-18 02:37:28 +02:00
jschoubben bdbca4b921 mesh-config drops an ended assignment as a removed one (issue 1098) 2026-10-18 02:37:28 +02:00
mesh-tools 44e7002d3c Merge pull request 'A group approved together merges in one pass and goes out in one delivery plan (design 47 milestone 3 fix: issues 1049, 1082, 1047, 1083, 1079)' (#356) from feat/group-one-pass into main 2026-10-17 23:44:21 +00:00
jschoubben 7383bc3170 A stale composed pass counts members judged since it was asked, whichever verdict lands first (issue 1047, review round 2)
mesh/merge-gate head 7383bc31703e merged into trunk 0c56f603059b — pass: builds gitea, mesh-delivery → novox; no bus step; every machine composes with the …
mesh/repo-check head 7383bc31703e merged into trunk 0c56f603059b — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
2026-10-18 01:20:18 +02:00
jschoubben ebd29223f3 Prove over a real bus that an edit of the description asks the Approve again (issue 1079, review round 1) 2026-10-18 01:18:43 +02:00
jschoubben e8a74765f8 A composed check that lands judged against a trunk that has moved is out of date and asked again (issue 1047, review round 1) 2026-10-18 01:18:13 +02:00
jschoubben 6556e61485 Read a pull request's review record again when its description is edited, and ask the Approve again if it was unanswered (issue 1079) 2026-10-18 01:12:40 +02:00
jschoubben 93365be98f A new pull request at a commit whose delivery was stopped and unmerged takes that delivery over (issue 1083) 2026-10-18 01:10:51 +02:00
jschoubben 814c1a991c A trunk move asks at once the check of a group member whose Approve is not asked yet (issue 1047) 2026-10-18 01:10:12 +02:00
jschoubben 983e4a6444 A closed group's member whose merge was refused asks the composed check again at once, and a refusal loop past ten minutes is said (issue 1082) 2026-10-18 01:09:37 +02:00
jschoubben da094008ea A group's composed verdict names each member's siblings at the heads it composed, so a sibling's own merge is no move (issue 1049) 2026-10-18 01:08:26 +02:00
mesh-tools 0c56f60305 Merge pull request 'Let a proposed record be withdrawn (issue 1081)' (#355) from fix/1081-withdraw into main 2026-10-17 22:15:25 +00:00
jochen 684e8801c4 Take the ask back on the router when withdrawing a record (issue 1081, review round 1)
mesh/merge-gate head 684e8801c420 merged into trunk 3395af96bc34 — pass: builds mesh-knowledge → novox; no bus step; every machine composes with the change…
mesh/repo-check head 684e8801c420 merged into trunk 3395af96bc34 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
WithdrawRecord only ended the waiting ask locally; the router kept it open, so
the operator still saw Approve/Decline for a withdrawn record and an answer to
it was dropped silently. It now also publishes a cancel on the router's
subject once the withdraw is saved, as the controller's asker does; a cancel
the router cannot be told of is logged and the withdraw still stands. The
module's existing "uses": ["operator-channel"] already grants this; no manifest
change needed, since the ask and cancel subjects are both under that seat.
2026-10-18 00:09:16 +02:00
jochen 6394a62a02 Merge remote-tracking branch 'origin/main' into fix/1081-withdraw 2026-10-18 00:03:16 +02:00
jochen ddf7a5ab09 Let a proposed record be withdrawn (issue 1081)
A dropped proposal or a stale reservation stayed proposed forever: there was
no way to say the operator decided against it. knowledge-base.withdraw takes
a reserved or proposed record to withdrawn as a new version naming why, ends
a waiting operator's ask and clears a pending review in the same version,
and refuses an already accepted, superseded or withdrawn record. The number
stays spent either way.

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

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

The catalogue's new checks/sshdir refuses any manifest but ssh-client naming a
path under ~/.ssh (ADR 0415 §6). The README cites §7 for config.d.
2026-10-17 13:36:27 +00:00
mesh-tools ce56b491cf Merge pull request 'ssh-client keeps ~/.ssh to what it knows: sweep every other path into a kept folder (issue 1017, ADR 0415)' (#346) from fix/1017-retire-ssh-leftovers into main 2026-10-17 13:07:05 +00:00
mesh-tools 9675babf52 Merge pull request 'mesh-delivery acts on events and keeps each pull request's key; the forge's announcements carry a message id (issues 975, 976, design 47 milestone 2)' (#345) from feat/events-and-queues into main 2026-10-17 12:52:42 +00:00
jschoubben 94f65cccd9 issue 975: an empty commit so mesh-delivery reads the group's after: line, which it reads only when a head is announced (the operator's word, 2026-10-17)
mesh/merge-gate head 94f65cccd9c1 merged into trunk a7d526838ab0 beside mesh-controller#368@0f21fe78 — pass: builds build-agent, gitea, mesh-delivery → ace…
mesh/repo-check head 94f65cccd9c1 merged into trunk a7d526838ab0 beside mesh-controller#368@0f21fe78 — pass: its merge-check.sh passed, each part in its to…
mesh/delivery delivered
mesh/delivery-group group feat/events-and-queues delivered: every member is delivered
2026-10-17 14:39:21 +02:00
mesh-tools acf075fa63 ssh-client keeps ~/.ssh to what it knows: sweep every other path into a kept folder (issue 1017, ADR 0415)
mesh/merge-gate head acf075fa63cc merged into trunk 9675babf528b — pass: builds ssh-client → ace, g14, novox, shanks; no bus step; every machine composes w…
mesh/repo-check head acf075fa63cc merged into trunk 9675babf528b — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
ssh-client owns ~/.ssh, but treated every file it did not place as found, so a
predecessor's drop-in and old copies stayed there for weeks. Its process now
sweeps at start and every ten minutes: keys at any depth (and links to them),
authorized_keys, authorized_keys2 and sockets stay, and a folder holding one is
gone into; everything else moves to ~/.local/state/ssh-client/retired/<UTC
time>/, a folder never shared with another sweep, with a list beside it, which
ssh_client_check shows. Nothing is deleted.
2026-10-17 12:38:16 +00:00
jschoubben 52f70f42b7 issue 975: a merge heard again changes nothing, and a verdict naming no trunk commit is not written to the key
mesh/delivery-group group feat/events-and-queues rejected: its order contradicts itself: novox/mesh-catalog@52f70f42b749 and novox/mesh-controller@0f21fe7888be…
mesh/merge-gate head 52f70f42b749 merged into trunk a7d526838ab0 beside mesh-controller#368@0f21fe78 — pass: builds build-agent, gitea, mesh-delivery → ace…
mesh/repo-check head 52f70f42b749 merged into trunk a7d526838ab0 beside mesh-controller#368@0f21fe78 — pass: its merge-check.sh passed, each part in its to…
mesh/delivery superseded: a newer head of the same pull request
2026-10-17 14:10:14 +02:00
jschoubben add91ff048 issue 976: the forge's announcements get a deterministic message id; the announcement files go, the poll's watermark is kept in the module's state 2026-10-17 13:52:47 +02:00
jschoubben 2c8c1543ba issue 975: the pull request's key written by compare-and-set, the ready flag on a ready mark's check, build-agent reads the key 2026-10-17 13:48:54 +02:00
jschoubben 27ca8bbcff issue 975: the acceptance over a real bus: a ready mark and its Approve are merged within seconds of their events 2026-10-17 13:28:21 +02:00
jschoubben 0809a1f982 issue 975: a test that fails on a ticker and on a timer that names no bounded step it retries 2026-10-17 13:25:33 +02:00
jschoubben b5225d3a2f issue 975: no forge poll; the open pull requests are read at start and when a merge check's status, a new head or a ready mark says they changed 2026-10-17 13:25:03 +02:00
jschoubben d675f5e7c6 issue 975: no tick and no reconcile loop; a look after each event, one timer for the steps that wait on the clock 2026-10-17 13:23:25 +02:00
jschoubben 95e11464fe issue 975: ready marks, verdicts and Approves ask the merge as events, not at the next minute 2026-10-17 13:19:01 +02:00
mesh-tools a7d526838a Merge pull request 'One forge user, nox-agent, for every machine's agents' SSH pushes, each machine's key titled by machine (issue 991, ADR 0410)' (#344) from fix/991-one-forge-agent-user into main 2026-10-17 00:51:08 +00:00
mesh-tools 3a2560dc51 One forge user, nox-agent, for every machine's agents' SSH pushes, each machine's key titled by machine (issue 991, ADR 0410)
mesh/merge-gate head 3a2560dc51fc merged into trunk 82df3bd58913 — pass: builds gitea → novox; no bus step; every machine composes with the change as it di…
mesh/repo-check head 3a2560dc51fc merged into trunk 82df3bd58913 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
2026-10-17 00:46:43 +00:00
mesh-tools 82df3bd589 Merge pull request 'Give the forge an ssh name of its own, so it no longer merges with the operator's entry for its machine (issue 955, ADR 0409)' (#343) from fix/955-forge-ssh-name into main 2026-10-17 00:05:57 +00:00
mesh-tools 661b83249c Give the forge an ssh name of its own, so it no longer merges with the operator's entry for its machine (issue 955, ADR 0409)
mesh/merge-gate head 661b83249c2a merged into trunk bb291b5ad8af — pass: builds gitea, ssh-client → ace, g14, novox, shanks; no bus step; every machine com…
mesh/repo-check head 661b83249c2a merged into trunk bb291b5ad8af — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
2026-10-17 00:01:42 +00:00
mesh-tools bb291b5ad8 Merge pull request 'Keep gitea's forge-account provider running when a watch is refused, so a later grant is picked up (issue 955)' (#342) from fix/955-provider-keeps-running into main 2026-10-16 23:30:28 +00:00
mesh-tools b159efd656 Keep gitea's forge-account provider running when a watch is refused, so a later grant is picked up (issue 955)
mesh/merge-gate head b159efd65673 merged into trunk 522780d5fcfa — pass: builds gitea → novox; no bus step; every machine composes with the change as it di…
mesh/repo-check head b159efd65673 merged into trunk 522780d5fcfa — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
2026-10-16 23:26:01 +00:00
mesh-tools 522780d5fc Merge pull request 'Let gitea's forge-account provider read the assignments it learns its consumers from (issue 955)' (#341) from fix/955-gitea-reads-assignments into main 2026-10-16 23:11:16 +00:00
mesh-tools 065ecd85f5 Let gitea's forge-account provider read the assignments it learns its consumers from (issue 955)
mesh/merge-gate head 065ecd85f53e merged into trunk 734a77f5a91a — pass: builds gitea → novox; no bus step; every machine composes with the change as it di…
mesh/repo-check head 065ecd85f53e merged into trunk 734a77f5a91a — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
2026-10-16 23:05:10 +00:00
mesh-tools 734a77f5a9 Merge pull request 'Feature agents-write-git, step 2: git over SSH with a key made on each machine (issue 955)' (#340) from feat/agents-write-git-ssh into main 2026-10-16 22:31:17 +00:00
mesh-tools ae9cda8531 Hand ssh-client's forge files to the provisioner client (issue 955)
mesh/merge-gate head ae9cda8531b3 merged into trunk 017b729bc95d — pass: builds gitea, ssh-client → ace, g14, novox, shanks; no bus step; every machine com…
mesh/repo-check head ae9cda8531b3 merged into trunk 017b729bc95d — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery covered after its failure: a later walk that contains it was delivered: plan-1792195559895153198 (novox/mesh-catalog at 82df3bd5 into main,…
mesh/delivery-group group feat/agents-write-git-ssh delivered: every member is delivered
2026-10-16 22:05:52 +00:00
jschoubben 6f45570d4c Do not remove keys or entries before the machines' keys are read (issue 955 F)
mesh/merge-gate head 6f45570d4c23 merged into trunk 017b729bc95d — fail: builds gitea, ssh-client → ace, g14, novox, shanks; no bus step; 4 problem(s) the …
mesh/repo-check head 6f45570d4c23 merged into trunk 017b729bc95d — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery-group group feat/agents-write-git-ssh rejected: a member's own check failed
mesh/delivery superseded: a newer head of the same pull request
2026-10-16 22:54:31 +02:00
jschoubben 6fdf3629d3 Remove a machine's mesh keys and entry when its key is no longer reported (issue 955 F) 2026-10-16 22:53:22 +02:00
jschoubben 896f1d2854 Merge feat/agents-write-git-ssh into feat/agents-write-git-ssh-f (issue 955 F) 2026-10-16 22:48:06 +02:00
jschoubben 5c64b71cd7 Test the forge-account provider whole against a real bus and a fake forge (issue 955 F5) 2026-10-16 22:47:59 +02:00
jschoubben 1081a5f3ea Declare the forge-account provision, its provider bundle, its fact and the keys it reads (issue 955 F4) 2026-10-16 22:46:27 +02:00
jschoubben c346bba34e Add the forge-account provider: an agent account, its team and its machine key per consumer (issue 955 F3) 2026-10-16 22:45:51 +02:00
fix-955-ssh 165cbcb35f Merge branch 'feat/agents-write-git-ssh' into feat/agents-write-git-ssh-s 2026-10-16 22:44:43 +02:00
fix-955-ssh 9cb615f121 Prove the forge key process over a real bus in the merge check (issue 955 S4) 2026-10-16 22:44:36 +02:00
fix-955-ssh ad5a0f8e44 Place the forge's known host, SSH Host and git rewrite (issue 955 S3) 2026-10-16 22:43:51 +02:00
fix-955-ssh a4cdd05cb2 Declare ssh-client's keys state and its forge-account requirement (issue 955 S2) 2026-10-16 22:43:29 +02:00
fix-955-ssh b2009f1bb5 Keep the machine's forge SSH key and its keys entry (issue 955 S1) 2026-10-16 22:43:18 +02:00
jschoubben 8131c13634 Register and prune an agent account's mesh SSH keys through the forge API (issue 955 F2) 2026-10-16 22:42:04 +02:00
jschoubben c8dfbdc813 Lift the agent account code into internal/forgeaccount for the forge-account provider (issue 955 F1) 2026-10-16 22:41:11 +02:00
mesh-tools 017b729bc9 Merge pull request 'Serve postgres's consumers the port its server is published on, not the container's 5432 (issue 957)' (#339) from fix/957-postgres-serves-published-port into main 2026-10-16 20:15:33 +00:00
lead-57 deb2020bd4 Serve postgres's consumers the port its server is published on, not the container's 5432 (issue 957)
mesh/merge-gate head deb2020bd47d merged into trunk 5652a1494493 — pass: builds postgres, sent nowhere; no bus step; 2 wait(s) for a person; every machine …
mesh/repo-check head deb2020bd47d merged into trunk 5652a1494493 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
On novox postgres is published 6852:5432; handed 5432, gitea and keycloak reached
nothing once their files were filled from postgres's entry.
2026-10-16 21:56:15 +02:00
mesh-tools 5652a14944 Merge pull request 'Feature agents-write-git, step 1: agents write with their own accounts, main stays closed, a merge into main waits for the operator (issues 948, 954)' (#338) from feat/agents-write-git into main 2026-10-16 19:45:36 +00:00
fix-deliv-awg@novox 9acd1f51c5 Merge branch 'feat/agents-write-git' into feat/agents-write-git-d
mesh/merge-gate head 9acd1f51c5cf merged into trunk 8d012fe4e270 — pass: builds gitea, mesh-delivery, mesh-provisioner-client → ace, g14, novox, shanks; no…
mesh/repo-check head 9acd1f51c5cf merged into trunk 8d012fe4e270 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
2026-10-16 21:36:10 +02:00
fix-deliv-awg@novox 4cccd817a0 End a group's ask and Approve when any member is pushed (issue 948, ADR 0398 §3) 2026-10-16 21:36:06 +02:00
fix-forge-awg 3cdea45f90 Keep main's rule right at every start and copy a covering glob's checks (issue 948, review round 1) 2026-10-16 21:35:51 +02:00
lead-57 4215b40547 Read the whole plan the controller writes: at, from, as, names and credential-tool (issue 954)
The client decodes a plan strictly, and since the controller carries these fields
(issues 923 and 928) it refused the plans of gitea, keycloak and claude-code.
2026-10-16 21:32:13 +02:00
fix-deliv-awg@novox 0bfece71da Merge branch 'feat/agents-write-git' into feat/agents-write-git-d 2026-10-16 21:29:58 +02:00
fix-deliv-awg@novox f61e00aeaa Ask the operator to Approve a merge into main and merge only on the warrant (issue 948, ADR 0398 §3) 2026-10-16 21:29:55 +02:00
fix-forge-awg 789aa0133f Keep the guard's push wording for main (issue 948) 2026-10-16 21:28:49 +02:00
fix-forge-awg 7807135a1b Main takes no push and only mesh-tools merges, set by setup and guarded (issue 948, ADR 0398 §2) 2026-10-16 21:28:43 +02:00
fix-deliv-awg@novox 417f69567c Merge only into main: a ready mark on another base is left to its author (issue 948, ADR 0398 §4) 2026-10-16 21:26:53 +02:00
fix-forge-awg b0f86671b6 Give the agent's token write scopes and the agents write team (issue 948, ADR 0398 §1) 2026-10-16 21:25:59 +02:00
mesh-tools 8d012fe4e2 Merge pull request 'Feature providers-after-switch-on, step 1: providers answer after the switch-on (issues 923, 928, 849, 945, 825)' (#337) from feat/providers-after-switch-on into main 2026-10-16 19:13:01 +00:00
lead-57 d2992f81d1 Take mesh-sdk a4e7f48 in the modules that use its provider helper (issues 825, 849, 945)
mesh/merge-gate head d2992f81d17f merged into trunk 3817099b2ca2 beside mesh-controller#363@df5f01bf — pass: builds keycloak, mesh-provisioner-client, mesh…
mesh/repo-check head d2992f81d17f merged into trunk 3817099b2ca2 beside mesh-controller#363@df5f01bf — pass: its merge-check.sh passed, each part in its to…
mesh/delivery delivered
mesh/delivery-group group feat/providers-after-switch-on delivered: every member is delivered
postgres, keycloak, mesh-provisioner-client, route-proxy and route-adapter now
build against the feature's mesh-sdk: the harness package, the vault-name key
and the helper's fixes.
2026-10-16 20:59:14 +02:00
fix-cat-pas@novox dcedd8d2b2 Give the route plan entries their credential-form, and read the entry's vault-name in the route providers' tests (issues 923, 945) 2026-10-16 19:44:19 +02:00
fix-cat-pas@novox 302eab1431 Restore the test that both keycloak paths leave the same client, over the SDK's moved path (issue 849) 2026-10-16 19:37:54 +02:00
fix-cat-pas@novox b0dc3323c9 Run keycloak on the SDK's harness and moved path, dropping its copies (issue 849) 2026-10-16 19:36:02 +02:00
fix-cat-pas@novox 33759f8b57 Run postgres on the SDK's harness and moved path, dropping its copies (issue 849) 2026-10-16 19:35:22 +02:00
fix-vault-pas@novox 2f09dd8501 Merge branch 'feat/providers-after-switch-on' into feat/providers-after-switch-on-v 2026-10-16 19:34:15 +02:00
fix-vault-pas@novox 5a00596908 Make the vault ask a software-issuing provider's tool itself for holders that hold nothing (issue 928) 2026-10-16 19:34:11 +02:00
fix-cat-pas@novox a51e2c0fb4 Pin route-proxy's acme templates beside its manifest (issue 849) 2026-10-16 19:32:20 +02:00
fix-vault-pas@novox 044a548cee Pin that the client asks a refused or not-yet secret again (issue 928) 2026-10-16 19:31:40 +02:00
fix-vault-pas@novox 0dab4475cd Answer an unreadable assignment as not yet, never a refusal (issue 928) 2026-10-16 19:30:15 +02:00
fix-cat-pas@novox 0b056edaaa Prove the client fills gitea's origins and keycloak's hostname from route-proxy's entry for a controller-shaped plan entry (issue 923) 2026-10-16 19:29:30 +02:00
fix-cat-pas@novox cc9e7a72ee Name the vault's credential vault-name in the client's bound-entry fixtures (issue 945) 2026-10-16 19:29:30 +02:00
mesh-tools 3817099b2c Merge pull request 'Ask the plans again when the moved path starts before its runtime answers (issue 943)' (#336) from fix/943-moved-path-waits-for-runtime into main 2026-10-16 16:55:41 +00:00
mesh-tools 6005c6001e Ask the plans again when the moved path starts before its runtime answers (issue 943)
mesh/merge-gate head 6005c6001e2f merged into trunk 832a0afd1814 — pass: builds keycloak, postgres, sent nowhere; no bus step; 3 wait(s) for a person; ever…
mesh/repo-check head 6005c6001e2f merged into trunk 832a0afd1814 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
postgres and keycloak start their moved path beside Serve, and a watch
asked before Serve runs is refused by the runtime. Run returned on that
refusal and main only logged it, so a start that lost the race left the
provider answering nobody and retiring nothing until its next restart:
postgres on novox at 16:52, 18:29 and 18:48, on ace at 16:22, which kept
issue 937's retirement condition on novox from clearing. Run now asks
again, the wait doubling from a second to a minute, until the runtime
answers or its context ends.
2026-10-16 16:52:52 +00:00
mesh-tools 832a0afd18 Merge pull request 'Say once after a restart that nothing waits, so an old retirement condition clears (issue 937)' (#335) from fix/937-settle-after-restart into main 2026-10-16 16:45:30 +00:00
mesh-tools d7a880a608 Say once after a restart that nothing waits, so an old retirement condition clears (issue 937)
mesh/merge-gate head d7a880a60878 merged into trunk 3ee754eb3b0c — pass: builds keycloak, postgres, sent nowhere; no bus step; 3 wait(s) for a person; ever…
mesh/repo-check head d7a880a60878 merged into trunk 3ee754eb3b0c — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
The controller clears a provider's retire-waiting condition only on its
settled word, and a provider says settled only for a set its own memory
held. A provider restarted after the set stopped waiting (here: the
delivery of the grant-file fix) never says it, so the condition kept
asking the operator to approve retiring what nobody waits on. The first
pass of a process that finds every consumer asked now says settled once,
with no set; the controller clears both conditions on it.
2026-10-16 16:42:39 +00:00
mesh-tools 3ee754eb3b Merge pull request 'Keep serving the consumers postgres's grant file gives on its moved path (issue 937)' (#334) from fix/937-postgres-grants-on-moved-path into main 2026-10-16 16:32:21 +00:00
mesh-tools 6268f8f94b Merge pull request 'Hold an ask no channel can carry now and offer it when one can (issue 926, ADR 0397)' (#333) from fix/926-a-held-ask-waits-for-a-channel into main 2026-10-16 16:28:21 +00:00
mesh-tools 41cdd03208 Keep serving the consumers postgres's grant file gives on its moved path (issue 937)
mesh/merge-gate head 41cdd032084e merged into trunk 6268f8f94bda — pass: builds keycloak, postgres, sent nowhere; no bus step; 3 wait(s) for a person; ever…
mesh/repo-check head 41cdd032084e merged into trunk 6268f8f94bda — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
Only a moved consumer's plan names postgres, so on the moved path every
consumer still given by the controller's grant file was counted unasked
and offered for retirement (ace 5 of 5, novox 7 of 9), and could have
been retired unasked where the bound allowed. The moved path now reads
MESH_RECEIVES again: its logins are asked, the ones no plan names are
made and checked by the harness's grant pass until R1 (issue 682), and
an unreadable grant file retires nothing (issue 241). The harness's
grant pass becomes apply, identically in keycloak's copy.
2026-10-16 16:25:52 +00:00
jschoubben 77d751fe83 Test the router whole over the real bus: a held ask is offered when a channel can carry it (issue 926, ADR 0397)
mesh/merge-gate head 77d751fe830d merged into trunk 08188663faff — pass: builds mesh-knowledge, messenger → novox; no bus step; every machine composes with…
mesh/repo-check head 77d751fe830d merged into trunk 08188663faff — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
2026-10-16 17:57:52 +02:00
jschoubben c211762813 Hold an ask no channel can carry now and offer it when one can; show held and why (issue 926, ADR 0397)
The router refused such an ask with 'no channel', so the asker waited for an Approve nobody saw. It is now held as an open ask with the per-kind reason, judged again when the kinds or a channel's standing change and every minute, and the knowledge base says held and why.
2026-10-16 17:51:32 +02:00
mesh-tools 08188663fa Merge pull request 'mesh-files holds node-files: file work on a node with trash, owned paths and an act log, no shell needed (issue 900, ADR 0394)' (#332) from feat/900-node-files into main 2026-10-16 14:25:12 +00:00
jschoubben d4a9260847 Merge remote-tracking branch 'origin/main' into feat/900-node-files
mesh/merge-gate head d4a9260847ec merged into trunk 8b53a3663244 beside mesh-controller#360@f4ddddd5 — pass: builds new: modules/mesh-files, sent nowhere; …
mesh/repo-check head d4a9260847ec merged into trunk 8b53a3663244 beside mesh-controller#360@f4ddddd5 — pass: its merge-check.sh passed, each part in its to…
mesh/delivery delivered
mesh/delivery-group group feat/900-node-files 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
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
1505 changed files with 134120 additions and 9911 deletions
+8 -2
View File
@@ -8,16 +8,22 @@ modules/messenger/cmd/messenger/messenger
modules/mesh-watcher/cmd/mesh-watcher/mesh-watcher
modules/claude-code/cmd/claude-code/claude-code
modules/dnsmasq/cmd/dnsmasq-tools/dnsmasq-tools
modules/mesh-delivery/cmd/mesh-delivery/mesh-delivery
# ...and the same built from the module's root (go build ./cmd/<name>), which names it after the module.
modules/messenger/messenger
modules/claude-code/claude-code
modules/mesh-watcher/mesh-watcher
modules/dnsmasq/dnsmasq
modules/mesh-delivery/mesh-delivery
modules/mesh-issues/cmd/mesh-issues/mesh-issues
modules/mounts/cmd/mounts/mounts
modules/mesh-knowledge/cmd/mesh-knowledge/mesh-knowledge
modules/mesh-knowledge/mesh-knowledge
modules/mesh-reads/mesh-reads
modules/mesh-reads/cmd/mesh-reads/mesh-reads
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
modules/brother/cmd/brother/brother
modules/mesh-build-go/cmd/mesh-build-go/mesh-build-go
modules/mesh-config/mesh-config
+6
View File
@@ -0,0 +1,6 @@
# Release 57: config and provisioning
This branch carries the work of hq design 57 (ADR 0348, ADR 0349) in this repository. Each package
merges here through its own reviewed pull request; the branch reaches main as one pull request, in one
delivery group with the branch of the same name in mesh-controller, mesh-host, mesh-sdk, mesh-catalog
and hq, after a rehearsal in the lab. This file is deleted by the last commit before that pull request.
+3
View File
@@ -0,0 +1,3 @@
module git.novox.be/novox/mesh-catalog/checks/config
go 1.22
+201
View File
@@ -0,0 +1,201 @@
// Command config refuses the settings form's names in a module that has moved to config (novox/hq ADR 0348
// clauses 1 and 11, design 57 package K6, issue 665).
//
// "Config" replaces "settings": a manifest declares its values under `config`, a mergeable file names the keys it
// takes under `takes-config`, and a file asks for a value with `${config:<key>}`. A mergeable file naming no keys at
// all is the settings form's last shape: it takes every key of its module's layer and declares none, so mesh-config
// could set none of them (ADR 0348 §10, ADR 0326 §3); it is found here like an old name (issue 735). The old names — `settings`,
// `takes-settings` and `${setting:<key>}` — stay accepted by the controller as aliases until every module has
// moved, and are then refused (ADR 0348 §1). The modules not yet moved are listed in the file `unmoved` beside this
// check, one per line: the catalogue's list ADR 0348 §11 names. Every other module says no old name, in its
// manifest or in a template file its manifest names; a module on the list that says none is taken off it; and a
// line the trunk's copy (the merge base's, given with -base) does not hold is refused, so the list only shrinks;
// while the trunk holds no copy, the modules in seed are the trunk's.
// When it is empty, the controller refuses the old names itself (design 57 package R1).
//
// Prose is not judged: a comment in a file's text that says "settings" composes into the machine's file, and
// rewording it would change what a node is sent.
//
// go run . [-base <the trunk's unmoved>] <catalogue root>
//
// Exits 1 on a finding.
package main
import (
"bufio"
"encoding/json"
"flag"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
)
// The settings form's names (ADR 0348 §1). A key of a JSON object is a quoted string followed by a colon; a
// string value is never followed by one.
var (
settingRef = regexp.MustCompile(`\$\{setting:[a-z0-9][a-z0-9_.-]*\}`)
takesSettingsKey = regexp.MustCompile(`"takes-settings"\s*:`)
oldPlaceholder = "${setting:…}"
oldTakes = "takes-settings"
oldDeclaration = "settings"
wholeLayer = "taking its whole layer"
listFile = "unmoved"
listPathInCatalog = "checks/config/" + listFile
)
// Check answers, per module that says any, the settings form's names its manifest and its template files say.
func Check(root string) (map[string][]string, error) {
manifests, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
if err != nil {
return nil, err
}
sort.Strings(manifests)
out := map[string][]string{}
for _, path := range manifests {
raw, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var m struct {
Module string `json:"module"`
Settings json.RawMessage `json:"settings"`
Resources []map[string]any `json:"resources"`
}
if err := json.Unmarshal(raw, &m); err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
var said []string
if len(m.Settings) > 0 && string(m.Settings) != "null" {
said = append(said, oldDeclaration)
}
if takesSettingsKey.Match(raw) {
said = append(said, oldTakes)
}
if settingRef.Match(raw) {
said = append(said, oldPlaceholder)
}
for _, r := range m.Resources {
// A mergeable file naming no keys takes every key of the layer (ADR 0326 §3), declaring none of them.
if how, _ := r["merge"].(string); how != "" {
_, oldList := r["takes-settings"]
_, newList := r["takes-config"]
if !oldList && !newList {
said = append(said, fmt.Sprintf("%v %s", r["id"], wholeLayer))
}
}
name, _ := r["template"].(string)
if name == "" {
continue
}
text, err := os.ReadFile(filepath.Join(filepath.Dir(path), filepath.FromSlash(name)))
if err != nil {
return nil, fmt.Errorf("%s names the template %s: %w", m.Module, name, err)
}
if settingRef.Match(text) {
said = append(said, oldPlaceholder+" in "+name)
}
}
if len(said) > 0 {
sort.Strings(said)
out[m.Module] = said
}
}
return out, nil
}
// seed is the list as it was first written, standing for the trunk's copy until the trunk holds one.
// The modules taking a whole layer were found after the list was first written and before the trunk held a copy
// (issue 735), so they stand in it too.
var seed = map[string]bool{
"betterbird": true, "fail2ban": true, "influxdb": true, "keycloak": true, "mailu": true, "searxng": true,
"baserow": true, "confluence": true, "gitea": true, "gitlab": true, "grafana": true, "home-assistant": true,
"icecast": true, "jira": true, "letta": true, "matrix": true, "nodered": true, "supabase": true, "unifi": true,
}
// Unmoved is the modules a list names, one per line; a line starting with # and a blank line are not modules.
func Unmoved(path string) (map[string]bool, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
out := map[string]bool{}
s := bufio.NewScanner(f)
for s.Scan() {
line := strings.TrimSpace(s.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
out[line] = true
}
return out, s.Err()
}
// Judge answers the modules that say an old name and are not on the list (refused), the modules on the list that
// say none (stale), and the modules on the list that the trunk's list does not hold (added).
func Judge(found map[string][]string, unmoved, base map[string]bool) (refused, stale, added []string) {
for m := range found {
if !unmoved[m] {
refused = append(refused, m)
}
}
for m := range unmoved {
if _, says := found[m]; !says {
stale = append(stale, m)
}
if !base[m] {
added = append(added, m)
}
}
sort.Strings(refused)
sort.Strings(stale)
sort.Strings(added)
return refused, stale, added
}
func main() {
basePath := flag.String("base", "", "the trunk's copy of unmoved (the merge base's); seed when absent")
flag.Parse()
root := "."
if flag.NArg() > 0 {
root = flag.Arg(0)
}
found, err := Check(root)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
unmoved, err := Unmoved(listFile)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
base := seed
if *basePath != "" {
if base, err = Unmoved(*basePath); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
refused, stale, added := Judge(found, unmoved, base)
for _, m := range refused {
fmt.Printf("%s says %s, the settings form's names, and is not on %s: write config, takes-config and "+
"${config:<key>} throughout, and name the keys each mergeable file takes (novox/hq ADR 0348 §1, §11)\n", m, strings.Join(found[m], ", "), listPathInCatalog)
}
for _, m := range stale {
fmt.Printf("%s names %s, which says no old name: take it off the list\n", listPathInCatalog, m)
}
for _, m := range added {
fmt.Printf("%s gains %s, which the trunk's copy does not hold: the list only shrinks — move the module to "+
"config instead (novox/hq ADR 0348 §11)\n", listPathInCatalog, m)
}
if len(refused) > 0 || len(stale) > 0 || len(added) > 0 {
os.Exit(1)
}
fmt.Printf("config: no moved module says settings, takes-settings or ${setting:<key>}, or takes a whole layer "+
"(%d not yet moved)\n",
len(unmoved))
}
+131
View File
@@ -0,0 +1,131 @@
package main
import (
"os"
"path/filepath"
"reflect"
"strings"
"testing"
)
func module(t *testing.T, root, name, body string, files map[string]string) {
t.Helper()
dir := filepath.Join(root, "modules", name)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "module.json"), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
for path, text := range files {
full := filepath.Join(dir, filepath.FromSlash(path))
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(full, []byte(text), 0o644); err != nil {
t.Fatal(err)
}
}
}
// Each of the settings form's names is found where it stands: the declaration, a mergeable file's keys, a
// placeholder in the manifest, and a placeholder in a template file the manifest names (novox/hq ADR 0348 §1).
func TestEveryOldNameIsFound(t *testing.T) {
root := t.TempDir()
module(t, root, "declares", `{"module":"declares","settings":{"size":{"kind":"preference","default":1,"why":"w"}},`+
`"resources":[{"id":"f","type":"file","content":"${setting:size}"}]}`, nil)
module(t, root, "takes", `{"module":"takes","resources":[{"id":"f","type":"file","merge":"json",`+
`"takes-settings":["a"],"content":"{}"}]}`, nil)
module(t, root, "asks", `{"module":"asks","serves":{"x":{"d":"${setting:domain}"}}}`, nil)
module(t, root, "templated", `{"module":"templated","config":{"size":{"type":"number","description":"d",`+
`"default":1}},"resources":[{"id":"f","type":"file","template":"files/conf"}]}`,
map[string]string{"files/conf": "size = ${setting:size}\n"})
module(t, root, "moved", `{"module":"moved","config":{"size":{"type":"number","description":"d","default":1}},`+
`"resources":[{"id":"f","type":"file","template":"files/conf"},{"id":"g","type":"file","merge":"json",`+
`"takes-config":[],"content":"{\"settings\": 1}"}]}`,
map[string]string{"files/conf": "size = ${config:size}\n# change the settings, never this file\n"})
module(t, root, "none", `{"module":"none","resources":[{"id":"f","type":"file","content":"no settings here"}]}`, nil)
got, err := Check(root)
if err != nil {
t.Fatal(err)
}
want := map[string][]string{
"asks": {"${setting:…}"},
"declares": {"${setting:…}", "settings"},
"takes": {"takes-settings"},
"templated": {"${setting:…} in files/conf"},
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("found %v, want %v", got, want)
}
}
// A module not on the list says no old name; one on the list that says none is taken off it; a line the trunk's
// list does not hold is refused, so the list only shrinks until it is empty (novox/hq ADR 0348 §11).
func TestTheListOfModulesNotYetMovedOnlyShrinks(t *testing.T) {
found := map[string][]string{"old": {"settings"}, "forgotten": {"takes-settings"}}
unmoved := map[string]bool{"old": true, "done": true, "new": true}
base := map[string]bool{"old": true, "done": true, "forgotten": true}
refused, stale, added := Judge(found, unmoved, base)
if !reflect.DeepEqual(refused, []string{"forgotten"}) || !reflect.DeepEqual(stale, []string{"done", "new"}) ||
!reflect.DeepEqual(added, []string{"new"}) {
t.Fatalf("refused %v, stale %v, added %v", refused, stale, added)
}
}
func TestTheListIsReadWithoutItsComments(t *testing.T) {
path := filepath.Join(t.TempDir(), "unmoved")
if err := os.WriteFile(path, []byte("# why\n\nmailu\n searxng \n"), 0o644); err != nil {
t.Fatal(err)
}
got, err := Unmoved(path)
if err != nil || !reflect.DeepEqual(got, map[string]bool{"mailu": true, "searxng": true}) {
t.Fatalf("%v %v", got, err)
}
}
// The catalogue itself: every moved module says config alone, and the list names exactly the modules that
// still say settings.
func TestTheCatalogueSaysConfigInEveryMovedModule(t *testing.T) {
found, err := Check(filepath.Join("..", ".."))
if err != nil {
t.Fatal(err)
}
unmoved, err := Unmoved("unmoved")
if err != nil {
t.Fatal(err)
}
refused, stale, added := Judge(found, unmoved, seed)
for _, m := range refused {
t.Errorf("%s is not on checks/config/unmoved and says %s", m, strings.Join(found[m], ", "))
}
for _, m := range stale {
t.Errorf("checks/config/unmoved names %s, which says no old name", m)
}
for _, m := range added {
t.Errorf("checks/config/unmoved gains %s, which its first copy did not hold", m)
}
}
// A mergeable file that names no keys takes every key of its module's layer (novox/hq ADR 0326 §3): the settings
// form's last shape, which declares none of what it takes, so mesh-config could set none of it (ADR 0348 §10). It
// is found like an old name, in a module that says no other (issue 735); one naming its keys, an empty list
// included, is not.
func TestAMergeableFileTakingTheWholeLayerIsFound(t *testing.T) {
root := t.TempDir()
module(t, root, "whole", `{"module":"whole","resources":[{"id":"runtime-config","type":"file","merge":"json",`+
`"template":"files/config.json"}]}`, map[string]string{"files/config.json": "{}\n"})
module(t, root, "names", `{"module":"names","config":{"url":{"type":"string","description":"d","default":""}},`+
`"resources":[{"id":"runtime-config","type":"file","merge":"json","takes-config":["url"],"content":"{}"}]}`, nil)
module(t, root, "none", `{"module":"none","resources":[{"id":"c","type":"file","merge":"json",`+
`"takes-config":[],"content":"{}"}]}`, nil)
module(t, root, "plain", `{"module":"plain","resources":[{"id":"c","type":"file","content":"{}"}]}`, nil)
got, err := Check(root)
if err != nil {
t.Fatal(err)
}
want := map[string][]string{"whole": {"runtime-config taking its whole layer"}}
if !reflect.DeepEqual(got, want) {
t.Fatalf("found %v, want %v", got, want)
}
}
+5
View File
@@ -0,0 +1,5 @@
# The modules still written in the settings form (`settings`, `takes-settings`, `${setting:<key>}`, or a mergeable
# file naming no keys, which takes its whole layer), one per line: the catalogue's list of modules not yet moved to
# config (novox/hq ADR 0348 §11). Every other module says config alone. This list only shrinks: a module that says no
# old name is taken off, and a line the trunk's copy does not hold is refused. When it is empty, the controller
# refuses the old names (design 57 package R1).
+3
View File
@@ -0,0 +1,3 @@
module git.novox.be/novox/mesh-catalog/checks/mailstore
go 1.22
+81
View File
@@ -0,0 +1,81 @@
// Command mailstore refuses any manifest but modules/mesh-mail claiming the mail-store seat (novox/hq ADR 0378
// §3, issue 800 part A): the operator's mail is read, searched and sent through this one seat, and a second
// claimant would be a second path to the same mail that ADR 0378 never accounted for.
//
// go run . <catalogue root>
//
// Exits 1 on a finding.
package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
)
// MailStoreSeat is the seat only mesh-mail may claim.
const MailStoreSeat = "mail-store"
// MailStoreModule is the one module allowed to claim it.
const MailStoreModule = "mesh-mail"
// Finding is one manifest refused: a module other than mesh-mail claiming mail-store.
type Finding struct {
Module string
}
// Check reads every manifest under root/modules and answers which ones it refuses.
func Check(root string) ([]Finding, error) {
manifests, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
if err != nil {
return nil, err
}
sort.Strings(manifests)
var out []Finding
for _, path := range manifests {
raw, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var m struct {
Module string `json:"module"`
Claims []struct {
Name string `json:"name"`
} `json:"claims"`
}
if err := json.Unmarshal(raw, &m); err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
if m.Module == MailStoreModule {
continue
}
for _, c := range m.Claims {
if c.Name == MailStoreSeat {
out = append(out, Finding{Module: m.Module})
break
}
}
}
return out, nil
}
func main() {
root := "."
if len(os.Args) > 1 {
root = os.Args[1]
}
found, err := Check(root)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
for _, f := range found {
fmt.Printf("%s claims the mail-store seat: only %s may (novox/hq ADR 0378 §3)\n", f.Module, MailStoreModule)
}
if len(found) > 0 {
os.Exit(1)
}
fmt.Println("mail-store: claimed only by mesh-mail")
}
+43
View File
@@ -0,0 +1,43 @@
package main
import (
"os"
"path/filepath"
"testing"
)
func manifest(t *testing.T, root, module, claims string) {
t.Helper()
dir := filepath.Join(root, "modules", module)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
body := `{"module":"` + module + `","claims":[` + claims + `]}`
if err := os.WriteFile(filepath.Join(dir, "module.json"), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
func TestAnotherModuleClaimingMailStoreIsRefused(t *testing.T) {
root := t.TempDir()
manifest(t, root, "mesh-mail", `{"name":"mail-store","scope":"mesh"}`)
manifest(t, root, "betterbird", `{"name":"mail-store","scope":"mesh"}`)
manifest(t, root, "dunst", `{"name":"node-notifier","scope":"node"}`)
found, err := Check(root)
if err != nil {
t.Fatal(err)
}
if len(found) != 1 || found[0].Module != "betterbird" {
t.Fatalf("refused %+v", found)
}
}
func TestTheCatalogueHasNone(t *testing.T) {
found, err := Check("../..")
if err != nil {
t.Fatal(err)
}
if len(found) != 0 {
t.Fatalf("%+v", found)
}
}
+3
View File
@@ -0,0 +1,3 @@
module git.novox.be/novox/mesh-catalog/checks/owned
go 1.22
+242
View File
@@ -0,0 +1,242 @@
// Command owned judges how the catalogue's directories and archives treat files they did not write (novox/hq
// ADR 0422 §8, issue 1024). It refuses, naming the module, the resource and the reason:
//
// 1. a directory or archive without `unknown`, or with a value but move-out, module or program;
//
// 2. `move-out` on a path not under ${machine:account-home} or ${machine:agent-home};
//
// 3. `knows` with an empty, absolute, `.`/`..`/empty-component or repeated path;
//
// 4. a path one module declares (a resource's path, or a fact with "home": true) lying inside a directory of
// another module, after ${dir:<id>} is resolved within each module and both homes are read as one root.
// Archives are not containers for this rule. This holds for ~/.ssh what checks/sshdir held (ADR 0415 §6).
//
// go run . <catalogue root>
//
// Exits 1 on a finding.
package main
import (
"encoding/json"
"fmt"
"os"
"path"
"path/filepath"
"regexp"
"sort"
"strings"
)
// Home is the one root both machine homes are read as.
const Home = "${home}"
var (
ofDir = regexp.MustCompile(`\$\{dir:([a-z0-9][a-z0-9_-]*)\}`)
homeVar = strings.NewReplacer("${machine:account-home}", Home, "${machine:agent-home}", Home)
values = map[string]bool{"move-out": true, "module": true, "program": true}
)
// Finding is one refusal.
type Finding struct {
Rule int
Module, ID, Why string
}
type resource struct {
ID, Type, Path, Unknown string
Knows []string
HasKnows bool
}
type parsed struct {
Module string
Resources []resource
Facts []string // paths of facts with "home": true, under the home
}
func load(file string) (*parsed, error) {
raw, err := os.ReadFile(file)
if err != nil {
return nil, err
}
var m struct {
Module string `json:"module"`
Resources []struct {
ID string `json:"id"`
Type string `json:"type"`
Path string `json:"path"`
Unknown *string `json:"unknown"`
Knows []string `json:"knows"`
} `json:"resources"`
Facts map[string]struct {
Path string `json:"path"`
Home bool `json:"home"`
} `json:"facts"`
}
if err := json.Unmarshal(raw, &m); err != nil {
return nil, fmt.Errorf("%s: %w", file, err)
}
out := &parsed{Module: m.Module}
if out.Module == "" {
out.Module = filepath.Base(filepath.Dir(file))
}
for _, r := range m.Resources {
x := resource{ID: r.ID, Type: r.Type, Path: r.Path, Knows: r.Knows, HasKnows: r.Knows != nil}
if r.Unknown != nil {
x.Unknown = *r.Unknown
if x.Unknown == "" {
x.Unknown = "(empty)"
}
}
out.Resources = append(out.Resources, x)
}
keys := make([]string, 0, len(m.Facts))
for k := range m.Facts {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
if f := m.Facts[k]; f.Home && f.Path != "" {
out.Facts = append(out.Facts, f.Path)
}
}
return out, nil
}
// norm resolves ${dir:<id>} from the module's own resources, reads both homes as one root and cleans the path.
func norm(p string, dirs map[string]string) string {
for i := 0; i < 8 && ofDir.MatchString(p); i++ {
p = ofDir.ReplaceAllStringFunc(p, func(w string) string {
if to, ok := dirs[ofDir.FindStringSubmatch(w)[1]]; ok {
return to
}
return w
})
}
p = homeVar.Replace(p)
if p == "" {
return ""
}
return path.Clean(p)
}
func underHome(p string) bool { return strings.HasPrefix(p, Home+"/") }
// badKnows says why a knows entry is refused, or "".
func badKnows(k string) string {
if k == "" {
return "is empty"
}
if strings.HasPrefix(k, "/") {
return "is absolute"
}
for _, c := range strings.Split(strings.TrimSuffix(k, "/"), "/") {
if c == "" || c == "." || c == ".." {
return "has an empty, . or .. component"
}
}
return ""
}
type declared struct {
module, id, path string
}
// Check reads every manifest under root/modules and answers what it refuses.
func Check(root string) ([]Finding, error) {
files, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
if err != nil {
return nil, err
}
sort.Strings(files)
var out []Finding
var paths []declared
type container struct{ module, path string }
var containers []container
for _, file := range files {
m, err := load(file)
if err != nil {
return nil, err
}
dirs := map[string]string{}
for _, r := range m.Resources {
switch {
case r.ID != "" && r.Path != "":
dirs[r.ID] = r.Path
case r.ID != "" && r.Type == "directory":
// A directory the mesh places for the module (`place`): where it lies is the node-engine's, but it is
// the module's own, so a path under it belongs to this module and to no other's directory of the same
// id (issue 1484: two runs-as modules each keep a .ssh in their own state directory).
dirs[r.ID] = "/placed/" + m.Module + "/" + r.ID
}
}
for _, r := range m.Resources {
p := norm(r.Path, dirs)
if p != "" {
paths = append(paths, declared{m.Module, r.ID, p})
}
if r.Type != "directory" && r.Type != "archive" {
continue
}
if r.Type == "directory" && p != "" {
containers = append(containers, container{m.Module, p})
}
add := func(rule int, why string) { out = append(out, Finding{rule, m.Module, r.ID, why}) }
switch {
case r.Unknown == "":
add(1, "a "+r.Type+" without `unknown` (move-out, module or program)")
case !values[r.Unknown]:
add(1, fmt.Sprintf("`unknown` is %q, not move-out, module or program", r.Unknown))
case r.Unknown == "move-out" && !underHome(p):
add(2, fmt.Sprintf("`move-out` on %q, which is not under ${machine:account-home} or ${machine:agent-home}", r.Path))
}
seen := map[string]bool{}
for _, k := range r.Knows {
if why := badKnows(k); why != "" {
add(3, fmt.Sprintf("`knows` entry %q %s", k, why))
continue
}
c := strings.TrimSuffix(k, "/")
if seen[c] {
add(3, fmt.Sprintf("`knows` entry %q is repeated", k))
}
seen[c] = true
}
if r.HasKnows && len(r.Knows) == 0 {
add(3, "`knows` is empty")
}
}
for _, f := range m.Facts {
if p := norm(Home+"/"+f, nil); p != "" {
paths = append(paths, declared{m.Module, "fact " + f, p})
}
}
}
for _, d := range paths {
for _, c := range containers {
if c.module != d.module && (d.path == c.path || strings.HasPrefix(d.path, c.path+"/")) {
out = append(out, Finding{4, d.module, d.id, fmt.Sprintf("declares %q, inside a directory of %s (%q)", d.path, c.module, c.path)})
}
}
}
return out, nil
}
func main() {
root := "."
if len(os.Args) > 1 {
root = os.Args[1]
}
found, err := Check(root)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
for _, f := range found {
fmt.Printf("%s: %s: %s (rule %d, novox/hq ADR 0422 §8, issue 1024)\n", f.Module, f.ID, f.Why, f.Rule)
}
if len(found) > 0 {
os.Exit(1)
}
fmt.Println("owned paths: every directory and archive says how it treats unknown files; no module declares inside another's directory")
}
+147
View File
@@ -0,0 +1,147 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
)
func manifest(t *testing.T, root, module, body string) {
t.Helper()
dir := filepath.Join(root, "modules", module)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "module.json"), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
func check(t *testing.T, root string) []Finding {
t.Helper()
found, err := Check(root)
if err != nil {
t.Fatal(err)
}
return found
}
// only asserts exactly one finding, of the rule, naming the module and the resource (issue 1024).
func only(t *testing.T, found []Finding, rule int, module, id string) {
t.Helper()
if len(found) != 1 || found[0].Rule != rule || found[0].Module != module || found[0].ID != id || found[0].Why == "" {
t.Fatalf("found %+v; want one finding of rule %d for %s/%s with a reason", found, rule, module, id)
}
}
func dir(id, path, rest string) string {
return `{"id":"` + id + `","type":"directory","path":"` + path + `"` + rest + `}`
}
func mod(name string, resources ...string) string {
return `{"module":"` + name + `","resources":[` + strings.Join(resources, ",") + `]}`
}
func TestMissingOrUnknownValueIsRefused(t *testing.T) {
root := t.TempDir()
manifest(t, root, "a", mod("a", dir("d", "/var/lib/a", "")))
only(t, check(t, root), 1, "a", "d")
root = t.TempDir()
manifest(t, root, "a", mod("a", `{"id":"z","type":"archive","path":"/opt/a","unknown":"delete"}`))
only(t, check(t, root), 1, "a", "z")
}
func TestMoveOutOutsideAHomeIsRefused(t *testing.T) {
root := t.TempDir()
manifest(t, root, "a", mod("a", dir("d", "/etc/a.d", `,"unknown":"move-out"`)))
only(t, check(t, root), 2, "a", "d")
}
func TestMoveOutUnderAHomeIsAllowed(t *testing.T) {
root := t.TempDir()
manifest(t, root, "a", mod("a",
dir("d", "${machine:account-home}/.config/a", `,"unknown":"move-out"`),
dir("e", "${dir:d}/sub", `,"unknown":"move-out"`),
dir("f", "${machine:agent-home}/x", `,"unknown":"move-out"`),
dir("g", "/var/lib/a", `,"unknown":"program"`),
dir("h", "${machine:account-home}/.ssh", `,"unknown":"module"`)))
if found := check(t, root); len(found) != 0 {
t.Fatalf("refused %+v", found)
}
}
func TestBadKnowsIsRefused(t *testing.T) {
for _, knows := range []string{`[""]`, `["/abs"]`, `["a/../b"]`, `["./a"]`, `["a//b"]`, `["a","a"]`, `["a","a/"]`} {
root := t.TempDir()
manifest(t, root, "a", mod("a", dir("d", "${machine:account-home}/.config/a", `,"unknown":"move-out","knows":`+knows)))
only(t, check(t, root), 3, "a", "d")
}
root := t.TempDir()
manifest(t, root, "a", mod("a", dir("d", "${machine:account-home}/.config/a", `,"unknown":"move-out","knows":["forge.credentials","kept/"]`)))
if found := check(t, root); len(found) != 0 {
t.Fatalf("refused %+v", found)
}
}
func TestAnotherModulesPathInsideADirectoryIsRefused(t *testing.T) {
root := t.TempDir()
manifest(t, root, "owner", mod("owner", dir("ssh", "${machine:account-home}/.ssh", `,"unknown":"module"`)))
manifest(t, root, "intruder", mod("intruder", `{"id":"f","type":"file","path":"${machine:account-home}/.ssh/config.d/20"}`))
manifest(t, root, "viahome", mod("viahome", `{"id":"g","type":"file","path":"${machine:agent-home}/.ssh/x"}`))
manifest(t, root, "facty", `{"module":"facty","facts":{"hosts":{"path":".ssh/config.d/00","home":true}}}`)
manifest(t, root, "outside", mod("outside", `{"id":"o","type":"file","path":"${machine:account-home}/.sshrc"}`))
got := map[string]int{}
for _, f := range check(t, root) {
if f.Rule != 4 || f.Why == "" {
t.Fatalf("finding %+v", f)
}
got[f.Module]++
}
if len(got) != 3 || got["intruder"] != 1 || got["viahome"] != 1 || got["facty"] != 1 {
t.Fatalf("refused %v; want intruder, viahome, facty", got)
}
}
func TestDirReferencesAreResolvedPerModule(t *testing.T) {
root := t.TempDir()
manifest(t, root, "owner", mod("owner", dir("base", "${machine:account-home}/.config/o", `,"unknown":"move-out"`)))
manifest(t, root, "other", mod("other",
dir("base", "${machine:account-home}/.config/p", `,"unknown":"move-out"`),
`{"id":"f","type":"file","path":"${dir:base}/x"}`))
if found := check(t, root); len(found) != 0 {
t.Fatalf("refused %+v", found)
}
manifest(t, root, "third", mod("third",
dir("d", "${machine:account-home}/.config/t", `,"unknown":"move-out"`),
`{"id":"f","type":"file","path":"${dir:d}/../o/x"}`))
only(t, check(t, root), 4, "third", "f")
}
func TestAnArchiveIsNotAContainerAndSameModuleIsFine(t *testing.T) {
root := t.TempDir()
manifest(t, root, "a", mod("a",
`{"id":"arc","type":"archive","path":"/opt/a","unknown":"program"}`,
dir("d", "/opt/a2", `,"unknown":"program"`),
`{"id":"f","type":"file","path":"/opt/a2/x"}`))
manifest(t, root, "b", mod("b", `{"id":"f","type":"file","path":"/opt/a/x"}`))
if found := check(t, root); len(found) != 0 {
t.Fatalf("refused %+v", found)
}
}
// Issue 1484: a directory the mesh places for a module (no path, `place`) is that module's own: another module's
// directory of the same id, and a path under it, are not inside it.
func TestPlacedDirectoriesOfTwoModulesAreTheirOwn(t *testing.T) {
root := t.TempDir()
for _, name := range []string{"first", "second"} {
manifest(t, root, name, mod(name,
`{"id":"state","type":"directory","place":".","unknown":"program"}`,
dir("ssh", "${dir:state}/.ssh", `,"unknown":"program"`),
`{"id":"conf","type":"file","path":"${dir:ssh}/config"}`))
}
if found := check(t, root); len(found) != 0 {
t.Fatalf("refused %+v", found)
}
}
+3
View File
@@ -0,0 +1,3 @@
module git.novox.be/novox/mesh-catalog/checks/predefined
go 1.22
+153
View File
@@ -0,0 +1,153 @@
// Command predefined refuses a change to predefined.json that removes a key a module still names (novox/hq ADR
// 0348 clause 7, design 57 package K2, issue 661).
//
// Mesh config and node config hold values only for the keys on the catalogue's list of predefined keys, at the
// root of the repository; a module's config value may fall back to one, `"predefined": "<key>"`. A key removed
// while a manifest still names it would leave that module falling back to nothing: the controller refuses its
// build then, at the next merge that touches it. So the removal is refused here, in the pull request that makes
// it, naming each module that names the key.
//
// **What it cannot know.** Whether a key still holds a value at the mesh or a node is mesh-config's to say (design
// 57 package K3): it holds the values, and the catalogue's check reads no store. Until mesh-config refuses that
// itself, removing a key nobody names is let through here, and said.
//
// The list it compares with is the trunk's at the merge base, given with -base; without one, nothing was removed.
// The controller the mesh runs reads the list itself (type, validation, every manifest against it) in the
// module check; this check reads only its keys.
//
// go run . [-base <the trunk's predefined.json>] <catalogue root>
//
// Exits 1 on a finding.
package main
import (
"encoding/json"
"errors"
"flag"
"fmt"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
)
// File is the list's file at the root of the catalogue.
const File = "predefined.json"
// Keys is the keys of a list, read as a JSON object; an absent file has none.
func Keys(path string) (map[string]bool, error) {
raw, err := os.ReadFile(path)
if errors.Is(err, fs.ErrNotExist) {
return map[string]bool{}, nil
}
if err != nil {
return nil, err
}
var keys map[string]json.RawMessage
if err := json.Unmarshal(raw, &keys); err != nil {
return nil, fmt.Errorf("%s is not a JSON object of keys: %w", path, err)
}
out := map[string]bool{}
for k := range keys {
out[k] = true
}
return out, nil
}
// Named is, per predefined key, the modules whose manifests name it: under `config`, or its alias `settings`.
func Named(root string) (map[string][]string, error) {
manifests, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
if err != nil {
return nil, err
}
sort.Strings(manifests)
named := map[string][]string{}
for _, path := range manifests {
raw, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var m struct {
Module string `json:"module"`
Config map[string]json.RawMessage `json:"config"`
Settings map[string]json.RawMessage `json:"settings"`
}
if err := json.Unmarshal(raw, &m); err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
for _, declared := range []map[string]json.RawMessage{m.Config, m.Settings} {
for _, d := range declared {
var value struct {
Predefined string `json:"predefined"`
}
if json.Unmarshal(d, &value) == nil && value.Predefined != "" {
named[value.Predefined] = append(named[value.Predefined], m.Module)
}
}
}
}
return named, nil
}
// Removed is each key the base list holds and the tree's does not, with the modules still naming it; a removed
// key nobody names is answered with none.
func Removed(base, tree map[string]bool, named map[string][]string) map[string][]string {
out := map[string][]string{}
for k := range base {
if !tree[k] {
out[k] = named[k]
}
}
return out
}
func main() {
basePath := flag.String("base", "", "the trunk's predefined.json at the merge base")
flag.Parse()
if flag.NArg() != 1 {
fmt.Fprintln(os.Stderr, "usage: predefined [-base <the trunk's predefined.json>] <catalogue root>")
os.Exit(2)
}
root := flag.Arg(0)
tree, err := Keys(filepath.Join(root, File))
if err != nil {
fmt.Println(err)
os.Exit(1)
}
if *basePath == "" {
fmt.Printf("%s: %d key(s); no trunk list given, so none was removed\n", File, len(tree))
return
}
base, err := Keys(*basePath)
if err != nil {
fmt.Println(err)
os.Exit(1)
}
named, err := Named(root)
if err != nil {
fmt.Println(err)
os.Exit(1)
}
removed := Removed(base, tree, named)
keys := make([]string, 0, len(removed))
for k := range removed {
keys = append(keys, k)
}
sort.Strings(keys)
failed := false
for _, k := range keys {
if modules := removed[k]; len(modules) > 0 {
failed = true
fmt.Printf("%s: the key %q is removed and still named by %s: move each off it first (novox/hq ADR 0348 "+
"clause 7)\n", File, k, strings.Join(modules, ", "))
} else {
fmt.Printf("%s: the key %q is removed and no module names it; whether the mesh or a node still holds a "+
"value for it is mesh-config's to refuse, not this check's\n", File, k)
}
}
if failed {
os.Exit(1)
}
fmt.Printf("%s: %d key(s), none removed that a module names\n", File, len(tree))
}
+100
View File
@@ -0,0 +1,100 @@
package main
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
// A catalogue whose dunst falls back to font-size, and the trunk's list holding font-size and
// agent-managed-settings.
func aCatalogue(t *testing.T, list string) (root, base string) {
t.Helper()
root = t.TempDir()
write := func(path, text string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(text), 0o644); err != nil {
t.Fatal(err)
}
}
write(filepath.Join(root, "modules", "dunst", "module.json"), `{"module": "dunst", "config": {"font-size":
{"type": "number", "description": "points", "default": 10, "predefined": "font-size"}}}`)
write(filepath.Join(root, "modules", "claude-code", "module.json"), `{"module": "claude-code", "settings": {
"managed_settings": {"kind": "preference", "default": "{}", "why": "none"}}}`)
write(filepath.Join(root, "modules", "plain", "module.json"), `{"module": "plain"}`)
if list != "" {
write(filepath.Join(root, File), list)
}
base = filepath.Join(t.TempDir(), "base.json")
write(base, `{"font-size": {"type": "number"}, "agent-managed-settings": {"type": "object"}}`)
return root, base
}
func run(t *testing.T, args ...string) (string, error) {
t.Helper()
out, err := exec.Command("go", append([]string{"run", "."}, args...)...).CombinedOutput()
return string(out), err
}
func TestARemovalOfAKeyAModuleStillNamesIsRefused(t *testing.T) {
// The planted removal: font-size goes while dunst still falls back to it.
root, base := aCatalogue(t, `{"agent-managed-settings": {"type": "object"}}`)
out, err := run(t, "-base", base, root)
if err == nil || !strings.Contains(out, `the key "font-size" is removed and still named by dunst`) {
t.Fatalf("a removal of a named key passed:\n%s", out)
}
}
func TestARemovalNobodyNamesPassesAndSaysWhatItCannotKnow(t *testing.T) {
root, base := aCatalogue(t, `{"font-size": {"type": "number"}}`)
out, err := run(t, "-base", base, root)
if err != nil || !strings.Contains(out, `"agent-managed-settings" is removed and no module names it`) ||
!strings.Contains(out, "mesh-config's to refuse") {
t.Fatalf("a removal nobody names: %v\n%s", err, out)
}
}
func TestAListUnchangedOrGrownPasses(t *testing.T) {
for _, list := range []string{
`{"font-size": {"type": "number"}, "agent-managed-settings": {"type": "object"}}`,
`{"font-size": {"type": "number"}, "agent-managed-settings": {"type": "object"}, "accent": {"type": "string"}}`,
} {
root, base := aCatalogue(t, list)
if out, err := run(t, "-base", base, root); err != nil || !strings.Contains(out, "none removed") {
t.Fatalf("%s: %v\n%s", list, err, out)
}
}
// No trunk list given: nothing was removed.
root, _ := aCatalogue(t, `{}`)
if out, err := run(t, root); err != nil || !strings.Contains(out, "none was removed") {
t.Fatalf("without a base: %v\n%s", err, out)
}
}
func TestTheFileRemovedWholeIsARemovalOfEveryKey(t *testing.T) {
root, base := aCatalogue(t, "")
out, err := run(t, "-base", base, root)
if err == nil || !strings.Contains(out, `"font-size" is removed and still named by dunst`) {
t.Fatalf("the list removed whole passed:\n%s", out)
}
}
func TestNamedReadsBothForms(t *testing.T) {
root, _ := aCatalogue(t, "")
if err := os.WriteFile(filepath.Join(root, "modules", "plain", "module.json"), []byte(`{"module": "plain",
"settings": {"size": {"predefined": "font-size"}}}`), 0o644); err != nil {
t.Fatal(err)
}
named, err := Named(root)
if err != nil {
t.Fatal(err)
}
if got := strings.Join(named["font-size"], ","); got != "dunst,plain" {
t.Fatalf("font-size named by %s", got)
}
}
+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`,
// naming the layer and the key, never the value. This check holds every such file to that: each top-level key
// whose value holds `${secret:…}`, anywhere below it, is either listed in the file's `protected`, or left out
// of the file's `takes-settings` (novox/hq ADR 0326), so that no layer reaches it.
// of the file's `takes-settings` (novox/hq ADR 0326), so that no layer reaches it. A file whose text is a template
// in files/ is judged by the template's text.
//
// A secret nested below a key the file takes for its other values (`server.secret_key` below `server`) cannot
// be protected alone today: the controller protects top-level keys. Such a file is named in the file `known`
@@ -64,6 +65,15 @@ func Check(root string) ([]Finding, error) {
}
id, _ := r["id"].(string)
content, _ := r["content"].(string)
// A file whose text is a template in its module's files/ (novox/hq ADR 0349 §2) is judged by that text,
// exactly as it would be inline.
if name, _ := r["template"].(string); name != "" {
text, err := os.ReadFile(filepath.Join(filepath.Dir(path), filepath.FromSlash(name)))
if err != nil {
return nil, fmt.Errorf("%s: %s names the template %s: %w", m.Module, id, name, err)
}
content = string(text)
}
var doc map[string]any
if strings.TrimSpace(content) == "" {
continue
+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) {
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})
+3
View File
@@ -0,0 +1,3 @@
module git.novox.be/novox/mesh-catalog/checks/sshdir
go 1.22
+161
View File
@@ -0,0 +1,161 @@
// Command sshdir refuses any manifest but modules/ssh-client naming a path under ~/.ssh (novox/hq ADR 0415 §6,
// issue 1020): ssh-client owns ~/.ssh and sweeps what it does not know, so a path another module placed there
// would be moved out at the next sweep. A module that needs something there provides it to ssh-client instead.
//
// go run . <catalogue root>
//
// Every string of a manifest is read, wherever it stands, except prose and a file's text (Prose): a path, a
// place, a volume; a path inside a directory of the module's own (`${dir:<id>}`) is not ~/.ssh. Exits 1 on a finding.
package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
)
// SSHModule is the one module allowed to name a path under ~/.ssh.
const SSHModule = "ssh-client"
// underSSH is a path under ~/.ssh, or ~/.ssh itself, however the home is spelled: ${machine:account-home}/.ssh,
// ~/.ssh, .ssh/config, or a volume's host side (…/.ssh:/root/.ssh).
var underSSH = regexp.MustCompile(`(^|[/~])\.ssh($|[/:])`)
// Prose are the keys whose strings are words or a file's text, not a path: a template may say ~/.ssh in a comment.
var Prose = map[string]bool{"description": true, "why": true, "does": true, "template": true, "content": true, "title": true}
// Finding is one path under ~/.ssh in a manifest other than ssh-client's.
type Finding struct {
Module string
Key string
Value string
}
// Check reads every manifest under root/modules and answers which paths it refuses.
func Check(root string) ([]Finding, error) {
manifests, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
if err != nil {
return nil, err
}
sort.Strings(manifests)
var out []Finding
for _, path := range manifests {
raw, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var m any
if err := json.Unmarshal(raw, &m); err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
module := filepath.Base(filepath.Dir(path))
if obj, ok := m.(map[string]any); ok {
if name, ok := obj["module"].(string); ok && name != "" {
module = name
}
}
if module == SSHModule {
continue
}
walk(m, "", dirsOf(m), func(key, value string) {
out = append(out, Finding{Module: module, Key: key, Value: value})
})
}
return out, nil
}
// walk calls found for every string under v, outside Prose, that names a path under ~/.ssh.
func walk(v any, key string, dirs map[string]string, found func(key, value string)) {
switch x := v.(type) {
case map[string]any:
keys := make([]string, 0, len(x))
for k := range x {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
if !Prose[k] {
walk(x[k], k, dirs, found)
}
}
case []any:
for _, e := range x {
walk(e, key, dirs, found)
}
case string:
// A path under a directory the mesh places for the module (${dir:<id>} of a directory with a place and no path,
// as a runs-as module's home) is that account's own .ssh, not the operator's ~/.ssh that ssh-client sweeps
// (issue 1484, ADR 0466 §4). Any other ${dir:<id>} is resolved to the path its resource declares and judged.
if resolved, placed := resolveDir(x, dirs); !placed && underSSH.MatchString(resolved) {
found(key, x)
}
}
}
var ofDir = regexp.MustCompile(`^\$\{dir:([a-z0-9][a-z0-9_-]*)\}`)
// placedDir marks a directory the mesh places for the module: it has no path of its own.
const placedDir = "\x00placed"
// dirsOf are the module's directories by id: the path each declares, or placedDir for one with a place and no path.
func dirsOf(m any) map[string]string {
dirs := map[string]string{}
obj, _ := m.(map[string]any)
list, _ := obj["resources"].([]any)
for _, r := range list {
res, _ := r.(map[string]any)
id, _ := res["id"].(string)
path, _ := res["path"].(string)
switch {
case id == "":
case path != "":
dirs[id] = path
case res["type"] == "directory":
dirs[id] = placedDir
}
}
return dirs
}
// resolveDir follows a leading ${dir:<id>} through the module's directories. It answers the path with the references
// resolved, and whether it ends under a placed directory.
func resolveDir(x string, dirs map[string]string) (string, bool) {
for i := 0; i < 8; i++ {
m := ofDir.FindStringSubmatch(x)
if m == nil {
return x, false
}
to, ok := dirs[m[1]]
if !ok {
return x, false
}
if to == placedDir {
return x, true
}
x = to + strings.TrimPrefix(x, m[0])
}
return x, false
}
func main() {
root := "."
if len(os.Args) > 1 {
root = os.Args[1]
}
found, err := Check(root)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
for _, f := range found {
fmt.Printf("%s names %q (%s) under ~/.ssh: only %s may, and the sweep moves anything else out (novox/hq ADR 0415 §6)\n", f.Module, f.Value, f.Key, SSHModule)
}
if len(found) > 0 {
os.Exit(1)
}
fmt.Println("~/.ssh: named only by ssh-client")
}
+78
View File
@@ -0,0 +1,78 @@
package main
import (
"os"
"path/filepath"
"testing"
)
func manifest(t *testing.T, root, module, body string) {
t.Helper()
dir := filepath.Join(root, "modules", module)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "module.json"), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
func TestAnotherModuleNamingSSHIsRefused(t *testing.T) {
root := t.TempDir()
manifest(t, root, "ssh-client", `{"module":"ssh-client","resources":[{"path":"${machine:account-home}/.ssh/config"}]}`)
manifest(t, root, "forge-account", `{"module":"forge-account","resources":[{"path":"${machine:account-home}/.ssh/config.d/20-forge"}]}`)
manifest(t, root, "roster", `{"module":"roster","facts":{"hosts":{"path":".ssh/known_hosts"}}}`)
manifest(t, root, "box", `{"module":"box","containers":[{"volumes":["~/.ssh:/root/.ssh:ro"]}]}`)
manifest(t, root, "quiet", `{"module":"quiet","description":"reads ~/.ssh/config","resources":[{"path":"${machine:account-home}/.sshrc-not","template":"# see ~/.ssh/config"},{"path":"/etc/ssh/sshd_config"}]}`)
found, err := Check(root)
if err != nil {
t.Fatal(err)
}
got := map[string]bool{}
for _, f := range found {
got[f.Module] = true
}
if len(found) != 3 || !got["forge-account"] || !got["roster"] || !got["box"] {
t.Fatalf("refused %+v; want forge-account, roster and box", found)
}
}
// Issue 1484: a runs-as module's own account has a .ssh of its own, inside its home (a directory of the module's);
// that is not the operator's ~/.ssh, which only ssh-client names. A path outside any ${dir:} is still refused.
func TestAModulesOwnDirectoryMayHoldADotSSH(t *testing.T) {
root := t.TempDir()
manifest(t, root, "builder", `{"module":"builder","resources":[{"id":"state","type":"directory","place":"."},{"id":"ssh-dir","type":"directory","path":"${dir:state}/.ssh"},{"path":"${dir:ssh-dir}/config"}]}`)
manifest(t, root, "other", `{"module":"other","resources":[{"path":"${machine:account-home}/.ssh/config"}]}`)
found, err := Check(root)
if err != nil {
t.Fatal(err)
}
if len(found) != 1 || found[0].Module != "other" {
t.Fatalf("refused %+v; want only other", found)
}
}
// Issue 1484 review: only a directory the mesh places is the module's own home. A ${dir:} that resolves to the
// operator's home is judged by the path it resolves to.
func TestADirResolvingToTheOperatorsHomeIsStillRefused(t *testing.T) {
root := t.TempDir()
manifest(t, root, "escape", `{"module":"escape","resources":[{"id":"h","type":"directory","path":"${machine:account-home}","unknown":"module"},{"path":"${dir:h}/.ssh/config"}]}`)
manifest(t, root, "placed", `{"module":"placed","resources":[{"id":"state","type":"directory","place":".","unknown":"program"},{"id":"s","type":"directory","path":"${dir:state}/.ssh"},{"path":"${dir:s}/config"}]}`)
found, err := Check(root)
if err != nil {
t.Fatal(err)
}
if len(found) != 1 || found[0].Module != "escape" {
t.Fatalf("refused %+v; want only escape", found)
}
}
func TestTheCatalogueHasNone(t *testing.T) {
found, err := Check("../..")
if err != nil {
t.Fatal(err)
}
if len(found) != 0 {
t.Fatalf("the catalogue names paths under ~/.ssh outside ssh-client: %+v", found)
}
}
+3
View File
@@ -0,0 +1,3 @@
module git.novox.be/novox/mesh-catalog/checks/templates
go 1.22
+9
View File
@@ -0,0 +1,9 @@
# The modules that still keep a file's text inline in module.json (`content`), one per line: the catalogue's list of
# modules whose file text has not yet moved to a template in files/ (novox/hq ADR 0349 §2, design 57), or in the
# templates directory its manifest names (`templates`, issue 731). Every other module's file resources name their
# template. This list only shrinks: a module that keeps no file text inline is taken off, and a line the trunk's copy
# does not hold is refused. When it is empty, no manifest in the catalogue carries file text (design 57 package T5,
# issue 658).
#
# A window-manager addition (`contributions`, such as files/i3/*.conf) is not a file resource and takes no template;
# it moves with the collections (design 57 package L2, issue 681) and is not judged here.
+178
View File
@@ -0,0 +1,178 @@
// Command templates refuses a file's text kept inline in a manifest, outside the modules still to move (novox/hq
// ADR 0349 §2, design 57 package T5, issue 658).
//
// A file resource names its text as a template in its module's templates directory (`"template": "<dir>/<name>"`;
// the directory is the manifest's `"templates"`, files/ when it says none), which the module's build packs into an
// archive; the controller reads each template back into the file's content, so the
// mesh holds and composes what it would for the same text inline. A module whose file text has not yet moved is
// listed in the file `inline` beside this check, one per line. Every other module's file resources say no
// `content`; a module on the list that keeps none inline is taken off it; and a line the trunk's copy (the merge
// base's, given with -base) does not hold is refused, so the list only shrinks; while the trunk holds no copy, the
// modules in seed are the trunk's. When it is empty, no manifest in the catalogue carries file text.
//
// Only file resources are judged: a window-manager addition (`contributions`) takes no template and moves with the
// collections (design 57 package L2, issue 681).
//
// go run . [-base <the trunk's inline>] <catalogue root>
//
// Every refusal names the refused module's own templates directory. Exits 1 on a finding.
package main
import (
"bufio"
"encoding/json"
"flag"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
)
const (
listFile = "inline"
listPathInCatalog = "checks/templates/" + listFile
)
// Check answers, per module that keeps any, the ids of its file resources that say their text inline.
func Check(root string) (map[string][]string, error) {
manifests, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
if err != nil {
return nil, err
}
sort.Strings(manifests)
out := map[string][]string{}
for _, path := range manifests {
raw, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var m struct {
Module string `json:"module"`
Resources []map[string]any `json:"resources"`
}
if err := json.Unmarshal(raw, &m); err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
for _, r := range m.Resources {
if _, inline := r["content"]; inline && r["type"] == "file" {
out[m.Module] = append(out[m.Module], fmt.Sprint(r["id"]))
}
}
}
return out, nil
}
// TemplatesDir answers the directory a module keeps its templates in, with a trailing slash: its manifest's
// `templates`, files/ when the manifest says none or cannot be read here.
func TemplatesDir(root, module string) string {
dir := "files/"
raw, err := os.ReadFile(filepath.Join(root, "modules", module, "module.json"))
if err != nil {
return dir
}
var m struct {
Templates string `json:"templates"`
}
if json.Unmarshal(raw, &m) == nil && m.Templates != "" {
dir = strings.TrimSuffix(m.Templates, "/") + "/"
}
return dir
}
// Refusal is the finding for a module that keeps file text inline and is not on the list.
func Refusal(module string, ids []string, dir string) string {
return fmt.Sprintf("%s keeps the text of %s inline and is not on %s: keep each file's text in %s and name it as "+
"the file's template, packed by the build's archive from %s (novox/hq ADR 0349 §2)",
module, strings.Join(ids, ", "), listPathInCatalog, dir, dir)
}
// Gained is the finding for a module the list gains against the trunk's copy.
func Gained(module, dir string) string {
return fmt.Sprintf("%s gains %s, which the trunk's copy does not hold: the list only shrinks — move its file text "+
"to %s instead (novox/hq ADR 0349 §2)", listPathInCatalog, module, dir)
}
// seed is the list as it was first written, standing for the trunk's copy until the trunk holds one.
var seed = map[string]bool{"asus-zephyrus-g14": true, "power": true, "showcase": true}
// Listed is the modules a list names, one per line; a line starting with # and a blank line are not modules.
func Listed(path string) (map[string]bool, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
out := map[string]bool{}
s := bufio.NewScanner(f)
for s.Scan() {
line := strings.TrimSpace(s.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
out[line] = true
}
return out, s.Err()
}
// Judge answers the modules that keep file text inline and are not on the list (refused), the modules on the list
// that keep none (stale), and the modules on the list that the trunk's list does not hold (added).
func Judge(found map[string][]string, listed, base map[string]bool) (refused, stale, added []string) {
for m := range found {
if !listed[m] {
refused = append(refused, m)
}
}
for m := range listed {
if _, keeps := found[m]; !keeps {
stale = append(stale, m)
}
if !base[m] {
added = append(added, m)
}
}
sort.Strings(refused)
sort.Strings(stale)
sort.Strings(added)
return refused, stale, added
}
func main() {
basePath := flag.String("base", "", "the trunk's copy of inline (the merge base's); seed when absent")
flag.Parse()
root := "."
if flag.NArg() > 0 {
root = flag.Arg(0)
}
found, err := Check(root)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
listed, err := Listed(listFile)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
base := seed
if *basePath != "" {
if base, err = Listed(*basePath); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
refused, stale, added := Judge(found, listed, base)
for _, m := range refused {
fmt.Println(Refusal(m, found[m], TemplatesDir(root, m)))
}
for _, m := range stale {
fmt.Printf("%s names %s, which keeps no file text inline: take it off the list\n", listPathInCatalog, m)
}
for _, m := range added {
fmt.Println(Gained(m, TemplatesDir(root, m)))
}
if len(refused) > 0 || len(stale) > 0 || len(added) > 0 {
os.Exit(1)
}
fmt.Printf("templates: no module keeps a file's text inline but the %d listed\n", len(listed))
}
+102
View File
@@ -0,0 +1,102 @@
package main
import (
"os"
"path/filepath"
"reflect"
"strings"
"testing"
)
func module(t *testing.T, root, name, body string) {
t.Helper()
dir := filepath.Join(root, "modules", name)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "module.json"), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
// A file resource that says its text inline is found, by module and resource; one naming its template, a
// resource that is not a file and a window-manager addition are not (novox/hq ADR 0349 §2).
func TestEveryInlineFileTextIsFound(t *testing.T) {
root := t.TempDir()
module(t, root, "inline", `{"module":"inline","resources":[{"id":"a","type":"file","content":"x"},`+
`{"id":"b","type":"file","template":"files/b"},{"id":"c","type":"file","content":""}]}`)
module(t, root, "templated", `{"module":"templated","resources":[{"id":"a","type":"file","template":"files/a"},`+
`{"id":"d","type":"directory","path":"/x"}],"contributions":{"i3":[{"content":"bindsym x"}]}}`)
got, err := Check(root)
if err != nil {
t.Fatal(err)
}
want := map[string][]string{"inline": {"a", "c"}}
if !reflect.DeepEqual(got, want) {
t.Fatalf("found %v, want %v", got, want)
}
}
// A module not on the list keeps no file text inline; one on the list that keeps none is taken off it; a line the
// trunk's list does not hold is refused, so the list only shrinks until it is empty.
func TestTheListOfModulesKeepingTextInlineOnlyShrinks(t *testing.T) {
found := map[string][]string{"old": {"a"}, "forgotten": {"b"}}
inline := map[string]bool{"old": true, "done": true, "new": true}
base := map[string]bool{"old": true, "done": true, "forgotten": true}
refused, stale, added := Judge(found, inline, base)
if !reflect.DeepEqual(refused, []string{"forgotten"}) || !reflect.DeepEqual(stale, []string{"done", "new"}) ||
!reflect.DeepEqual(added, []string{"new"}) {
t.Fatalf("refused %v, stale %v, added %v", refused, stale, added)
}
}
func TestTheListIsReadWithoutItsComments(t *testing.T) {
path := filepath.Join(t.TempDir(), "inline")
if err := os.WriteFile(path, []byte("# why\n\npower\n showcase \n"), 0o644); err != nil {
t.Fatal(err)
}
got, err := Listed(path)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(got, map[string]bool{"power": true, "showcase": true}) {
t.Fatalf("read %v", got)
}
}
// The catalogue itself: no module keeps a file's text inline but those the list names, and the list names none
// that keeps it no longer (design 57 package T5, issue 658: the ten modules without a build moved).
func TestTheCatalogueKeepsFileTextInlineOnlyWhereListed(t *testing.T) {
found, err := Check("../..")
if err != nil {
t.Fatal(err)
}
inline, err := Listed(listFile)
if err != nil {
t.Fatal(err)
}
refused, stale, _ := Judge(found, inline, inline)
if len(refused) > 0 || len(stale) > 0 {
t.Fatalf("keeping file text inline and not listed: %v; listed and keeping none: %v", refused, stale)
}
}
// A refusal names the refused module's own templates directory, not files/ when the module names another (issue 809).
func TestARefusalNamesTheModulesOwnTemplatesDirectory(t *testing.T) {
root := t.TempDir()
module(t, root, "own", `{"module":"own","templates":"t/","resources":[{"id":"a","type":"file","content":"x"}]}`)
module(t, root, "plain", `{"module":"plain","resources":[{"id":"a","type":"file","content":"x"}]}`)
module(t, root, "bare", `{"module":"bare","templates":"templates","resources":[]}`)
for module, dir := range map[string]string{"own": "t/", "plain": "files/", "bare": "templates/"} {
if got := TemplatesDir(root, module); got != dir {
t.Fatalf("%s: directory %q, want %q", module, got, dir)
}
}
msg := Refusal("own", []string{"a"}, TemplatesDir(root, "own"))
if !strings.Contains(msg, "t/") || strings.Contains(msg, "files/") {
t.Fatalf("refusal %q", msg)
}
if msg := Gained("own", TemplatesDir(root, "own")); !strings.Contains(msg, "t/") || strings.Contains(msg, "files/") {
t.Fatalf("gained %q", msg)
}
}
+3
View File
@@ -0,0 +1,3 @@
module git.novox.be/novox/mesh-catalog/checks/toolchains
go 1.22
+104
View File
@@ -0,0 +1,104 @@
// Command toolchains refuses any manifest but the owner's declaring a language's toolchain package (novox/hq
// ADR 0477 clause 4, issue 1573): the package go only in modules/go, and nodejs and npm only in modules/nodejs.
// Everything that compiles or runs that language requires the owner's provision instead (go-toolchain,
// node-toolchain), so the package is declared once, and a node without the owner refuses the module that
// needs it at resolution rather than failing at its first build. The owners must declare their packages.
//
// go run . <catalogue root>
//
// Exits 1 on a finding.
package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
)
// Owners names, for each toolchain package, the one module that declares it.
var Owners = map[string]string{
"go": "go",
"nodejs": "nodejs",
"npm": "nodejs",
}
// Finding is one refusal: Module declares Package, which Owner alone may, or (Missing) Owner does not declare
// the Package it owns.
type Finding struct {
Module, Package, Owner string
Missing bool
}
// Check reads every manifest under root/modules and answers what 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
declared := map[string]map[string]bool{} // module -> packages
for _, path := range manifests {
raw, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var m struct {
Module string `json:"module"`
Resources []struct {
Type string `json:"type"`
Package string `json:"package"`
} `json:"resources"`
}
if err := json.Unmarshal(raw, &m); err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
declared[m.Module] = map[string]bool{}
for _, r := range m.Resources {
if r.Type != "package" {
continue
}
declared[m.Module][r.Package] = true
if owner, ok := Owners[r.Package]; ok && owner != m.Module {
out = append(out, Finding{Module: m.Module, Package: r.Package, Owner: owner})
}
}
}
var pkgs []string
for p := range Owners {
pkgs = append(pkgs, p)
}
sort.Strings(pkgs)
for _, p := range pkgs {
if !declared[Owners[p]][p] {
out = append(out, Finding{Module: Owners[p], Package: p, Owner: Owners[p], Missing: true})
}
}
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 {
if f.Missing {
fmt.Printf("%s does not declare the package %s it owns (novox/hq ADR 0477 clause 4)\n", f.Owner, f.Package)
continue
}
fmt.Printf("%s declares the package %s: only %s may; require its provision instead (novox/hq ADR 0477 clause 4)\n",
f.Module, f.Package, f.Owner)
}
if len(found) > 0 {
os.Exit(1)
}
fmt.Println("toolchains: go, nodejs and npm are declared only by their owners")
}
+64
View File
@@ -0,0 +1,64 @@
package main
import (
"os"
"path/filepath"
"reflect"
"testing"
)
func catalogue(t *testing.T, manifests map[string]string) string {
t.Helper()
root := t.TempDir()
for name, body := range manifests {
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)
}
}
return root
}
func TestOnlyTheOwnerDeclaresAToolchainPackage(t *testing.T) {
root := catalogue(t, map[string]string{
"go": `{"module":"go","resources":[{"id":"go","type":"package","package":"go"}]}`,
"nodejs": `{"module":"nodejs","resources":[{"id":"nodejs","type":"package","package":"nodejs"},{"id":"npm","type":"package","package":"npm"}]}`,
"lab": `{"module":"lab","resources":[{"id":"git","type":"package","package":"git"},{"id":"go","type":"package","package":"go"}]}`,
"builder": `{"module":"builder","resources":[{"id":"n","type":"package","package":"nodejs"},{"id":"dir","type":"directory","path":"/go"}]}`,
})
got, err := Check(root)
if err != nil {
t.Fatal(err)
}
want := []Finding{{Module: "builder", Package: "nodejs", Owner: "nodejs"}, {Module: "lab", Package: "go", Owner: "go"}}
if !reflect.DeepEqual(got, want) {
t.Errorf("found %v, want %v", got, want)
}
}
func TestTheOwnersMustExistAndDeclareTheirPackages(t *testing.T) {
root := catalogue(t, map[string]string{
"go": `{"module":"go","resources":[]}`,
})
got, err := Check(root)
if err != nil {
t.Fatal(err)
}
if len(got) != 3 {
t.Errorf("an owner missing its packages gave %v, want three findings (go, nodejs, npm)", got)
}
}
// The catalogue as it stands: this is the check itself, run here too.
func TestTheCatalogueGivesEachToolchainPackageToItsOwnerAlone(t *testing.T) {
got, err := Check("../..")
if err != nil {
t.Fatal(err)
}
if len(got) != 0 {
t.Errorf("the catalogue declares a toolchain package outside its owner: %v", got)
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
66
59
The long-running resources of this catalogue that do not say how they are ready (`health`, novox/hq ADR 0240
rule 8), as `module check` counts them. It may only go down: merge-check.sh fails a change that raises it, and
one that lowers it writes the new number on the first line. From 2026-11-18, or once it is 0, a long-running
+42 -2
View File
@@ -23,9 +23,51 @@
#
# By hand: MESH_CHECK_MODULES=gitea sh merge-check-typescript.sh, in the toolchain image (it reads
# /app/node_modules), or with TOOLCHAIN_MODULES pointing at a node_modules that holds the same.
#
# **In an agent's session, outside the toolchain image** (novox/hq issue 742), the same node_modules is
# assembled first, in a throwaway directory under the check's own workspace, the way the image's `deps` stage
# assembles it: the toolchain's own package.json and lock (mesh-tools' node-tools/, read from the forge this
# clone came from, so the list is never copied here) installed with `npm ci`, then the SDK the mesh last
# published (`@novox/mesh-sdk@latest`), the @novox scope from the forge's package registry, read without a
# credential. Node and npm are the dev-tools module's; nothing is installed on the machine, no npm
# configuration or cache of the account's is read or written. Only when neither TOOLCHAIN_MODULES nor
# /app/node_modules is there: the forge's check, in the image, fetches nothing, as before. The forge is
# MESH_FORGE (default: the origin remote's), the owner MESH_FORGE_OWNER (default: the origin's owner).
set -eu
work=$(mktemp -d)
trap 'rm -rf "$work"' EXIT
deps="${TOOLCHAIN_MODULES:-/app/node_modules}"
if [ -z "${TOOLCHAIN_MODULES:-}" ] && [ ! -f "$deps/typescript/bin/tsc" ]; then
origin=$(git remote get-url origin 2>/dev/null || true)
forge="${MESH_FORGE:-$(printf '%s\n' "$origin" | sed -n 's#^\(https\{0,1\}://[^/]*\)/.*#\1#p')}"
owner="${MESH_FORGE_OWNER:-$(printf '%s\n' "$origin" | sed -n 's#^https\{0,1\}://[^/]*/\([^/]*\)/.*#\1#p')}"
if [ -z "$forge" ] || [ -z "$owner" ]; then
echo "NOT CHECKED: no TypeScript toolchain at $deps, and no forge to assemble one from (origin is '$origin'; set MESH_FORGE and MESH_FORGE_OWNER, or TOOLCHAIN_MODULES)"
exit 1
fi
echo "no TypeScript toolchain at $deps: assembling one from $forge/$owner/mesh-tools node-tools/ and $owner's package registry (novox/hq issue 742)"
tc="$work/toolchain"
mkdir -p "$tc"
printf '@%s:registry=%s/api/packages/%s/npm/\n' "$owner" "$forge" "$owner" >"$work/npmrc"
# Read with Node's own fetch (dev-tools declares Node, not curl); a status that is not 2xx fails.
if ! { node -e '
const [base, dir] = process.argv.slice(1);
Promise.all(["package.json", "package-lock.json"].map(async (f) => {
const r = await fetch(base + f);
if (!r.ok) throw new Error(base + f + ": " + r.status);
require("fs").writeFileSync(dir + "/" + f, await r.text());
})).catch((e) => { console.error(e.message); process.exit(1); });' \
"$forge/$owner/mesh-tools/raw/branch/main/node-tools/" "$tc" &&
(cd "$tc" && export npm_config_userconfig="$work/npmrc" npm_config_cache="$work/npm-cache" &&
npm ci --ignore-scripts --no-audit --no-fund --loglevel=error &&
npm install --ignore-scripts --no-audit --no-fund --no-save --loglevel=error "@$owner/mesh-sdk@latest"); }; then
echo "NOT CHECKED: the TypeScript toolchain could not be assembled from $forge (novox/hq issue 742)"
exit 1
fi
deps="$tc/node_modules"
fi
tsc="$deps/typescript/bin/tsc"
if [ ! -f "$tsc" ]; then
echo "NOT CHECKED: no TypeScript compiler at $tsc — this part runs in the TypeScript toolchain"
@@ -39,8 +81,6 @@ touched=$({
printf '%s\n' "${MESH_CHECK_CHANGED:-}" | tr ',' '\n' | sed -n 's#^modules/\([^/]*\)/.*#\1#p'
} | sed '/^$/d' | sort -u)
work=$(mktemp -d)
trap 'rm -rf "$work"' EXIT
failed=""
for m in $touched; do
d="modules/$m"
+67 -3
View File
@@ -17,7 +17,8 @@
# toolchain has a C compiler. A requirement of the mesh's own forge the module cache does not hold (a
# pinned SDK commit) is taken from its repository's clone beside the check (checks/deps); a module whose
# dependencies still cannot be had **fails the check**, by name and why — its tests could not run, and a
# test that could not run is a failure, never a note (novox/hq issue 385). A module written in
# test that could not run is a failure, never a note (novox/hq issue 385). The integration tests (`//go:build
# integration`) run here, with -tags integration, and only here. A module written in
# TypeScript is type-checked and tested by
# merge-check-typescript.sh, declared above, in the TypeScript toolchain (novox/hq issues 302, 311);
# 3. the words (novox/hq ADR 0244): no word the glossary retired for the tools, as copied in
@@ -33,11 +34,36 @@
# 6. the secrets (novox/hq issue 342): no mergeable file fills a key with a vault secret that a setting could
# replace — the key is `protected`, or left out of the file's `takes-settings`. checks/secrets/known names
# the nested ones the controller cannot protect alone yet, and only shrinks.
# 7. the list of predefined keys (novox/hq ADR 0348 clause 7): a change to predefined.json removes no key a
# module still names, judged against the trunk's copy at the merge base. Whether a removed key still holds a
# value at the mesh or a node is mesh-config's to refuse; this check reads no store. The list itself and
# every manifest against it are judged by the controller's module check in step 1.
# 8. the config form (novox/hq ADR 0348 clauses 1 and 11): no module says `settings`, `takes-settings` or
# `${setting:<key>}`, in its manifest or a template it names, or has a mergeable file naming no keys (which
# takes its whole layer), unless checks/config/unmoved lists it as not yet moved; that list only shrinks,
# judged against the trunk's copy at the merge base.
# 9. the file text (novox/hq ADR 0349 §2): no module keeps a file's text inline in its manifest (`content`)
# unless checks/templates/inline lists it as not yet moved; each names its template in files/. That list
# only shrinks, judged against the trunk's copy at the merge base.
# 10. the mail-store seat (novox/hq ADR 0378 §3, issue 800): no manifest but modules/mesh-mail claims it.
# 11. owned paths (novox/hq ADR 0422 §8, issue 1024; it holds ~/.ssh for ssh-client as ADR 0415 §6 asked):
# every directory and archive says `unknown` (move-out, module or program) and a valid `knows`; `move-out`
# only under a home; and no module declares a path inside a directory of another module.
# checks/sshdir (ADR 0415 §6) stays while main runs it; it goes under issue 1035 once main runs checks/owned.
set -eu
(cd checks/words && go test -count=1 . && go run . ../..)
# The toolchains (novox/hq ADR 0477 clause 4): the package go only in modules/go, nodejs and npm only in modules/nodejs.
(cd checks/toolchains && go test -count=1 . && go run . ../..)
# The agent's home (novox/hq ADR 0266): no archive, and no file written into, under it.
(cd checks/agenthome && go test -count=1 . && go run . ../..)
# The mail-store seat (novox/hq ADR 0378 §3): only mesh-mail may claim it.
(cd checks/mailstore && go test -count=1 . && go run . ../..)
# ~/.ssh (novox/hq ADR 0415 §6): only ssh-client may name a path under it. Main's merge check runs this line, so
# checks/sshdir stays until main runs checks/owned; it goes under issue 1035.
(cd checks/sshdir && go test -count=1 . && go run . ../..)
# Owned paths (novox/hq ADR 0422 §8): unknown files named per directory; no path inside another module's directory.
(cd checks/owned && go test -count=1 . && go run . ../..)
# The secrets (novox/hq issue 342): a password is not a setting, so no setting can replace the vault's. The
# known list only shrinks: judged against the trunk's copy at the merge base, a line it does not hold is refused.
secrets_base=""
@@ -51,6 +77,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"
fi
(cd checks/secrets && go test -count=1 . && if [ -n "$secrets_base" ]; then go run . -base "$secrets_base" ../..; else go run . ../..; fi)
# The list of predefined keys (novox/hq ADR 0348 clause 7): no key removed that a module still names. The trunk's
# list at the merge base is what it is judged against; a trunk without the file had none.
predefined_base=""
if merge_base=$(git merge-base HEAD "origin/${MESH_CHECK_BASE:-main}" 2>/dev/null); then
predefined_base=$(mktemp)
if ! git show "$merge_base:predefined.json" >"$predefined_base" 2>/dev/null; then
echo '{}' >"$predefined_base"
fi
else
echo "NOT COMPARED WITH THE TRUNK: no merge base with origin/${MESH_CHECK_BASE:-main}; no removal from predefined.json can be seen"
fi
(cd checks/predefined && go test -count=1 . && if [ -n "$predefined_base" ]; then go run . -base "$predefined_base" ../..; else go run . ../..; fi)
# The config form (novox/hq ADR 0348 §1, §11): a moved module says no old name, and the list of modules not yet moved
# only shrinks, judged against the trunk's copy at the merge base; a trunk without one is the list's first copy.
unmoved_base=""
if merge_base=$(git merge-base HEAD "origin/${MESH_CHECK_BASE:-main}" 2>/dev/null); then
if git cat-file -e "$merge_base:checks/config/unmoved" 2>/dev/null; then
unmoved_base=$(mktemp)
git show "$merge_base:checks/config/unmoved" >"$unmoved_base"
fi
else
echo "NOT COMPARED WITH THE TRUNK: no merge base with origin/${MESH_CHECK_BASE:-main}; checks/config/unmoved is held to the list it was first written with"
fi
(cd checks/config && go test -count=1 . && if [ -n "$unmoved_base" ]; then go run . -base "$unmoved_base" ../..; else go run . ../..; fi)
# The file text (novox/hq ADR 0349 §2): a file's text is a template in files/, outside the modules not yet moved; the
# list of those only shrinks, judged against the trunk's copy at the merge base, a trunk without one being its first.
inline_base=""
if merge_base=$(git merge-base HEAD "origin/${MESH_CHECK_BASE:-main}" 2>/dev/null); then
if git cat-file -e "$merge_base:checks/templates/inline" 2>/dev/null; then
inline_base=$(mktemp)
git show "$merge_base:checks/templates/inline" >"$inline_base"
fi
else
echo "NOT COMPARED WITH THE TRUNK: no merge base with origin/${MESH_CHECK_BASE:-main}; checks/templates/inline is held to the list it was first written with"
fi
(cd checks/templates && go test -count=1 . && if [ -n "$inline_base" ]; then go run . -base "$inline_base" ../..; else go run . ../..; fi)
# The lists that narrow the paths check may not grow unseen: the base branch's copies are what they are
# judged against (MESH_CHECK_BASE, main when unset), read from the clone the check runs in.
base_lists=""
@@ -124,8 +186,10 @@ for m in $touched; do
fi
cgo=0
[ -n "$race" ] && cgo=1
(cd "modules/$m" && CGO_ENABLED=$cgo GOPRIVATE=git.novox.be go vet ${modfile:+"-modfile=$modfile"} ./... &&
CGO_ENABLED=$cgo GOPRIVATE=git.novox.be go test ${modfile:+"-modfile=$modfile"} $race -count=1 ./...)
# The integration tests (a file whose first line is `//go:build integration`: a test that starts a bus, a store or
# another process) run here and nowhere else: the unit suite stays under a second (novox/hq playbook 07).
(cd "modules/$m" && CGO_ENABLED=$cgo GOPRIVATE=git.novox.be go vet -tags integration ${modfile:+"-modfile=$modfile"} ./... &&
CGO_ENABLED=$cgo GOPRIVATE=git.novox.be go test -tags integration ${modfile:+"-modfile=$modfile"} $race -count=1 ./...)
done
if [ -n "$untestable" ]; then
echo "the check fails: the tests of$untestable could not run (novox/hq issue 385)"
+4 -4
View File
@@ -22,11 +22,11 @@ for GTK, Qt, the portal and the cursor, dark by default. It claims no seat, beca
| path | class | from |
|---|---|---|
| `~/.config/gtk-3.0/settings.ini` | owned (the found file kept once) | [`config/gtk-settings.ini`](config/gtk-settings.ini) |
| `~/.config/gtk-3.0/settings.ini` | owned (the found file kept once) | [`files/gtk-settings.ini`](files/gtk-settings.ini) |
| `~/.config/gtk-4.0/settings.ini` | owned | the same file |
| `~/.config/qt6ct/qt6ct.conf` | owned | [`config/qt6ct.conf`](config/qt6ct.conf) |
| `~/.config/xdg-desktop-portal/portals.conf` | owned | [`config/portals.conf`](config/portals.conf) |
| `~/.icons/default/index.theme` | owned | [`config/cursor-index.theme`](config/cursor-index.theme): the cursor for programs that read neither `XCURSOR_THEME` nor the resources |
| `~/.config/qt6ct/qt6ct.conf` | owned | [`files/qt6ct.conf`](files/qt6ct.conf) |
| `~/.config/xdg-desktop-portal/portals.conf` | owned | [`files/portals.conf`](files/portals.conf) |
| `~/.icons/default/index.theme` | owned | [`files/cursor-index.theme`](files/cursor-index.theme): the cursor for programs that read neither `XCURSOR_THEME` nor the resources |
**`qt6ct.conf` is the mesh's now.** A change made in qt6ct's own window is replaced at the next push,
and the window's saved geometry goes with it. The theme is this module's to say. Settings will make it
@@ -18,10 +18,18 @@ type manifest struct {
Environment struct {
Variables map[string]string `json:"variables"`
} `json:"environment"`
Items []struct {
Collection string `json:"collection"`
Attributes map[string]string `json:"attributes"`
Template string `json:"template"`
} `json:"items"`
Shell []struct {
For, Slot, Code string
} `json:"shell"`
Resources []map[string]any `json:"resources"`
Build struct {
Artifacts []map[string]any `json:"artifacts"`
} `json:"build"`
}
func readManifest(t *testing.T) manifest {
@@ -33,6 +41,23 @@ func readManifest(t *testing.T) manifest {
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
// The module's code and variables are the templates of its items (design 57 L3, issues 795 and 807).
for _, it := range m.Items {
text, err := os.ReadFile(filepath.Join("..", "..", it.Template))
if err != nil {
t.Fatal(err)
}
parts := strings.Split(it.Collection, ":")
switch {
case len(parts) == 3 && parts[0] == "shell":
m.Shell = append(m.Shell, struct{ For, Slot, Code string }{parts[1], parts[2], string(text)})
case it.Collection == "environment" && it.Attributes["kind"] == "variable":
if m.Environment.Variables == nil {
m.Environment.Variables = map[string]string{}
}
m.Environment.Variables[it.Attributes["name"]] = strings.TrimSuffix(string(text), "\n")
}
}
return m
}
@@ -96,9 +121,10 @@ func TestItOwnsTheFilesItsSourcesHoldAndNoQt5Duplicate(t *testing.T) {
pkgs = append(pkgs, r["package"].(string))
continue
}
raw, _ := os.ReadFile(filepath.Join("..", "..", "config", sources[id]))
if r["content"] != string(raw) || r["into"] != nil || r["owner"] != "${machine:account}" {
t.Errorf("%s is not config/%s, whole and the account's", id, sources[id])
_, inline := r["content"]
if _, err := os.Stat(filepath.Join("..", "..", "files", sources[id])); err != nil || inline ||
r["template"] != "files/"+sources[id] || r["into"] != nil || r["owner"] != "${machine:account}" {
t.Errorf("%s is not files/%s, whole, as its template, and the account's", id, sources[id])
}
if strings.Contains(r["path"].(string), "qt5ct") {
t.Error("qt5ct: both workstations run Qt 6 programs through qt6ct; a second tool is a duplicate")
@@ -107,7 +133,7 @@ func TestItOwnsTheFilesItsSourcesHoldAndNoQt5Duplicate(t *testing.T) {
if strings.Join(pkgs, ",") != "gnome-themes-extra,adwaita-icon-theme,adwaita-cursors,qt6ct,xdg-desktop-portal-gtk" {
t.Fatalf("%v", pkgs)
}
qt := readINI(filepath.Join("..", "..", "config", "qt6ct.conf"))
qt := readINI(filepath.Join("..", "..", "files", "qt6ct.conf"))
if qt["Appearance"]["style"] != "Fusion" || qt["Appearance"]["custom_palette"] != "true" || !strings.Contains(qt["Fonts"]["general"], "Inter,11") {
t.Fatalf("%v", qt)
}
@@ -116,7 +142,7 @@ func TestItOwnsTheFilesItsSourcesHoldAndNoQt5Duplicate(t *testing.T) {
t.Fatalf("qt6ct ships no %s", qt["Appearance"]["color_scheme_path"])
}
}
gtk := readINI(filepath.Join("..", "..", "config", "gtk-settings.ini"))["Settings"]
gtk := readINI(filepath.Join("..", "..", "files", "gtk-settings.ini"))["Settings"]
if gtk["gtk-theme-name"] != "Adwaita" || gtk["gtk-application-prefer-dark-theme"] != "1" || gtk["gtk-font-name"] != "Inter 11" {
t.Fatalf("%v", gtk)
}
@@ -244,3 +270,31 @@ func asJSON(v any) string {
b, _ := json.Marshal(v)
return string(b)
}
// No file resource carries its text inline: each names its template in files/, and the build packs files/
// into the one archive the controller reads the templates back from (novox/hq ADR 0349 §2).
func TestEveryFileTakesItsTextFromATemplate(t *testing.T) {
m := readManifest(t)
for _, r := range m.Resources {
if _, inline := r["content"]; inline {
t.Errorf("resource %v keeps its text inline: keep it in files/ and name it as the template", r["id"])
}
if name, ok := r["template"].(string); ok {
if _, err := os.Stat(filepath.Join("..", "..", name)); err != nil {
t.Errorf("resource %v names %s: %v", r["id"], name, err)
}
}
}
packs := 0
for _, a := range m.Build.Artifacts {
if a["kind"] == "archive" && a["from"] == "files" {
packs++
if a["name"] != "files" {
t.Errorf("the templates archive is named %v, not files", a["name"])
}
}
}
if packs != 1 {
t.Errorf("the build packs files/ into %d archives, not one", packs)
}
}
+1
View File
@@ -0,0 +1 @@
Adwaita:dark
+1
View File
@@ -0,0 +1 @@
/usr/share/themes/Adwaita-dark/gtk-2.0/gtkrc
@@ -0,0 +1 @@
qt6ct
+1
View File
@@ -0,0 +1 @@
6
@@ -0,0 +1 @@
Fusion
+1
View File
@@ -0,0 +1 @@
24
+1
View File
@@ -0,0 +1 @@
Adwaita
+10
View File
@@ -0,0 +1,10 @@
# The appearance (module adwaita): GSettings is where the portal reads dark or light, and the portal is
# the only way it reaches Electron, Chromium, Firefox and flatpaks. Set at every session start to the
# module's default; adwaita_appearance switches it for a session.
gsettings set org.gnome.desktop.interface color-scheme 'prefer-dark' || true
gsettings set org.gnome.desktop.interface gtk-theme 'Adwaita' || true
gsettings set org.gnome.desktop.interface icon-theme 'Adwaita' || true
gsettings set org.gnome.desktop.interface cursor-theme 'Adwaita' || true
gsettings set org.gnome.desktop.interface cursor-size 24 || true
gsettings set org.gnome.desktop.interface font-name 'Inter 11' || true
gsettings set org.gnome.desktop.interface monospace-font-name 'JetBrainsMono Nerd Font 11' || true
+3
View File
@@ -0,0 +1,3 @@
! adwaita: the cursor, for X programs that take it from the resources.
Xcursor.theme: Adwaita
Xcursor.size: 24
+81 -23
View File
@@ -1,6 +1,7 @@
{
"module": "adwaita",
"version": "1",
"moved": true,
"capabilities": [
"package-manager"
],
@@ -10,27 +11,79 @@
"adwaita_icons",
"adwaita_portal_check"
],
"environment": {
"variables": {
"GTK_THEME": "Adwaita:dark",
"GTK2_RC_FILES": "/usr/share/themes/Adwaita-dark/gtk-2.0/gtkrc",
"QT_QPA_PLATFORMTHEME": "qt6ct",
"QT_STYLE_OVERRIDE": "Fusion",
"QT_SELECT": "6",
"XCURSOR_THEME": "Adwaita",
"XCURSOR_SIZE": "24"
}
},
"shell": [
"items": [
{
"for": "xresources",
"slot": "normal",
"code": "! adwaita: the cursor, for X programs that take it from the resources.\nXcursor.theme: Adwaita\nXcursor.size: 24\n"
"collection": "environment",
"name": "gtk-theme",
"attributes": {
"kind": "variable",
"name": "GTK_THEME"
},
"template": "files/env-gtk-theme"
},
{
"for": "xinitrc",
"slot": "normal",
"code": "# The appearance (module adwaita): GSettings is where the portal reads dark or light, and the portal is\n# the only way it reaches Electron, Chromium, Firefox and flatpaks. Set at every session start to the\n# module's default; adwaita_appearance switches it for a session.\ngsettings set org.gnome.desktop.interface color-scheme 'prefer-dark' || true\ngsettings set org.gnome.desktop.interface gtk-theme 'Adwaita' || true\ngsettings set org.gnome.desktop.interface icon-theme 'Adwaita' || true\ngsettings set org.gnome.desktop.interface cursor-theme 'Adwaita' || true\ngsettings set org.gnome.desktop.interface cursor-size 24 || true\ngsettings set org.gnome.desktop.interface font-name 'Inter 11' || true\ngsettings set org.gnome.desktop.interface monospace-font-name 'JetBrainsMono Nerd Font 11' || true\n"
"collection": "environment",
"name": "gtk2-rc-files",
"attributes": {
"kind": "variable",
"name": "GTK2_RC_FILES"
},
"template": "files/env-gtk2-rc-files"
},
{
"collection": "environment",
"name": "qt-qpa-platformtheme",
"attributes": {
"kind": "variable",
"name": "QT_QPA_PLATFORMTHEME"
},
"template": "files/env-qt-qpa-platformtheme"
},
{
"collection": "environment",
"name": "qt-style-override",
"attributes": {
"kind": "variable",
"name": "QT_STYLE_OVERRIDE"
},
"template": "files/env-qt-style-override"
},
{
"collection": "environment",
"name": "qt-select",
"attributes": {
"kind": "variable",
"name": "QT_SELECT"
},
"template": "files/env-qt-select"
},
{
"collection": "environment",
"name": "xcursor-theme",
"attributes": {
"kind": "variable",
"name": "XCURSOR_THEME"
},
"template": "files/env-xcursor-theme"
},
{
"collection": "environment",
"name": "xcursor-size",
"attributes": {
"kind": "variable",
"name": "XCURSOR_SIZE"
},
"template": "files/env-xcursor-size"
},
{
"collection": "shell:xresources:normal",
"name": "xresources-normal",
"template": "files/xresources-normal"
},
{
"collection": "shell:xinitrc:normal",
"name": "xinitrc-normal",
"template": "files/xinitrc-normal"
}
],
"resources": [
@@ -65,7 +118,7 @@
"path": "${machine:account-home}/.config/gtk-3.0/settings.ini",
"owner": "${machine:account}",
"mode": "0644",
"content": "# Written by the mesh (module adwaita, novox/hq ADR 0208), for GTK 3 and GTK 4 alike. Replaced at\n# every push; adwaita_appearance switches dark and light for the running session.\n[Settings]\ngtk-theme-name=Adwaita\ngtk-icon-theme-name=Adwaita\ngtk-cursor-theme-name=Adwaita\ngtk-cursor-theme-size=24\ngtk-font-name=Inter 11\ngtk-application-prefer-dark-theme=1\n"
"template": "files/gtk-settings.ini"
},
{
"id": "gtk4",
@@ -73,7 +126,7 @@
"path": "${machine:account-home}/.config/gtk-4.0/settings.ini",
"owner": "${machine:account}",
"mode": "0644",
"content": "# Written by the mesh (module adwaita, novox/hq ADR 0208), for GTK 3 and GTK 4 alike. Replaced at\n# every push; adwaita_appearance switches dark and light for the running session.\n[Settings]\ngtk-theme-name=Adwaita\ngtk-icon-theme-name=Adwaita\ngtk-cursor-theme-name=Adwaita\ngtk-cursor-theme-size=24\ngtk-font-name=Inter 11\ngtk-application-prefer-dark-theme=1\n"
"template": "files/gtk-settings.ini"
},
{
"id": "qt6ct",
@@ -81,7 +134,7 @@
"path": "${machine:account-home}/.config/qt6ct/qt6ct.conf",
"owner": "${machine:account}",
"mode": "0644",
"content": "[Appearance]\ncolor_scheme_path=/usr/share/qt6ct/colors/darker.conf\ncustom_palette=true\nicon_theme=Adwaita\nstandard_dialogs=default\nstyle=Fusion\n\n[Fonts]\nfixed=\"JetBrainsMono Nerd Font,11,-1,5,50,0,0,0,0,0\"\ngeneral=\"Inter,11,-1,5,50,0,0,0,0,0\"\n\n[Interface]\nactivate_item_on_single_click=1\nbuttonbox_layout=0\ncursor_flash_time=1000\ndialog_buttons_have_icons=1\ndouble_click_interval=400\ngui_effects=@Invalid()\nkeyboard_scheme=2\nmenus_have_icons=true\nshow_shortcuts_in_context_menus=true\nstylesheets=@Invalid()\ntoolbutton_style=4\nunderline_shortcut=1\nwheel_scroll_lines=3\n\n[Troubleshooting]\nforce_raster_widgets=1\nignored_applications=@Invalid()\n"
"template": "files/qt6ct.conf"
},
{
"id": "portals",
@@ -89,7 +142,7 @@
"path": "${machine:account-home}/.config/xdg-desktop-portal/portals.conf",
"owner": "${machine:account}",
"mode": "0644",
"content": "# Written by the mesh (module adwaita, novox/hq ADR 0208). Replaced at every push.\n#\n# Which portal backend answers each interface. i3 is not a desktop xdg-desktop-portal knows, so with\n# no preference it uses whichever backend happens to be installed: fine while gtk is the only one,\n# wrong the day another arrives as somebody else's dependency. Named instead. gtk also serves\n# org.freedesktop.appearance (dark or light) from GSettings, which the session's start sets.\n[preferred]\ndefault=gtk\n# Secrets for sandboxed programs come from the keyring's backend. Without this line no backend answers\n# the interface: gtk does not implement it, and gnome-keyring's names only GNOME as its desktop.\norg.freedesktop.impl.portal.Secret=gnome-keyring\n"
"template": "files/portals.conf"
},
{
"id": "cursor",
@@ -97,7 +150,7 @@
"path": "${machine:account-home}/.icons/default/index.theme",
"owner": "${machine:account}",
"mode": "0644",
"content": "# Written by the mesh (module adwaita, novox/hq ADR 0208): the default cursor theme, for programs that\n# read neither XCURSOR_THEME nor the X resources.\n[Icon Theme]\nName=Default\nInherits=Adwaita\n"
"template": "files/cursor-index.theme"
}
],
"build": {
@@ -112,6 +165,11 @@
"loads": [
"adwaita-tools"
]
},
{
"name": "files",
"kind": "archive",
"from": "files"
}
]
}
+6
View File
@@ -109,3 +109,9 @@ store's current or an older name, a definition not read said first, escalation t
tools served are exactly the manifest's `tools` and its `invokes` exactly the two controller verbs and the
catalogue's two tools.
Any command other than the two read-only scripts fails the test.
## Files it does not know
Each directory or archive this module declares says how it treats a file it did not write (ADR 0422, ADR 0416).
- `${dir:state}`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
@@ -0,0 +1,64 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"testing"
)
// The module's directory, where its manifest and its files/ are.
const moduleDir = "../.."
// templateText is the text a file resource places on the machine: its template's, in the module's files/
// (novox/hq ADR 0349 §2). A resource that names no template, or a template that is not there, fails the test.
func templateText(t *testing.T, r map[string]any) string {
t.Helper()
name, _ := r["template"].(string)
if name == "" {
t.Fatalf("resource %v names no template", r["id"])
}
raw, err := os.ReadFile(filepath.Join(moduleDir, filepath.FromSlash(name)))
if err != nil {
t.Fatalf("resource %v names %s: %v", r["id"], name, err)
}
return string(raw)
}
// No file resource carries its text inline: each names its template in files/, and the build packs files/
// into the one archive the controller reads the templates back from (novox/hq ADR 0349 §2).
func TestEveryFileTakesItsTextFromATemplate(t *testing.T) {
raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json"))
if err != nil {
t.Fatal(err)
}
var m struct {
Resources []map[string]any `json:"resources"`
Build struct {
Artifacts []map[string]any `json:"artifacts"`
} `json:"build"`
}
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
for _, r := range m.Resources {
if _, inline := r["content"]; inline {
t.Errorf("resource %v keeps its text inline: keep it in files/ and name it as the template", r["id"])
}
if r["type"] == "file" {
templateText(t, r)
}
}
packs := 0
for _, a := range m.Build.Artifacts {
if a["kind"] == "archive" && a["from"] == "files" {
packs++
if a["name"] != "files" {
t.Errorf("the templates archive is named %v, not files", a["name"])
}
}
}
if packs != 1 {
t.Errorf("the build packs files/ into %d archives, not one", packs)
}
}
@@ -0,0 +1,4 @@
# The names the artifact store had before, from the setting older-store-names, written by the mesh.
# artifact_store_references reads every definition under these too (novox/hq issue 323).
# Replaced on every push; change the setting, never this file.
older=${config:older-store-names}
+16 -9
View File
@@ -13,19 +13,21 @@
"artifact_store_references",
"artifact_store_collect"
],
"settings": {
"older-store-names": {
"kind": "preference",
"default": "none",
"why": "the names the artifact store had before, as a definition may still pin an image under them: host names or host:port, separated by spaces; none when it had no other. artifact_store_references reads every definition under these as well as under the store's current name, so an image a definition pins under one is said to be named by a definition (novox/hq issue 323)"
}
},
"config": {
"older-store-names": {
"type": "string",
"description": "The names the artifact store had before, as host names or host:port separated by spaces, or none when it had no other. artifact_store_references reads every definition under these as well as under the store's current name, so an image a definition pins under one is said to be named by a definition (novox/hq issue 323).",
"pattern": "none|[A-Za-z0-9.:-]+( [A-Za-z0-9.:-]+)*",
"default": "none"
}
},
"resources": [
{
"id": "state",
"type": "directory",
"mode": "0700",
"place": "."
"place": ".",
"unknown": "program"
},
{
"id": "store-names",
@@ -33,7 +35,7 @@
"path": "${dir:state}/store-names",
"mode": "0600",
"trusted": false,
"content": "# The names the artifact store had before, from the setting older-store-names, written by the mesh.\n# artifact_store_references reads every definition under these too (novox/hq issue 323).\n# Replaced on every push; change the setting, never this file.\nolder=${setting:older-store-names}\n"
"template": "files/store-names"
}
],
"build": {
@@ -52,6 +54,11 @@
"MESH_ARTIFACT_STORE_CONTAINER": "mesh-registry",
"MESH_ARTIFACT_STORE_NAMES_FILE": "${dir:state}/store-names"
}
},
{
"name": "files",
"kind": "archive",
"from": "files"
}
]
}
+12
View File
@@ -385,3 +385,15 @@ The module no longer writes a file into i3's `config.d`. Its window-manager line
under `files/i3/` where it had one) are a contribution to `node-display-session`. The i3 module places
them in its own configuration under a `# <module>` line, so this module depends on a window manager
being assigned beside it.
## Files it does not know
Each directory or archive this module declares says how it treats a file it did not write (ADR 0422, ADR 0416).
- `/usr/local/lib/asus-zephyrus-g14`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
- `/etc/systemd/system/systemd-suspend.service.d`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
- `/etc/systemd/system/systemd-hibernate.service.d`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
- `/etc/systemd/system/systemd-suspend-then-hibernate.service.d`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
- `/etc/systemd/system/nvidia-powerd.service.d`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
- `/etc/UPower/UPower.conf.d`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
- `/etc/X11/xorg.conf.d`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
@@ -34,11 +34,9 @@ func TestKeysListsTriggersI3LinesAndFirmwareKeys(t *testing.T) {
home := "/home/operator"
t.Setenv("MESH_OPERATOR_HOME", home)
// The i3 module's file as the controller composes it: other modules' lines around this one's.
var own string
for _, c := range m.Contributions {
if c.Seat == "node-display-session" {
own += c.Content
}
own := m.i3Lines()
if own == "" || len(m.Contributions) != 0 {
t.Fatalf("the lines for the window manager are one item, and no contribution is left: %q %v", own, m.Contributions)
}
put(t, f.root, home+"/"+I3Config, "set $mod Mod4\n# rofi\nbindsym $mod+d exec rofi\n# "+ModuleName+"\n"+own+
"# triggerhappy-not-here\nbindsym $mod+Shift+z exec other\n#########################################\ninclude x\n")
@@ -15,19 +15,41 @@ type manifest struct {
Tools []string `json:"tools"`
Emits []string `json:"emits"`
Resources []map[string]any `json:"resources"`
// Contributions to other modules' seats (novox/hq ADR 0212): the vendor keys go to node-hotkeys.
Contributions []struct {
Seat string `json:"seat"`
Kind string `json:"kind"`
Content string `json:"content"`
} `json:"contributions"`
// Items of collections (novox/hq ADR 0349 clause 13): the vendor keys are an item of the node-hotkeys
// triggers, its text the template file beside the manifest.
Items []struct {
Collection string `json:"collection"`
Template string `json:"template"`
} `json:"items"`
// Old-form contributions to other modules' seats (novox/hq ADR 0212): none is left.
Contributions []any `json:"contributions"`
}
// triggers is the module's contribution to node-hotkeys: its vendor keys.
// i3Lines is the module's lines for the window manager: the text of the template of its item of the collection
// contribution:node-display-session:config (design 57 L3, issue 848).
func (m manifest) i3Lines() string {
for _, it := range m.Items {
if it.Collection == "contribution:node-display-session:config" {
raw, err := os.ReadFile(filepath.Join("..", "..", it.Template))
if err != nil {
return ""
}
return string(raw)
}
}
return ""
}
// triggers is the module's contribution to node-hotkeys: its vendor keys, the text of the template of its
// item of the collection contribution:node-hotkeys:trigger.
func (m manifest) triggers() string {
for _, c := range m.Contributions {
if c.Seat == "node-hotkeys" && c.Kind == "trigger" {
return c.Content
for _, it := range m.Items {
if it.Collection == "contribution:node-hotkeys:trigger" {
raw, err := os.ReadFile(filepath.Join("..", "..", it.Template))
if err != nil {
return ""
}
return string(raw)
}
}
return ""
@@ -0,0 +1,83 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
// Each file's text is a template in templates/, the directory the manifest names (novox/hq ADR 0349 §2, issue 731),
// never in files/: files/ is the archive the machine unpacks, and stays exactly what the machine is given.
func TestEveryFileTakesItsTextFromATemplateOutsideTheUnpackedFiles(t *testing.T) {
raw, err := os.ReadFile("../../module.json")
if err != nil {
t.Fatal(err)
}
var m struct {
Templates string `json:"templates"`
Resources []map[string]any `json:"resources"`
Build struct {
Artifacts []map[string]any `json:"artifacts"`
} `json:"build"`
}
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
if m.Templates != "templates" {
t.Fatalf("the manifest names the templates directory %q, not templates", m.Templates)
}
files := 0
for _, r := range m.Resources {
if _, inline := r["content"]; inline {
t.Errorf("resource %v keeps its text inline: keep it in templates/ and name it as the template", r["id"])
}
if r["type"] != "file" {
continue
}
files++
name, _ := r["template"].(string)
if !strings.HasPrefix(name, "templates/") {
t.Errorf("resource %v names the template %q, not one in templates/", r["id"], name)
continue
}
if st, err := os.Lstat(filepath.Join("../..", name)); err != nil || !st.Mode().IsRegular() {
t.Errorf("resource %v names the template %s, which is not a plain file: %v", r["id"], name, err)
}
}
if files == 0 {
t.Fatal("no file resource")
}
packs := 0
for _, a := range m.Build.Artifacts {
if a["kind"] == "archive" && a["from"] == "templates" {
packs++
if a["name"] != "templates" {
t.Errorf("the templates archive is named %v, not templates", a["name"])
}
}
}
if packs != 1 {
t.Fatalf("the build packs templates/ into %d archives, not one", packs)
}
// The templates archive is read by the controller, never unpacked onto the machine.
for _, r := range m.Resources {
if r["artifact"] == "templates" {
t.Errorf("resource %v places the templates archive on the machine", r["id"])
}
}
// And files/ holds what the machine runs, no template.
err = filepath.WalkDir("../../files", func(at string, d os.DirEntry, err error) error {
if err != nil {
return err
}
if !d.IsDir() && !strings.HasPrefix(filepath.ToSlash(at), "../../files/bin/") {
t.Errorf("files/ holds %s, which is not one of the scripts the machine unpacks", at)
}
return nil
})
if err != nil {
t.Fatal(err)
}
}
+46 -41
View File
@@ -1,6 +1,8 @@
{
"module": "asus-zephyrus-g14",
"version": "1",
"moved": true,
"templates": "templates",
"capabilities": [
"package-manager",
"service-manager"
@@ -52,80 +54,85 @@
"id": "scripts",
"type": "archive",
"path": "/usr/local/lib/asus-zephyrus-g14",
"artifact": "scripts"
"artifact": "scripts",
"unknown": "program"
},
{
"id": "nvidia-options",
"type": "file",
"path": "/etc/modprobe.d/g14-nvidia-power.conf",
"mode": "0644",
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The discrete GPU's driver options on the ROG Zephyrus G14 (GA403, RTX 40 series, hybrid graphics).\n#\n# NVreg_DynamicPowerManagement=0x00 turns runtime D3 off. With it on, a change of power source sends\n# the driver an ACPI notification it fails to handle on this model (\"RmHandleDNotifierEvent: Failed to\n# handle ACPI D-Notifier event, status=0x62\"), and the GPU stops making progress until the machine is\n# powered off. Off costs a few idle watts in hybrid mode and keeps the machine up.\n#\n# NVreg_PreserveVideoMemoryAllocations=1 saves video memory across suspend, so what used the GPU still\n# works after waking. It needs nvidia-suspend, -hibernate and -resume to run around a sleep, which this\n# module's drop-ins on the sleep services ask for.\n#\n# A change here applies when the driver next loads: at the next boot.\noptions nvidia NVreg_PreserveVideoMemoryAllocations=1\noptions nvidia NVreg_DynamicPowerManagement=0x00\n"
"template": "templates/modprobe.d/g14-nvidia-power.conf"
},
{
"id": "video-options",
"type": "file",
"path": "/etc/modprobe.d/video-brightness-switch.conf",
"mode": "0644",
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The ACPI video driver does not change the backlight itself on the brightness keys: on this model it\n# moves the wrong one. The keys are triggerhappy's (see /etc/triggerhappy/triggers.d/asus-g14.conf).\n# Applies when the module next loads: at the next boot.\noptions video brightness_switch_enabled=0\n"
"template": "templates/modprobe.d/video-brightness-switch.conf"
},
{
"id": "suspend-drop-ins",
"type": "directory",
"path": "/etc/systemd/system/systemd-suspend.service.d",
"mode": "0755"
"mode": "0755",
"unknown": "program"
},
{
"id": "nvidia-on-suspend",
"type": "file",
"path": "/etc/systemd/system/systemd-suspend.service.d/asus-zephyrus-g14-nvidia.conf",
"mode": "0644",
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The NVIDIA driver's own sleep actions, asked for by the sleep itself rather than enabled as\n# links: the mesh declares files and never makes links (novox/hq ADR 0012), and the host's service\n# shape must not start these units by hand, which would put the GPU to sleep with the machine awake.\n[Unit]\nWants=nvidia-suspend.service nvidia-resume.service\n"
"template": "templates/systemd/system/systemd-suspend.service.d/asus-zephyrus-g14-nvidia.conf"
},
{
"id": "hibernate-drop-ins",
"type": "directory",
"path": "/etc/systemd/system/systemd-hibernate.service.d",
"mode": "0755"
"mode": "0755",
"unknown": "program"
},
{
"id": "nvidia-on-hibernate",
"type": "file",
"path": "/etc/systemd/system/systemd-hibernate.service.d/asus-zephyrus-g14-nvidia.conf",
"mode": "0644",
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The NVIDIA driver's own sleep actions, asked for by the sleep itself rather than enabled as\n# links: the mesh declares files and never makes links (novox/hq ADR 0012), and the host's service\n# shape must not start these units by hand, which would put the GPU to sleep with the machine awake.\n[Unit]\nWants=nvidia-hibernate.service nvidia-resume.service\n"
"template": "templates/systemd/system/systemd-hibernate.service.d/asus-zephyrus-g14-nvidia.conf"
},
{
"id": "suspend-then-hibernate-drop-ins",
"type": "directory",
"path": "/etc/systemd/system/systemd-suspend-then-hibernate.service.d",
"mode": "0755"
"mode": "0755",
"unknown": "program"
},
{
"id": "nvidia-on-suspend-then-hibernate",
"type": "file",
"path": "/etc/systemd/system/systemd-suspend-then-hibernate.service.d/asus-zephyrus-g14-nvidia.conf",
"mode": "0644",
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The NVIDIA driver's own sleep actions, asked for by the sleep itself rather than enabled as\n# links: the mesh declares files and never makes links (novox/hq ADR 0012), and the host's service\n# shape must not start these units by hand, which would put the GPU to sleep with the machine awake.\n[Unit]\nWants=nvidia-suspend-then-hibernate.service nvidia-resume.service\n"
"template": "templates/systemd/system/systemd-suspend-then-hibernate.service.d/asus-zephyrus-g14-nvidia.conf"
},
{
"id": "powerd-drop-ins",
"type": "directory",
"path": "/etc/systemd/system/nvidia-powerd.service.d",
"mode": "0755"
"mode": "0755",
"unknown": "program"
},
{
"id": "powerd-opt-in",
"type": "file",
"path": "/etc/systemd/system/nvidia-powerd.service.d/asus-zephyrus-g14.conf",
"mode": "0644",
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# nvidia-powerd (Dynamic Boost) was the first error in the chain that hung this model's GPU on a change\n# of power source, and asusd starts it on mains. It runs only when the kernel command line says\n# zephyrus.nvidia-powerd — an explicit opt-in, at boot.\n[Unit]\nConditionKernelCommandLine=zephyrus.nvidia-powerd\n"
"template": "templates/systemd/system/nvidia-powerd.service.d/asus-zephyrus-g14.conf"
},
{
"id": "backlight-rule",
"type": "file",
"path": "/etc/udev/rules.d/90-backlight.rules",
"mode": "0644",
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The backlights are writable by the video group, so the brightness keys and the module's brightness tool\n# move the panel without root.\nACTION==\"add\", SUBSYSTEM==\"backlight\", RUN+=\"/usr/bin/chgrp video /sys/class/backlight/%k/brightness\", RUN+=\"/usr/bin/chmod g+w /sys/class/backlight/%k/brightness\"\n"
"template": "templates/udev/rules.d/90-backlight.rules"
},
{
"id": "udev",
@@ -144,14 +151,15 @@
"id": "upower-drop-ins",
"type": "directory",
"path": "/etc/UPower/UPower.conf.d",
"mode": "0755"
"mode": "0755",
"unknown": "program"
},
{
"id": "low-battery",
"type": "file",
"path": "/etc/UPower/UPower.conf.d/50-asus-zephyrus-g14.conf",
"mode": "0644",
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# On low battery the machine suspends rather than powering off, at 7 % — s2idle still draws a little,\n# so it leaves headroom. A drop-in over the package's own UPower.conf, which stays the package's.\n[UPower]\nUsePercentageForPolicy=true\nPercentageLow=15.0\nPercentageCritical=10.0\nPercentageAction=7.0\nCriticalPowerAction=Suspend\nAllowRiskyCriticalPowerAction=true\n"
"template": "templates/UPower/UPower.conf.d/50-asus-zephyrus-g14.conf"
},
{
"id": "upower",
@@ -167,14 +175,30 @@
"id": "xorg-drop-ins",
"type": "directory",
"path": "/etc/X11/xorg.conf.d",
"mode": "0755"
"mode": "0755",
"unknown": "program"
},
{
"id": "touchpad",
"type": "file",
"path": "/etc/X11/xorg.conf.d/30-asus-zephyrus-g14-touchpad.conf",
"mode": "0644",
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The touchpad's settings, applied by X every time the device appears — at login and after every\n# resume, when the device is initialised again. This replaces the predecessor's sleep hook, which ran\n# xinput after a resume as a named person on a guessed display.\nSection \"InputClass\"\n Identifier \"asus-zephyrus-g14 touchpad\"\n MatchIsTouchpad \"on\"\n Option \"Tapping\" \"on\"\n Option \"NaturalScrolling\" \"true\"\n Option \"AccelSpeed\" \"0.15\"\nEndSection\n"
"template": "templates/X11/xorg.conf.d/30-asus-zephyrus-g14-touchpad.conf"
}
],
"items": [
{
"collection": "shell:after-wake:normal",
"template": "templates/after-wake"
},
{
"collection": "contribution:node-hotkeys:trigger",
"template": "templates/hotkeys-triggers"
},
{
"collection": "contribution:node-display-session:config",
"name": "display-session",
"template": "templates/display-session"
}
],
"build": {
@@ -194,31 +218,12 @@
"name": "scripts",
"kind": "archive",
"from": "files"
},
{
"name": "templates",
"kind": "archive",
"from": "templates"
}
]
},
"contributions": [
{
"seat": "node-hotkeys",
"kind": "trigger",
"content": "# The ROG Zephyrus G14's vendor keys, which reach no X client: media (the M-keys), panel brightness,\n# and the touchpad key. Each runs this module's own script, as the operator's account.\nKEY_PROG1\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media play-pause\nKEY_PROG3\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media previous\nKEY_PROG4\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media next\nKEY_BRIGHTNESSDOWN\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight -\nKEY_BRIGHTNESSDOWN\t2\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight -\nKEY_BRIGHTNESSUP\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight +\nKEY_BRIGHTNESSUP\t2\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight +\nKEY_F21\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-touchpad reset\n"
},
{
"seat": "node-display-session",
"kind": "config",
"content": "# The laptop's own lines in i3 (module asus-zephyrus-g14, novox/hq ADR 0208, ADR 0210). Owned by the\n# mesh: replaced at every push. Once the controller places contributions to node-display-session\n# (ADR 0210), these become the module's contribution instead of a file in i3's directory.\n#\n# The keys the firmware turns into ordinary key presses. The vendor keys that reach no X client are\n# triggerhappy's (/etc/triggerhappy/triggers.d/asus-g14.conf): M4 and Fn+F4/F5 for media, Fn+F7/F8 for\n# the panel, Fn+F10 for the touchpad. The keyboard backlight (Fn+F2/F3) is the firmware's and asusd's.\n\n# Fn+F6, the screenshot key: the firmware sends Super+Shift+S. Released before it runs, because the\n# screenshot grabs the pointer to select a region, which fails while the key is still held.\nbindsym --release $mod+Shift+s exec --no-startup-id $XDG_CONFIG_HOME/i3/scripts/screenshot.sh\n\n# Fn+F9, the display key: the firmware sends Super+P. Odd workspaces to the panel, even ones to the\n# external output.\nbindsym $mod+p exec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-display order\n\n# The keyboard backlight's level, shown when it changes.\nexec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-kbd-notify\n"
},
{
"seat": "node-display-session",
"kind": "config",
"content": "# The laptop's own lines in i3 (module asus-zephyrus-g14, novox/hq ADR 0208, ADR 0210). Owned by the\n# mesh: replaced at every push. Once the controller places contributions to node-display-session\n# (ADR 0210), these become the module's contribution instead of a file in i3's directory.\n#\n# The model's panel and touchpad.\n\n# The internal panel is the primary output, where the bars' tray goes. Which monitors are on and where\n# is the display server's (autorandr, run at every session start).\nexec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-display primary\n\n# The touchpad's settings again, by hand. X applies them itself whenever the device appears.\nbindsym $mod+Shift+x exec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-touchpad reset\n"
}
],
"shell": [
{
"for": "after-wake",
"slot": "normal",
"code": "# The touchpad's settings again after waking, in the operator's session: X applies the module's\n# input class when the device appears, and this covers a wake that does not initialise it again.\n# Runs as root from the power module; the reset itself runs as the session's owner.\nsleep 2\nowner=$(ps -o user= -C i3 | head -n 1)\n[ -n \"$owner\" ] && runuser -u \"$owner\" -- /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-touchpad reset\ntrue\n"
}
]
}
}
@@ -0,0 +1,11 @@
# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.
#
# On low battery the machine suspends rather than powering off, at 7 % — s2idle still draws a little,
# so it leaves headroom. A drop-in over the package's own UPower.conf, which stays the package's.
[UPower]
UsePercentageForPolicy=true
PercentageLow=15.0
PercentageCritical=10.0
PercentageAction=7.0
CriticalPowerAction=Suspend
AllowRiskyCriticalPowerAction=true
@@ -0,0 +1,12 @@
# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.
#
# The touchpad's settings, applied by X every time the device appears — at login and after every
# resume, when the device is initialised again. This replaces the predecessor's sleep hook, which ran
# xinput after a resume as a named person on a guessed display.
Section "InputClass"
Identifier "asus-zephyrus-g14 touchpad"
MatchIsTouchpad "on"
Option "Tapping" "on"
Option "NaturalScrolling" "true"
Option "AccelSpeed" "0.15"
EndSection
@@ -0,0 +1,7 @@
# The touchpad's settings again after waking, in the operator's session: X applies the module's
# input class when the device appears, and this covers a wake that does not initialise it again.
# Runs as root from the power module; the reset itself runs as the session's owner.
sleep 2
owner=$(ps -o user= -C i3 | head -n 1)
[ -n "$owner" ] && runuser -u "$owner" -- /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-touchpad reset
true
@@ -0,0 +1,30 @@
# The laptop's own lines in i3 (module asus-zephyrus-g14, novox/hq ADR 0208, ADR 0210). Owned by the
# mesh: replaced at every push. Once the controller places contributions to node-display-session
# (ADR 0210), these become the module's contribution instead of a file in i3's directory.
#
# The keys the firmware turns into ordinary key presses. The vendor keys that reach no X client are
# triggerhappy's (/etc/triggerhappy/triggers.d/asus-g14.conf): M4 and Fn+F4/F5 for media, Fn+F7/F8 for
# the panel, Fn+F10 for the touchpad. The keyboard backlight (Fn+F2/F3) is the firmware's and asusd's.
# Fn+F6, the screenshot key: the firmware sends Super+Shift+S. Released before it runs, because the
# screenshot grabs the pointer to select a region, which fails while the key is still held.
bindsym --release $mod+Shift+s exec --no-startup-id $XDG_CONFIG_HOME/i3/scripts/screenshot.sh
# Fn+F9, the display key: the firmware sends Super+P. Odd workspaces to the panel, even ones to the
# external output.
bindsym $mod+p exec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-display order
# The keyboard backlight's level, shown when it changes.
exec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-kbd-notify
# The laptop's own lines in i3 (module asus-zephyrus-g14, novox/hq ADR 0208, ADR 0210). Owned by the
# mesh: replaced at every push. Once the controller places contributions to node-display-session
# (ADR 0210), these become the module's contribution instead of a file in i3's directory.
#
# The model's panel and touchpad.
# The internal panel is the primary output, where the bars' tray goes. Which monitors are on and where
# is the display server's (autorandr, run at every session start).
exec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-display primary
# The touchpad's settings again, by hand. X applies them itself whenever the device appears.
bindsym $mod+Shift+x exec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-touchpad reset
@@ -0,0 +1,10 @@
# The ROG Zephyrus G14's vendor keys, which reach no X client: media (the M-keys), panel brightness,
# and the touchpad key. Each runs this module's own script, as the operator's account.
KEY_PROG1 1 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media play-pause
KEY_PROG3 1 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media previous
KEY_PROG4 1 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media next
KEY_BRIGHTNESSDOWN 1 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight -
KEY_BRIGHTNESSDOWN 2 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight -
KEY_BRIGHTNESSUP 1 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight +
KEY_BRIGHTNESSUP 2 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight +
KEY_F21 1 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-touchpad reset
@@ -0,0 +1,16 @@
# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.
#
# The discrete GPU's driver options on the ROG Zephyrus G14 (GA403, RTX 40 series, hybrid graphics).
#
# NVreg_DynamicPowerManagement=0x00 turns runtime D3 off. With it on, a change of power source sends
# the driver an ACPI notification it fails to handle on this model ("RmHandleDNotifierEvent: Failed to
# handle ACPI D-Notifier event, status=0x62"), and the GPU stops making progress until the machine is
# powered off. Off costs a few idle watts in hybrid mode and keeps the machine up.
#
# NVreg_PreserveVideoMemoryAllocations=1 saves video memory across suspend, so what used the GPU still
# works after waking. It needs nvidia-suspend, -hibernate and -resume to run around a sleep, which this
# module's drop-ins on the sleep services ask for.
#
# A change here applies when the driver next loads: at the next boot.
options nvidia NVreg_PreserveVideoMemoryAllocations=1
options nvidia NVreg_DynamicPowerManagement=0x00
@@ -0,0 +1,6 @@
# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.
#
# The ACPI video driver does not change the backlight itself on the brightness keys: on this model it
# moves the wrong one. The keys are triggerhappy's (see /etc/triggerhappy/triggers.d/asus-g14.conf).
# Applies when the module next loads: at the next boot.
options video brightness_switch_enabled=0
@@ -0,0 +1,7 @@
# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.
#
# nvidia-powerd (Dynamic Boost) was the first error in the chain that hung this model's GPU on a change
# of power source, and asusd starts it on mains. It runs only when the kernel command line says
# zephyrus.nvidia-powerd — an explicit opt-in, at boot.
[Unit]
ConditionKernelCommandLine=zephyrus.nvidia-powerd
@@ -0,0 +1,7 @@
# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.
#
# The NVIDIA driver's own sleep actions, asked for by the sleep itself rather than enabled as
# links: the mesh declares files and never makes links (novox/hq ADR 0012), and the host's service
# shape must not start these units by hand, which would put the GPU to sleep with the machine awake.
[Unit]
Wants=nvidia-hibernate.service nvidia-resume.service
@@ -0,0 +1,7 @@
# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.
#
# The NVIDIA driver's own sleep actions, asked for by the sleep itself rather than enabled as
# links: the mesh declares files and never makes links (novox/hq ADR 0012), and the host's service
# shape must not start these units by hand, which would put the GPU to sleep with the machine awake.
[Unit]
Wants=nvidia-suspend-then-hibernate.service nvidia-resume.service
@@ -0,0 +1,7 @@
# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.
#
# The NVIDIA driver's own sleep actions, asked for by the sleep itself rather than enabled as
# links: the mesh declares files and never makes links (novox/hq ADR 0012), and the host's service
# shape must not start these units by hand, which would put the GPU to sleep with the machine awake.
[Unit]
Wants=nvidia-suspend.service nvidia-resume.service
@@ -0,0 +1,5 @@
# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.
#
# The backlights are writable by the video group, so the brightness keys and the module's brightness tool
# move the panel without root.
ACTION=="add", SUBSYSTEM=="backlight", RUN+="/usr/bin/chgrp video /sys/class/backlight/%k/brightness", RUN+="/usr/bin/chmod g+w /sys/class/backlight/%k/brightness"
+11
View File
@@ -0,0 +1,11 @@
# audit-logger
The `audit-logger` module of the mesh catalogue: declares directories on the node.
## Files it does not know
Each directory or archive this module declares says how it treats a file it did not write (ADR 0422, ADR 0416).
- `${dir:state}`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
- `${dir:trail}`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
- `${dir:spool}`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
+6 -3
View File
@@ -47,17 +47,20 @@
"id": "state",
"type": "directory",
"mode": "0700",
"place": "."
"place": ".",
"unknown": "program"
},
{
"id": "trail",
"type": "directory",
"mode": "0700"
"mode": "0700",
"unknown": "program"
},
{
"id": "spool",
"type": "directory",
"mode": "0700"
"mode": "0700",
"unknown": "program"
}
],
"capabilities": [
+11
View File
@@ -0,0 +1,11 @@
# baserow
The `baserow` module of the mesh catalogue: runs the container image `baserow/baserow`.
## Files it does not know
Each directory or archive this module declares says how it treats a file it did not write (ADR 0422, ADR 0416).
- `${dir:mesh-state}`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
- `${dir:state}`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
- `${dir:data}`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
+4
View File
@@ -0,0 +1,4 @@
{
"password": "${secret:admin}",
"host": "${bound:route:name}"
}
+7
View File
@@ -0,0 +1,7 @@
DATABASE_HOST=${bound:postgres-database:at}
DATABASE_PORT=${bound:postgres-database:port}
DATABASE_NAME=${bound:postgres-database:as}
DATABASE_USER=${bound:postgres-database:as}
DATABASE_PASSWORD_FILE=/run/secrets/database
DISABLE_EMBEDDED_PSQL=true
BASEROW_PUBLIC_URL=https://${bound:route:name}
+34 -5
View File
@@ -46,31 +46,54 @@
}
]
},
"config": {
"host": {
"type": "string",
"description": "The Host header the module's tools send to Baserow, a host name; the file's default is the name the module's route was given.",
"trusted": true,
"default": "${bound:route:name}"
},
"email": {
"type": "string",
"description": "The email address of the Baserow account the module's tools sign in as; unset, the code falls back to its environment.",
"trusted": true,
"default": ""
},
"url": {
"type": "string",
"description": "The address the module's tools reach Baserow at, as a URL; unset, the code falls back to its environment, else this machine's Baserow on port 80.",
"trusted": true,
"default": ""
}
},
"resources": [
{
"id": "mesh-state",
"type": "directory",
"mode": "0700",
"place": "mesh"
"place": "mesh",
"unknown": "program"
},
{
"id": "state",
"type": "directory",
"mode": "0700",
"place": "."
"place": ".",
"unknown": "program"
},
{
"id": "data",
"type": "directory",
"mode": "0755",
"owner": "9999:9999"
"owner": "9999:9999",
"unknown": "program"
},
{
"id": "server-env",
"type": "file",
"path": "${dir:state}/server.env",
"mode": "0600",
"content": "DATABASE_HOST=${bound:postgres-database:at}\nDATABASE_PORT=${bound:postgres-database:port}\nDATABASE_NAME=${bound:postgres-database:as}\nDATABASE_USER=${bound:postgres-database:as}\nDATABASE_PASSWORD_FILE=/run/secrets/database\nDISABLE_EMBEDDED_PSQL=true\nBASEROW_PUBLIC_URL=https://${bound:route:name}\n"
"template": "files/server.env"
},
{
"id": "net",
@@ -102,8 +125,9 @@
"type": "file",
"path": "${dir:mesh-state}/config.json",
"mode": "0600",
"content": "{\n \"password\": \"${secret:admin}\",\n \"host\": \"${bound:route:name}\"\n}\n",
"template": "files/config.json",
"merge": "json",
"takes-config": ["host", "email", "url"],
"protected": ["password"]
}
],
@@ -123,6 +147,11 @@
"MESH_BASEROW_URL": "http://127.0.0.1:${port:80}",
"MESH_BASEROW_CONFIG_FILE": "${dir:mesh-state}/config.json"
}
},
{
"name": "files",
"kind": "archive",
"from": "files"
}
]
}
+12 -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
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
user part carries a password (§2). A setting holds no line break, so a signature is a list of its lines.
@@ -347,3 +347,14 @@ outside the mesh. With this version, a push to a node (each waits for the operat
- The controller's `settings` verb, for every proposal and to read a layer as it stands; its `nodes` and `node`
verbs, for which machines hold the module; and the module's own `betterbird_derive` on those machines
(`invokes`).
## Files it does not know
Each directory or archive this module declares says how it treats a file it did not write (ADR 0422, ADR 0416).
- `~/.config/betterbird`: holds only what this module declares and its own tools' records: `accounts-record.json` (`cmd/betterbird-tools/adoption.go:36`), `configuration-record.json` (`owned.go:33`) and the `kept/` folder of replaced filter files (`filters.go:261`); anything else is moved out by the node-engine to `~/.local/state/mesh-moved/betterbird/` with a list, never deleted (ADR 0422 §4).
- `/opt/betterbird`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
- `/opt/betterbird/owl`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
- `/opt/betterbird/distribution`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
- `/opt/betterbird/defaults/pref`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
- `/usr/local/lib/mesh-betterbird`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
@@ -7,7 +7,8 @@ package main
//
// - ~/.config/betterbird/configuration.json: identities, calendars, address-books and filters, trusted as
// accounts is (§5), because each names where a password, a reply or forwarded mail goes;
// - ~/.config/betterbird/tags.json: tags, which names no server and sends no mail, and is not trusted.
// - ~/.config/betterbird/tags.json: tags, which names no server and sends no mail; trusted too since issue 751,
// so a change to it is the operator's to approve.
//
// An entry is keyed by a name of the operator's (lower-case letters and digits, as an account's), a tag by its
// own key. A node's layer may set an entry to null to take it out on that node, and may give an identity, a
@@ -25,7 +25,7 @@ type manifest struct {
Upgrade map[string]string `json:"upgrade"`
Requires []string `json:"requires"`
Invokes []string `json:"invokes"`
Settings map[string]any `json:"settings"`
Config map[string]any `json:"config"`
Tools []string `json:"tools"`
Resources []map[string]any `json:"resources"`
Build struct {
@@ -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
// names the keys it takes; four kinds and accounts and prefs are trusted, tags is not; no setting has a default in the
// definition, since each is the operator's own value; and each file's default declares nothing.
func TestTheSettingsAreMergeableFilesThatNameTheirKeysAndTagsAloneIsUntrusted(t *testing.T) {
// ADR 0329 §4–§6, ADR 0326, issue 586, ADR 0348: every value reaches the machine as a mergeable file of the
// account's that names the keys it takes, and every one is trusted, tags too since issue 751 (the operator's
// decision); each is an object whose default is the file's own, empty, never required, since a node without the
// operator's setup still runs Betterbird; and each file's default declares nothing.
func TestTheValuesAreTrustedMergeableFilesThatNameTheirKeys(t *testing.T) {
m, _ := readManifest(t)
if len(m.Settings) != 0 {
t.Fatalf("no setting has a default in the definition: the operator's setup is the settings' mesh-wide layer (issue 586, ADR 0262): %v", m.Settings)
if _, said := m.Config["tags"]; !said || len(m.Config) != 7 {
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 {
path, mode string
@@ -130,7 +138,7 @@ func TestTheSettingsAreMergeableFilesThatNameTheirKeysAndTagsAloneIsUntrusted(t
"prefs": {prefsFile, "0644", []any{"prefs"}, true},
"accounts": {accountsFile, "0600", []any{"accounts"}, true},
"configuration": {configurationFile, "0600", []any{"identities", "calendars", "address-books", "filters"}, true},
"tags": {tagsFile, "0644", []any{"tags"}, false},
"tags": {tagsFile, "0644", []any{"tags"}, true},
}
merging := 0
for _, r := range m.Resources {
@@ -144,27 +152,27 @@ func TestTheSettingsAreMergeableFilesThatNameTheirKeysAndTagsAloneIsUntrusted(t
for id, want := range files {
r := resource(t, m, id)
if r["path"] != "${machine:account-home}/"+want.path || r["merge"] != "json" || r["trusted"] != want.trusted ||
r["mode"] != want.mode || r["owner"] != "${machine:account}" || !reflect.DeepEqual(r["takes-settings"], want.takes) {
r["mode"] != want.mode || r["owner"] != "${machine:account}" || !reflect.DeepEqual(r["takes-config"], want.takes) {
t.Errorf("%s: %v", id, r)
}
var content map[string]any
if err := json.Unmarshal([]byte(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)
}
}
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 {
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 {
t.Fatalf("its default declares no preference: %v %v", prefs, err)
}
var c Configuration
if err := decodeInto([]byte(resource(t, m, "configuration")["content"].(string)), configurationFile, &c); err != nil {
if err := decodeInto([]byte(templateText(t, resource(t, m, "configuration"))), configurationFile, &c); err != nil {
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)
}
}
@@ -268,13 +276,13 @@ func TestThePolicyTurnsTheUpdaterOffAndInstallsTheMeshsOwlEverywhere(t *testing.
func TestTheStartPreparesThenStartsBetterbirdAndTheLauncherUsesIt(t *testing.T) {
m, _ := readManifest(t)
start := resource(t, m, "start")
content := start["content"].(string)
content := templateText(t, start)
if start["path"] != "/usr/local/bin/betterbird" || start["mode"] != "0755" ||
!strings.Contains(content, "\n"+preparation+" prepare ||") || !strings.HasSuffix(content, "\nexec "+startBin+" \"$@\"\n") {
t.Fatalf("the start runs the preparation, says when it did not complete, and starts Betterbird either way: %v", start)
}
launcher := resource(t, m, "launcher")
lc := launcher["content"].(string)
lc := templateText(t, launcher)
if launcher["path"] != "/usr/local/share/applications/eu.betterbird.Betterbird.desktop" ||
!strings.Contains(lc, "\nExec=/usr/local/bin/betterbird %u\n") || !strings.Contains(lc, "\nIcon="+installDir+"/") ||
!strings.Contains(lc, "\nStartupWMClass=eu.betterbird.Betterbird\n") {
@@ -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": {}
}
+73 -17
View File
@@ -33,13 +33,59 @@
"betterbird_drift",
"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": [
{
"id": "configuration-dir",
"type": "directory",
"path": "${machine:account-home}/.config/betterbird",
"owner": "${machine:account}",
"mode": "0755"
"mode": "0755",
"unknown": "move-out",
"knows": ["accounts-record.json", "configuration-record.json", "kept/"]
},
{
"id": "prefs",
@@ -48,10 +94,10 @@
"owner": "${machine:account}",
"mode": "0644",
"merge": "json",
"takes-settings": [
"takes-config": [
"prefs"
],
"content": "{\n \"prefs\": {}\n}\n",
"template": "files/prefs.json",
"trusted": true
},
{
@@ -61,10 +107,10 @@
"owner": "${machine:account}",
"mode": "0600",
"merge": "json",
"takes-settings": [
"takes-config": [
"accounts"
],
"content": "{\n \"accounts\": {}\n}\n",
"template": "files/accounts.json",
"trusted": true
},
{
@@ -74,13 +120,13 @@
"owner": "${machine:account}",
"mode": "0600",
"merge": "json",
"takes-settings": [
"takes-config": [
"identities",
"calendars",
"address-books",
"filters"
],
"content": "{\n \"identities\": {},\n \"calendars\": {},\n \"address-books\": {},\n \"filters\": {}\n}\n",
"template": "files/configuration.json",
"trusted": true
},
{
@@ -90,11 +136,11 @@
"owner": "${machine:account}",
"mode": "0644",
"merge": "json",
"takes-settings": [
"takes-config": [
"tags"
],
"content": "{\n \"tags\": {}\n}\n",
"trusted": false
"template": "files/tags.json",
"trusted": true
},
{
"id": "community-package",
@@ -106,45 +152,50 @@
"id": "installation",
"type": "archive",
"path": "/opt/betterbird",
"artifact": "archive"
"artifact": "archive",
"unknown": "program"
},
{
"id": "owl",
"type": "archive",
"path": "/opt/betterbird/owl",
"artifact": "owl"
"artifact": "owl",
"unknown": "program"
},
{
"id": "policies",
"type": "archive",
"path": "/opt/betterbird/distribution",
"artifact": "distribution"
"artifact": "distribution",
"unknown": "program"
},
{
"id": "spelling",
"type": "archive",
"path": "/opt/betterbird/defaults/pref",
"artifact": "defaults"
"artifact": "defaults",
"unknown": "program"
},
{
"id": "preparation",
"type": "archive",
"path": "/usr/local/lib/mesh-betterbird",
"artifact": "tools"
"artifact": "tools",
"unknown": "program"
},
{
"id": "start",
"type": "file",
"path": "/usr/local/bin/betterbird",
"mode": "0755",
"content": "#!/bin/sh\n# Betterbird's start, written by the mesh (module betterbird, novox/hq ADR 0316). Replaced at every push.\n#\n# Before Betterbird starts, the module's tools place its user.js in the profile (the settings prefs, accounts, identities, calendars,\n# address-books and tags) and write what Betterbird rewrites itself: the account lists and each declared account's filters. They do\n# it only while no Betterbird of this account runs, because Betterbird rewrites its saved preferences when it quits; a\n# second start while one runs only hands over to it. What the preparation could not do it says here, and\n# betterbird_check says it too. Betterbird starts either way: its configuration never holds the mail back.\n/usr/local/lib/mesh-betterbird/betterbird-tools prepare || echo \"betterbird: the mesh's preparation did not complete; betterbird_check says why\" >&2\nexec /opt/betterbird/betterbird \"$@\"\n"
"template": "files/betterbird"
},
{
"id": "launcher",
"type": "file",
"path": "/usr/local/share/applications/eu.betterbird.Betterbird.desktop",
"mode": "0644",
"content": "[Desktop Entry]\nVersion=1.0\nType=Application\nName=Betterbird\nGenericName=Mail Client\nComment=Send and receive mail with Betterbird\nExec=/usr/local/bin/betterbird %u\nIcon=/opt/betterbird/chrome/icons/default/default128.png\nTerminal=false\nCategories=Network;Email;\nMimeType=message/rfc822;x-scheme-handler/mailto;application/x-xpinstall;application/x-extension-ics;text/calendar;text/vcard;text/x-vcard;x-scheme-handler/webcal;x-scheme-handler/webcals;x-scheme-handler/mid;\nStartupNotify=true\nStartupWMClass=eu.betterbird.Betterbird\nKeywords=Email;E-mail;Calendar;Thunderbird;\nActions=ComposeMessage;OpenAddressBook;\n# Written by the mesh (module betterbird, novox/hq ADR 0316 §3), in place of the community package's entry:\n# it starts Betterbird through the module's start, and names the icon in the installation, with no link.\n\n[Desktop Action ComposeMessage]\nName=Write new message\nExec=/usr/local/bin/betterbird -compose\n\n[Desktop Action OpenAddressBook]\nName=Open address book\nExec=/usr/local/bin/betterbird -addressbook\n"
"template": "files/eu.betterbird.Betterbird.desktop"
}
],
"build": {
@@ -191,6 +242,11 @@
"name": "defaults",
"kind": "archive",
"from": "install/defaults-pref"
},
{
"name": "files",
"kind": "archive",
"from": "files"
}
]
}
+144
View File
@@ -0,0 +1,144 @@
# brother — the Brother multifunction device: scan, printer state, admin pages
One module for one device (novox/hq ADR 0467, issue 1453): the scanner and the printer are one machine on the
network, so one module serves both. It claims two seats, each held once for the mesh: `scanner` (verbs `scan`,
`status`, `list`, `document`) and `printer` (verbs `status`, `settings`, `print`, `upload`; `restart` follows in a later round). It replaces the
`scan` module of issue 1440 (ADR 0464), which ran on no node and is gone from the catalogue.
## The scanner seat
Built for issue 1440 as novox/hq ADR 0464 decides. It scans the glass of a network scanner (any scanner that
speaks eSCL, also called AirScan or Mopria), cuts the document out of the lid's white, files it as a one-page PDF
in a folder and says `scan.added` on the bus, so other modules can react. A scan happens when a tool is called:
the module runs no process of its own, and it runs as the node's operator account.
## What a scan does
1. Asks the scanner for its sizes and puts one job to it (`POST <scanner>/ScanJobs`) at the configured resolution
and colour mode, as a JPEG: from the glass (`source` glass, the default) or from the document feeder (`source`
feeder, with `duplex` true for both sides of each sheet; refused on the glass). The region is A4, cut to what the
input can scan (the glass is 3507 high, not A4's 3508). The body is written on one line: the Brother ignores the
whole body, and runs its default job, when it holds a line break (issue 1452). The glass page is fetched once;
a feeder job is asked `NextDocument` until the scanner answers 404, at most 100 pages. A job that fails is deleted.
2. Reads the resolution from the image's own JFIF density, never from the request: a scanner may answer a
300 dpi request with 200 dpi, and the page size and the event's millimetres follow what it gave.
3. Crops the glass page (a feeder page is a whole sheet and is never cropped): the lid's level is measured in the corner farthest from where documents lie, a pixel is document when
it is darker than the lid, and the rows and columns holding enough of them give the box. No document, or one
filling nearly the whole glass, keeps the whole image (`cropped` false), and so does `crop` false.
With `apart` true (opt-in; without it the glass gives one document, as it always did), several documents lying apart on the glass are each cut out and filed as a one-page PDF of its own (own
object, own `scan.added`, own file); the answer then lists them as `documents` (each with its `path`) and
`count`, in reading order, and one that could not be kept or filed is named in `failed` without holding back
the others. The rule (`Boxes` in `crop.go`): the picture is cut into blocks of 1 mm; a block is document when a
quarter of its pixels are darker than the lid; blocks not at least three across (dust, JPEG ringing) are dropped;
blocks within 10 mm of each other are one region, so a white band narrower than that does not split a document;
a region under 20 mm on a side is a stain, not a document; regions whose boxes overlap are one document (a frame
and the text inside it). One document, or none, is cropped exactly as before.
4. Files a PDF holding the JPEGs, one page for the glass and one per image for the feeder (so six for three
sheets both sides), each page as large as its image really is: written whole beside its place as `.scan-*.tmp` and renamed to
`scan-YYYY-MM-DD-HHMMSS.pdf` (local time); a taken name gets `-2`, `-3`, and no file is ever overwritten.
5. Says `scan.added` once, after the rename, under the file's sha256 as its message id. It never carries the image.
A scanner that refuses or gives no page files nothing and says nothing on the bus; the tool's error says why.
## Config
| key | meaning | default |
|---|---|---|
| `scanner` | the scanner's eSCL address, such as `http://192.0.2.10/eSCL` (required, trusted) | none |
| `scan-folder` | the absolute path the PDFs are filed in (required, trusted) | none |
| `printer` | the printer's host, such as `192.0.2.10`, for its admin page and IPP (required, trusted) | none |
| `printer-certificate` | the SHA-256 fingerprint of the certificate the admin page may serve, `AA:BB:...`; several, separated by commas, when it serves more than one (required, trusted) | none |
| `resolution` | dpi asked: 100, 200, 300 or 600 | 300 |
| `color-mode` | `color` or `gray` | `color` |
| `crop` | cut the document out of the lid's white | true |
The values reach the code in one file, `settings.json` in the module's state directory, which the mesh writes
from the config; the code reads it at every call. With `scanner` or `scan-folder` unset, every scanner verb says which and
writes and asks nothing; `printer` and `printer-certificate` are the printer verbs' to say.
The scan folder is not a manifest resource: a directory's path cannot take a config value (issue 1441), so the code
makes it (mode 0755) when it files the first scan.
## Tools
| verb (tool) | does |
|---|---|
| `scanner.scan` | `source` (`glass`, default, or `feeder`), `duplex` (feeder only) and `apart` (glass only): asks the device process to scan, crop (glass only), keep the PDF in the bucket under its sha256 and say `scan.added`; then fetches it in parts, checks the sha256 and files it in `scan-folder`, in the background, so a scan is filed whether or not the caller still waits. A glass scan is waited for up to 180 s. Several glass pages in one PDF (a page turned over, a stapled document): `document` `start` scans page 1 into a new document and answers its id (nothing is filed yet), `document` with that id scans the next page, and `document` with the id and `finish` true closes it into one multi-page PDF, filed once with one `scan.added` (nothing is scanned). The pages are kept in the bucket under `pending/<id>/` (not in memory, so a restart of the device process loses none); a document that gets no page for 30 minutes is closed and filed so by the tool process (a restart of the tool process forgets that timer; `finish` still works). The pages in the bucket are not deleted, the bucket having no delete. A feeder scan is waited for 25 s (the mesh console gives up after 30 s); if it is not done, the answer is `started: true` with its `id`, and `scanner.scan {id}` reads the outcome (`state` running, done with the event's body and `path`, or failed with `why`). The device process ends a feeder job at 20 s a page (at least 3 min, at most 30 min). A scan whose PDF the tool could not fetch (over 100 MiB) is refused before it is kept or announced. Answers the event's body and `path` |
| `scanner.document` | one part of a kept scan (`object`, `part` from 1): `part`, `parts`, `size`, `sha256` (of the whole), `data` (base64). Join the parts and check the sha256 |
| `scanner.status` | whether the scanner answers, its state (Idle or Processing) and its document feeder's state; a scanner that does not answer is `answers: false` with why. |
| `brother_health` | the module's health, asked by the node-engine every 60 s (ADR 0240) as the tool check of the `watch` process, beside the device process's unit check: the certificates the printer serves, one for each key type (ECDSA and RSA, told apart by the cipher suites offered under TLS 1.2), read with no login and compared with the pinned `printer-certificate` list. Unhealthy when one is not pinned, `why` naming the new fingerprint, so the pin can be changed before the admin pages are needed. With `printer` or `printer-certificate` not given it answers `state: waits` with `waits` naming each setting (the SDK's health.Waiting, ADR 0283), not an error. Each handshake is bounded by 3 s and the whole check by 7 s. A printer that does not answer is healthy, with `why` saying nothing was compared (it may be switched off). The process's liveness is judged by the node-engine without a declaration |
| `scanner.list` | the newest filed scans, newest first (`limit`, default 20). Read-only |
## The printer seat
| verb (tool) | does |
|---|---|
| `printer.status` | without a login: over IPP (`Get-Printer-Attributes`, encoded by hand, on port 631) the state (idle, processing, stopped), the state reasons (such as `toner-low-warning`) and the markers' names, levels (percent; below 0 is unknown) and colours; over eSCL the scanner's state and its feeder's. A part that does not answer is `answers: false` with why. Needs `printer` and `scanner` |
| `printer.upload` | one part of a document to print later (`upload` id, `part`, `parts`, `sha256` of the whole, `data` base64): stored as `uploads/<upload>.pdf` or `.jpg` once all parts came and the sha256 matches; a wrong sha256, a part out of range or a document that is neither PDF nor JPEG stores nothing |
| `printer.print` | prints a file on the machine the tool runs on (`path`, absolute, a regular file under the tool account's home folder after symbolic links are resolved; any other path is refused with the one message "path refused: only a file under the home folder", which says nothing of whether it exists: the tool reads it, uploads it in parts and prints it in one call; PDF or JPEG by its bytes, never its name), a kept scan (`object`) or a completed upload (`upload`, for modules that send bytes over the bus) as an IPP Print-Job (operation 0x0002, port 631) in its own format, `copies` 1 to 99 (default 1), `sides` one-sided, two-sided-long-edge or two-sided-short-edge (default one-sided), `media` (default `iso_a4_210x297mm`); answers the job's id and state (with `path`, the upload id and the size when `path` was given) |
| `printer.settings` | asks the device process (below) to log in, read one admin page and log out, and waits up to 60 s for its answer. Argument `page`: a path on the admin site, starting with `/` and ending in `.html` (default `/general/status.html`). Answers the page's title and forms with their fields, each with its current value (a radio group as one field with the checked button's value, a checkbox with `checked`, a select with the selected option) and `options`; the page's links only with `links: true`, hidden fields only with `hidden: true`; a password field's value is never given. Read-only: it sends the login and the logout and nothing else |
### Trust and the password
The admin page is HTTPS with a self-signed certificate. The module verifies no chain: it accepts the one certificate
whose SHA-256 fingerprint is one of those in `printer-certificate` and refuses any other, in the handshake, so no
password is sent to it; the error names the served and the pinned fingerprints. Give each as 32 upper-case hex pairs
joined by colons, several separated by commas. The printer serves two self-signed certificates for one name, an
ECDSA one and an RSA one, and which it serves depends on the signature algorithms the client offers, so pin both
(issue 1503): read them with `openssl s_client -connect <printer>:443 -sigalgs ECDSA+SHA256` and `-sigalgs
RSA-PSS+SHA256`, each piped to `openssl x509 -noout -fingerprint -sha256`.
The admin password is the module's own secret `admin-password` (ADR 0467 clause 4; ADR 0283): the operator gives it
at their desk, and the mesh never generates or rotates it. It is owned by the account `brother-device`, which the
module makes, because the tools run as the operator account and must never read it (ADR 0436 §1, §12; the shape of
mesh-mail's sender). The file is `${dir:state}/admin-password.secret`, mode 0600, and `BROTHER_ADMIN_PASSWORD` names
it for the device process alone; the tools' bundle is given no such name, and `admin_boundary_test.go` checks that
no tool file imports the package that reads it. While it is not given, `printer.settings` answers that the secret
is not given, and the printer is not contacted.
### Documents in parts
The bus refuses a message over 1 MiB, so a document crosses it in parts of at most 700 KiB (both the size this module
cuts and the most it accepts, as ADR 0468 clause 3 says),
counted from 1, each carried base64-encoded in JSON. A caller joins the parts and checks the SHA-256. The bucket
(`s3-bucket`, from `minio`) is spoken to by the device process alone, from `store.json` (owner `brother-device`, mode
0600), with the S3 client mesh-mail uses; a scan is kept once as `<sha256>.pdf` and an upload as `uploads/<id>.<ext>`.
### The device process
`brother device` runs as `brother-device` with a bus account of its own (the own secret `broker-device`), never a
seat, and does all the device and bucket work: the scan (and `scan.added`, emitted by it with the object's key and no
path, since filing in the scan folder is the tool's), the documents, the printing, and the admin pages. It consumes the work-queue stream `device-asks` (event `device.asked`, body `id`, `page`, `asked-at`) and
writes each answer, once, into the state `device-answers` under the ask's id (ten minutes' life): the page's
title, links and form fields, or `error`. It alone reads the password, pins the certificate, logs in, reads the
page and logs out. An ask older than two minutes is dropped without contacting the printer; at most three
deliveries. It reads `settings.json` (mode 0644, no secret in it) and the state directory is 0711 so that it can
reach its own files; both are the choices that let its account read what it needs. The device process is judged by its unit; `brother_health` (the printer's certificates against the pins) is the tool check of the `watch` process beside it.
The login reads the form each time: the device generates the password field's name, so the code takes the name from
the form, posts it with the form's hidden fields, and keeps the session cookie. A login is failed when the answer is
the login page again. The logout follows a logout link or form on the page; a page with none is said in the answer
(`logout`), and the session is left to expire on the device.
## The event
`scan.added`, said by the device process once the PDF is stored, before the file is written (the tool side files it afterwards, from the store, by a link the device signed; the verb's answer adds the file's `path`), on the wire `mesh.mod.brother.event.scan.added`; a consumer declares `brother.scan.added`. Its body:
`object` (the key of the stored PDF), `node`, `bytes`, `sha256`, `width-mm`, `height-mm`, `resolution` (dpi, as the scanner gave it),
`color-mode`, `cropped`, `pages` (1 for the glass), `scanned-at` (RFC 3339, UTC).
## Files it does not know
Each directory or archive this module declares says how it treats a file it did not write (ADR 0422, ADR 0416).
- `${dir:state}`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
- The scan folder (`scan-folder`) is declared by no manifest. The module writes only the `scan-*.pdf` files it
files and their `.scan-*.tmp` while they are written; it never changes, moves or removes any other file there,
and `scanner.list` only reads.
## Tests
The unit tests use the captured answers of a real scanner (`cmd/brother/testdata/escl`, captured 2026-10-20) served
by a test HTTP server, the captured IPP exchange (`testdata/ipp`) replayed, and the captured login page
(`testdata/admin/login-page.html`) served by a test TLS server with its own certificate. The pages behind the login
are synthetic (`testdata/admin/synthetic-*.html`) until round 2 captures the real ones. No test reaches the real
device.
+231
View File
@@ -0,0 +1,231 @@
package main
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"net/http/cookiejar"
"net/url"
"strings"
"time"
)
// Admin is a session on the printer's admin web page, over HTTPS to the one certificate pinned (ADR 0467
// clauses 3 and 4). The device names the password field itself, so every login reads the form first.
type Admin struct {
Host string
Password string
HTTP *http.Client
}
// loginStart is where a login starts: the root, which the printer answers with a redirect to the page it serves
// its login form on (/home/status.html). Asking for a page behind the login instead is answered by a redirect
// to an error page, and a loginurl naming a page this firmware lacks is a 404 after the login (issue 1514).
const loginStart = "/"
// maxPage bounds a page of the admin site.
const maxPage = 4 << 20
// NewAdmin is a client for the printer's host that trusts the certificate with this fingerprint alone, with a
// cookie jar for the session.
func NewAdmin(host, fingerprint, password string) (*Admin, error) {
jar, err := cookiejar.New(nil)
if err != nil {
return nil, err
}
return &Admin{
Host: host,
Password: password,
HTTP: &http.Client{
Timeout: 30 * time.Second,
Jar: jar,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return checkRedirect(host, req, via)
},
Transport: &http.Transport{
TLSClientConfig: pinnedTLS(fingerprint),
DisableKeepAlives: true,
},
},
}, nil
}
// checkRedirect follows a redirect only on https on the printer's own host, and never with a body: a 307 or 308
// answer to the login post would make the client send the post again, the password with it (issue 1515).
func checkRedirect(host string, req *http.Request, via []*http.Request) error {
if len(via) >= 10 {
return errors.New("the printer redirected more than ten times")
}
if req.URL.Scheme != "https" || req.URL.Host != host {
return fmt.Errorf("the printer redirected to %s, not to https on the printer %s: not followed", req.URL.Scheme+"://"+req.URL.Host, host)
}
if req.Method != http.MethodGet && req.Method != http.MethodHead {
return fmt.Errorf("the printer redirected a %s with its body (a 307 or 308): not followed, so the password is not sent again", via[len(via)-1].Method)
}
return nil
}
func (a *Admin) url(path string) string { return "https://" + a.Host + path }
// fetch is one request; the body is read whole, up to maxPage. The final URL is the one after redirects.
func (a *Admin) fetch(ctx context.Context, method, rawURL string, form url.Values) ([]byte, *url.URL, error) {
var req *http.Request
var err error
if form != nil {
req, err = http.NewRequestWithContext(ctx, method, rawURL, strings.NewReader(form.Encode()))
if err == nil {
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}
} else {
req, err = http.NewRequestWithContext(ctx, method, rawURL, nil)
}
if err != nil {
return nil, nil, err
}
resp, err := a.HTTP.Do(req)
if err != nil {
return nil, nil, err
}
defer resp.Body.Close()
buf, err := io.ReadAll(io.LimitReader(resp.Body, maxPage+1))
if err != nil {
return nil, nil, fmt.Errorf("the page %s cannot be read: %w", rawURL, err)
}
if len(buf) > maxPage {
return nil, nil, fmt.Errorf("the page %s is larger than %d bytes", rawURL, maxPage)
}
if resp.StatusCode >= 400 {
if resp.StatusCode == http.StatusNotFound {
return nil, nil, &notFoundError{Path: resp.Request.URL.Path}
}
return nil, nil, fmt.Errorf("the printer answered %s for %s", resp.Status, resp.Request.URL.Path)
}
return buf, resp.Request.URL, nil
}
// passwordForm is the form of a page that holds a password field, or nil.
func passwordForm(p Page) (*Form, *Field) {
for i := range p.Forms {
for j := range p.Forms[i].Fields {
if p.Forms[i].Fields[j].Type == "password" {
return &p.Forms[i], &p.Forms[i].Fields[j]
}
}
}
return nil, nil
}
func hasField(f *Form, name string) bool {
for _, fl := range f.Fields {
if fl.Name == name {
return true
}
}
return false
}
// Login asks for the printer's login page, reads its login form, and posts the form's own fields with the
// password in the field the form names and the form's loginurl set to the default page, which every firmware has; the page asked for is a request of its own afterwards. A login
// is failed when the answer is a login page again.
func (a *Admin) Login(ctx context.Context) error {
body, at, err := a.fetch(ctx, http.MethodGet, a.url(loginStart), nil)
if err != nil {
return fmt.Errorf("the printer's admin page cannot be asked for its login: %w", err)
}
form, pw := passwordForm(ParsePage(body))
if form == nil {
return fmt.Errorf("the printer's answer to %s holds no login form with a password field", at.Path)
}
target, err := at.Parse(form.Action)
if err != nil {
return fmt.Errorf("the login form's action %q is not a URL: %w", form.Action, err)
}
if target.Scheme != "https" || target.Host != a.Host {
return fmt.Errorf("the login form posts to %s, not to https on the printer %s: the password is not sent", target.Scheme+"://"+target.Host, a.Host)
}
vals := url.Values{}
for _, f := range form.Fields {
if f.Type == "hidden" {
vals.Set(f.Name, f.Value)
}
}
if _, ok := vals["loginurl"]; ok {
vals.Set("loginurl", DefaultPage)
}
vals.Set(pw.Name, a.Password)
answer, _, err := a.fetch(ctx, http.MethodPost, target.String(), vals)
if err != nil {
// The post may have opened a session before its answer failed: end it if there is one.
if home, hat, herr := a.Get(context.Background(), DefaultPage); herr == nil {
a.Logout(context.Background(), home, hat)
}
return fmt.Errorf("the login was not answered: %w", err)
}
// A login page again is a form with a password field and the loginurl field; a page that was asked for may
// hold a password field of its own.
if again, _ := passwordForm(ParsePage(answer)); again != nil && hasField(again, "loginurl") {
return fmt.Errorf("the login failed: the printer showed its login page again, so the secret admin-password is not its password")
}
return nil
}
// Get is a page of the admin site, after a login.
func (a *Admin) Get(ctx context.Context, path string) ([]byte, *url.URL, error) {
return a.fetch(ctx, http.MethodGet, a.url(path), nil)
}
// Logout ends the session through the logout link or form of a page of it, and says what it did. A page with
// none leaves the session to expire, and the answer says so.
func (a *Admin) Logout(ctx context.Context, page []byte, at *url.URL) string {
p := ParsePage(page)
isLogout := func(s string) bool {
s = strings.ToLower(strings.ReplaceAll(s, " ", ""))
return strings.Contains(s, "logout") || strings.Contains(s, "logoff")
}
for _, f := range p.Forms {
if !isLogout(f.Action) {
continue
}
target, err := at.Parse(f.Action)
if err != nil || target.Host != a.Host {
continue
}
vals := url.Values{}
for _, fl := range f.Fields {
if fl.Type == "hidden" {
vals.Set(fl.Name, fl.Value)
}
}
method := http.MethodPost
var send url.Values = vals
if f.Method == "get" {
method, send = http.MethodGet, nil
target.RawQuery = vals.Encode()
}
if _, _, err := a.fetch(ctx, method, target.String(), send); err != nil {
return "logout failed: " + err.Error()
}
return "logged out"
}
for _, l := range p.Links {
if !isLogout(l.Href) && !isLogout(l.Text) {
continue
}
target, err := at.Parse(l.Href)
if err != nil || target.Host != a.Host {
continue
}
if _, _, err := a.fetch(ctx, http.MethodGet, target.String(), nil); err != nil {
return "logout failed: " + err.Error()
}
return "logged out"
}
return "no logout link or form on the page: the session is left to expire on the printer"
}
// notFoundError is the printer's 404 for a page.
type notFoundError struct{ Path string }
func (e *notFoundError) Error() string { return "the printer answered 404 Not Found for " + e.Path }
+351
View File
@@ -0,0 +1,351 @@
package main
import (
"context"
"crypto/tls"
"net"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"testing"
)
// fakePrinter is a TLS server standing in for the printer's admin page. Its login page is the captured one
// (testdata/admin/login-page.html, 2026-10-20) with the password field's name changed to the one the test
// chooses, as the real device generates it. The pages behind the login are SYNTHETIC (testdata/admin/
// synthetic-*.html): the real ones arrive in round 2 of issue 1453.
type fakePrinter struct {
*httptest.Server
field string // the password field's name the login form gives
password string
mu sync.Mutex
conns int // connections opened to it
requests []string // method and path of every request
posted []url.Values // every form body posted
session string // the cookie value of the one logged-in session
loggedIn map[string]bool // cookie values that are logged in
pages map[string]string // path -> body behind the login
}
func adminFixture(t *testing.T, name string) string {
t.Helper()
b, err := os.ReadFile(filepath.Join("testdata", "admin", name))
if err != nil {
t.Fatal(err)
}
return string(b)
}
func newFakePrinter(t *testing.T, field, password string) *fakePrinter {
t.Helper()
f := &fakePrinter{field: field, password: password, loggedIn: map[string]bool{}, pages: map[string]string{}}
login := strings.ReplaceAll(adminFixture(t, "login-page.html"), "B1891", field)
// The login page the printer serves at /home/status.html carries that page as its loginurl (captured live
// 2026-10-20, testdata/admin/live/home_status_html.body); the page it serves at /etc/loginerror.html carries
// the page that was asked for.
homeLogin := strings.ReplaceAll(login, `value="/general/status.html"`, `value="/home/status.html"`)
synthetic := adminFixture(t, "synthetic-status.html")
// This firmware has no /general/status.html behind the login (issue 1514): asking for it is a 404.
f.pages["/home/status.html"] = synthetic
f.pages["/general/other.html"] = adminFixture(t, "synthetic-settings.html")
authed := func(r *http.Request) bool {
c, err := r.Cookie("session")
return err == nil && f.loggedIn[c.Value]
}
mux := http.NewServeMux()
mux.HandleFunc("/etc/loginerror.html", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
w.Write([]byte(login))
})
mux.HandleFunc("/home/status.html", func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
if r.Method != http.MethodPost {
if authed(r) {
w.Write([]byte(synthetic))
return
}
w.Write([]byte(homeLogin))
return
}
r.ParseForm()
f.posted = append(f.posted, r.PostForm)
if r.PostForm.Get(field) != password || r.PostForm.Get("loginurl") == "" {
w.Write([]byte(login))
return
}
f.session = "s" + field
f.loggedIn[f.session] = true
http.SetCookie(w, &http.Cookie{Name: "session", Value: f.session, Path: "/"})
// Like Brother's form, a good login goes on to the page named in loginurl.
http.Redirect(w, r, r.PostForm.Get("loginurl"), http.StatusFound)
})
mux.HandleFunc("/home/logout.html", func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
if c, err := r.Cookie("session"); err == nil {
delete(f.loggedIn, c.Value)
}
w.Write([]byte(login))
})
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
if r.URL.Path == "/" {
http.Redirect(w, r, "/home/status.html", http.StatusMovedPermanently)
return
}
f.requests = append(f.requests, r.Method+" "+r.URL.Path)
// Captured: a page behind the login is answered by a redirect to the login page whether it exists or not.
if !authed(r) {
http.Redirect(w, r, "/etc/loginerror.html?url="+url.QueryEscape(r.URL.Path), http.StatusFound)
return
}
body, ok := f.pages[r.URL.Path]
if !ok {
http.NotFound(w, r)
return
}
w.Write([]byte(body))
})
f.Server = httptest.NewUnstartedServer(mux)
f.Config.ConnState = func(_ net.Conn, st http.ConnState) {
if st == http.StateNew {
f.mu.Lock()
f.conns++
f.mu.Unlock()
}
}
f.StartTLS()
t.Cleanup(f.Close)
return f
}
func (f *fakePrinter) host() string { return strings.TrimPrefix(f.URL, "https://") }
// print is the fingerprint of the certificate the server serves.
func (f *fakePrinter) print() string { return Fingerprint(f.TLS.Certificates[0].Certificate[0]) }
func (f *fakePrinter) connsNow() int {
f.mu.Lock()
defer f.mu.Unlock()
return f.conns
}
func (f *fakePrinter) loggedInNow() int {
f.mu.Lock()
defer f.mu.Unlock()
return len(f.loggedIn)
}
// 1453/AC2: a certificate whose fingerprint is not the pinned one is refused, the error names both
// fingerprints, and the server saw no request at all, so no password was sent.
func TestACertificateOtherThanThePinnedOneIsRefusedBeforeAnythingIsSent(t *testing.T) {
f := newFakePrinter(t, "B1891", "hunter2")
other := strings.Repeat("AA:", 31) + "AA"
a, err := NewAdmin(f.host(), other, "hunter2")
if err != nil {
t.Fatal(err)
}
err = a.Login(context.Background())
if err == nil {
t.Fatal("a certificate that is not the pinned one was accepted")
}
for _, want := range []string{f.print(), other} {
if !strings.Contains(err.Error(), want) {
t.Errorf("error %q does not name the fingerprint %s", err, want)
}
}
if len(f.requests) != 0 || len(f.posted) != 0 {
t.Errorf("the server was sent %v and the forms %v: the password may have gone", f.requests, f.posted)
}
}
// 1453/AC2: the pinned certificate itself is accepted, in either case of hex digits.
func TestThePinnedCertificateIsAccepted(t *testing.T) {
f := newFakePrinter(t, "B1891", "hunter2")
a, _ := NewAdmin(f.host(), strings.ToLower(f.print()), "hunter2")
if err := a.Login(context.Background()); err != nil {
t.Fatal(err)
}
}
// 1453/AC3: the login posts the password in the field the form names, whatever the name is (the test changes
// it), along with the form's hidden fields, and the session cookie it gets is the one later pages are asked with.
func TestLoginUsesThePasswordFieldNameTheFormGives(t *testing.T) {
for _, name := range []string{"B1891", "Zq77x"} {
f := newFakePrinter(t, name, "s3cret pass")
a, _ := NewAdmin(f.host(), f.print(), "s3cret pass")
ctx := context.Background()
if err := a.Login(ctx); err != nil {
t.Fatalf("field %s: %v", name, err)
}
if len(f.posted) != 1 {
t.Fatalf("field %s: %d forms posted", name, len(f.posted))
}
if got := f.posted[0].Get(name); got != "s3cret pass" {
t.Errorf("field %s: the password field holds %q", name, got)
}
if f.posted[0].Get("loginurl") != DefaultPage {
t.Errorf("field %s: the form's hidden loginurl was %q", name, f.posted[0].Get("loginurl"))
}
if len(f.posted[0]) != 2 {
t.Errorf("field %s: posted %v, want the password and loginurl only", name, f.posted[0])
}
body, _, err := a.Get(ctx, "/general/other.html")
if err != nil || !strings.Contains(string(body), "Synthetic settings") {
t.Errorf("field %s: a page behind the login was not given with the session cookie: %v %.60s", name, err, body)
}
}
}
// 1453/AC4: a wrong password is a failed login, said so: the login page comes back.
func TestAWrongPasswordIsAFailedLogin(t *testing.T) {
f := newFakePrinter(t, "B1891", "right")
a, _ := NewAdmin(f.host(), f.print(), "wrong")
err := a.Login(context.Background())
if err == nil || !strings.Contains(err.Error(), "login failed") {
t.Fatalf("error = %v, want a failed login", err)
}
if f.loggedInNow() != 0 {
t.Errorf("a session was opened")
}
}
// 1453: the logout link on a page ends the session; a page with none says so.
func TestLogoutEndsTheSessionAndAPageWithoutOneSaysSo(t *testing.T) {
f := newFakePrinter(t, "B1891", "pw")
a, _ := NewAdmin(f.host(), f.print(), "pw")
ctx := context.Background()
if err := a.Login(ctx); err != nil {
t.Fatal(err)
}
body, at, err := a.Get(ctx, DefaultPage)
if err != nil {
t.Fatal(err)
}
if got := a.Logout(ctx, body, at); got != "logged out" {
t.Errorf("logout = %q", got)
}
if f.loggedInNow() != 0 {
t.Errorf("the session is still open")
}
if got := a.Logout(ctx, []byte("<html><body>nothing</body></html>"), at); !strings.Contains(got, "no logout") {
t.Errorf("logout without a link = %q", got)
}
}
// 1453: the module never sends the password to a host other than the printer, even if the form says so.
func TestThePasswordIsNotPostedToAnotherHost(t *testing.T) {
// A login form whose action names another host.
srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`<form method="post" action="https://elsewhere.example/x"><input type="password" name="p"/></form>`))
}))
srv.TLS = &tls.Config{}
srv.StartTLS()
defer srv.Close()
host := strings.TrimPrefix(srv.URL, "https://")
a, _ := NewAdmin(host, Fingerprint(srv.TLS.Certificates[0].Certificate[0]), "pw")
err := a.Login(context.Background())
if err == nil || !strings.Contains(err.Error(), "not sent") {
t.Fatalf("error = %v", err)
}
}
// 1514 review: a page the printer does not have leaves no session open, and the error says the page is missing,
// not that the login failed.
func TestAMissingPageLeavesNoSessionOpenAndSaysSo(t *testing.T) {
f := newFakePrinter(t, "B1891", "pw")
_, run := printerTools(t, f, "pw")
_, err := run(map[string]any{"page": "/general/nope.html"})
if err == nil || !strings.Contains(err.Error(), "the printer has no page /general/nope.html") {
t.Fatalf("error = %v, want the printer has no page", err)
}
if strings.Contains(err.Error(), "login was not answered") {
t.Errorf("a missing page is said to be a failed login: %v", err)
}
if f.loggedInNow() != 0 {
t.Errorf("%d sessions are still open on the printer", f.loggedInNow())
}
}
// 1514 review: the password is posted to https on the printer's host only. A form whose action is plain http
// on the same host, or a root that redirects to plain http, is refused and nothing is posted.
func TestThePasswordIsNotPostedOverPlainHTTP(t *testing.T) {
var posted int
mux := http.NewServeMux()
page := func(action string) string {
return `<html><body><form method="post" action="` + action + `"><input type="password" name="B1891"/><input type="hidden" name="loginurl" value="/home/status.html"/></form></body></html>`
}
var host string
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
posted++
}
w.Write([]byte(page("http://" + host + "/home/status.html")))
})
srv := httptest.NewTLSServer(mux)
defer srv.Close()
host = strings.TrimPrefix(srv.URL, "https://")
a, _ := NewAdmin(host, Fingerprint(srv.TLS.Certificates[0].Certificate[0]), "pw")
err := a.Login(context.Background())
if err == nil || !strings.Contains(err.Error(), "password is not sent") {
t.Errorf("error = %v, want the plain http action refused", err)
}
if posted != 0 {
t.Errorf("%d posts reached the server", posted)
}
}
// 1515: a 307 or 308 after the login post would make the HTTP client send the post again, the password with
// it, to the place the answer names; a redirect off https or off the printer's host is refused as well.
func TestNoRedirectResendsThePasswordOrLeavesHTTPSOnThePrinter(t *testing.T) {
const loginPage = `<html><body><form action="/login" method="post"><input type="password" name="pw"><input type="hidden" name="loginurl" value="/x"></form></body></html>`
cases := []struct {
name string
status int
location string // "SAME" is a path on the printer itself
}{
{"307 on the printer", 307, "/again"},
{"308 on the printer", 308, "/again"},
{"307 to plain http", 307, "http://192.0.2.1/steal"},
{"302 to another host", 302, "https://192.0.2.1/steal"},
{"302 to plain http", 302, "http://192.0.2.1/steal"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
var mu sync.Mutex
var posts []string
srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
defer mu.Unlock()
switch {
case r.Method == http.MethodGet && r.URL.Path == "/":
w.Write([]byte(loginPage))
case r.Method == http.MethodPost:
r.ParseForm()
posts = append(posts, r.URL.Path+" "+r.PostForm.Get("pw"))
http.Redirect(w, r, c.location, c.status)
default:
w.Write([]byte("ok"))
}
}))
srv.StartTLS()
defer srv.Close()
a, _ := NewAdmin(strings.TrimPrefix(srv.URL, "https://"), Fingerprint(srv.TLS.Certificates[0].Certificate[0]), "hunter2")
err := a.Login(context.Background())
if err == nil {
t.Fatalf("the login went on after a %d to %s", c.status, c.location)
}
mu.Lock()
defer mu.Unlock()
if len(posts) != 1 {
t.Errorf("the password was posted %d times: %v", len(posts), posts)
}
})
}
}
+182
View File
@@ -0,0 +1,182 @@
package main
import (
"bytes"
"context"
"fmt"
"image/jpeg"
"math"
"time"
)
// The device process's scan (novox/hq ADR 0468 clause 4): the glass scanned over eSCL, the document cut out of
// the platen's white and built into a one-page PDF, or the feeder's sheets (both sides on request) into one multi-page PDF. Keeping it in the bucket and saying scan.added are the
// device process's (deviceproc.go); filing it in the scan folder is the tool's (scan.go).
func mm(pixels, dpi int) float64 {
return math.Round(float64(pixels)/float64(dpi)*25.4*10) / 10
}
// Capture is a scanned page or pages as a PDF, with what the event says of it. Width, height and resolution are
// the first page's.
type Capture struct {
PDF []byte
SHA256 string
WidthMM float64
HeightMM float64
Resolution int
Cropped bool
Pages int
At time.Time
}
// ScanRequest is what one scan asks beyond the configuration: the feeder instead of the glass, and both sides.
type ScanRequest struct {
Feeder bool
Duplex bool
// Apart cuts each of several documents lying apart on the glass out as its own PDF (issue 1528); without it
// the glass gives one document, as it always did.
Apart bool
}
// ParseSource reads the scanner.scan arguments source (glass, the default, or feeder) and duplex. Duplex is for the
// feeder: the glass holds one sheet.
func ParseSource(source string, duplex bool) (ScanRequest, error) {
return ParseScan(source, duplex, false)
}
// ParseScan is ParseSource with apart: several documents on the glass, each its own file; a feeder scan is of
// sheets and has none lying apart.
func ParseScan(source string, duplex, apart bool) (ScanRequest, error) {
req, err := parseSource(source, duplex)
if err == nil && apart && req.Feeder {
return ScanRequest{}, fmt.Errorf("apart is for the glass: the feeder takes each sheet as its own page already")
}
req.Apart = apart
return req, err
}
func parseSource(source string, duplex bool) (ScanRequest, error) {
switch source {
case "", "glass":
if duplex {
return ScanRequest{}, fmt.Errorf("duplex is for the feeder: the glass holds one page (set source to feeder)")
}
return ScanRequest{}, nil
case "feeder":
return ScanRequest{Feeder: true, Duplex: duplex}, nil
}
return ScanRequest{}, fmt.Errorf("source %q is not glass or feeder", source)
}
// Capture scans the glass or the feeder and builds the PDFs: one for a feeder job (every page of it), and for the
// glass one for each separate document lying on the platen when crop is set (issue 1528), in reading order. A glass
// page is cropped when the settings say so; a feeder page is a whole sheet and never cropped. Nothing is kept and
// nothing is said here; an error says why the scanner gave no page. The resolution is the image's own, never the
// one asked (1440/AC2).
func (s *Service) Capture(ctx context.Context, req ScanRequest) ([]Capture, error) {
at := s.Now()
scans, err := NewClient(s.Settings.Scanner).ScanPages(ctx, ScanOptions{Resolution: s.Settings.Resolution,
ColorMode: s.Settings.ColorMode, Feeder: req.Feeder, Duplex: req.Duplex})
if err != nil {
return nil, err
}
if !req.Feeder {
dpi, err := Density(scans[0])
if err != nil {
return nil, fmt.Errorf("page 1: %w", err)
}
cuts, err := Crop(scans[0], s.Settings.Crop)
one := []Cropped{cuts}
if req.Apart {
one, err = CropAll(scans[0], true, dpi)
}
if err != nil {
return nil, err
}
out := make([]Capture, len(one))
for i, cut := range one {
if out[i], err = captureOf([]PDFPage{{JPEG: cut.JPEG, DPI: dpi}}, cut, dpi, at); err != nil {
return nil, err
}
}
return out, nil
}
pages := make([]PDFPage, len(scans))
var first Cropped
var firstDPI int
for i, scan := range scans {
dpi, err := Density(scan)
if err != nil {
return nil, fmt.Errorf("page %d: %w", i+1, err)
}
cfg, err := jpeg.DecodeConfig(bytes.NewReader(scan))
if err != nil {
return nil, fmt.Errorf("page %d: the scanned image cannot be read: %w", i+1, err)
}
pages[i] = PDFPage{JPEG: scan, DPI: dpi}
if i == 0 {
first, firstDPI = Cropped{JPEG: scan, Width: cfg.Width, Height: cfg.Height}, dpi
}
}
c, err := captureOf(pages, first, firstDPI, at)
if err != nil {
return nil, err
}
return []Capture{c}, nil
}
// captureOf is the PDF of the pages, with what the event says of it from the first page.
func captureOf(pages []PDFPage, first Cropped, firstDPI int, at time.Time) (Capture, error) {
pdf, err := PDFPages(pages)
if err != nil {
return Capture{}, err
}
return Capture{PDF: pdf, SHA256: sha256Hex(pdf), WidthMM: mm(first.Width, firstDPI), HeightMM: mm(first.Height, firstDPI),
Resolution: firstDPI, Cropped: first.Cropped, Pages: len(pages), At: at}, nil
}
// Body is the event scan.added's body (ADR 0464 §5 with the object key of ADR 0468 clause 2): never the image,
// and no path, since filing in a folder is the tool's, after the event.
func (c Capture) Body(node, colorMode string) map[string]any {
pages := max(c.Pages, 1)
return map[string]any{
"object": c.SHA256 + ".pdf",
"node": node,
"bytes": len(c.PDF),
"sha256": c.SHA256,
"width-mm": c.WidthMM,
"height-mm": c.HeightMM,
"resolution": c.Resolution,
"color-mode": colorMode,
"cropped": c.Cropped,
"pages": pages,
"scanned-at": c.At.UTC().Format(time.RFC3339),
}
}
// GlassImage is a page scanned from the glass.
type GlassImage struct {
JPEG []byte
DPI, Width, Height int
Cropped bool
}
// GlassPage scans one page from the glass for a document of several pages: cropped to its document when the settings
// say so (the first document, never split in several), with the resolution the scanner gave.
func (s *Service) GlassPage(ctx context.Context) (page GlassImage, err error) {
scans, err := NewClient(s.Settings.Scanner).ScanPages(ctx, ScanOptions{Resolution: s.Settings.Resolution, ColorMode: s.Settings.ColorMode})
if err != nil {
return page, err
}
dpi, err := Density(scans[0])
if err != nil {
return page, err
}
cut, err := Crop(scans[0], s.Settings.Crop)
if err != nil {
return page, err
}
page.JPEG, page.DPI, page.Width, page.Height, page.Cropped = cut.JPEG, dpi, cut.Width, cut.Height, cut.Cropped
return page, nil
}
+143
View File
@@ -0,0 +1,143 @@
package main
import (
"context"
"crypto/tls"
"fmt"
"net"
"slices"
"strings"
"time"
"git.novox.be/novox/mesh-sdk/go/health"
)
// The module's health (issue 1510, novox/hq ADR 0240): the node-engine asks the tool brother_health, and the tool
// asks the printer for the certificates it serves, one for each key type, with no login, and compares them with
// the pinned list (the config value printer-certificate). A certificate that is not pinned (the printer was reset,
// new certificates were made, a firmware update changed them) is unhealthy and names the new fingerprint, so the
// operator can approve the new pin before the admin pages are needed. A printer that does not answer is not
// unhealthy (it is switched off, or asleep): nothing can be compared, and the answer says so.
// keyKinds are the two certificates the printer serves for one name. Which one it serves depends on the cipher
// suites the client offers, so each handshake offers only one kind's, under TLS 1.2 (where the suite decides).
var keyKinds = []struct {
Name string
Suites []uint16
}{
{"ecdsa", []uint16{
tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256, tls.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA, tls.TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA,
}},
{"rsa", []uint16{
tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256, tls.TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA, tls.TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA,
tls.TLS_RSA_WITH_AES_128_GCM_SHA256, tls.TLS_RSA_WITH_AES_256_GCM_SHA384, tls.TLS_RSA_WITH_AES_128_CBC_SHA, tls.TLS_RSA_WITH_AES_256_CBC_SHA,
}},
}
// certDial bounds one handshake and certTotal the whole check (two handshakes), so the node-engine's look, which has
// ten seconds, is always answered (issue 1510).
var (
certDial = 3 * time.Second
certTotal = 7 * time.Second
)
// Served is a certificate the printer served to a handshake of one key type.
type Served struct {
Key string `json:"key"`
Fingerprint string `json:"fingerprint,omitempty"`
Pinned bool `json:"pinned"`
Note string `json:"note,omitempty"`
}
// CertHealth is brother_health's answer: healthy, why when it is not (or what could not be compared), and what the
// printer served.
type CertHealth struct {
Healthy bool `json:"healthy"`
State string `json:"state"` // healthy, unhealthy or waits (ADR 0283)
Why string `json:"why"`
Served []Served `json:"served"`
}
// CertWaits is the answer while printer or printer-certificate is not given: the module waits for the operator,
// which is not a fault (ADR 0283), and names each setting as the SDK's health.Wait asks.
func CertWaits(s Settings) (health.Answer, bool) {
var waits []health.Wait
var names []string
for _, c := range []struct{ name, value, what string }{
{"printer", s.Printer, "the printer's address"},
{"printer-certificate", s.PrinterCertificate, "the SHA-256 fingerprints of the certificates the printer serves"},
} {
if c.value == "" {
waits = append(waits, health.Wait{Part: "the printer's certificate check", Setting: c.name, What: c.what})
names = append(names, c.name)
}
}
if len(waits) == 0 {
return health.Answer{}, false
}
return health.Waiting("waits for the operator to give the config value "+strings.Join(names, " and "), waits...), true
}
// serveAddr is the host with the HTTPS port when it names none.
func serveAddr(host string) string {
if _, _, err := net.SplitHostPort(host); err == nil {
return host
}
return net.JoinHostPort(strings.Trim(host, "[]"), "443")
}
// served is the leaf's fingerprint the printer gives a handshake that offers only these suites, or why it gave none.
func served(ctx context.Context, addr string, suites []uint16) (string, error) {
ctx, cancel := context.WithTimeout(ctx, certDial)
defer cancel()
d := tls.Dialer{NetDialer: &net.Dialer{Timeout: certDial}, Config: &tls.Config{
MinVersion: tls.VersionTLS12, MaxVersion: tls.VersionTLS12, CipherSuites: suites,
// Nothing is sent over this connection, and the certificate is only read, to be compared with the pins.
InsecureSkipVerify: true,
}}
conn, err := d.DialContext(ctx, "tcp", addr)
if err != nil {
return "", err
}
defer conn.Close()
state := conn.(*tls.Conn).ConnectionState()
if len(state.PeerCertificates) == 0 {
return "", fmt.Errorf("the printer served no certificate")
}
return Fingerprint(state.PeerCertificates[0].Raw), nil
}
// CheckCertificates compares what the printer serves with the pins. The login is never used.
func CheckCertificates(ctx context.Context, host, pins string) CertHealth {
ctx, cancel := context.WithTimeout(ctx, certTotal)
defer cancel()
want := pinList(pins)
addr := serveAddr(host)
h := CertHealth{Healthy: true, State: "healthy"}
var changed, unreachable []string
for _, k := range keyKinds {
s := Served{Key: k.Name}
fp, err := served(ctx, addr, k.Suites)
if err != nil {
s.Note = err.Error()
unreachable = append(unreachable, k.Name+": "+err.Error())
} else {
s.Fingerprint, s.Pinned = fp, slices.Contains(want, fp)
if !s.Pinned {
changed = append(changed, fmt.Sprintf("the %s certificate is now %s", k.Name, fp))
}
}
h.Served = append(h.Served, s)
}
switch {
case len(changed) > 0:
h.Healthy, h.State = false, "unhealthy"
h.Why = fmt.Sprintf("the printer serves a certificate that is not pinned: %s; the pinned printer-certificate is %s. If the printer was reset or given new certificates, set printer-certificate to the new fingerprint(s) (with the old ones kept if they still serve)",
strings.Join(changed, "; "), strings.Join(want, ", "))
case len(unreachable) == len(keyKinds):
h.Why = "the printer gave no certificate, so none was compared (it may be switched off or asleep): " + strings.Join(unreachable, "; ")
}
return h
}
@@ -0,0 +1,160 @@
package main
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/json"
"math/big"
"net"
"strings"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/health"
)
// selfSigned is a self-signed certificate over the key.
func selfSigned(t *testing.T, key any, pub any) tls.Certificate {
t.Helper()
tpl := &x509.Certificate{SerialNumber: big.NewInt(time.Now().UnixNano()), Subject: pkix.Name{CommonName: "printer.test"},
NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(24 * time.Hour), DNSNames: []string{"printer.test"}}
der, err := x509.CreateCertificate(rand.Reader, tpl, tpl, pub, key)
if err != nil {
t.Fatal(err)
}
return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key}
}
// twoCertPrinter is a TLS server with an ECDSA and an RSA certificate, as the printer has; it answers its address
// and the two fingerprints.
func twoCertPrinter(t *testing.T) (addr, ecdsaFP, rsaFP string) {
t.Helper()
ek, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
rk, _ := rsa.GenerateKey(rand.Reader, 2048)
ec, rc := selfSigned(t, ek, &ek.PublicKey), selfSigned(t, rk, &rk.PublicKey)
ln, err := tls.Listen("tcp", "127.0.0.1:0", &tls.Config{Certificates: []tls.Certificate{ec, rc}})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { ln.Close() })
go func() {
for {
c, err := ln.Accept()
if err != nil {
return
}
go func() { c.(*tls.Conn).Handshake(); c.Close() }()
}
}()
return ln.Addr().String(), Fingerprint(ec.Certificate[0]), Fingerprint(rc.Certificate[0])
}
// 1510: both certificates pinned is healthy; a changed one is unhealthy and names the new fingerprint; none
// pinned names both.
func TestTheHealthComparesBothServedCertificatesWithThePins(t *testing.T) {
addr, e, r := twoCertPrinter(t)
ctx := context.Background()
if h := CheckCertificates(ctx, addr, e+", "+r); !h.Healthy || len(h.Served) != 2 || !h.Served[0].Pinned || !h.Served[1].Pinned {
t.Errorf("both pinned: %+v", h)
}
old := strings.Repeat("AB:", 31) + "AB"
h := CheckCertificates(ctx, addr, e+", "+old) // the RSA certificate changed
if h.Healthy || !strings.Contains(h.Why, r) || strings.Contains(h.Why, "ecdsa certificate is now") {
t.Errorf("rsa changed: %+v", h)
}
h = CheckCertificates(ctx, addr, old+", "+r) // the ECDSA certificate changed
if h.Healthy || !strings.Contains(h.Why, e) || strings.Contains(h.Why, "rsa certificate is now") {
t.Errorf("ecdsa changed: %+v", h)
}
h = CheckCertificates(ctx, addr, old)
if h.Healthy || !strings.Contains(h.Why, e) || !strings.Contains(h.Why, r) || !strings.Contains(h.Why, old) {
t.Errorf("none pinned: %+v", h)
}
}
// 1510: a printer that does not answer is not an unhealthy module: nothing can be compared, and the answer says so.
func TestAPrinterThatDoesNotAnswerIsNotUnhealthy(t *testing.T) {
ln, _ := net.Listen("tcp", "127.0.0.1:0")
addr := ln.Addr().String()
ln.Close()
h := CheckCertificates(context.Background(), addr, strings.Repeat("AB:", 31)+"AB")
if !h.Healthy || !strings.Contains(h.Why, "none was compared") {
t.Errorf("%+v", h)
}
}
// 1510: brother_health is a tool of the module, read from the settings at each call.
func TestBrotherHealthIsAToolThatReadsTheSettings(t *testing.T) {
addr, e, r := twoCertPrinter(t)
js := `{"printer":"` + addr + `","printer-certificate":"` + e + `, ` + r + `"}`
run := tool(t, newRig(t, js, "pw").tl, "brother_health").Run
got, err := run(map[string]any{})
if err != nil {
t.Fatal(err)
}
if h, ok := got.(CertHealth); !ok || !h.Healthy {
t.Errorf("answer = %+v", got)
}
bad := `{"printer":"` + addr + `","printer-certificate":"` + strings.Repeat("AB:", 31) + `AB"}`
got, _ = tool(t, newRig(t, bad, "pw").tl, "brother_health").Run(map[string]any{})
if h := got.(CertHealth); h.Healthy {
t.Errorf("a wrong pin is healthy: %+v", h)
}
}
// 1510: with the printer not given the answer is waits, read by the SDK as the engine reads it: state waits, each
// wait naming exactly one setting, a part and what is wanted; not an error and not unhealthy.
func TestHealthWaitsForTheSettingsTheOperatorHasYetToGive(t *testing.T) {
for js, want := range map[string][]string{`{}`: {"printer", "printer-certificate"}, `{"printer":"192.0.2.10"}`: {"printer-certificate"}} {
got, err := tool(t, newRig(t, js, "pw").tl, "brother_health").Run(map[string]any{})
if err != nil {
t.Fatal(err)
}
raw, _ := json.Marshal(got)
a, err := health.Read(raw)
if err != nil || a.State != health.StateWaits || len(a.Waits) != len(want) {
t.Fatalf("%s: read as %+v, %v", js, a, err)
}
for i, w := range a.Waits {
if w.Setting != want[i] || w.Check() != nil {
t.Errorf("%s: wait %d = %+v (%v)", js, i, w, w.Check())
}
}
}
}
// 1510: each handshake is bounded by 3 s and the whole check by 7 s, and a printer that accepts and never answers
// ends the check at the bound with the module healthy (nothing compared).
func TestTheCheckIsBoundedAndAHungPrinterEndsItAtTheBound(t *testing.T) {
if certDial != 3*time.Second || certTotal != 7*time.Second {
t.Fatalf("bounds = %s and %s, want 3 s and 7 s", certDial, certTotal)
}
ln, _ := net.Listen("tcp", "127.0.0.1:0")
defer ln.Close()
go func() {
for {
c, err := ln.Accept()
if err != nil {
return
}
defer c.Close() // held open, never answered
}
}()
oldD, oldT := certDial, certTotal
certDial, certTotal = 200*time.Millisecond, 500*time.Millisecond
t.Cleanup(func() { certDial, certTotal = oldD, oldT })
start := time.Now()
h := CheckCertificates(context.Background(), ln.Addr().String(), strings.Repeat("AB:", 31)+"AB")
if took := time.Since(start); took > 700*time.Millisecond {
t.Errorf("the check took %s", took)
}
if !h.Healthy || !strings.Contains(h.Why, "none was compared") {
t.Errorf("%+v", h)
}
}
+207
View File
@@ -0,0 +1,207 @@
package main
// Conversion before printing (novox/hq ADR 0468 clause 7, issue 1600). The printer takes PDF and JPEG. A stored
// document of a type the module only-office converts (seat document-converter) is turned into a PDF first: the device
// process signs a GET link on the document and a PUT link on <sha>.pdf with the binding's key, calls
// document-converter.convert, checks that the PDF the converter says it put is the PDF the bucket holds, and prints
// that. Any other type is refused, naming the types taken.
import (
"archive/zip"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
"unicode/utf8"
"git.novox.be/novox/mesh-sdk/go/objectlink"
)
// ConvertFollow is how long the device process follows one conversion, in calls of under 25 s each.
const ConvertFollow = 3 * time.Minute
// ConvertTypes are the types document-converter takes (clause 7).
var ConvertTypes = []string{"docx", "doc", "xlsx", "xls", "pptx", "ppt", "odt", "ods", "odp", "rtf", "txt", "html"}
// contentTypes are the media types of a fetched document, by the type the converter is asked for.
var contentTypes = map[string]string{
"text/html": "html", "application/xhtml+xml": "html", "text/plain": "txt", "application/rtf": "rtf", "text/rtf": "rtf",
"application/msword": "doc", "application/vnd.ms-excel": "xls", "application/vnd.ms-powerpoint": "ppt",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": "docx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "xlsx",
"application/vnd.openxmlformats-officedocument.presentationml.presentation": "pptx",
"application/vnd.oasis.opendocument.text": "odt",
"application/vnd.oasis.opendocument.spreadsheet": "ods",
"application/vnd.oasis.opendocument.presentation": "odp",
}
// the first bytes of an OLE compound file (the legacy Word, Excel and PowerPoint formats), and the stream names that
// tell them apart, as UTF-16 (MS-CFB: directory entry names are UTF-16LE).
var oleMagic = []byte{0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1}
func utf16le(s string) []byte {
var b []byte
for _, r := range s {
b = append(b, byte(r), byte(r>>8))
}
return b
}
// ConvertibleType is the type a document is, among the converter's twelve, told by its bytes and, for a document that
// came from a web server, by its Content-Type; "" when it is none of them. A document that is a PDF or a JPEG is
// not asked about: the printer takes it as it is.
func ConvertibleType(doc []byte, contentType string) string {
if i := strings.IndexByte(contentType, ';'); i >= 0 {
contentType = contentType[:i]
}
byHeader := contentTypes[strings.ToLower(strings.TrimSpace(contentType))]
switch {
case bytes.HasPrefix(doc, []byte("PK\x03\x04")):
if t := zipType(doc); t != "" {
return t
}
return ""
case bytes.HasPrefix(doc, oleMagic):
for name, t := range map[string]string{"WordDocument": "doc", "Workbook": "xls", "PowerPoint Document": "ppt"} {
if bytes.Contains(doc, utf16le(name)) {
return t
}
}
return ""
case bytes.HasPrefix(doc, []byte(`{\rtf`)):
return "rtf"
}
if byHeader == "html" || looksLikeHTML(doc) {
if utf8.Valid(doc) && !bytes.Contains(doc, []byte{0}) {
return "html"
}
return ""
}
if utf8.Valid(doc) && !bytes.Contains(doc, []byte{0}) && len(doc) > 0 {
return "txt"
}
return ""
}
func looksLikeHTML(doc []byte) bool {
head := doc
if len(head) > 512 {
head = head[:512]
}
s := strings.ToLower(strings.TrimLeft(strings.TrimPrefix(string(head), "\xef\xbb\xbf"), " \t\r\n"))
return strings.HasPrefix(s, "<!doctype html") || strings.HasPrefix(s, "<html")
}
// zipType tells the packages apart by their parts: OOXML by its main part (ECMA-376 Part 2), OpenDocument by its
// first file `mimetype` (ODF 1.2 Part 3 §3.3).
func zipType(doc []byte) string {
zr, err := zip.NewReader(bytes.NewReader(doc), int64(len(doc)))
if err != nil {
return ""
}
for _, f := range zr.File {
switch f.Name {
case "word/document.xml":
return "docx"
case "xl/workbook.xml":
return "xlsx"
case "ppt/presentation.xml":
return "pptx"
case "mimetype":
rc, err := f.Open()
if err != nil {
return ""
}
mt, _ := io.ReadAll(io.LimitReader(rc, 256))
rc.Close()
switch strings.TrimSpace(string(mt)) {
case "application/vnd.oasis.opendocument.text":
return "odt"
case "application/vnd.oasis.opendocument.spreadsheet":
return "ods"
case "application/vnd.oasis.opendocument.presentation":
return "odp"
}
}
}
return ""
}
// toPDF turns a stored document of a converter type into a stored PDF and answers the PDF and its key. contentType is
// the media type a web server gave, or "".
func (w *deviceWorker) toPDF(ctx context.Context, objects Objects, key string, doc []byte, contentType string) ([]byte, string, error) {
typ := ConvertibleType(doc, contentType)
if typ == "" {
return nil, "", fmt.Errorf("the document is neither a PDF nor a JPEG, nor a type the converter takes (%s): nothing is converted or printed",
strings.Join(ConvertTypes, ", "))
}
signer, ok := objects.(Presigner)
if !ok {
return nil, "", errors.New("the bucket cannot sign a link, so the document cannot be converted")
}
if w.Convert == nil {
return nil, "", errors.New("this process has no way to call the document converter")
}
sum := sha256Hex(doc)
pdfKey := sum + ".pdf"
source, err := signer.Link(http.MethodGet, key, objectlink.MaxExpires)
if err != nil {
return nil, "", err
}
target, err := signer.Link(http.MethodPut, pdfKey, objectlink.MaxExpires)
if err != nil {
return nil, "", err
}
raw, err := w.Convert(map[string]any{"source": source, "type": typ, "target": target, "sha256": sum})
if err != nil {
return nil, "", fmt.Errorf("the document (%s) could not be converted: %w", typ, err)
}
// The converter answers within 25 s, the tool runner cutting every call at 30 s; a conversion not done by then is
// answered as converting with its ask id, and is read again with convert {ask}, each call under 25 s, for as long
// as the device process follows a conversion.
follow := w.ConvertFollow
if follow <= 0 {
follow = ConvertFollow
}
until := time.Now().Add(follow)
var ans struct {
SHA256 string `json:"sha256"`
Error string `json:"error"`
State string `json:"state"`
Ask string `json:"ask"`
}
for {
ans.State, ans.Ask, ans.SHA256, ans.Error = "", "", "", ""
if err := json.Unmarshal(raw, &ans); err != nil {
return nil, "", fmt.Errorf("the converter's answer is not JSON: %w", err)
}
if ans.State != "converting" {
break
}
if ans.Ask == "" || !time.Now().Before(until) {
return nil, "", fmt.Errorf("the document (%s) was still converting after %s (ask %s): nothing is printed", typ, follow, ans.Ask)
}
if raw, err = w.Convert(map[string]any{"ask": ans.Ask}); err != nil {
return nil, "", fmt.Errorf("the document (%s) could not be converted: %w", typ, err)
}
}
if ans.Error != "" {
return nil, "", fmt.Errorf("the document (%s) could not be converted: %s", typ, ans.Error)
}
pdf, err := objects.Get(ctx, pdfKey)
if err != nil {
return nil, "", fmt.Errorf("the converter answered, but no PDF is kept as %s: %w", pdfKey, err)
}
if got := sha256Hex(pdf); !strings.EqualFold(got, ans.SHA256) {
return nil, "", fmt.Errorf("the converter says it put a PDF of SHA-256 %s, but the bucket holds one of %s as %s: nothing is printed", ans.SHA256, got, pdfKey)
}
if _, _, err := SniffFormat(pdf); err != nil {
return nil, "", fmt.Errorf("the converter's result %s: %w", pdfKey, err)
}
return pdf, pdfKey, nil
}
+268
View File
@@ -0,0 +1,268 @@
package main
import (
"archive/zip"
"bytes"
"context"
"encoding/json"
"errors"
"net/url"
"os"
"strconv"
"strings"
"testing"
"time"
)
// docx builds a minimal Word package: the parts ECMA-376 Part 2 requires of one ([Content_Types].xml, the package
// relationships and the main part word/document.xml). No real Word file is captured; the layout is the standard's.
func docx(t *testing.T) []byte {
t.Helper()
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
for name, body := range map[string]string{
"[Content_Types].xml": `<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"/>`,
"_rels/.rels": `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"/>`,
"word/document.xml": `<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body/></w:document>`,
} {
w, err := zw.Create(name)
if err != nil {
t.Fatal(err)
}
w.Write([]byte(body))
}
zw.Close()
return buf.Bytes()
}
// converterFake stands for document-converter.convert: it records the call and, as the converter process would, puts a
// PDF in the bucket under the key the target link names and answers its size and SHA-256.
type converterFake struct {
r *printerRig
calls []map[string]any
pdf []byte
answer func(body map[string]any, stored []byte) (json.RawMessage, error)
}
func withConverter(r *printerRig, pdf []byte) *converterFake {
c := &converterFake{r: r, pdf: pdf}
r.worker.Convert = func(body map[string]any) (json.RawMessage, error) {
c.calls = append(c.calls, body)
if body["target"] != nil { // a start puts the PDF; a read of an ask (convert {ask}) puts nothing
target, _ := url.Parse(body["target"].(string))
r.objects.Put(context.Background(), strings.TrimPrefix(target.Path, "/"), "application/pdf", c.pdf)
}
if c.answer != nil {
return c.answer(body, c.pdf)
}
return json.Marshal(map[string]any{"size": len(c.pdf), "sha256": sha256Hex(c.pdf)})
}
return c
}
// 1600/AC1: a stored .docx is converted: document-converter.convert is called with a GET link on the source and a PUT
// link on <sha>.pdf, both signed in the bucket and expiring within ten minutes, and the PDF is printed.
func TestAStoredDocxIsConvertedThenPrinted(t *testing.T) {
fake := newIPPFake(t, 51, 5)
r := newRig(t, `{"printer":"192.0.2.10"}`, "pw")
doc := docx(t)
key := sha256Hex(doc)
r.objects.Put(t.Context(), key, "application/octet-stream", doc)
pdf := pdfOf(2500)
conv := withConverter(r, pdf)
got, err := r.call(t, "printer.print", map[string]any{"object": key})
if err != nil {
t.Fatal(err)
}
if len(conv.calls) != 1 {
t.Fatalf("%d converter calls", len(conv.calls))
}
call := conv.calls[0]
src, _ := url.Parse(call["source"].(string))
dst, _ := url.Parse(call["target"].(string))
if src.Path != "/"+key || dst.Path != "/"+key+".pdf" || call["type"] != "docx" || call["sha256"] != key {
t.Errorf("call = %v", call)
}
for _, u := range []*url.URL{src, dst} {
if secs, _ := strconv.Atoi(u.Query().Get("X-Amz-Expires")); secs < 1 || time.Duration(secs)*time.Second > 10*time.Minute {
t.Errorf("%s lives %d s", u, secs)
}
}
if strings.Join(r.objects.links, ",") != "GET "+key+" 600,PUT "+key+".pdf 600" {
t.Errorf("links signed: %v", r.objects.links)
}
m := got.(map[string]any)
if m["job-id"] != float64(51) || m["format"] != "application/pdf" || m["object"] != key+".pdf" || m["converted-from"] != key {
t.Errorf("answer = %v", m)
}
if _, sent := fake.request(t); string(sent) != string(pdf) {
t.Error("the printer was not sent the converted PDF")
}
}
// 1600/AC2: a converter answer whose SHA-256 differs from the stored PDF's is refused and nothing is printed.
func TestAConverterAnswerOfAnotherHashPrintsNothing(t *testing.T) {
fake := newIPPFake(t, 1, 3)
r := newRig(t, `{"printer":"192.0.2.10"}`, "pw")
doc := docx(t)
r.objects.Put(t.Context(), sha256Hex(doc), "application/octet-stream", doc)
conv := withConverter(r, pdfOf(900))
conv.answer = func(map[string]any, []byte) (json.RawMessage, error) {
return json.Marshal(map[string]any{"size": 900, "sha256": sha256Hex([]byte("another pdf"))})
}
_, err := r.call(t, "printer.print", map[string]any{"object": sha256Hex(doc)})
if err == nil || !strings.Contains(err.Error(), "nothing is printed") {
t.Fatalf("error = %v", err)
}
if fake.body != nil {
t.Error("something was printed")
}
}
// 1600/AC3: a converter error is answered with its code and meaning.
func TestAConverterErrorIsAnsweredWithItsCodeAndMeaning(t *testing.T) {
fake := newIPPFake(t, 1, 3)
r := newRig(t, `{"printer":"192.0.2.10"}`, "pw")
doc := docx(t)
r.objects.Put(t.Context(), sha256Hex(doc), "application/octet-stream", doc)
conv := withConverter(r, pdfOf(10))
conv.answer = func(map[string]any, []byte) (json.RawMessage, error) {
return nil, errors.New("the document server refused the conversion, error -4: error while downloading the document file to be converted")
}
_, err := r.call(t, "printer.print", map[string]any{"object": sha256Hex(doc)})
if err == nil || !strings.Contains(err.Error(), "-4: error while downloading the document file to be converted") {
t.Fatalf("error = %v", err)
}
// An error the converter wrote in its answer is the same.
conv.answer = func(map[string]any, []byte) (json.RawMessage, error) {
return json.RawMessage(`{"error": "-3: conversion error"}`), nil
}
if _, err := r.call(t, "printer.print", map[string]any{"object": sha256Hex(doc)}); err == nil || !strings.Contains(err.Error(), "-3: conversion error") {
t.Fatalf("error = %v", err)
}
if fake.body != nil {
t.Error("something was printed")
}
}
// 1600/AC4: a type that is neither PDF, JPEG nor one of the converter's twelve is refused naming the types taken, and
// convert is not called.
func TestAnotherTypeIsRefusedNamingTheTypesTaken(t *testing.T) {
fake := newIPPFake(t, 1, 3)
r := newRig(t, `{"printer":"192.0.2.10"}`, "pw")
exe := []byte("MZ\x90\x00\x03\x00\x00\x00\x04\x00\x00\x00\xff\xff\x00\x00")
r.objects.Put(t.Context(), sha256Hex(exe), "application/octet-stream", exe)
conv := withConverter(r, pdfOf(10))
_, err := r.call(t, "printer.print", map[string]any{"object": sha256Hex(exe)})
if err == nil || !strings.Contains(err.Error(), "docx, doc, xlsx, xls, pptx, ppt, odt, ods, odp, rtf, txt, html") {
t.Fatalf("error = %v", err)
}
if len(conv.calls) != 0 || fake.body != nil {
t.Errorf("%d converter calls, printed %v", len(conv.calls), fake.body != nil)
}
}
// 1600: each of the twelve types is told by its bytes (and a web page also by its Content-Type).
func TestTheTypesAreToldByTheirBytes(t *testing.T) {
ole := func(stream string) []byte {
return append(append([]byte{}, oleMagic...), append(make([]byte, 64), utf16le(stream)...)...)
}
odf := func(mime string) []byte {
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
w, _ := zw.Create("mimetype")
w.Write([]byte(mime))
zw.Close()
return buf.Bytes()
}
ooxml := func(part string) []byte {
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
w, _ := zw.Create(part)
w.Write([]byte("<x/>"))
zw.Close()
return buf.Bytes()
}
for want, c := range map[string]struct {
doc []byte
ct string
}{
"docx": {docx(t), ""}, "xlsx": {ooxml("xl/workbook.xml"), ""}, "pptx": {ooxml("ppt/presentation.xml"), ""},
"doc": {ole("WordDocument"), ""}, "xls": {ole("Workbook"), ""}, "ppt": {ole("PowerPoint Document"), ""},
"odt": {odf("application/vnd.oasis.opendocument.text"), ""}, "ods": {odf("application/vnd.oasis.opendocument.spreadsheet"), ""},
"odp": {odf("application/vnd.oasis.opendocument.presentation"), ""},
"rtf": {[]byte(`{\rtf1\ansi hello}`), ""}, "txt": {[]byte("just some words\n"), ""},
"html": {[]byte("<!DOCTYPE html><html><body>hi</body></html>"), ""}, "html ": {[]byte("<p>a fragment</p>"), "text/html; charset=utf-8"},
} {
if got := ConvertibleType(c.doc, c.ct); got != strings.TrimSpace(want) {
t.Errorf("%s: told as %q", want, got)
}
}
for name, doc := range map[string][]byte{"empty": {}, "binary": {0, 1, 2, 3}, "a zip of nothing known": ooxml("other.xml"), "ole of nothing known": ole("Other")} {
if got := ConvertibleType(doc, ""); got != "" {
t.Errorf("%s: told as %q", name, got)
}
}
}
// 1600/AC5: the device process invokes seat:document-converter.convert.
func TestTheDeviceProcessInvokesTheConverter(t *testing.T) {
for _, r := range readManifest(t).Resources {
if r["id"] == "device" {
inv, _ := r["bus"].(map[string]any)["invokes"].([]any)
if len(inv) != 1 || inv[0] != "seat:document-converter.convert" {
t.Errorf("invokes = %v", inv)
}
return
}
}
t.Fatal("no device process")
}
// 1600 (review round 1): a conversion answered as converting is followed with convert {ask}, each call its own, until
// it is done; one still converting after the device process's bound is not printed, and the process.CallTimeout of the
// SDK is not touched.
func TestAConversionAnsweredAsConvertingIsFollowedWithItsAsk(t *testing.T) {
fake := newIPPFake(t, 61, 5)
r := newRig(t, `{"printer":"192.0.2.10"}`, "pw")
doc := docx(t)
key := sha256Hex(doc)
r.objects.Put(t.Context(), key, "application/octet-stream", doc)
pdf := pdfOf(700)
conv := withConverter(r, pdf)
rounds := 0
conv.answer = func(body map[string]any, stored []byte) (json.RawMessage, error) {
if body["ask"] != nil {
rounds++
if rounds < 3 {
return json.RawMessage(`{"state": "converting", "ask": "` + body["ask"].(string) + `"}`), nil
}
return json.Marshal(map[string]any{"size": len(stored), "sha256": sha256Hex(stored)})
}
return json.RawMessage(`{"state": "converting", "ask": "a1b2c3a1b2c3a1b2c3a1b2c3"}`), nil
}
if _, err := r.call(t, "printer.print", map[string]any{"object": key}); err != nil {
t.Fatal(err)
}
if len(conv.calls) != 4 || conv.calls[1]["ask"] != "a1b2c3a1b2c3a1b2c3a1b2c3" || conv.calls[1]["source"] != nil {
t.Errorf("calls = %v", conv.calls)
}
if _, sent := fake.request(t); string(sent) != string(pdf) {
t.Error("the converted PDF was not printed")
}
// Always converting: given up at the bound, nothing printed.
fake.body = nil
r.worker.ConvertFollow = 30 * time.Millisecond
conv.answer = func(body map[string]any, _ []byte) (json.RawMessage, error) {
time.Sleep(5 * time.Millisecond)
return json.RawMessage(`{"state": "converting", "ask": "a1b2c3a1b2c3a1b2c3a1b2c3"}`), nil
}
if _, err := r.call(t, "printer.print", map[string]any{"object": key}); err == nil || !strings.Contains(err.Error(), "still converting") || fake.body != nil {
t.Errorf("error = %v, printed %v", err, fake.body != nil)
}
src, _ := os.ReadFile("deviceproc.go")
if strings.Contains(string(src), "CallTimeout") {
t.Error("deviceproc.go sets the SDK's call timeout")
}
}
+343
View File
@@ -0,0 +1,343 @@
package main
import (
"bytes"
"fmt"
"image"
"image/color"
"image/jpeg"
"sort"
)
// Cropping cuts the document out of the platen. The scanner sends the whole glass, a document in one corner
// and the lid's white everywhere else. The lid's level is measured where no document can be (the corner
// farthest from the origin), a pixel is document when it is darker than the lid by a margin, and the rows and
// columns holding enough document pixels give the box (ADR 0464).
const (
// lidBlock is the side of the corner block the lid's level is measured on.
lidBlock = 300
// darker is how much darker than the lid a pixel is document: paper on a white lid is a level or two
// under it, an edge shadow far more.
darker = 1
// rowShare is the fraction of a row's (column's) length that must be document for the row (column) to
// count: specks of dust stay under it.
rowShare = 0.05
// margin is the pixels kept around the box.
margin = 6
// wholeShare is the fraction of the platen a box may fill before it is no crop at all.
wholeShare = 0.9
// jpegQuality is the quality the cropped image is encoded with.
jpegQuality = 90
)
// gray is the pixel's level, 0 to 255.
func grayAt(img image.Image, x, y int) int {
switch m := img.(type) {
case *image.YCbCr:
return int(m.Y[m.YOffset(x, y)])
case *image.Gray:
return int(m.Pix[m.PixOffset(x, y)])
}
return int(color.GrayModel.Convert(img.At(x, y)).(color.Gray).Y)
}
// Box finds the document's box in the image, and whether there is one: false when no document is found or
// when it fills (almost) the platen.
func Box(img image.Image) (image.Rectangle, bool) {
b := img.Bounds()
w, h := b.Dx(), b.Dy()
if w < 2*margin || h < 2*margin {
return b, false
}
limit := lidLimit(img)
cols := make([]int, w)
rows := make([]int, h)
for y := b.Min.Y; y < b.Max.Y; y++ {
for x := b.Min.X; x < b.Max.X; x++ {
if grayAt(img, x, y) < limit {
cols[x-b.Min.X]++
rows[y-b.Min.Y]++
}
}
}
x0, x1 := span(cols, int(rowShare*float64(h)))
y0, y1 := span(rows, int(rowShare*float64(w)))
if x0 < 0 || y0 < 0 {
return b, false
}
box := image.Rect(max(0, x0-margin), max(0, y0-margin), min(w, x1+1+margin), min(h, y1+1+margin)).Add(b.Min)
if float64(box.Dx()*box.Dy()) >= wholeShare*float64(w*h) {
return b, false
}
return box, true
}
// lidLimit is the level under which a pixel is document: the lid's level, measured in the corner block farthest
// from the origin, less darker.
func lidLimit(img image.Image) int {
b := img.Bounds()
w, h := b.Dx(), b.Dy()
side := lidBlock
if side > w/2 {
side = w / 2
}
if side > h/2 {
side = h / 2
}
sum := 0
for y := b.Max.Y - side; y < b.Max.Y; y++ {
for x := b.Max.X - side; x < b.Max.X; x++ {
sum += grayAt(img, x, y)
}
}
lid := int(float64(sum)/float64(side*side) + 0.5)
return lid - darker
}
// span is the first and last index whose count is over the floor, or -1 when none is.
func span(counts []int, floor int) (int, int) {
first, last := -1, -1
for i, c := range counts {
if c > floor {
if first < 0 {
first = i
}
last = i
}
}
return first, last
}
// Cropped is what cropping a scan gave.
type Cropped struct {
// JPEG is the image to file: the scanner's own bytes when kept whole, a re-encoded crop otherwise.
JPEG []byte
Width, Height int
Cropped bool
}
// Crop cuts the document out of a scanned JPEG when crop is set and a document is found; otherwise the whole
// image is kept, its bytes untouched, and Cropped is false.
func Crop(scan []byte, crop bool) (Cropped, error) {
img, err := jpeg.Decode(bytes.NewReader(scan))
if err != nil {
return Cropped{}, fmt.Errorf("the scanned image cannot be read: %w", err)
}
b := img.Bounds()
whole := Cropped{JPEG: scan, Width: b.Dx(), Height: b.Dy()}
if !crop {
return whole, nil
}
box, found := Box(img)
if !found {
return whole, nil
}
sub, ok := img.(interface {
SubImage(image.Rectangle) image.Image
})
if !ok {
return whole, nil
}
var out bytes.Buffer
if err := jpeg.Encode(&out, sub.SubImage(box), &jpeg.Options{Quality: jpegQuality}); err != nil {
return Cropped{}, fmt.Errorf("the cropped image cannot be encoded: %w", err)
}
return Cropped{JPEG: out.Bytes(), Width: box.Dx(), Height: box.Dy(), Cropped: true}, nil
}
// Several documents on the glass (issue 1528). Where the rows-and-columns box of Box would be one image around
// all of them, Boxes finds each document apart from the others: the picture is cut into blocks of about a
// millimetre, a block is document when a quarter of its pixels are darker than the lid (dust is not), blocks
// within gapMM of each other are one region (so a white area inside a document, or a gap in it, does not split
// it), and a region of at least minMM on both sides is a document. Regions whose boxes touch or overlap
// (a text block inside a document's frame) are one document.
const (
// blockMM is the side of a block, in millimetres.
blockMM = 1.0
// blockShare is the fraction of a block's pixels that must be document for the block to count.
blockShare = 0.25
// gapMM is how near two regions must be to be one document, in millimetres: documents lying farther apart than
// this are separate ones.
gapMM = 10.0
// minMM is the least a region may measure, on both sides, to be a document and not a speck or a stain.
minMM = 20.0
)
// Boxes is the box of each separate document on the platen, in reading order (top to bottom by the box's top,
// then left to right), at the image's resolution dpi. It is Box's white-detection (the lid's level and darker) on
// a grid of blocks. A box that fills (almost) the platen is no crop: then the whole image is the answer, as one
// box with found false.
func Boxes(img image.Image, dpi int) ([]image.Rectangle, bool) {
b := img.Bounds()
w, h := b.Dx(), b.Dy()
if dpi <= 0 || w < 2*margin || h < 2*margin {
return []image.Rectangle{b}, false
}
bs := max(2, int(blockMM*float64(dpi)/25.4+0.5))
gw, gh := (w+bs-1)/bs, (h+bs-1)/bs
limit := lidLimit(img)
doc := make([]bool, gw*gh)
for gy := 0; gy < gh; gy++ {
for gx := 0; gx < gw; gx++ {
dark, total := 0, 0
for y := b.Min.Y + gy*bs; y < min(b.Min.Y+(gy+1)*bs, b.Max.Y); y++ {
for x := b.Min.X + gx*bs; x < min(b.Min.X+(gx+1)*bs, b.Max.X); x++ {
total++
if grayAt(img, x, y) < limit {
dark++
}
}
}
doc[gy*gw+gx] = float64(dark) >= blockShare*float64(total)
}
}
// Dust and the ringing of the JPEG around it make single blocks, or a pair, of "document": an opening (every
// block must have all eight neighbours document to survive, and survivors give back their neighbours that are
// document) removes what is not at least three blocks across.
eroded := make([]bool, gw*gh)
for gy := 1; gy < gh-1; gy++ {
for gx := 1; gx < gw-1; gx++ {
all := true
for y := gy - 1; y <= gy+1 && all; y++ {
for x := gx - 1; x <= gx+1; x++ {
if !doc[y*gw+x] {
all = false
break
}
}
}
eroded[gy*gw+gx] = all
}
}
opened := make([]bool, gw*gh)
for gy := 0; gy < gh; gy++ {
for gx := 0; gx < gw; gx++ {
if !eroded[gy*gw+gx] {
continue
}
for y := gy - 1; y <= gy+1; y++ {
for x := gx - 1; x <= gx+1; x++ {
opened[y*gw+x] = true
}
}
}
}
doc = opened
// Join the blocks within the gap: a block is near when it is within gapBlocks/2 of a document block in each
// direction (so two blocks up to gapBlocks apart meet), then the nearby blocks are labelled in 4-connected
// regions; each region's box is that of the document blocks in it.
radius := int(gapMM * float64(dpi) / 25.4 / float64(bs) / 2)
near := make([]bool, gw*gh)
for gy := 0; gy < gh; gy++ {
for gx := 0; gx < gw; gx++ {
if !doc[gy*gw+gx] {
continue
}
for y := max(0, gy-radius); y <= min(gh-1, gy+radius); y++ {
for x := max(0, gx-radius); x <= min(gw-1, gx+radius); x++ {
near[y*gw+x] = true
}
}
}
}
seen := make([]bool, gw*gh)
var boxes []image.Rectangle
for start := range near {
if !near[start] || seen[start] {
continue
}
minX, minY, maxX, maxY := gw, gh, -1, -1
stack := []int{start}
seen[start] = true
for len(stack) > 0 {
c := stack[len(stack)-1]
stack = stack[:len(stack)-1]
cx, cy := c%gw, c/gw
if doc[c] {
minX, minY, maxX, maxY = min(minX, cx), min(minY, cy), max(maxX, cx), max(maxY, cy)
}
for _, d := range [4][2]int{{1, 0}, {-1, 0}, {0, 1}, {0, -1}} {
x, y := cx+d[0], cy+d[1]
if x < 0 || y < 0 || x >= gw || y >= gh || !near[y*gw+x] || seen[y*gw+x] {
continue
}
seen[y*gw+x] = true
stack = append(stack, y*gw+x)
}
}
if maxX < 0 {
continue
}
r := image.Rect(minX*bs, minY*bs, (maxX+1)*bs, (maxY+1)*bs)
if float64(r.Dx())/float64(dpi)*25.4 < minMM || float64(r.Dy())/float64(dpi)*25.4 < minMM {
continue
}
boxes = append(boxes, r)
}
// Boxes that touch or overlap are one document: a frame and the text inside it, or a faint paper whose
// parts the lid's white hides, are one however far apart their blocks lie.
kept := boxes
for merged := true; merged; {
merged = false
for i := 0; i < len(kept) && !merged; i++ {
for j := i + 1; j < len(kept); j++ {
if kept[i].Overlaps(kept[j]) {
kept[i] = kept[i].Union(kept[j])
kept = append(kept[:j], kept[j+1:]...)
merged = true
break
}
}
}
}
if len(kept) == 0 {
return []image.Rectangle{b}, false
}
out := make([]image.Rectangle, len(kept))
for i, r := range kept {
out[i] = image.Rect(max(0, r.Min.X-margin), max(0, r.Min.Y-margin), min(w, r.Max.X+margin), min(h, r.Max.Y+margin)).Add(b.Min)
}
sort.SliceStable(out, func(i, j int) bool {
if out[i].Min.Y != out[j].Min.Y {
return out[i].Min.Y < out[j].Min.Y
}
return out[i].Min.X < out[j].Min.X
})
if len(out) == 1 && float64(out[0].Dx()*out[0].Dy()) >= wholeShare*float64(w*h) {
return []image.Rectangle{b}, false
}
return out, true
}
// CropAll cuts each separate document out of a scanned glass JPEG when crop is set and there are two or more; one
// document, none or crop off is Crop's answer, exactly as before. dpi is the scan's resolution.
func CropAll(scan []byte, crop bool, dpi int) ([]Cropped, error) {
one, err := Crop(scan, crop)
if err != nil || !crop {
return []Cropped{one}, err
}
img, err := jpeg.Decode(bytes.NewReader(scan))
if err != nil {
return nil, fmt.Errorf("the scanned image cannot be read: %w", err)
}
boxes, found := Boxes(img, dpi)
if !found || len(boxes) < 2 {
return []Cropped{one}, nil
}
sub, ok := img.(interface {
SubImage(image.Rectangle) image.Image
})
if !ok {
return []Cropped{one}, nil
}
out := make([]Cropped, 0, len(boxes))
for _, box := range boxes {
var buf bytes.Buffer
if err := jpeg.Encode(&buf, sub.SubImage(box), &jpeg.Options{Quality: jpegQuality}); err != nil {
return nil, fmt.Errorf("the cropped image cannot be encoded: %w", err)
}
out = append(out, Cropped{JPEG: buf.Bytes(), Width: box.Dx(), Height: box.Dy(), Cropped: true})
}
return out, nil
}
+229
View File
@@ -0,0 +1,229 @@
package main
import (
"bytes"
"image"
"image/color"
"image/draw"
"image/jpeg"
"math/rand"
"testing"
)
// lidWhite is a w x h gray image of the lid's white, with a few specks of dust in one patch.
func lidWhite(w, h int, seed int64) *image.Gray {
g := image.NewGray(image.Rect(0, 0, w, h))
draw.Draw(g, g.Bounds(), &image.Uniform{color.Gray{255}}, image.Point{}, draw.Src)
r := rand.New(rand.NewSource(seed))
for i := 0; i < 40; i++ {
g.SetGray(100+r.Intn(w/4), 100+r.Intn(h/4), color.Gray{120}) // all in one patch, so counting them would make a box
}
return g
}
func encode(t *testing.T, img image.Image) []byte {
t.Helper()
var b bytes.Buffer
if err := jpeg.Encode(&b, img, &jpeg.Options{Quality: 95}); err != nil {
t.Fatal(err)
}
return b.Bytes()
}
// 1440/AC3: the captured receipt, top-left of a 1696x2203 scan at 200 dpi.
func TestCropFindsTheCapturedReceipt(t *testing.T) {
c, err := Crop(fixture(t, "scan.jpg"), true)
if err != nil {
t.Fatal(err)
}
if !c.Cropped {
t.Fatal("cropped = false for a scan with a receipt on it")
}
img, err := jpeg.Decode(bytes.NewReader(fixture(t, "scan.jpg")))
if err != nil {
t.Fatal(err)
}
box, found := Box(img)
if !found {
t.Fatal("no box found")
}
if box.Min.X > 10 || box.Min.Y > 10 {
t.Errorf("box starts at %v, want within 10 px of (0,0)", box.Min)
}
if box.Max.X < 630 || box.Max.X > 660 {
t.Errorf("right edge %d, want 630..660", box.Max.X)
}
if box.Max.Y < 1730 || box.Max.Y > 1810 {
t.Errorf("bottom %d, want 1730..1810", box.Max.Y)
}
if c.Width != box.Dx() || c.Height != box.Dy() {
t.Errorf("cropped image is %dx%d, the box %dx%d", c.Width, c.Height, box.Dx(), box.Dy())
}
got, err := jpeg.DecodeConfig(bytes.NewReader(c.JPEG))
if err != nil || got.Width != box.Dx() || got.Height != box.Dy() {
t.Errorf("the cropped JPEG is %+v (%v), want %dx%d", got, err, box.Dx(), box.Dy())
}
// About 80-84 mm wide at 200 dpi.
if mm := float64(c.Width) / 200 * 25.4; mm < 80 || mm > 84 {
t.Errorf("%.1f mm wide, want 80-84", mm)
}
}
// 1440/AC4: a plain lid with specks has no document.
func TestCropKeepsAPlainLidWhole(t *testing.T) {
scan := encode(t, lidWhite(800, 1100, 1))
c, err := Crop(scan, true)
if err != nil {
t.Fatal(err)
}
if c.Cropped || c.Width != 800 || c.Height != 1100 || !bytes.Equal(c.JPEG, scan) {
t.Fatalf("a plain lid was cropped: %+v", c)
}
}
// 1440/AC4: a document that fills the platen is no crop either.
func TestCropKeepsAFullPlatenDocumentWhole(t *testing.T) {
g := lidWhite(800, 1100, 2)
draw.Draw(g, image.Rect(0, 0, 790, 1090), &image.Uniform{color.Gray{200}}, image.Point{}, draw.Src)
scan := encode(t, g)
c, err := Crop(scan, true)
if err != nil {
t.Fatal(err)
}
if c.Cropped || c.Width != 800 || c.Height != 1100 {
t.Fatalf("a document filling the platen was cropped: %+v", c)
}
}
// 1440/AC4: crop=false keeps the captured scan whole, though it has a document.
func TestCropOffKeepsTheWholeImage(t *testing.T) {
scan := fixture(t, "scan.jpg")
c, err := Crop(scan, false)
if err != nil {
t.Fatal(err)
}
if c.Cropped || c.Width != 1696 || c.Height != 2203 || !bytes.Equal(c.JPEG, scan) {
t.Fatalf("crop=false cropped: %+v", c)
}
}
// 1440/AC3: a document away from the origin is found where it is, not assumed in the corner.
func TestCropFindsADocumentInTheMiddleOfTheGlass(t *testing.T) {
g := lidWhite(800, 1100, 3)
draw.Draw(g, image.Rect(200, 300, 500, 700), &image.Uniform{color.Gray{240}}, image.Point{}, draw.Src)
img, err := jpeg.Decode(bytes.NewReader(encode(t, g)))
if err != nil {
t.Fatal(err)
}
box, found := Box(img)
if !found {
t.Fatal("no box")
}
if box.Min.X < 190 || box.Min.X > 205 || box.Max.X < 495 || box.Max.X > 510 || box.Min.Y < 290 || box.Min.Y > 305 || box.Max.Y < 695 || box.Max.Y > 710 {
t.Fatalf("box = %v, want about (200,300)-(500,700)", box)
}
}
// onWhite is a lid-white image of w x h with the rectangles drawn on it in gray (generated, not captured: the
// thresholds of Boxes are in millimetres, and this is 100 dpi, 1 mm about 4 px).
func onWhite(t *testing.T, seed int64, rects ...image.Rectangle) []byte {
t.Helper()
g := lidWhite(800, 1100, seed)
for _, r := range rects {
draw.Draw(g, r, &image.Uniform{color.Gray{200}}, image.Point{}, draw.Src)
}
return encode(t, g)
}
// 1528: documents lying apart on the white are each cut out, in reading order; one document, as before, is one.
func TestCropAllCutsEachDocumentApartFromTheOthers(t *testing.T) {
a := image.Rect(100, 100, 300, 300) // 51 mm square
b := image.Rect(450, 100, 650, 400) // 38 mm right of a
c := image.Rect(100, 600, 400, 900)
for name, tc := range map[string]struct {
rects []image.Rectangle
want []image.Rectangle
}{
"one": {[]image.Rectangle{a}, nil},
"two": {[]image.Rectangle{b, a}, []image.Rectangle{a, b}},
"three": {[]image.Rectangle{c, b, a}, []image.Rectangle{a, b, c}},
} {
got, err := CropAll(onWhite(t, 5, tc.rects...), true, 100)
if err != nil {
t.Fatal(err)
}
if tc.want == nil {
one, _ := Crop(onWhite(t, 5, a), true)
if len(got) != 1 || !bytes.Equal(got[0].JPEG, one.JPEG) {
t.Errorf("%s: one document is not Crop's answer", name)
}
continue
}
if len(got) != len(tc.want) {
t.Fatalf("%s: %d documents, want %d", name, len(got), len(tc.want))
}
for i, w := range tc.want {
// about the rectangle, within the margin and a block of rounding
if !got[i].Cropped || got[i].Width < w.Dx() || got[i].Width > w.Dx()+2*margin+2*4 || got[i].Height < w.Dy() || got[i].Height > w.Dy()+2*margin+2*4 {
t.Errorf("%s: document %d is %dx%d, want about %dx%d", name, i, got[i].Width, got[i].Height, w.Dx(), w.Dy())
}
cfg, err := jpeg.DecodeConfig(bytes.NewReader(got[i].JPEG))
if err != nil || cfg.Width != got[i].Width {
t.Errorf("%s: document %d JPEG %+v %v", name, i, cfg, err)
}
}
}
}
// 1528: one document with a white area inside it is not split: a frame with an island inside it, a sheet with a
// white band across it narrower than the gap, and specks of dust beside it.
func TestCropAllDoesNotSplitOneDocumentWithWhiteInsideIt(t *testing.T) {
g := lidWhite(800, 1100, 6)
frame := image.Rect(100, 100, 600, 800)
draw.Draw(g, frame, &image.Uniform{color.Gray{200}}, image.Point{}, draw.Src)
draw.Draw(g, frame.Inset(50), &image.Uniform{color.Gray{255}}, image.Point{}, draw.Src) // white inside the frame
draw.Draw(g, image.Rect(300, 400, 400, 500), &image.Uniform{color.Gray{200}}, image.Point{}, draw.Src) // an island in it
got, err := CropAll(encode(t, g), true, 100)
if err != nil {
t.Fatal(err)
}
if len(got) != 1 {
t.Fatalf("a frame with an island inside was cut into %d documents", len(got))
}
g = lidWhite(800, 1100, 7)
draw.Draw(g, image.Rect(100, 100, 500, 500), &image.Uniform{color.Gray{200}}, image.Point{}, draw.Src)
draw.Draw(g, image.Rect(100, 530, 500, 900), &image.Uniform{color.Gray{200}}, image.Point{}, draw.Src) // 30 px = 7.6 mm of white between
got, err = CropAll(encode(t, g), true, 100)
if err != nil || len(got) != 1 {
t.Fatalf("a sheet with a narrow white band across it: %d documents, %v", len(got), err)
}
}
// 1528: a speck smaller than the minimum is no document, and crop off files the whole platen as one.
func TestCropAllIgnoresSpecksAndCropOffIsOneWholeImage(t *testing.T) {
scan := onWhite(t, 8, image.Rect(100, 100, 300, 300), image.Rect(600, 800, 620, 815)) // 5 x 4 mm stain
got, err := CropAll(scan, true, 100)
if err != nil || len(got) != 1 {
t.Fatalf("a stain made %d documents (%v)", len(got), err)
}
two := onWhite(t, 9, image.Rect(100, 100, 300, 300), image.Rect(450, 100, 650, 400))
got, err = CropAll(two, false, 100)
if err != nil || len(got) != 1 || got[0].Cropped {
t.Fatalf("crop off: %d documents, cropped %v", len(got), got[0].Cropped)
}
}
// 1528: the captured scan (one receipt) is one document, the same bytes Crop gives.
func TestCropAllOfTheCapturedReceiptIsCropsAnswer(t *testing.T) {
scan := fixture(t, "scan.jpg")
got, err := CropAll(scan, true, 200)
one, _ := Crop(scan, true)
if err != nil || len(got) != 1 || !bytes.Equal(got[0].JPEG, one.JPEG) {
t.Fatalf("%d documents (%v), want Crop's one", len(got), err)
}
img, _ := jpeg.Decode(bytes.NewReader(scan))
if boxes, _ := Boxes(img, 200); len(boxes) != 1 {
t.Errorf("the receipt is %d regions: %v", len(boxes), boxes)
}
}
@@ -0,0 +1,95 @@
package main
import (
"go/ast"
"go/parser"
"go/token"
"sort"
"strconv"
"testing"
)
// 1528: a tool reads only arguments its input schema declares; the console refuses an undeclared
// one before it reaches the tool (scanner.scan read apart, which its schema never named).
func TestEveryArgumentAToolReadsIsDeclared(t *testing.T) {
fs := token.NewFileSet()
f, err := parser.ParseFile(fs, "main.go", nil, 0)
if err != nil {
t.Fatal(err)
}
checked := 0
ast.Inspect(f, func(n ast.Node) bool {
lit, ok := n.(*ast.CompositeLit)
if !ok {
return true
}
var name string
var input, run ast.Expr
for _, e := range lit.Elts {
kv, ok := e.(*ast.KeyValueExpr)
if !ok {
continue
}
switch k, _ := kv.Key.(*ast.Ident); {
case k == nil:
case k.Name == "Name":
if b, ok := kv.Value.(*ast.BasicLit); ok {
name, _ = strconv.Unquote(b.Value)
}
case k.Name == "Input":
input = kv.Value
case k.Name == "Run":
run = kv.Value
}
}
if name == "" || input == nil || run == nil {
return true
}
declared := map[string]bool{}
ast.Inspect(input, func(m ast.Node) bool {
kv, ok := m.(*ast.KeyValueExpr)
if !ok {
return true
}
if k, ok := kv.Key.(*ast.BasicLit); ok && k.Value == `"properties"` {
if props, ok := kv.Value.(*ast.CompositeLit); ok {
for _, p := range props.Elts {
if pkv, ok := p.(*ast.KeyValueExpr); ok {
if b, ok := pkv.Key.(*ast.BasicLit); ok {
s, _ := strconv.Unquote(b.Value)
declared[s] = true
}
}
}
}
return false
}
return true
})
var missing []string
ast.Inspect(run, func(m ast.Node) bool {
ix, ok := m.(*ast.IndexExpr)
if !ok {
return true
}
if id, ok := ix.X.(*ast.Ident); ok && id.Name == "args" {
if b, ok := ix.Index.(*ast.BasicLit); ok && b.Kind == token.STRING {
s, _ := strconv.Unquote(b.Value)
if !declared[s] {
missing = append(missing, s)
}
}
}
return true
})
checked++
if len(missing) > 0 {
sort.Strings(missing)
t.Errorf("%s reads %v, which its input schema does not declare", name, missing)
}
return false
})
if checked < 5 {
t.Fatalf("checked %d tools; the test no longer finds the tool literals", checked)
}
}
@@ -0,0 +1,70 @@
package main
// The tools never open the admin password (novox/hq ADR 0436 §12, ADR 0467 clause 4): only the device process
// reads it, as its own account, brother-device. The build seat runs every test under one account, so no
// permission test can show the split; this is the file-level proxy mesh-mail's secret_boundary_test.go uses
// instead: an AST check that the tools' files import no password-reading package and name no password file.
import (
"go/parser"
"go/token"
"os"
"strings"
"testing"
)
const secretPackage = "brother/cmd/brother/internal/secret"
// toolFiles are the files the tools' bundle runs (the scanner's, the printer.status's and the printer.settings'
// ask), as opposed to the device process's: deviceproc.go, admin.go, pin.go and html.go.
var toolFiles = []string{"main.go", "printer.go", "scan.go", "settings.go", "ipp.go", "escl.go", "file.go", "crop.go", "pdf.go", "parts.go", "certcheck.go"}
// 1453: none of the tools' files imports the password-reading package or names the password's file.
func TestTheToolsNeverOpenTheAdminPassword(t *testing.T) {
for _, file := range toolFiles {
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, file, nil, parser.ImportsOnly)
if err != nil {
t.Fatalf("%s: %v", file, err)
}
for _, imp := range f.Imports {
if strings.Trim(imp.Path.Value, `"`) == secretPackage {
t.Errorf("%s imports %s, which only the device process may (ADR 0436 §12)", file, secretPackage)
}
}
src, err := os.ReadFile(file)
if err != nil {
t.Fatal(err)
}
for _, bad := range []string{`Getenv("BROTHER_ADMIN_PASSWORD")`, `Getenv("BROTHER_STORE")`, "ReadStoreFacts", "NewS3", "StoreFacts"} {
if strings.Contains(string(src), bad) {
t.Errorf("%s names %s: the password's and the bucket key's files are the device process's alone (ADR 0468 clause 1)", file, bad)
}
}
}
}
// 1453: the device process does read it through the package, and the package is the one place a password file is
// opened.
func TestTheAdminProcessReadsThePasswordThroughThePackage(t *testing.T) {
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, "deviceproc.go", nil, parser.ImportsOnly)
if err != nil {
t.Fatal(err)
}
found := false
for _, imp := range f.Imports {
if strings.Trim(imp.Path.Value, `"`) == secretPackage {
found = true
}
}
if !found {
t.Errorf("deviceproc.go does not import %s", secretPackage)
}
for _, file := range append(append([]string{}, toolFiles...), "admin.go", "deviceproc.go", "pin.go", "html.go", "objects.go", "capture.go") {
src, _ := os.ReadFile(file)
if strings.Contains(string(src), "os.ReadFile") && file != "settings.go" && file != "file.go" && file != "escl.go" && file != "objects.go" {
t.Errorf("%s reads a file: only settings.json (settings.go) and the scan folder's PDFs are read outside the secret package", file)
}
}
}
@@ -0,0 +1,130 @@
//go:build integration
package main
// The device process over a real bus (novox/hq ADR 0467 clause 4; the shape of mesh-mail's
// sender_integration_test.go, ADR 0436 §7): a JetStream server of the test's own, the work-queue stream
// device-asks and its consumer, the state bucket device-answers, and a fake printer. An ask published on the
// stream is answered in the state under its id and acknowledged. Compiled by the merge gate's
// `go vet -tags integration`; never run on this machine.
import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/process"
"github.com/nats-io/nats-server/v2/server"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
"brother/cmd/brother/internal/secret"
)
// printerAskedSubject is the wire subject the controller composes for the stream's one event:
// "mesh.stream." + module + ".event." + event (mesh-controller internal/broker/modstreams.go).
const printerAskedSubject = "mesh.stream.brother.event." + EventAsked
func newTestBus(t *testing.T) string {
t.Helper()
opts := &server.Options{Host: "127.0.0.1", Port: server.RANDOM_PORT, JetStream: true, StoreDir: t.TempDir(),
NoLog: true, NoSigs: true}
s, err := server.NewServer(opts)
if err != nil {
t.Fatalf("a bus for this test could not be made: %v", err)
}
go s.Start()
if !s.ReadyForConnections(30 * time.Second) {
s.Shutdown()
t.Fatal("a bus for this test did not come up within 30s")
}
t.Cleanup(func() { s.Shutdown(); s.WaitForShutdown() })
return s.ClientURL()
}
// 1453: an ask on device-asks is answered under its id in device-answers by the device process, over the
// printer's pinned HTTPS and the login, and acknowledged; stopping it leaves nothing in hand.
func TestTheAdminProcessAnswersAnAskOverARealBus(t *testing.T) {
url := newTestBus(t)
ctx := context.Background()
nc, err := nats.Connect(url)
if err != nil {
t.Fatal(err)
}
defer nc.Close()
js, err := jetstream.New(nc)
if err != nil {
t.Fatal(err)
}
if _, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: AsksStream, Subjects: []string{printerAskedSubject},
Retention: jetstream.WorkQueuePolicy, Discard: jetstream.DiscardNew, MaxAge: 24 * time.Hour, MaxBytes: 8 << 20}); err != nil {
t.Fatal(err)
}
account := process.Account{URL: url, Node: "g14", Module: "brother", User: "g14.brother." + DeviceProcessID, Password: "pw"}
if _, err := js.CreateConsumer(ctx, AsksStream, jetstream.ConsumerConfig{Durable: account.QueueConsumerName(),
AckPolicy: jetstream.AckExplicitPolicy, AckWait: 30 * time.Second, MaxDeliver: 3}); err != nil {
t.Fatal(err)
}
kv, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: account.Module + "_" + AnswersState, TTL: 10 * time.Minute})
if err != nil {
t.Fatal(err)
}
f := newFakePrinter(t, "B1891", "pw")
dir := t.TempDir()
settings := filepath.Join(dir, "settings.json")
os.WriteFile(settings, []byte(`{"printer":"`+f.host()+`","printer-certificate":"`+f.print()+`"}`), 0o600)
pwFile := filepath.Join(dir, "admin-password.secret")
os.WriteFile(pwFile, []byte("pw\n"), 0o600)
w := &deviceWorker{
Settings: func() (Settings, error) { return LoadSettings(settings) },
Password: func() (string, error) { return secret.Read(pwFile) },
Now: time.Now,
}
runCtx, cancel := context.WithCancel(ctx)
done := make(chan error, 1)
go func() { done <- runDevice(runCtx, account, w) }()
body, _ := json.Marshal(Ask{ID: "ask-1", Op: OpSettings, Page: "/general/other.html", At: time.Now()})
if _, err := js.Publish(ctx, printerAskedSubject, body, jetstream.WithMsgID("ask-1")); err != nil {
t.Fatal(err)
}
var answer string
for deadline := time.Now().Add(15 * time.Second); time.Now().Before(deadline); time.Sleep(50 * time.Millisecond) {
if e, err := kv.Get(ctx, "ask-1"); err == nil {
answer = string(e.Value())
break
}
}
if !strings.Contains(answer, "Synthetic settings") {
t.Fatalf("the answer under ask-1 is %q", answer)
}
if strings.Contains(answer, "shouldnotshow") {
t.Errorf("a password field's value is in the answer")
}
time.Sleep(500 * time.Millisecond)
cancel()
select {
case err := <-done:
if err == nil {
t.Fatal("runDevice returned nil, want ctx.Err() once its context was cancelled")
}
case <-time.After(10 * time.Second):
t.Fatal("runDevice did not stop once its context was cancelled")
}
cons, err := js.Consumer(ctx, AsksStream, account.QueueConsumerName())
if err != nil {
t.Fatal(err)
}
info, err := cons.Info(ctx)
if err != nil {
t.Fatal(err)
}
if info.NumPending != 0 || info.NumAckPending != 0 {
t.Errorf("the ask was not acknowledged: %d pending, %d awaiting an acknowledgement", info.NumPending, info.NumAckPending)
}
}
+816
View File
@@ -0,0 +1,816 @@
package main
// mesh brother device (novox/hq ADR 0467 clause 4, in the shape of ADR 0436 §7): the device process, a process
// with a bus account of its own (brother-device), never a seat. It alone of the module's parts reads the admin
// password and logs in to the printer's admin page; the tools queue an ask on the work-queue stream device-asks
// and read its answer in the state device-answers. The admin password is never in an answer, an event or a log.
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"os/signal"
"slices"
"strings"
"sync"
"syscall"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/objectlink"
"git.novox.be/novox/mesh-sdk/go/process"
"github.com/nats-io/nats.go/jetstream"
"brother/cmd/brother/internal/secret"
)
// Answers is the state the worker writes an answer in, once.
type Answers interface {
Create(key string, value any) (uint64, error)
}
// action is what is done to the queued message.
type action int
const (
actionAck action = iota
actionRetry
actionTerm
// actionSettled says the message was acknowledged already, before the work began: what is not safe to do twice
// (a scan, a print) is acknowledged first, so a slow one is not delivered again after the bus's wait.
actionSettled
)
// pendingUpload is the parts of an upload that have come.
type pendingUpload struct {
parts map[int][]byte
n int
sha string
at time.Time
}
// pendingFor is how long an incomplete upload's parts are kept.
const pendingFor = 15 * time.Minute
// deviceWorker answers the asks: it alone talks to the device (the scanner, the printer's pages and IPP) and to
// the bucket. Settings and the password are read at each ask, so a changed config value or a password given later
// is the next ask's.
type deviceWorker struct {
Settings func() (Settings, error)
Password func() (string, error)
Objects func() (Objects, error)
// Emit says scan.added on the bus under a message id.
Emit func(body map[string]any, id string) error
Answers Answers
Node string
// Capture scans; nil is the device's own (a test puts a stand-in).
Capture func(ctx context.Context, req ScanRequest) ([]Capture, error)
// Convert calls the seat document-converter's convert verb as this process's own account (clause 7); nil: none.
Convert func(body map[string]any) (json.RawMessage, error)
// ConvertFollow is how long a conversion is followed; zero is ConvertFollow (a test makes it shorter).
ConvertFollow time.Duration
// Fetcher is the guarded fetch of a web link (fetch.go); it reads its facts when it is first needed.
Fetcher func() (*Fetcher, error)
// answerPart is the raw size of a document part in an answer; zero is AnswerPartSize (a test makes it larger).
answerPart int
Now func() time.Time
Logf func(format string, a ...any)
mu sync.Mutex
pending map[string]*pendingUpload
// docs serialises the work on open documents.
docs sync.Mutex
}
func (w *deviceWorker) logf(format string, a ...any) {
if w.Logf != nil {
w.Logf(format, a...)
}
}
// tooLargeCode is the bus's error code for a message over the maximum allowed (the state refuses a value so).
const tooLargeCode = 10054
// Handle answers one queued ask. An ask that cannot be read is terminated; an ask too old for anyone to wait for
// is acknowledged and the device not touched; every other outcome, a failure included, is written as the answer
// under the ask's id and the message acknowledged; an answer the state refuses as too large is
// replaced by a small error answer and the message terminated; an answer that could not be written for another
// reason (the store away, a timeout) is redelivered (the bus's limit ends that). early acknowledges the message; it is called before a scan or a print, which are not
// done twice.
func (w *deviceWorker) Handle(body []byte, early func()) (action, time.Duration) {
var ask Ask
if err := json.Unmarshal(body, &ask); err != nil || ask.ID == "" {
w.logf("[brother] device: a queued ask could not be read: %v", err)
return actionTerm, 0
}
if age := w.Now().Sub(ask.At); age > StaleAfter {
w.logf("[brother] device: ask %s is %s old; nobody waits for it, the device is not touched", ask.ID, age.Round(time.Second))
return actionAck, 0
}
if !ask.Deadline.IsZero() && w.Now().After(ask.Deadline) {
// The asker has stopped waiting for this ask to be taken up: nothing is scanned or printed for it (issue
// 1539). The reason is its answer, for a late reader, and the message ends here.
w.logf("[brother] device: ask %s was taken up after its deadline %s; the device is not touched", ask.ID, ask.Deadline.Format(time.RFC3339))
if _, err := w.Answers.Create(ask.ID, map[string]any{"error": "the ask was taken up after its deadline (the device process was busy): nothing was done for it; ask again"}); err != nil && !errors.Is(err, stdio.ErrChanged) {
w.logf("[brother] device: the answer to %s could not be written: %v", ask.ID, err)
}
return actionTerm, 0
}
settled := false
if (ask.Op == OpScan || ask.Op == OpPrint) && early != nil {
early()
settled = true
}
// A scan of the feeder may run as long as FeederMaxBound (the scan itself ends sooner by FeederBound, which
// scales with its pages) and the PDF is then stored; every other ask is done in 5 minutes.
bound := 5 * time.Minute
if ask.Op == OpScan && ask.Source == "feeder" {
bound = FeederMaxBound + 5*time.Minute
}
ctx, cancel := context.WithTimeout(context.Background(), bound)
defer cancel()
var answer map[string]any
if got, err := w.run(ctx, ask); err != nil {
answer = map[string]any{"error": err.Error()}
} else {
answer = got
}
if _, err := w.Answers.Create(ask.ID, answer); err != nil && !errors.Is(err, stdio.ErrChanged) {
w.logf("[brother] device: the answer to %s could not be written: %v", ask.ID, err)
var api *jetstream.APIError
if !errors.As(err, &api) || api.ErrorCode != tooLargeCode {
// The store is away, timed out or cut: the answer may be written next time (a scan is settled already).
if settled {
return actionSettled, 0
}
return actionRetry, 5 * time.Second
}
// The ask ends here: a refused value would be refused again.
// Say the failure as a small error answer, so the tool's wait ends with the reason (issue 1535).
msg := fmt.Sprintf("the answer to this ask could not be written in the state %s: %v", AnswersState, err)
if _, err2 := w.Answers.Create(ask.ID, map[string]any{"error": msg}); err2 != nil && !errors.Is(err2, stdio.ErrChanged) {
w.logf("[brother] device: the failure of %s could not be written either: %v", ask.ID, err2)
}
if settled {
return actionSettled, 0
}
return actionTerm, 0
}
if settled {
return actionSettled, 0
}
return actionAck, 0
}
// run is the operation an ask names.
func (w *deviceWorker) run(ctx context.Context, ask Ask) (map[string]any, error) {
switch ask.Op {
case OpSettings:
return w.read(ctx, ask.Page, ask.Links, ask.Hidden)
case OpScan:
return w.scan(ctx, ask)
case OpDocument:
return w.document(ctx, ask)
case OpUpload:
return w.upload(ctx, ask)
case OpPrint:
return w.print(ctx, ask)
case OpOpen:
return w.openDocuments(ctx)
}
return nil, fmt.Errorf("the ask %s names an operation, %q, the device process does not do", ask.ID, ask.Op)
}
// scan scans the glass (cropped) or the feeder (whole sheets, one PDF of all pages), builds the PDF, keeps it in the bucket under its SHA-256 and says scan.added with
// the object's key (ADR 0468 clause 2), in that order: nothing is said of a scan that is not kept, and nothing is
// kept nor said when the scanner gives no page (1440/AC6). The answer is the event's body with key, size and the
// number of parts.
func (w *deviceWorker) scan(ctx context.Context, ask Ask) (map[string]any, error) {
req, err := ParseScan(ask.Source, ask.Duplex, ask.Apart)
if err != nil {
return nil, err
}
set, err := w.Settings()
if err != nil {
return nil, err
}
if set.Scanner == "" {
return nil, unsetError("scanner")
}
objects, err := w.Objects()
if err != nil {
return nil, err
}
svc := &Service{Settings: set, Node: w.Node, Now: w.Now}
if ask.Document != "" {
return w.scanDocument(ctx, ask, svc, objects)
}
capture := svc.Capture
if w.Capture != nil {
capture = w.Capture
}
caps, err := capture(ctx, req)
if err != nil {
return nil, err
}
return w.keep(ctx, objects, set, caps)
}
// keep stores each captured PDF in the bucket and says scan.added for it, in that order. One document is answered
// as today: the event's body with key, size and the number of parts. Several (documents lying apart on the glass)
// are answered as documents, a list of those, each filed on its own by the tool. A document that could not be kept
// or announced is named in failed and does not hold back the others: what is announced is always filed.
func (w *deviceWorker) keep(ctx context.Context, objects Objects, set Settings, caps []Capture) (map[string]any, error) {
if len(caps) == 0 {
return nil, fmt.Errorf("the scanner gave no document")
}
var docs []map[string]any
var failed []string
for i, c := range caps {
one, err := w.keepOne(ctx, objects, set, c)
if err != nil {
if len(caps) == 1 {
return nil, err
}
failed = append(failed, fmt.Sprintf("document %d: %v", i+1, err))
continue
}
docs = append(docs, one)
}
if len(docs) == 0 {
return nil, fmt.Errorf("no document could be kept: %s", strings.Join(failed, "; "))
}
if len(caps) == 1 {
return docs[0], nil
}
out := map[string]any{"documents": docs}
if len(failed) > 0 {
out["failed"] = failed
}
return out, nil
}
// keepOne keeps one captured PDF and announces it.
func (w *deviceWorker) keepOne(ctx context.Context, objects Objects, set Settings, c Capture) (map[string]any, error) {
// A scan over a document's limit is not kept nor announced: an announced scan is always filed (issue 1539).
if len(c.PDF) > int(objectlink.MaxDocument) {
return nil, fmt.Errorf("the scan's PDF is %d bytes, over the %d a document may be (novox/hq ADR 0468 §3): it is not kept; scan with a lower resolution or fewer sheets",
len(c.PDF), objectlink.MaxDocument)
}
signer, ok := objects.(Presigner)
if !ok {
return nil, errors.New("the bucket cannot sign a link: the scan is not kept")
}
body := c.Body(w.Node, set.ColorMode)
key := body["object"].(string)
if err := Store(ctx, objects, key, "application/pdf", c.PDF); err != nil {
return nil, err
}
if err := w.Emit(body, c.SHA256); err != nil {
return nil, fmt.Errorf("the scan is kept as %s, but %s could not be said on the bus: %w", key, EventAdded, err)
}
// The answer carries a link to read the stored PDF, signed with the binding's key (novox/hq ADR 0468 §4): the tool
// side reads the scan by it and files it; no byte of the document is in the answer.
link, err := signer.Link(http.MethodGet, key, objectlink.MaxExpires)
if err != nil {
return nil, fmt.Errorf("the scan is kept as %s, but a link to it could not be signed: %w", key, err)
}
answer := map[string]any{"key": key, "size": len(c.PDF), "link": link, "link-seconds": int(objectlink.MaxExpires / time.Second)}
for k, v := range body {
answer[k] = v
}
return answer, nil
}
// document is one part of a scan kept in the bucket.
func (w *deviceWorker) document(ctx context.Context, ask Ask) (map[string]any, error) {
if err := checkDocument(ask); err != nil {
return nil, err
}
objects, err := w.Objects()
if err != nil {
return nil, err
}
data, err := objects.Get(ctx, ask.Object)
if err != nil {
return nil, err
}
each := w.answerPart
if each <= 0 {
each = AnswerPartSize
}
part, n, err := cutPartOf(data, ask.Part, each)
if err != nil {
return nil, err
}
return map[string]any{"part": ask.Part, "parts": n, "size": len(data), "sha256": sha256Hex(data),
"data": base64.StdEncoding.EncodeToString(part)}, nil
}
// upload keeps a part, and once all have come joins them, checks the SHA-256 and the format and stores the document
// as uploads/<upload>.<ext>. A wrong SHA-256 or a document that is neither PDF nor JPEG stores nothing, and the
// parts are dropped; a part out of range is refused.
func (w *deviceWorker) upload(ctx context.Context, ask Ask) (map[string]any, error) {
if err := checkUpload(ask); err != nil {
return nil, err
}
raw, _ := base64.StdEncoding.DecodeString(ask.Data)
w.mu.Lock()
if w.pending == nil {
w.pending = map[string]*pendingUpload{}
}
for id, p := range w.pending {
if w.Now().Sub(p.at) > pendingFor {
delete(w.pending, id)
}
}
p := w.pending[ask.Upload]
if p == nil {
p = &pendingUpload{parts: map[int][]byte{}, n: ask.Parts, sha: ask.SHA256}
w.pending[ask.Upload] = p
}
if p.n != ask.Parts || p.sha != ask.SHA256 {
w.mu.Unlock()
return nil, fmt.Errorf("upload %s was begun with %d parts and SHA-256 %s: its parts all say the same", ask.Upload, p.n, p.sha)
}
p.parts[ask.Part] = raw
p.at = w.Now()
if len(p.parts) < p.n {
got := len(p.parts)
w.mu.Unlock()
return map[string]any{"upload": ask.Upload, "complete": false, "received": got, "parts": ask.Parts}, nil
}
delete(w.pending, ask.Upload)
w.mu.Unlock()
ordered := make([][]byte, p.n)
for i := range ordered {
ordered[i] = p.parts[i+1]
}
doc, err := joinParts(ordered, p.sha)
if err != nil {
return nil, fmt.Errorf("upload %s: %w", ask.Upload, err)
}
format, ext, err := SniffFormat(doc)
if err != nil {
return nil, fmt.Errorf("upload %s: %w", ask.Upload, err)
}
objects, err := w.Objects()
if err != nil {
return nil, err
}
key := "uploads/" + ask.Upload + "." + ext
if err := objects.Put(ctx, key, format, doc); err != nil {
return nil, err
}
return map[string]any{"upload": ask.Upload, "complete": true, "object": key, "size": len(doc), "sha256": p.sha}, nil
}
// print fetches a scan or a completed upload from the bucket and sends it to the printer as an IPP Print-Job in
// its own format, and answers the job's id and state. The printer's host is its IPP address; nothing is fetched
// or sent before the ask is checked.
func (w *deviceWorker) print(ctx context.Context, ask Ask) (map[string]any, error) {
if err := checkPrint(&ask); err != nil {
return nil, err
}
set, err := w.Settings()
if err != nil {
return nil, err
}
if set.Printer == "" {
return nil, unsetError("printer")
}
objects, err := w.Objects()
if err != nil {
return nil, err
}
key := ask.Object
var doc []byte
contentType := ""
switch {
case ask.URL != "":
fetched, err := w.fetch(ctx, ask.URL)
if err != nil {
return nil, err
}
// The fetched document is kept once under its SHA-256, as a scan is, and printed as that object.
doc, key, contentType = fetched.Body, sha256Hex(fetched.Body), fetched.ContentType
if err := Store(ctx, objects, key, fetched.ContentType, doc); err != nil {
return nil, err
}
case key == "":
for _, ext := range []string{"pdf", "jpg"} {
k := "uploads/" + ask.Upload + "." + ext
if has, err := objects.Has(ctx, k); err != nil {
return nil, err
} else if has {
key = k
break
}
}
if key == "" {
return nil, fmt.Errorf("upload %s is not complete: no document is kept under it", ask.Upload)
}
fallthrough
default:
var err error
if doc, err = objects.Get(ctx, key); err != nil {
return nil, err
}
}
format, _, err := SniffFormat(doc)
var convertedFrom string
if err != nil {
// Not a PDF or a JPEG: a type the converter takes becomes a PDF first (clause 7), any other is refused.
pdf, pdfKey, cerr := w.toPDF(ctx, objects, key, doc, contentType)
if cerr != nil {
return nil, cerr
}
doc, convertedFrom, key = pdf, key, pdfKey
format = "application/pdf"
}
// The printer is asked what it takes before the job, and a format it does not list is refused with the list
// (novox/hq ADR 0468 clause 5): a printer that cannot be asked prints nothing.
listed, err := FormatsOf(ctx, set.Printer)
if err != nil {
return nil, fmt.Errorf("the printer's document formats could not be read, so nothing is sent: %w", err)
}
if !slices.Contains(listed, format) {
return nil, fmt.Errorf("the printer does not take %s: its document-format-supported lists %s", format, strings.Join(listed, ", "))
}
res, err := PrintJob(ctx, set.Printer, PrintOptions{Format: format, Copies: ask.Copies, Sides: ask.Sides, Media: ask.Media, Name: "brother print"}, doc)
if err != nil {
return nil, err
}
out := map[string]any{"job-id": res.JobID, "job-state": res.State, "job-state-code": res.StateCode,
"job-state-reasons": res.Reasons, "format": format, "copies": ask.Copies, "sides": ask.Sides, "media": ask.Media, "object": key}
if convertedFrom != "" {
out["converted-from"] = convertedFrom
}
return out, nil
}
// fetch gets a web link under the rules of fetch.go.
func (w *deviceWorker) fetch(ctx context.Context, link string) (*Fetched, error) {
if w.Fetcher == nil {
return nil, errors.New("this process has no way to fetch a web link")
}
f, err := w.Fetcher()
if err != nil {
return nil, err
}
return f.Fetch(ctx, link)
}
// read logs in, reads one page of the admin site and logs out. It is read-only: the login (and the logout) are
// the only things it sends, and a password field's value is never in the answer. The printer is not contacted
// before the settings and the password are known to be given (1453/AC4).
func (w *deviceWorker) read(ctx context.Context, page string, links, hidden bool) (map[string]any, error) {
if page == "" {
page = DefaultPage
}
if !validPage(page) {
return nil, fmt.Errorf("page %q is not a page of the admin site: it starts with / and ends in .html", page)
}
set, err := w.Settings()
if err != nil {
return nil, err
}
if err := set.MissingPrinter(); err != nil {
return nil, err
}
pw, err := w.Password()
if err != nil {
return nil, err
}
a, err := NewAdmin(set.Printer, set.PrinterCertificate, pw)
if err != nil {
return nil, err
}
if err := a.Login(ctx); err != nil {
return nil, err
}
// Log out with a context of its own: the ask's may be what ended this.
lctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
body, at, err := a.Get(ctx, page)
if err != nil {
if home, hat, herr := a.Get(lctx, DefaultPage); herr == nil {
a.Logout(lctx, home, hat)
}
var nf *notFoundError
if errors.As(err, &nf) {
return nil, fmt.Errorf("the printer has no page %s", page)
}
return nil, fmt.Errorf("page %s cannot be read: %w", page, err)
}
logout := a.Logout(lctx, body, at)
p := ParsePage(body)
out := map[string]any{
"page": page,
"title": p.Title,
"forms": nonNilForms(withoutHidden(p.Forms, hidden)),
"logout": logout,
}
if links {
out["links"] = nonNil(p.Links)
}
return out, nil
}
// withoutHidden leaves the hidden fields (the CSRF token and the like, noise to a reader) out of the forms
// unless they are asked for.
func withoutHidden(forms []Form, keep bool) []Form {
if keep {
return forms
}
out := make([]Form, 0, len(forms))
for _, f := range forms {
var fields []Field
for _, fl := range f.Fields {
if fl.Type != "hidden" {
fields = append(fields, fl)
}
}
if len(fields) == 0 && len(f.Fields) > 0 {
continue // a form of nothing but hidden fields (the logout form) has nothing to read
}
f.Fields = fields
out = append(out, f)
}
return out
}
func nonNil(l []Link) []Link {
if l == nil {
return []Link{}
}
return l
}
func nonNilForms(f []Form) []Form {
if f == nil {
return []Form{}
}
return f
}
// device is `brother device`: it reads its own bus account, connects, and consumes device-asks until stopped.
// It refuses to start naming what is missing when the account cannot be read, and never logs a password.
func device() int {
settings, passwordFile, brokerFile := os.Getenv("BROTHER_SETTINGS"), os.Getenv("BROTHER_ADMIN_PASSWORD"), os.Getenv("BROTHER_BROKER")
store, fetchFile := os.Getenv("BROTHER_STORE"), os.Getenv("BROTHER_FETCH")
if brokerFile == "" {
fmt.Fprintln(os.Stderr, "[brother] device was started without BROTHER_BROKER, its bus account's file")
return 2
}
account, err := process.ReadAccount(brokerFile)
if err != nil {
fmt.Fprintf(os.Stderr, "[brother] device: %v\n", err)
return 1
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
w := &deviceWorker{
Settings: func() (Settings, error) { return LoadSettings(settings) },
Password: func() (string, error) { return secret.Read(passwordFile) },
Objects: func() (Objects, error) {
facts, err := ReadStoreFacts(store)
if err != nil {
return nil, err
}
return NewS3(facts), nil
},
Fetcher: func() (*Fetcher, error) {
r, err := ReadFetchFacts(fetchFile)
if err != nil {
return nil, err
}
return NewFetcher(r), nil
},
Now: time.Now,
Logf: func(f string, a ...any) { fmt.Fprintf(os.Stderr, f+"\n", a...) },
}
fmt.Fprintf(os.Stderr, "[brother] device is consuming %s as %s\n", AsksStream, account.User)
err = runDevice(ctx, account, w)
if err != nil && ctx.Err() == nil {
fmt.Fprintf(os.Stderr, "[brother] device stopped: %v\n", err)
return 1
}
return 0
}
// runDevice connects as the device process's own account and consumes device-asks until ctx ends, answering each
// ask in the state device-answers on that connection and then settling the message as Handle says. Apart from
// device so a test can drive it over a bus of its own.
func runDevice(ctx context.Context, account process.Account, w *deviceWorker) error {
conn, err := process.Connect(account)
if err != nil {
return fmt.Errorf("the bus cannot be reached: %w", err)
}
defer conn.Close()
w.Answers = conn.State(AnswersState)
w.Convert = func(body map[string]any) (json.RawMessage, error) {
return conn.Call("seat:document-converter.convert", body)
}
if w.Node == "" {
w.Node = account.Node
}
w.Emit = func(body map[string]any, id string) error {
return conn.Emit(EventAdded, body, stdio.EmitOptions{MessageID: id})
}
return conn.Consume(ctx, AsksStream, func(msg *process.Message) {
var settleErr error
act, delay := w.Handle(msg.Body, func() { settleErr = msg.Ack() })
if settleErr != nil {
w.logf("[brother] device: acknowledging %s early: %v", msg.Subject, settleErr)
}
var aerr error
switch act {
case actionAck:
aerr = msg.Ack()
case actionRetry:
aerr = msg.NakWithDelay(delay)
case actionTerm:
aerr = msg.Term()
}
if aerr != nil {
w.logf("[brother] device: settling %s: %v", msg.Subject, aerr)
}
})
}
// An open document (issue 1538) is kept in the bucket, not in memory, so that a restart of the device process
// loses none of its pages: each page's JPEG under pending/<id>/<n>.jpg and a manifest, pending/<id>/manifest.json,
// that says how many pages there are and, once it is closed, the key of its PDF. The pages are not deleted when it
// is closed (the bucket has no delete); they are the bucket's to age out.
const mostDocumentPages = 100
type docPage struct {
Key string `json:"key"`
DPI int `json:"dpi"`
Width int `json:"width"`
Height int `json:"height"`
Cropped bool `json:"cropped"`
}
type docManifest struct {
Started time.Time `json:"started"`
Updated time.Time `json:"updated"` // the last page's time
Pages []docPage `json:"pages"`
Finished string `json:"finished,omitempty"` // the closed document's object key
}
func manifestKey(id string) string { return "pending/" + id + "/manifest.json" }
func loadManifest(ctx context.Context, o Objects, id string) (*docManifest, error) {
has, err := o.Has(ctx, manifestKey(id))
if err != nil {
return nil, err
}
if !has {
return nil, fmt.Errorf("no document %s is open: its id is wrong, or it was closed", id)
}
raw, err := o.Get(ctx, manifestKey(id))
if err != nil {
return nil, err
}
var m docManifest
if err := json.Unmarshal(raw, &m); err != nil {
return nil, fmt.Errorf("document %s: its manifest cannot be read: %w", id, err)
}
if m.Finished != "" {
return nil, fmt.Errorf("document %s is closed already, as %s", id, m.Finished)
}
return &m, nil
}
func saveManifest(ctx context.Context, o Objects, id string, m *docManifest) error {
raw, _ := json.Marshal(m)
return o.Put(ctx, manifestKey(id), "application/json", raw)
}
// scanDocument adds a glass page to an open document (document start opens one, with the ask's id as its id), or
// with finish closes the document into one multi-page PDF, kept and announced once like any scan.
func (w *deviceWorker) scanDocument(ctx context.Context, ask Ask, svc *Service, objects Objects) (map[string]any, error) {
if ask.Source == "feeder" || ask.Duplex {
return nil, fmt.Errorf("a document of several pages is scanned from the glass, page by page; the feeder takes the whole stack in one scan")
}
id := ask.Document
if id == "start" {
if ask.Finish {
return nil, fmt.Errorf("finish closes an open document: name its id, not start")
}
id = ask.ID
}
if !uploadPattern.MatchString(id) {
return nil, fmt.Errorf("document %q is not an id: letters, digits, - and _, at most 64", id)
}
w.docs.Lock()
defer w.docs.Unlock()
var m *docManifest
if ask.Document == "start" {
m = &docManifest{Started: w.Now().UTC(), Updated: w.Now().UTC()}
} else {
var err error
if m, err = loadManifest(ctx, objects, id); err != nil {
return nil, err
}
}
if ask.Finish {
return w.finishDocument(ctx, id, m, svc, objects)
}
if len(m.Pages) >= mostDocumentPages {
return nil, fmt.Errorf("document %s has %d pages, the most one takes: close it", id, len(m.Pages))
}
page, err := svc.GlassPage(ctx)
if err != nil {
return nil, err
}
key := fmt.Sprintf("pending/%s/%03d.jpg", id, len(m.Pages)+1)
if err := objects.Put(ctx, key, "image/jpeg", page.JPEG); err != nil {
return nil, err
}
m.Updated = w.Now().UTC()
m.Pages = append(m.Pages, docPage{Key: key, DPI: page.DPI, Width: page.Width, Height: page.Height, Cropped: page.Cropped})
if err := saveManifest(ctx, objects, id, m); err != nil {
return nil, err
}
return map[string]any{"document": id, "pages": len(m.Pages), "state": "open",
"next": "scanner.scan {document: \"" + id + "\"} adds a page; scanner.scan {document: \"" + id + "\", finish: true} closes it into one PDF"}, nil
}
// finishDocument builds the open document's pages into one PDF and keeps and announces it.
func (w *deviceWorker) finishDocument(ctx context.Context, id string, m *docManifest, svc *Service, objects Objects) (map[string]any, error) {
if len(m.Pages) == 0 {
return nil, fmt.Errorf("document %s has no page", id)
}
pages := make([]PDFPage, len(m.Pages))
for i, p := range m.Pages {
jpg, err := objects.Get(ctx, p.Key)
if err != nil {
return nil, fmt.Errorf("document %s: page %d is not kept: %w", id, i+1, err)
}
pages[i] = PDFPage{JPEG: jpg, DPI: p.DPI}
}
first := m.Pages[0]
c, err := captureOf(pages, Cropped{Width: first.Width, Height: first.Height, Cropped: first.Cropped}, first.DPI, w.Now())
if err != nil {
return nil, err
}
set, err := w.Settings()
if err != nil {
return nil, err
}
answer, err := w.keepOne(ctx, objects, set, c)
if err != nil {
return nil, err
}
m.Finished = answer["key"].(string)
if err := saveManifest(ctx, objects, id, m); err != nil {
return nil, fmt.Errorf("document %s is kept as %s, but could not be marked closed: %w", id, m.Finished, err)
}
// The pages are not needed any more: delete them. A bucket that refuses leaves them, and the answer says so.
for _, p := range m.Pages {
if err := objects.Delete(ctx, p.Key); err != nil {
answer["pending-kept"] = fmt.Sprintf("the pages of document %s could not be deleted from the bucket and stay under pending/%s/: %v", id, id, err)
break
}
}
return answer, nil
}
// openDocuments lists the documents of several pages that are open in the bucket, with the time of each one's last
// page, so that the tool can arm their expiry again after it was restarted.
func (w *deviceWorker) openDocuments(ctx context.Context) (map[string]any, error) {
objects, err := w.Objects()
if err != nil {
return nil, err
}
keys, err := objects.List(ctx, "pending/")
if err != nil {
return nil, err
}
open := []map[string]any{}
for _, k := range keys {
rest, ok := strings.CutPrefix(k, "pending/")
id, name, _ := strings.Cut(rest, "/")
if !ok || name != "manifest.json" || !uploadPattern.MatchString(id) {
continue
}
m, err := loadManifest(ctx, objects, id)
if err != nil {
continue // closed, or unreadable: not open
}
at := m.Updated
if at.IsZero() {
at = m.Started
}
open = append(open, map[string]any{"id": id, "pages": len(m.Pages), "updated": at.UTC().Format(time.RFC3339)})
}
return map[string]any{"open": open}, nil
}
@@ -0,0 +1,574 @@
package main
import (
"bytes"
"context"
"encoding/base64"
"encoding/binary"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// pdfOf is a document that is a PDF to the sniff and size bytes long, its bytes varying so that a part out of
// place changes the join.
func pdfOf(size int) []byte {
b := make([]byte, size)
copy(b, "%PDF-1.4\n")
for i := 9; i < size; i++ {
b[i] = byte(i*7 + i/251)
}
return b
}
// 1453/AC10: a document is cut into parts of at most PartSize, under the limit, and the parts joined in
// order are the document, with the SHA-256 checked; a wrong SHA-256 or a part missing is refused; a part out of
// range is refused naming the range.
func TestADocumentIsCutIntoPartsAndJoinedWithItsSha256Checked(t *testing.T) {
doc := pdfOf(2*AnswerPartSize + 1234)
n := answerPartCount(len(doc))
if n != 3 {
t.Fatalf("%d parts, want 3", n)
}
var parts [][]byte
for i := 1; i <= n; i++ {
p, total, err := cutPart(doc, i)
if err != nil || total != n {
t.Fatalf("part %d: %v (of %d)", i, err, total)
}
if len(p) > AnswerPartSize {
t.Errorf("part %d is %d bytes, over %d", i, len(p), AnswerPartSize)
}
parts = append(parts, p)
}
got, err := joinParts(parts, sha256Hex(doc))
if err != nil || !bytes.Equal(got, doc) {
t.Fatalf("the join is not the document: %v", err)
}
if _, err := joinParts(parts, sha256Hex([]byte("other"))); err == nil || !strings.Contains(err.Error(), sha256Hex(doc)) {
t.Errorf("a wrong SHA-256 was accepted or the error does not name the joined one: %v", err)
}
if _, err := joinParts(parts[:2], sha256Hex(doc)); err == nil {
t.Errorf("a join with a part missing passed its SHA-256")
}
for _, bad := range []int{0, -1, n + 1} {
if _, _, err := cutPart(doc, bad); err == nil || !strings.Contains(err.Error(), "out of range") {
t.Errorf("part %d: error = %v", bad, err)
}
}
if partCount(0) != 1 {
t.Errorf("an empty document has one part")
}
}
// 1453/AC10: scanner.document gives each part of a kept scan with the part count, the size and the SHA-256, each
// answer within the bus's 1 MiB; the parts joined are the scan. A part out of range, or a key that is no scan's, is
// refused, the first before anything is queued.
func TestScannerDocumentGivesEachPartOfAKeptScan(t *testing.T) {
r := newRig(t, `{}`, "pw")
doc := pdfOf(AnswerPartSize + 5000)
key := sha256Hex(doc) + ".pdf"
r.objects.Put(t.Context(), key, "application/pdf", doc)
var joined []byte
for i := 1; i <= 2; i++ {
got, err := r.call(t, "scanner.document", map[string]any{"object": key, "part": float64(i)})
if err != nil {
t.Fatalf("part %d: %v", i, err)
}
m := got.(map[string]any)
if m["parts"] != float64(2) || m["size"] != float64(len(doc)) || m["sha256"] != sha256Hex(doc) || m["part"] != float64(i) {
t.Errorf("part %d answer = %v", i, m)
}
raw, err := base64.StdEncoding.DecodeString(m["data"].(string))
if err != nil {
t.Fatal(err)
}
joined = append(joined, raw...)
}
if !bytes.Equal(joined, doc) {
t.Errorf("the parts fetched are not the scan")
}
before := r.bus.asks()
if _, err := r.call(t, "scanner.document", map[string]any{"object": key, "part": float64(0)}); err == nil {
t.Errorf("part 0 was asked for")
}
if r.bus.asks() != before {
t.Errorf("an ask was queued for a part the tool could refuse")
}
if _, err := r.call(t, "scanner.document", map[string]any{"object": key, "part": float64(3)}); err == nil || !strings.Contains(err.Error(), "out of range") {
t.Errorf("part 3: error = %v", err)
}
for _, k := range []string{"../etc/passwd", "uploads/x.pdf", strings.ToUpper(key)} {
if _, err := r.call(t, "scanner.document", map[string]any{"object": k, "part": float64(1)}); err == nil || !strings.Contains(err.Error(), "not a scan's key") {
t.Errorf("object %q: error = %v", k, err)
}
}
if _, err := r.call(t, "scanner.document", map[string]any{"object": sha256Hex([]byte("none")) + ".pdf", "part": float64(1)}); err == nil {
t.Errorf("a scan that is not kept was given")
}
}
// 1453/AC10, 1601: a scan the size of several answer parts is read whole from the store by scanner.scan, checked and filed.
func TestScanNowFetchesAScanOfSeveralParts(t *testing.T) {
f := newFakeScanner(t, fixture(t, "scan.jpg"))
r, _ := scanRig(t, f, Settings{Crop: false})
// Everything a scan is made of but its size is the real one; the stored object is what the parts carry, so a
// bigger object stands for a bigger scan: replace what the device keeps with a three-part document.
big := pdfOf(2*AnswerPartSize + 99)
bigKey := sha256Hex(big) + ".pdf"
r.objects.Put(t.Context(), bigKey, "application/pdf", big)
got := make([][]byte, 0, 3)
for i := 1; i <= 3; i++ {
p, err := r.worker.document(t.Context(), Ask{Op: OpDocument, Object: bigKey, Part: i})
if err != nil {
t.Fatal(err)
}
raw, _ := base64.StdEncoding.DecodeString(p["data"].(string))
got = append(got, raw)
}
if joined, err := joinParts(got, sha256Hex(big)); err != nil || !bytes.Equal(joined, big) {
t.Fatalf("three parts do not join: %v", err)
}
body, err := r.scan(t)
if err != nil {
t.Fatal(err)
}
if body["path"] == nil {
t.Errorf("no path: %v", body)
}
}
func upload(t *testing.T, r *printerRig, id string, doc []byte, sha string, part, parts int, piece []byte) (any, error) {
t.Helper()
return r.call(t, "printer.upload", map[string]any{"upload": id, "part": float64(part), "parts": float64(parts), "sha256": sha,
"data": base64.StdEncoding.EncodeToString(piece)})
}
// 1453/AC11: an upload in parts, in any order, is stored as uploads/<id>.pdf once all have come and the joined
// SHA-256 matches; until then it says how many came and stores nothing.
func TestAnUploadIsStoredOnceAllItsPartsCameAndTheSha256Matches(t *testing.T) {
r := newRig(t, `{}`, "pw")
doc := pdfOf(PartSize + 700)
sum := sha256Hex(doc)
p1, _, _ := cutPartOf(doc, 1, PartSize)
p2, _, _ := cutPartOf(doc, 2, PartSize)
got, err := upload(t, r, "up-1", doc, sum, 2, 2, p2)
if err != nil {
t.Fatal(err)
}
if m := got.(map[string]any); m["complete"] != false || m["received"] != float64(1) {
t.Errorf("after one part: %v", m)
}
if len(r.objects.keys()) != 0 {
t.Errorf("a document was stored before its parts all came: %v", r.objects.keys())
}
got, err = upload(t, r, "up-1", doc, sum, 1, 2, p1)
if err != nil {
t.Fatal(err)
}
m := got.(map[string]any)
if m["complete"] != true || m["object"] != "uploads/up-1.pdf" || m["sha256"] != sum || m["size"] != float64(len(doc)) {
t.Errorf("after the last part: %v", m)
}
if kept, err := r.objects.Get(t.Context(), "uploads/up-1.pdf"); err != nil || !bytes.Equal(kept, doc) {
t.Errorf("the stored document is not the upload (%v)", err)
}
// A JPEG is stored with its own extension.
jpg := append([]byte{0xFF, 0xD8, 0xFF, 0xE0}, bytes.Repeat([]byte{7}, 100)...)
if _, err := upload(t, r, "pic", jpg, sha256Hex(jpg), 1, 1, jpg); err != nil {
t.Fatal(err)
}
if _, err := r.objects.Get(t.Context(), "uploads/pic.jpg"); err != nil {
t.Errorf("the JPEG is not stored as uploads/pic.jpg: %v", err)
}
}
// 1453/AC11: a wrong SHA-256 on the joined document stores nothing and is said; a document that is neither PDF nor
// JPEG stores nothing; a part out of range, a part larger than the limit, a part that is not base64 and parts that
// disagree are refused; the upload can then be sent again whole.
func TestAnUploadWithAWrongSha256OrAnOutOfRangePartStoresNothing(t *testing.T) {
r := newRig(t, `{}`, "pw")
doc := pdfOf(300)
wrong := sha256Hex([]byte("not it"))
if _, err := upload(t, r, "bad", doc, wrong, 1, 1, doc); err == nil || !strings.Contains(err.Error(), "refused") || !strings.Contains(err.Error(), sha256Hex(doc)) {
t.Errorf("a wrong SHA-256: error = %v", err)
}
if len(r.objects.keys()) != 0 || r.objects.puts != 0 {
t.Errorf("a document with a wrong SHA-256 was stored: %v", r.objects.keys())
}
text := []byte("plain text, no PDF")
if _, err := upload(t, r, "txt", text, sha256Hex(text), 1, 1, text); err == nil || !strings.Contains(err.Error(), "neither a PDF nor a JPEG") {
t.Errorf("a text upload: error = %v", err)
}
for _, c := range []struct{ part, parts int }{{0, 1}, {2, 1}, {1, 0}, {1, MostParts + 1}} {
if _, err := upload(t, r, "range", doc, sha256Hex(doc), c.part, c.parts, doc); err == nil {
t.Errorf("part %d of %d was taken", c.part, c.parts)
}
}
if _, err := upload(t, r, "range", doc, sha256Hex(doc), 2, 1, doc); err == nil || !strings.Contains(err.Error(), "out of range") {
t.Errorf("part 2 of 1: error = %v", err)
}
if _, err := r.call(t, "printer.upload", map[string]any{"upload": "x", "part": float64(1), "parts": float64(1), "sha256": sha256Hex(doc), "data": "!!"}); err == nil {
t.Errorf("a part that is not base64 was taken")
}
if _, err := upload(t, r, "big", doc, sha256Hex(doc), 1, 1, make([]byte, MaxPart+1)); err == nil || !strings.Contains(err.Error(), "at most") {
t.Errorf("a part over the limit: error = %v", err)
}
if r.bus.asks() != 2 {
// Only the two uploads that were complete in form reached the device process; every refusal above them
// was the tool's.
t.Errorf("%d asks queued, want the 2 whose form was right", r.bus.asks())
}
// Parts that disagree about the whole.
p := pdfOf(PartSize + 10)
p1, _, _ := cutPartOf(p, 1, PartSize)
if _, err := upload(t, r, "mix", p, sha256Hex(p), 1, 2, p1); err != nil {
t.Fatal(err)
}
if _, err := upload(t, r, "mix", p, sha256Hex(p), 2, 3, p1); err == nil || !strings.Contains(err.Error(), "all say the same") {
t.Errorf("parts that disagree: error = %v", err)
}
if len(r.objects.keys()) != 0 {
t.Errorf("objects stored: %v", r.objects.keys())
}
}
// 1453/AC11: an incomplete upload's parts are dropped after fifteen minutes.
func TestAnIncompleteUploadIsDroppedAfterAWhile(t *testing.T) {
r := newRig(t, `{}`, "pw")
doc := pdfOf(PartSize + 10)
p1, _, _ := cutPartOf(doc, 1, PartSize)
p2, _, _ := cutPartOf(doc, 2, PartSize)
now := fixedNow
r.worker.Now = func() time.Time { return now }
if _, err := upload(t, r, "late", doc, sha256Hex(doc), 1, 2, p1); err != nil {
t.Fatal(err)
}
now = now.Add(pendingFor + time.Minute)
// Asked of the worker directly: the tool's own clock is the fixed one, and an ask this old would be stale.
got, err := r.worker.upload(t.Context(), Ask{Op: OpUpload, Upload: "late", Part: 2, Parts: 2, SHA256: sha256Hex(doc),
Data: base64.StdEncoding.EncodeToString(p2)})
if err != nil {
t.Fatal(err)
}
if got["complete"] != false {
t.Errorf("the dropped part was still there: %v", got)
}
}
// ippFake is a printer's IPP port: it records the request and answers a Print-Job answer with a job id and state.
type ippFake struct {
url string
body []byte // the Print-Job request
status int
// formats is document-format-supported, as the printer lists it; asked counts the Get-Printer-Attributes requests.
formats []string
asked int
// delay is how long a Print-Job takes to be answered.
delay time.Duration
}
func newIPPFake(t *testing.T, jobID, jobState int) *ippFake {
t.Helper()
f := &ippFake{formats: []string{"application/octet-stream", "application/pdf", "image/jpeg"}}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
if binary.BigEndian.Uint16(body[2:4]) == uint16(ippGetPrinterAttributes) {
// Get-Printer-Attributes (RFC 8011 §4.2.5): the formats are one attribute of several values, a value
// after the first carrying an empty name (§4.1.4), each of the mimeMediaType syntax.
f.asked++
ans := []byte{0x02, 0x00, 0x00, 0x00, 0, 0, 0, 1, ippOperation}
ans = appendAttr(ans, ippCharset, "attributes-charset", "utf-8")
ans = append(ans, 0x04) // printer-attributes-tag, RFC 8010 §3.5.1
for i, format := range f.formats {
name := "document-format-supported"
if i > 0 {
name = ""
}
ans = appendAttr(ans, ippMimeType, name, format)
}
ans = append(ans, ippEnd)
w.Header().Set("Content-Type", "application/ipp")
w.Write(ans)
return
}
f.body = body
time.Sleep(f.delay)
ans := []byte{0x02, 0x00, byte(f.status >> 8), byte(f.status), 0, 0, 0, 1, ippOperation}
ans = appendAttr(ans, ippCharset, "attributes-charset", "utf-8")
ans = append(ans, ippJob)
ans = appendAttr(ans, ippInteger, "job-id", string(binary.BigEndian.AppendUint32(nil, uint32(jobID))))
ans = appendAttr(ans, ippEnum, "job-state", string(binary.BigEndian.AppendUint32(nil, uint32(jobState))))
ans = appendAttr(ans, ippKeyword, "job-state-reasons", "none")
ans = append(ans, ippEnd)
w.Header().Set("Content-Type", "application/ipp")
w.Write(ans)
}))
t.Cleanup(srv.Close)
f.url = srv.URL
old := ippURL
ippURL = func(string) string { return srv.URL }
t.Cleanup(func() { ippURL = old })
return f
}
// request splits what the fake was sent into its attributes and its document.
func (f *ippFake) request(t *testing.T) (IPPAnswer, []byte) {
t.Helper()
a, off, err := decodeIPP(f.body)
if err != nil {
t.Fatal(err)
}
return a, f.body[off:]
}
// 1453/AC12: printer.print sends a kept scan to the printer as an IPP Print-Job (operation 0x0002): the document
// bytes after the attributes, document-format by sniffing, copies, sides and media as asked, to the printer's own
// IPP address; the answer is the job's id and state.
func TestPrinterPrintSendsAPrintJobAndAnswersTheJob(t *testing.T) {
fake := newIPPFake(t, 77, 5)
r := newRig(t, `{"printer":"192.0.2.10"}`, "pw")
doc := pdfOf(5000)
key := sha256Hex(doc) + ".pdf"
r.objects.Put(t.Context(), key, "application/pdf", doc)
got, err := r.call(t, "printer.print", map[string]any{"object": key, "copies": float64(2), "sides": "two-sided-long-edge", "media": "na_letter_8.5x11in"})
if err != nil {
t.Fatal(err)
}
m := got.(map[string]any)
if m["job-id"] != float64(77) || m["job-state"] != "processing" || m["format"] != "application/pdf" {
t.Errorf("answer = %v", m)
}
if binary.BigEndian.Uint16(fake.body[2:4]) != 0x0002 {
t.Errorf("operation = 0x%04x, want Print-Job 0x0002", binary.BigEndian.Uint16(fake.body[2:4]))
}
attrs, sent := fake.request(t)
if !bytes.Equal(sent, doc) {
t.Errorf("the document sent is not the kept one (%d bytes, want %d)", len(sent), len(doc))
}
text := func(n string) string { return strings.Join(attrs.texts(n), ",") }
if text("document-format") != "application/pdf" || text("sides") != "two-sided-long-edge" || text("media") != "na_letter_8.5x11in" ||
text("printer-uri") != "ipp://192.0.2.10/ipp/port1" {
t.Errorf("attributes = %v", attrs.Attributes)
}
if c := attrs.Attributes["copies"]; len(c) != 1 || c[0].Integer != 2 {
t.Errorf("copies = %v", c)
}
}
// 1453/AC12: the defaults are one copy, one-sided, A4; a JPEG is sent as image/jpeg; a document stored under its
// SHA-256 (as node-files.upload keeps one) is printed by its key.
func TestPrinterPrintDefaultsAndFormatsAndStoredDocuments(t *testing.T) {
fake := newIPPFake(t, 8, 3)
r := newRig(t, `{"printer":"192.0.2.10"}`, "pw")
jpg := append([]byte{0xFF, 0xD8, 0xFF, 0xE0}, bytes.Repeat([]byte{9}, 200)...)
r.objects.Put(t.Context(), sha256Hex(jpg), "image/jpeg", jpg)
got, err := r.call(t, "printer.print", map[string]any{"object": sha256Hex(jpg)})
if err != nil {
t.Fatal(err)
}
if got.(map[string]any)["job-state"] != "pending" {
t.Errorf("answer = %v", got)
}
attrs, sent := fake.request(t)
if !bytes.Equal(sent, jpg) || strings.Join(attrs.texts("document-format"), "") != "image/jpeg" ||
strings.Join(attrs.texts("sides"), "") != "one-sided" || strings.Join(attrs.texts("media"), "") != DefaultMedia || attrs.Attributes["copies"][0].Integer != 1 {
t.Errorf("attributes = %v, %d bytes sent", attrs.Attributes, len(sent))
}
if _, err := r.call(t, "printer.print", map[string]any{"object": sha256Hex([]byte("never stored"))}); err == nil {
t.Errorf("an object never stored was printed")
}
}
// 1453/AC12: a print that is not well formed is refused before anything is queued; the printer's refusal is an
// error naming its IPP status; a printer not set is said by name.
func TestPrinterPrintRefusesWhatIsNotWellFormed(t *testing.T) {
fake := newIPPFake(t, 1, 3)
r := newRig(t, `{"printer":"192.0.2.10"}`, "pw")
key := sha256Hex([]byte("x")) + ".pdf"
for name, args := range map[string]map[string]any{
"neither": {},
"both": {"object": key, "upload": "u"},
"copies 0": {"object": key, "copies": float64(-1)},
"copies 100": {"object": key, "copies": float64(100)},
"sides": {"object": key, "sides": "sideways"},
"media": {"object": key, "media": "A4 paper"},
"not a key": {"object": "../x.pdf"},
"copies float": {"object": key, "copies": 1.5},
} {
if _, err := r.call(t, "printer.print", args); err == nil {
t.Errorf("%s: accepted", name)
}
}
if r.bus.asks() != 0 || fake.body != nil {
t.Errorf("%d asks queued, the printer was sent %d bytes", r.bus.asks(), len(fake.body))
}
doc := pdfOf(100)
dk := sha256Hex(doc) + ".pdf"
r.objects.Put(t.Context(), dk, "application/pdf", doc)
fake.status = 0x0400
if _, err := r.call(t, "printer.print", map[string]any{"object": dk}); err == nil || !strings.Contains(err.Error(), "0x0400") {
t.Errorf("a refused job: error = %v", err)
}
// A kept object that is no document the printer takes, nor one the converter takes (1600), is not sent.
fake.status, fake.body = 0, nil
r.objects.Put(t.Context(), key, "application/octet-stream", []byte{0x00, 0x01, 0xFE, 0xFF, 0x02})
if _, err := r.call(t, "printer.print", map[string]any{"object": key}); err == nil || !strings.Contains(err.Error(), "neither a PDF nor a JPEG") || fake.body != nil {
t.Errorf("a text object: error = %v, sent %d bytes", err, len(fake.body))
}
un := newRig(t, `{}`, "pw")
if _, err := un.call(t, "printer.print", map[string]any{"object": key}); err == nil || !strings.Contains(err.Error(), "printer is not set") || un.bus.asks() != 0 {
t.Errorf("no printer: error = %v", err)
}
}
// The Print-Job request is the one RFC 8011 gives: header, operation attributes in order, the job's own, the
// end-of-attributes tag, then the document.
func TestThePrintJobRequestIsWellFormed(t *testing.T) {
doc := []byte("%PDF-1.4 hello")
b := EncodePrintJob("ipp://192.0.2.10/ipp/port1", PrintOptions{Format: "application/pdf", Copies: 3, Sides: "one-sided", Media: DefaultMedia, Name: "n"}, doc)
if !bytes.Equal(b[:8], []byte{2, 0, 0, 2, 0, 0, 0, 1}) || b[8] != ippOperation {
t.Errorf("header = %x", b[:9])
}
a, off, err := decodeIPP(b)
if err != nil || !bytes.Equal(b[off:], doc) {
t.Fatalf("decode: %v, document %q", err, b[off:])
}
if first := string(b[9:]); !strings.HasPrefix(first[3:], "attributes-charset") {
t.Errorf("the first attribute is not attributes-charset")
}
if len(a.Attributes["copies"]) != 1 || a.Attributes["copies"][0].Integer != 3 {
t.Errorf("copies = %v", a.Attributes["copies"])
}
}
// 1453: a scan and a print are acknowledged before the work (a slow one is not delivered again and done twice);
// the other operations are acknowledged after it.
func TestAScanAndAPrintAreAcknowledgedBeforeTheWork(t *testing.T) {
f := newFakeScanner(t, fixture(t, "scan.jpg"))
r, _ := scanRig(t, f, Settings{Crop: true})
body, _ := json.Marshal(Ask{ID: "s1", Op: OpScan, At: fixedNow})
calls := 0
var postsAtAck int
act, _ := r.worker.Handle(body, func() { calls++; postsAtAck = f.posts })
if calls != 1 || postsAtAck != 0 || act != actionSettled || f.posts != 1 {
t.Errorf("early called %d times with %d scans begun; action %v; %d scans in all", calls, postsAtAck, act, f.posts)
}
body, _ = json.Marshal(Ask{ID: "s2", Op: OpSettings, Page: DefaultPage, At: fixedNow})
calls = 0
if act, _ := r.worker.Handle(body, func() { calls++ }); calls != 0 || act != actionAck {
t.Errorf("settings: early called %d times, action %v", calls, act)
}
}
// 1453/AC11: a part of 700 KiB is taken and one of a byte more is refused, by the module's own message naming
// 700 KiB, before it is queued; the limit and the size the module cuts are one.
func TestAPartOverSevenHundredKiBIsRefusedByTheModulesOwnMessage(t *testing.T) {
if MaxPart != PartSize || PartSize != 700<<10 {
t.Fatalf("MaxPart %d, PartSize %d: both are 700 KiB", MaxPart, PartSize)
}
r := newRig(t, `{}`, "pw")
big := make([]byte, 700<<10+1)
_, err := upload(t, r, "over", big, sha256Hex(big), 1, 1, big)
if err == nil || !strings.Contains(err.Error(), "700 KiB") {
t.Fatalf("a part of 700 KiB + 1 byte: error = %v, want the module's own message naming 700 KiB", err)
}
if r.bus.asks() != 0 {
t.Errorf("an oversized part was queued")
}
ok := pdfOf(700 << 10)
if _, err := upload(t, r, "exact", ok, sha256Hex(ok), 1, 1, ok); err != nil {
t.Errorf("a part of exactly 700 KiB: %v", err)
}
}
// 1535: a scan larger than the state's value limit (256 KiB) is filed whole, its SHA-256 equal, and the answer to
// the scan itself is small: it carries the event's body and never the document. Every part answer fits the state.
func TestAScanLargerThanTheStatesValueLimitIsFiledWholeAndItsAnswerIsSmall(t *testing.T) {
f := newFakeScanner(t, fixture(t, "scan.jpg"))
r, folder := scanRig(t, f, Settings{Crop: false})
// The real capture is small; the worker keeps a stand-in of 3 parts' size under the key the scan names.
big := pdfOf(222388 + 2*AnswerPartSize)
if len(big) <= StateMaxValue {
t.Fatal("the stand-in is not larger than the state's value limit")
}
r.worker.Capture = func(ctx context.Context, req ScanRequest) ([]Capture, error) {
return []Capture{{PDF: big, SHA256: sha256Hex(big), Resolution: 300, At: fixedNow}}, nil
}
body, err := r.scan(t)
if err != nil {
t.Fatal(err)
}
path, _ := body["path"].(string)
got, err := os.ReadFile(path)
if err != nil || sha256Hex(got) != sha256Hex(big) || filepath.Dir(path) != folder {
t.Fatalf("filed %s: %v, SHA-256 equal %v", path, err, err == nil && sha256Hex(got) == sha256Hex(big))
}
for id, raw := range r.answers.m {
if len(raw) > StateMaxValue {
t.Errorf("answer %s is %d bytes, over the state's %d", id, len(raw), StateMaxValue)
}
}
for _, a := range r.bus.published {
if a.Op == OpScan {
if raw := r.answers.get(a.ID); len(raw) > 4<<10 || bytes.Contains(raw, []byte(`"data"`)) {
t.Errorf("the scan's answer is %d bytes and may carry the document: %s", len(raw), raw)
}
}
}
}
// 1535: an answer the state refuses ends the ask: a small error answer says why, and the queued message is
// terminated, never redelivered into the same failure; a scan's message, acknowledged early, stays settled.
func TestARefusedAnswerEndsTheAskWithAnErrorAndNoRedelivery(t *testing.T) {
r := newRig(t, `{}`, "pw")
big := pdfOf(300 << 10)
key := sha256Hex(big) + ".pdf"
r.objects.Put(t.Context(), key, "application/pdf", big)
// A part answer of 700 KiB, as the old cut made it, is over the state's limit; the worker must not loop on it.
r.worker.answerPart = PartSize
ask, _ := json.Marshal(Ask{ID: "big1", Op: OpDocument, Object: key, Part: 1, At: fixedNow})
act, _ := r.worker.Handle(ask, nil)
if act != actionTerm {
t.Errorf("action = %v, want term", act)
}
raw := r.answers.get("big1")
if raw == nil || !bytes.Contains(raw, []byte(`"error"`)) || len(raw) > 4<<10 {
t.Errorf("no small error answer: %s", raw)
}
}
// 1535: only a refused value (error code 10054, message too large) ends the ask; a store that is away, a timeout
// or a cut connection is redelivered, a document fetch being safe to repeat.
func TestOnlyATooLargeAnswerEndsTheAskATransientFailureIsRetried(t *testing.T) {
r := newRig(t, `{}`, "pw")
big := pdfOf(300 << 10)
key := sha256Hex(big) + ".pdf"
r.objects.Put(t.Context(), key, "application/pdf", big)
ask, _ := json.Marshal(Ask{ID: "t1", Op: OpDocument, Object: key, Part: 1, At: fixedNow})
r.answers.fail = fmt.Errorf("nats: timeout")
if act, d := r.worker.Handle(ask, nil); act != actionRetry || d <= 0 {
t.Errorf("a timeout: action %v delay %v, want retry", act, d)
}
r.answers.fail = nil
r.worker.answerPart = PartSize
ask2, _ := json.Marshal(Ask{ID: "t2", Op: OpDocument, Object: key, Part: 1, At: fixedNow})
if act, _ := r.worker.Handle(ask2, nil); act != actionTerm {
t.Errorf("a too large answer: action %v, want term", act)
}
}
// home makes a home folder for the tool account and returns it.
func home(t *testing.T) string {
t.Helper()
h := t.TempDir()
t.Setenv("HOME", h)
return h
}
+454
View File
@@ -0,0 +1,454 @@
package main
import (
"context"
"encoding/xml"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"sync"
"time"
)
// eSCL (the AirScan protocol) over HTTP: POST <base>/ScanJobs answers 201 and the job's Location, GET
// <Location>/NextDocument answers the image, and a second NextDocument answers 404 once the job is done (that is what closes it; DELETE of the job does too).
// maxImage bounds what a scanner may send: a 600 dpi A4 JPEG is a few tens of megabytes.
const maxImage = 256 << 20
// Client speaks eSCL to one scanner.
type Client struct {
Base string
HTTP *http.Client
RetryWait time.Duration // the wait before the one retry of a busy scanner (default 4 s)
Logf func(format string, a ...any)
}
// NewClient is a client for the eSCL base URL, with a timeout that outlasts a slow scan.
func NewClient(base string) *Client {
return &Client{Base: strings.TrimRight(base, "/"), HTTP: &http.Client{Timeout: 3 * time.Minute}}
}
// Size is the largest scan region of one input, in the 300ths of an inch the region is asked in.
type Size struct{ MaxWidth, MaxHeight int }
// Capabilities are the largest scan regions of the platen and of the document feeder (simplex and duplex). A
// scanner with no feeder has zero sizes for it.
type Capabilities struct {
MaxWidth, MaxHeight int // the platen
Feeder, FeederDuplex Size
}
type capabilitiesXML struct {
MaxWidth int `xml:"Platen>PlatenInputCaps>MaxWidth"`
MaxHeight int `xml:"Platen>PlatenInputCaps>MaxHeight"`
AdfWidth int `xml:"Adf>AdfSimplexInputCaps>MaxWidth"`
AdfHeight int `xml:"Adf>AdfSimplexInputCaps>MaxHeight"`
DuplexWidth int `xml:"Adf>AdfDuplexInputCaps>MaxWidth"`
DuplexHeight int `xml:"Adf>AdfDuplexInputCaps>MaxHeight"`
}
// Status is what /ScannerStatus says.
type Status struct {
State string `xml:"State" json:"state"`
AdfState string `xml:"AdfState" json:"adf-state"`
}
func (c *Client) get(ctx context.Context, rawURL string) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil {
return nil, err
}
return c.HTTP.Do(req)
}
func readBody(resp *http.Response, limit int64) ([]byte, error) {
defer resp.Body.Close()
return io.ReadAll(io.LimitReader(resp.Body, limit))
}
// Capabilities asks the scanner how large its glass is.
func (c *Client) Capabilities(ctx context.Context) (Capabilities, error) {
resp, err := c.get(ctx, c.Base+"/ScannerCapabilities")
if err != nil {
return Capabilities{}, fmt.Errorf("the scanner does not answer: %w", err)
}
body, err := readBody(resp, 1<<20)
if err != nil {
return Capabilities{}, fmt.Errorf("the scanner's capabilities cannot be read: %w", err)
}
if resp.StatusCode != http.StatusOK {
return Capabilities{}, fmt.Errorf("the scanner refused its capabilities: %s", resp.Status)
}
return parseCapabilities(body)
}
func parseCapabilities(body []byte) (Capabilities, error) {
var x capabilitiesXML
if err := xml.Unmarshal(body, &x); err != nil {
return Capabilities{}, fmt.Errorf("the scanner's capabilities are not XML: %w", err)
}
if x.MaxWidth <= 0 || x.MaxHeight <= 0 {
return Capabilities{}, fmt.Errorf("the scanner's capabilities name no platen size")
}
return Capabilities{MaxWidth: x.MaxWidth, MaxHeight: x.MaxHeight,
Feeder: Size{x.AdfWidth, x.AdfHeight}, FeederDuplex: Size{x.DuplexWidth, x.DuplexHeight}}, nil
}
// Status asks what the scanner is doing.
func (c *Client) Status(ctx context.Context) (Status, error) {
resp, err := c.get(ctx, c.Base+"/ScannerStatus")
if err != nil {
return Status{}, fmt.Errorf("the scanner does not answer: %w", err)
}
body, err := readBody(resp, 1<<20)
if err != nil {
return Status{}, fmt.Errorf("the scanner's status cannot be read: %w", err)
}
if resp.StatusCode != http.StatusOK {
return Status{}, fmt.Errorf("the scanner refused its status: %s", resp.Status)
}
var s Status
if err := xml.Unmarshal(body, &s); err != nil {
return Status{}, fmt.Errorf("the scanner's status is not XML: %w", err)
}
return s, nil
}
// colorModes maps the config's words to eSCL's.
var colorModes = map[string]string{"color": "RGB24", "gray": "Grayscale8"}
// ScanOptions is what one job asks: the resolution and colour mode of the configuration, and the input. Duplex is
// for the feeder.
type ScanOptions struct {
Resolution int
ColorMode string
Feeder bool
Duplex bool
// Bound is how long a feeder job may have run when it has this many pages and is asked for the next; nil is
// FeederBound. The job is deleted and an error given when it has run longer.
Bound func(pages int) time.Duration
}
// FeederBound is how long a feeder job may run when it has this many pages (issue 1539): 20 seconds for each,
// at least 3 minutes and at most 30. The scanner gives a duplex sheet about every 3.4 seconds, so this is generous
// and only ends a job that is stuck.
func FeederBound(pages int) time.Duration {
return min(max(time.Duration(pages)*20*time.Second, 3*time.Minute), FeederMaxBound)
}
// FeederMaxBound is the longest a feeder job may run.
const FeederMaxBound = 30 * time.Minute
// A4 in the 300ths of an inch the region is asked in; a region is cut to what the input can scan.
const a4Width, a4Height = 2480, 3508
// maxFeederPages bounds one feeder job: the feeder holds 50 sheets, which duplex is 100 pages. After them the
// scanner has to answer 404.
const maxFeederPages = 100
// ScanSettings is the request body, on ONE line: the Brother ignores the whole body, and runs its default job (the
// platen, 200 dpi), when there is a line break anywhere before the closing root tag (issue 1452). The region is A4
// cut to the input's largest region.
func ScanSettings(c Capabilities, o ScanOptions) (string, error) {
escl, ok := colorModes[o.ColorMode]
if !ok {
return "", fmt.Errorf("color-mode %q is not color or gray", o.ColorMode)
}
size, source := Size{c.MaxWidth, c.MaxHeight}, "Platen"
if o.Duplex && !o.Feeder {
return "", fmt.Errorf("duplex is for the feeder")
}
if o.Feeder {
source, size = "Feeder", c.Feeder
if o.Duplex {
size = c.FeederDuplex
}
if size.MaxWidth <= 0 || size.MaxHeight <= 0 {
return "", fmt.Errorf("the scanner names no document feeder%s", map[bool]string{true: " that scans both sides", false: ""}[o.Duplex])
}
}
duplex := ""
if o.Duplex {
duplex = "<scan:Duplex>true</scan:Duplex>"
}
return fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?><scan:ScanSettings xmlns:scan="http://schemas.hp.com/imaging/escl/2011/05/03" xmlns:pwg="http://www.pwg.org/schemas/2010/12/sm">`+
`<pwg:Version>2.0</pwg:Version><scan:Intent>Document</scan:Intent>`+
`<pwg:ScanRegions><pwg:ScanRegion><pwg:ContentRegionUnits>escl:ThreeHundredthsOfInches</pwg:ContentRegionUnits><pwg:XOffset>0</pwg:XOffset><pwg:YOffset>0</pwg:YOffset><pwg:Width>%d</pwg:Width><pwg:Height>%d</pwg:Height></pwg:ScanRegion></pwg:ScanRegions>`+
`<pwg:InputSource>%s</pwg:InputSource><scan:ColorMode>%s</scan:ColorMode><pwg:DocumentFormat>image/jpeg</pwg:DocumentFormat>`+
`<scan:XResolution>%d</scan:XResolution><scan:YResolution>%d</scan:YResolution>%s</scan:ScanSettings>`,
min(a4Width, size.MaxWidth), min(a4Height, size.MaxHeight), source, escl, o.Resolution, o.Resolution, duplex), nil
}
// Scan puts one job for the glass to the scanner and returns the JPEG it sends. The resolution asked is only a
// request: the real one is the image's own density (Density).
//
// A job stays open on the scanner (pwg:State Processing, and 503 to the next ScanJobs) until NextDocument has
// answered 404 or the job is DELETEd, so Scan always ends the job it made: after the page it asks NextDocument
// again, and on any failure after the job was created it deletes the job.
func (c *Client) Scan(ctx context.Context, resolution int, colorMode string) ([]byte, error) {
pages, err := c.ScanPages(ctx, ScanOptions{Resolution: resolution, ColorMode: colorMode})
if err != nil {
return nil, err
}
return pages[0], nil
}
// ScanPages puts one job to the scanner, the glass or the feeder, and returns its pages in order. The glass holds
// one page (a second is an error); the feeder is asked NextDocument until it answers 404, at most maxFeederPages
// times. Either way the job is ended, and deleted on any failure after it was created.
func (c *Client) ScanPages(ctx context.Context, o ScanOptions) ([][]byte, error) {
caps, err := c.Capabilities(ctx)
if err != nil {
return nil, err
}
settings, err := ScanSettings(caps, o)
if err != nil {
return nil, err
}
job, err := c.createJob(ctx, settings)
if err != nil {
return nil, err
}
doc := job + "/NextDocument"
if o.Feeder {
pages, err := c.feederPages(ctx, doc, o.Bound)
if err != nil {
c.deleteJob(job)
return nil, err
}
return pages, nil
}
image, err := c.page(ctx, doc)
if err != nil {
c.deleteJob(job)
return nil, err
}
closed, extra := c.closeJob(ctx, doc)
if !closed {
c.deleteJob(job)
}
if extra > 0 {
return nil, fmt.Errorf("the scanner gave more pages than the glass holds: %d", 1+extra)
}
return [][]byte{image}, nil
}
// feederPages asks NextDocument until the scanner answers 404, which ends the job. The first page missing means no
// paper was in the feeder.
func (c *Client) feederPages(ctx context.Context, doc string, bound func(int) time.Duration) ([][]byte, error) {
if bound == nil {
bound = FeederBound
}
start := time.Now()
var pages [][]byte
for len(pages) <= maxFeederPages {
if len(pages) > 0 {
if run, limit := time.Since(start), bound(len(pages)); run > limit {
return nil, fmt.Errorf("the feeder scan has run %s and has %d pages, past its bound of %s: it is stopped", run.Round(time.Second), len(pages), limit)
}
}
got, err := c.get(ctx, doc)
if err != nil {
return nil, fmt.Errorf("the scanned page %d cannot be fetched: %w", len(pages)+1, err)
}
image, err := readBody(got, maxImage)
if err != nil {
return nil, fmt.Errorf("the scanned page %d cannot be read: %w", len(pages)+1, err)
}
switch got.StatusCode {
case http.StatusOK:
case http.StatusNotFound:
if len(pages) == 0 {
return nil, fmt.Errorf("the scanner gave no page: is paper in the feeder?")
}
return pages, nil
case http.StatusGone:
return nil, fmt.Errorf("the scanner dropped the job: %s", got.Status)
default:
return nil, fmt.Errorf("the scanner gave no page %d: %s", len(pages)+1, got.Status)
}
if len(pages) == maxFeederPages {
break
}
if ct := got.Header.Get("Content-Type"); !strings.HasPrefix(ct, "image/jpeg") {
return nil, fmt.Errorf("the scanner sent %q, not image/jpeg", ct)
}
pages = append(pages, image)
}
return nil, fmt.Errorf("the feeder gave more than %d pages and the scanner did not end the job", maxFeederPages)
}
// leftOpen is the job URL, per scanner, that brother could neither finish nor delete.
var leftOpen sync.Map
func (c *Client) logf(format string, a ...any) {
if c.Logf != nil {
c.Logf(format, a...)
return
}
fmt.Fprintf(os.Stderr, format+"\n", a...)
}
func (c *Client) retryWait() time.Duration {
if c.RetryWait > 0 {
return c.RetryWait
}
return 4 * time.Second
}
// createJob posts the settings and returns the job's URL. A 503 is a scanner busy with another job; when the job
// brother itself left open on this scanner is now closed, the post goes once more, never more than once.
func (c *Client) createJob(ctx context.Context, settings string) (string, error) {
resp, reply, err := c.postJob(ctx, settings)
if err != nil {
return "", err
}
if resp.StatusCode == http.StatusServiceUnavailable {
if left, ok := leftOpen.Load(c.Base); ok {
select {
case <-time.After(c.retryWait()):
case <-ctx.Done():
return "", ctx.Err()
}
if c.deleteJob(left.(string)) {
if resp, reply, err = c.postJob(ctx, settings); err != nil {
return "", err
}
}
}
if resp.StatusCode == http.StatusServiceUnavailable {
return "", fmt.Errorf("the scanner is busy with another job: %s %s", resp.Status, strings.TrimSpace(string(reply)))
}
}
if resp.StatusCode != http.StatusCreated {
return "", fmt.Errorf("the scanner refused the scan job: %s %s", resp.Status, strings.TrimSpace(string(reply)))
}
loc := resp.Header.Get("Location")
if loc == "" {
return "", fmt.Errorf("the scanner took the scan job but named no Location")
}
base, _ := url.Parse(c.Base + "/")
ref, err := url.Parse(loc)
if err != nil {
return "", fmt.Errorf("the scan job's Location %q is not a URL: %w", loc, err)
}
return strings.TrimRight(base.ResolveReference(ref).String(), "/"), nil
}
func (c *Client) postJob(ctx context.Context, settings string) (*http.Response, []byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.Base+"/ScanJobs", strings.NewReader(settings))
if err != nil {
return nil, nil, err
}
req.Header.Set("Content-Type", "text/xml")
resp, err := c.HTTP.Do(req)
if err != nil {
return nil, nil, fmt.Errorf("the scanner does not answer: %w", err)
}
reply, _ := readBody(resp, 1<<20)
return resp, reply, nil
}
// page fetches the job's one page.
func (c *Client) page(ctx context.Context, doc string) ([]byte, error) {
got, err := c.get(ctx, doc)
if err != nil {
return nil, fmt.Errorf("the scanned page cannot be fetched: %w", err)
}
image, err := readBody(got, maxImage)
if err != nil {
return nil, fmt.Errorf("the scanned page cannot be read: %w", err)
}
if got.StatusCode == http.StatusGone {
return nil, fmt.Errorf("the scanner dropped the job: %s", got.Status)
}
if got.StatusCode != http.StatusOK {
return nil, fmt.Errorf("the scanner gave no page: %s", got.Status)
}
if ct := got.Header.Get("Content-Type"); !strings.HasPrefix(ct, "image/jpeg") {
return nil, fmt.Errorf("the scanner sent %q, not image/jpeg", ct)
}
return image, nil
}
// maxClosing bounds the NextDocument requests that ask for the end of a job.
const maxClosing = 1
// closeJob asks NextDocument until the scanner answers 404, which ends the job; true when it did (or the job is
// gone already), and how many further pages it sent instead; any is an error for the caller, for a Platen job holds
// one page. It stops at the first further page, and the job is then deleted.
func (c *Client) closeJob(ctx context.Context, doc string) (closed bool, extra int) {
ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 15*time.Second)
defer cancel()
for i := 0; i < maxClosing; i++ {
resp, err := c.get(ctx, doc)
if err != nil {
c.logf("brother: closing the scan job: %v", err)
return false, 0
}
readBody(resp, maxImage)
switch resp.StatusCode {
case http.StatusNotFound, http.StatusGone:
return true, 0
case http.StatusOK:
c.logf("brother: the scanner sent a further page after the glass's one")
return false, 1
}
c.logf("brother: closing the scan job: NextDocument answered %s", resp.Status)
return false, 0
}
return false, 0
}
// deleteJob deletes the job, best effort; true when the job is gone. A job that cannot be deleted is remembered, so
// a 503 on the next scan can be told from another client's job.
func (c *Client) deleteJob(job string) bool {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, job, nil)
if err == nil {
var resp *http.Response
if resp, err = c.HTTP.Do(req); err == nil {
readBody(resp, 1<<20)
if resp.StatusCode/100 == 2 || resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusGone {
leftOpen.Delete(c.Base)
return true
}
err = fmt.Errorf("answered %s", resp.Status)
}
}
c.logf("brother: deleting the scan job %s: %v", job, err)
leftOpen.Store(c.Base, job)
return false
}
// Density is the image's own resolution in dpi, from its JFIF header (units dpi, or dots per cm).
func Density(jpegBytes []byte) (int, error) {
b := jpegBytes
if len(b) < 4 || b[0] != 0xFF || b[1] != 0xD8 {
return 0, fmt.Errorf("the image is not a JPEG")
}
for i := 2; i+4 <= len(b) && b[i] == 0xFF; {
marker, n := b[i+1], int(b[i+2])<<8|int(b[i+3])
if marker == 0xE0 && n >= 16 && i+4+12 <= len(b) && string(b[i+4:i+9]) == "JFIF\x00" {
units := b[i+11]
x := int(b[i+12])<<8 | int(b[i+13])
switch {
case units == 1 && x > 0:
return x, nil
case units == 2 && x > 0:
return int(float64(x)*2.54 + 0.5), nil
}
return 0, fmt.Errorf("the image's JFIF header gives no resolution")
}
if marker == 0xDA || n < 2 {
break
}
i += 2 + n
}
return 0, fmt.Errorf("the image has no JFIF header, so its resolution is unknown")
}

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