Compare commits

...
Author SHA1 Message Date
jschoubben 1319432e55 Test that the integration level gets MESH_CHECK_BESIDE only when siblings were placed (issue 1677) 2026-10-21 21:49:37 +02:00
jschoubben 34b310b9f8 Drop the unused imports from the Node fetch preview 2026-10-21 21:24:44 +02:00
jschoubben 33d8b71cf7 Drop the unused imports from the Node fetch preview 2026-10-21 21:24:33 +02:00
jschoubben aa0eaa6c52 Build the merge preview's module trees with mesh-sdk's ModuleTrees and re-pin mesh-sdk to 78364e31, so the two fetch steps share it (issues 1675, 1676) 2026-10-21 21:24:24 +02:00
jschoubben d5804927b3 Make the build modules' fetch merge the head onto a trunk commit and place siblings beside the tree, so the checker judges the merge and tests read their siblings (issues 1675, 1676, 1677) 2026-10-21 21:04:59 +02:00
mesh-tools cdbdb47474 Merge pull request 'xterm: every resource a config value, today's values as defaults (issue 1656)' (#416) from fix/1656-xterm-config into main 2026-10-21 18:36:28 +00:00
mesh-tools db5e6c5b48 Merge pull request 'Name the wait in the Stop hook's suggested calls, route adopt's counter reset through fsys, and stop writing empty counter files' (#421) from fix/1749-1757-wait-list into main 2026-10-21 18:19:19 +00:00
jschoubben b9dcf19c3f Merge remote-tracking branch 'origin/main' into fix/1656-xterm-config
mesh/repo-check head b9dcf19c3f91 merged into trunk db5e6c5b48f4 beside mesh-controller#420@9d260412 — pass: its merge-check.sh passed, each part in its to…
mesh/merge-gate head b9dcf19c3f91 merged into trunk db5e6c5b48f4 — pass: group fix/1656-xterm-config composed: every machine composes with the change as it…
mesh/delivery covered after its failure: a later walk that contains it was delivered: plan-1792875397682979170 (novox/mesh-catalog at 6fb0ceee into main,…
mesh/delivery-group group fix/1656-xterm-config merged: every member is merged
2026-10-21 19:43:29 +02:00
jochen e3a714d5f0 Stop writing an empty refusal-counter file on every stop of every session (issue 1749)
mesh/merge-gate head e3a714d5f0f0 merged into trunk c3564c1accf3 — pass: builds claude-code → ace, g14, novox, shanks; no bus step; every machine composes …
mesh/repo-check head e3a714d5f0f0 merged into trunk c3564c1accf3 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
writeRefusalCounters wrote "{}" and, since it now routes through fsys
(this branch's earlier commit), created the waits folder on every single
stop, even for a session that never had a wait: one counter file per
session id, piling up on a node that uses the wait list at all. It now
removes the file instead of writing empty counters, a no-op when there
was never one to begin with, so a stop with nothing to record leaves
neither the file nor the folder; a refusal still writes it, and the
stop right after the wait is done (or let through) removes it again.
2026-10-21 19:41:05 +02:00
jochen 08cec8cb1c Route adopt's refusal-counter reset through fsys, and name the wait in the Stop hook's suggestions (issues 1749, 1757)
issue 1749: AdoptName reset both session ids' refusal-counter files with
os.WriteFile, bypassing fsys; on a node whose agent has its own account
(ADR 0266) the module runs as the operator's account and cannot write
into the agent's home directly, so the reset silently failed. writeRefusalCounters
now goes through fsys (MkdirAll then WriteFile), as every other write into
the agent's home does; it is also called from the Stop hook itself, which
already runs as the agent's own account, where fsys is its zero value and
behaves exactly as the plain write did before.

issue 1757: the Stop hook's suggested watched and said calls left out `by`,
which claude_code_wait requires on every op, so a session following the
suggestion word for word was refused. Both now carry "by" from the wait's
own By field (already kept per wait since issue 1629). The SessionStart
hook's suggested adopt call already carried by; added a test for it too,
alongside the Stop hook's two, each extracting the exact JSON the hook
prints and feeding it back to the tool's own dispatch to prove it is
accepted as printed.
2026-10-21 19:35:33 +02:00
mesh-tools c3564c1acc Merge pull request 'A session's wait list and the plugin's Stop hook, so a written-down wait is never left unwatched (ADR 0489, ADR 0490)' (#420) from feat/wait-list into main 2026-10-21 17:18:48 +00:00
jschoubben e4cbffbaa9 Merge remote-tracking branch 'origin/main' into fix/1656-xterm-config
mesh/merge-gate out of date: judged against trunk 7a7a4034852a, the trunk is now c3564c1accf3; judged again on a recheck or when it is merged
mesh/repo-check out of date: judged against trunk 7a7a4034852a, the trunk is now c3564c1accf3; judged again on a recheck or when it is merged
mesh/delivery superseded: a newer head of the same pull request
2026-10-21 19:16:12 +02:00
jochen 0f159a1662 Fix review round 2 of the wait list and its hooks (issues 1629, 1743, 1744, ADR 0490)
mesh/merge-gate head 0f159a166200 merged into trunk 7a7a4034852a — pass: builds claude-code → ace, g14, novox, shanks; no bus step; every machine composes …
mesh/repo-check head 0f159a166200 merged into trunk 7a7a4034852a — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
The Stop hook treated a missing waits file exactly like one that could
not be read: a notice on every stop of every session with no waits —
the normal state, not an exceptional one. DecideStop now treats
os.ErrNotExist as the ordinary case of no open waits, silently; the
notice is still said for a file that exists but cannot be read or does
not parse.

ADR 0490 §1 keeps waits under a name, but the file a hook reads is kept
per session id: writing it from only the name an op just acted on meant
a second name bound to the same session id had its waits silently drop
out of enforcement the moment the first name was touched, and adopting
one name away emptied the id's file outright even when another name was
still bound to it. Every write now rewrites a session's file from the
union of every name currently bound to it (sessionWaitsUnion), adopt's
rewrite of the superseded id included.

Also: each atomic write gets its own temp file name, so two writes to
the same path can never interleave on one; adopt clears the refusal
counter of both the superseded and the adopting session, so a fresh
three-strikes count starts under the new binding; and list is read-only
again — it no longer claims an unbound name or refuses a session that
is not (yet) the one bound to it, and instead says who currently holds
the name.
2026-10-21 18:43:03 +02:00
jochen f0beea153e Key a wait list by name, not session id, so it survives a /clear (issues 1629, 1744, ADR 0490)
Claude Code gives a session a new session_id at /clear, with nothing
naming the earlier one, so keying claude_code_wait's state by session id
lost a session's waits the moment it cleared. ADR 0490 changes ADR 0489
§1: waits are now kept under the caller's name and node (by, who@node);
every op also takes session, the caller's current id, and the module
keeps a binding of the name to the session that last called for it. An
unbound name is claimed by the first call for it; a session whose own
id no longer matches the binding is refused on every op, naming the
session that holds it, until it calls the new op adopt, which always
takes the name over at once (no bound protects a recent call) and tells
the earlier session so on its own next call.

The mirrored file a hook reads stays keyed by session id (a hook only
ever has that), holding whichever name's waits are currently bound to
it; adopting rewrites both the old id's file (emptied) and the new id's
(filled) in the same call. A second mirrored file, the node-wide index,
lists every name on the node with an open wait; the SessionStart hook
now also reads it, on every start, and prints the exact adopt call for
each name, so a session with a brand new id can find and reclaim its
own waits without calling back into the module.
2026-10-21 18:32:50 +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
jochen 02a8837348 Fix review round 1 of the wait list and its hooks (issues 1629, 1743, 1744)
A session id reaching the Stop and SessionStart hooks was only checked
for blank, so "../../x" or "a/b" could read or write outside the waits
directory; both hooks now hold every session id to waitSessionID before
building any path, and allow (saying why) rather than fail on a bad one.
claude_code_wait's list/watched/done/said held only add to that same
check, letting a malformed id through as an ordinary, always-empty key;
they all refuse it now.

The Stop hook's three-strikes cap could be bypassed forever if its
counter file could not be written on a refusal: it now allows instead,
saying so, so a wait can never hold a turn open indefinitely. Its advice
offered mesh_wait on a wait's event family unconditionally, ignoring
that the session's tool runner may not be granted it (ADR 0367 §2); it
now offers mesh_wait only "if this session's tool runner is granted it,
otherwise start a watcher", and drops the family entirely when a wait
has none.

Both hooks' fail-open and let-through notices were said only on stderr,
which Claude Code does not show the operator on an allowed (exit 0)
outcome; they now also print the same words as a "systemMessage" JSON
object on stdout, which Claude Code does show.
2026-10-21 18:23:42 +02:00
jochen 0fa427dad3 Add the plugin's Stop hook so a written-down wait is never left unwatched (issue 1743)
A session could end its turn while a wait it wrote down with
claude_code_wait had nothing following it. The plugin's Stop hook
(claude-code stop <waits-dir>) refuses the stop while a wait is within
its bound and unwatched, or past its bound and not said, reading the
waits file directly with no network and a 5s bound; it fails open on a
missing or malformed file or a panic, and lets a wait through after
three refusals in a row rather than holding the session forever.
Renames TestThePluginsOnlyHookIsTheGuard to cover the session-start and
Stop hooks too.
2026-10-21 18:08:13 +02:00
jochen 37628f4bce Add the plugin's SessionStart hook so a session learns its own id (issue 1744)
A session is never told its session id by any tool, only by a hook's
stdin, so it cannot name itself to claude_code_wait. The plugin's
SessionStart hook (claude-code session-start <waits-dir>) prints the id
and how to give it, plus the session's still-open waits after a /clear,
reading the waits file claude_code_wait mirrors directly rather than
calling back into this module. Carries the Stop hook's command and the
GuardFiles.WaitsDir plumbing MarketplaceWith needs for both (issue 1743
wires the Stop hook's dispatch next).
2026-10-21 18:07:36 +02: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 closed: a member was closed before the group merged
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
jochen 56c68be7fa Keep a session's wait list in the claude-code module (issue 1629)
A session can end its turn while an Approve it expects is still open and
nothing then watches it. claude_code_wait (add/watched/done/said/list)
keeps each session's waits in the module's waits state, keyed by its
session id and node, and mirrors them whole and atomically to a file
under the agent's home that the plugin's coming hooks (1743, 1744) will
read without calling back into this module.
2026-10-21 17:59:10 +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 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
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
jschoubben f178aab641 Make every xterm resource a config value with today's value as default; tools read the rendered resources (issue 1656)
mesh/delivery-group group fix/1656-xterm-config rejected: a member's own check failed
mesh/merge-gate waits for its group's composed check
mesh/repo-check head f178aab641dc merged into trunk 3259765d46bc beside mesh-controller#420@69a67876 — fail: its merge-check.sh failed: files asking for a …
mesh/delivery superseded: a newer head of the same pull request
2026-10-21 12:51:40 +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 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 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
244 changed files with 12614 additions and 705 deletions
+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)
}
}
+2
View File
@@ -53,6 +53,8 @@
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.
+2 -2
View File
@@ -122,8 +122,8 @@ the login page again. The logout follows a logout link or form on the page; a pa
## The event
`scan.added`, said by this module, on the wire `mesh.mod.brother.event.scan.added`; a consumer declares `brother.scan.added`. Its body:
`path`, `node`, `bytes`, `sha256`, `width-mm`, `height-mm`, `resolution` (dpi, as the scanner gave it),
`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
+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")
}
}
+90 -14
View File
@@ -11,14 +11,17 @@ import (
"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"
@@ -66,6 +69,12 @@ type deviceWorker struct {
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
@@ -247,10 +256,14 @@ func (w *deviceWorker) keep(ctx context.Context, objects Objects, set Settings,
// 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 the tool could not take is not kept nor announced: an announced scan is always filed (issue 1539).
if most := MostAnswerParts * AnswerPartSize; len(c.PDF) > most {
return nil, fmt.Errorf("the scan's PDF is %d bytes, over the %d the tool can fetch (%d parts of %d): it is not kept; scan with a lower resolution or fewer sheets",
len(c.PDF), most, MostAnswerParts, AnswerPartSize)
// 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)
@@ -260,7 +273,13 @@ func (w *deviceWorker) keepOne(ctx context.Context, objects Objects, set Setting
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)
}
answer := map[string]any{"key": key, "size": len(c.PDF), "parts": answerPartCount(len(c.PDF))}
// 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
}
@@ -370,7 +389,20 @@ func (w *deviceWorker) print(ctx context.Context, ask Ask) (map[string]any, erro
return nil, err
}
key := ask.Object
if key == "" {
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 {
@@ -383,21 +415,55 @@ func (w *deviceWorker) print(ctx context.Context, ask Ask) (map[string]any, erro
if key == "" {
return nil, fmt.Errorf("upload %s is not complete: no document is kept under it", ask.Upload)
}
}
doc, err := objects.Get(ctx, key)
if err != nil {
return nil, err
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 {
return nil, err
// 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
}
return 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}, nil
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
@@ -497,7 +563,7 @@ func nonNilForms(f []Form) []Form {
// 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 := os.Getenv("BROTHER_STORE")
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
@@ -519,6 +585,13 @@ func device() int {
}
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...) },
}
@@ -541,6 +614,9 @@ func runDevice(ctx context.Context, account process.Account, w *deviceWorker) er
}
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
}
+39 -94
View File
@@ -115,7 +115,7 @@ func TestScannerDocumentGivesEachPartOfAKeptScan(t *testing.T) {
}
}
// 1453/AC10: a scan of more than one part is fetched whole by scanner.scan, joined, checked and filed.
// 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})
@@ -266,15 +266,41 @@ func TestAnIncompleteUploadIsDroppedAfterAWhile(t *testing.T) {
// 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
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{}
f := &ippFake{formats: []string{"application/octet-stream", "application/pdf", "image/jpeg"}}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
f.body, _ = io.ReadAll(r.Body)
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)
@@ -337,15 +363,14 @@ func TestPrinterPrintSendsAPrintJobAndAnswersTheJob(t *testing.T) {
}
}
// 1453/AC12: the defaults are one copy, one-sided, A4; a JPEG is sent as image/jpeg; an upload is printed by its id.
func TestPrinterPrintDefaultsAndFormatsAndUploads(t *testing.T) {
// 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)...)
if _, err := upload(t, r, "photo", jpg, sha256Hex(jpg), 1, 1, jpg); err != nil {
t.Fatal(err)
}
got, err := r.call(t, "printer.print", map[string]any{"upload": "photo"})
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)
}
@@ -357,8 +382,8 @@ func TestPrinterPrintDefaultsAndFormatsAndUploads(t *testing.T) {
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{"upload": "never-uploaded"}); err == nil || !strings.Contains(err.Error(), "not complete") {
t.Errorf("an upload never made: error = %v", err)
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")
}
}
@@ -392,9 +417,9 @@ func TestPrinterPrintRefusesWhatIsNotWellFormed(t *testing.T) {
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 not a document the printer takes is not sent.
// 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, "text/plain", []byte("hello"))
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))
}
@@ -547,83 +572,3 @@ func home(t *testing.T) string {
t.Setenv("HOME", h)
return h
}
// 1551: printer.print with a path reads the file, uploads it in parts and prints it in one call; a document of
// several parts arrives whole, and the answer has the job's id and state.
func TestPrinterPrintTakesAPathAndUploadsAndPrintsInOneCall(t *testing.T) {
fake := newIPPFake(t, 31, 5)
r := newRig(t, `{"printer":"192.0.2.10"}`, "pw")
doc := pdfOf(2*PartSize + 1234)
file := filepath.Join(home(t), "letter.bin") // the name says nothing: the bytes are read
if err := os.WriteFile(file, doc, 0o600); err != nil {
t.Fatal(err)
}
got, err := r.call(t, "printer.print", map[string]any{"path": file, "copies": float64(2)})
if err != nil {
t.Fatal(err)
}
m := got.(map[string]any)
if m["job-id"] != float64(31) || m["job-state"] != "processing" || m["format"] != "application/pdf" || m["bytes"] != len(doc) {
t.Errorf("answer = %v", m)
}
attrs, sent := fake.request(t)
if !bytes.Equal(sent, doc) || attrs.Attributes["copies"][0].Integer != 2 {
t.Errorf("sent %d bytes (want %d), attributes %v", len(sent), len(doc), attrs.Attributes)
}
ops := r.bus.ops()
if len(ops) != 4 || ops[0] != OpUpload || ops[2] != OpUpload || ops[3] != OpPrint {
t.Errorf("asks = %v, want three upload parts and a print", ops)
}
}
// 1551: only a regular file under the tool account's home folder is read, after symbolic links are resolved; every
// other path is refused in one fixed way that says nothing of whether it exists or how large it is; a file in the
// home folder that is no PDF or JPEG by its bytes is refused by name of the type.
func TestPrinterPrintReadsOnlyAFileUnderTheHomeFolder(t *testing.T) {
fake := newIPPFake(t, 1, 5)
r := newRig(t, `{"printer":"192.0.2.10"}`, "pw")
h := home(t)
outside := t.TempDir()
secret := filepath.Join(outside, "secret.pdf")
os.WriteFile(secret, pdfOf(3000), 0o600)
link := filepath.Join(h, "link.pdf")
os.Symlink(secret, link)
dirLink := filepath.Join(h, "dirlink")
os.Symlink(outside, dirLink)
os.Mkdir(filepath.Join(h, "folder"), 0o700)
for name, path := range map[string]string{
"outside home": secret,
"link out of home": link,
"folder link out": filepath.Join(dirLink, "secret.pdf"),
"dot-dot out of home": filepath.Join(h, "..", filepath.Base(outside), "secret.pdf"),
"missing in home": filepath.Join(h, "none.pdf"),
"missing outside home": filepath.Join(outside, "none.pdf"),
"folder": filepath.Join(h, "folder"),
"the home folder itself": h,
"relative": "secret.pdf",
"proc": "/proc/self/environ",
} {
_, err := r.call(t, "printer.print", map[string]any{"path": path})
if err == nil || err.Error() != PathRefused {
t.Errorf("%s: error = %v, want exactly %q", name, err, PathRefused)
}
}
text := filepath.Join(h, "fake.pdf")
os.WriteFile(text, []byte("just text, named .pdf"), 0o600)
if _, err := r.call(t, "printer.print", map[string]any{"path": text}); err == nil || !strings.Contains(err.Error(), "neither a PDF nor a JPEG") {
t.Errorf("text in home: error = %v", err)
}
if _, err := r.call(t, "printer.print", map[string]any{"path": text, "object": sha256Hex([]byte("x")) + ".pdf"}); err == nil {
t.Error("path with object accepted")
}
if r.bus.asks() != 0 || fake.body != nil {
t.Errorf("%d asks queued, printer sent %d bytes", r.bus.asks(), len(fake.body))
}
// A symbolic link inside the home folder to a file inside it is fine.
inside := filepath.Join(h, "real.pdf")
os.WriteFile(inside, pdfOf(3000), 0o600)
os.Symlink(inside, filepath.Join(h, "ok.pdf"))
if _, err := r.call(t, "printer.print", map[string]any{"path": filepath.Join(h, "ok.pdf")}); err != nil {
t.Errorf("a link within home: %v", err)
}
}
+227
View File
@@ -0,0 +1,227 @@
package main
// A web link the device process fetches (novox/hq ADR 0468 clause 6, issue 1599). The process holds the bucket's key and
// the printer's password and sits inside the mesh, so a URL from any caller would otherwise reach the store, the
// printer's admin pages or the controller. Only http and https are taken. The fetch takes at most 60 s, 100 MiB and 5
// redirects. Each host is resolved once, every address it gives is checked, and the connection is made to an address
// that was checked, never to a second lookup's. Loopback, private (RFC 1918, RFC 4193), link-local, shared (RFC 6598),
// unspecified, multicast and the mesh's own tunnel range are refused, after every redirect too.
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/netip"
"net/url"
"os"
"strings"
"time"
"git.novox.be/novox/mesh-sdk/go/objectlink"
)
const (
// FetchTime, FetchMost and FetchRedirects are the fetch's bounds (ADR 0468 clause 6).
FetchTime = 60 * time.Second
FetchMost = objectlink.MaxDocument
FetchRedirects = 5
// MostURL is the longest address printer.print takes.
MostURL = 2048
)
// FetchFacts is the file the mesh writes for the fetch (files/fetch.json): the tunnel's range, which a module cannot
// know (mesh-sdk go/catalogue/machine_into_files.go, the fact `mesh-range`).
type FetchFacts struct {
MeshRange string `json:"mesh-range"`
}
// ReadFetchFacts reads the fetch's facts; a file that names no range is refused, since a fetch that does not know the
// mesh's range could reach it.
func ReadFetchFacts(path string) (netip.Prefix, error) {
if path == "" {
return netip.Prefix{}, errors.New("the module was started without the fetch's facts (BROTHER_FETCH)")
}
raw, err := os.ReadFile(path)
if err != nil {
return netip.Prefix{}, fmt.Errorf("the fetch's facts cannot be read: %w", err)
}
var f FetchFacts
if err := json.Unmarshal(raw, &f); err != nil {
return netip.Prefix{}, fmt.Errorf("the fetch's facts are not the module's: %v", err)
}
p, err := netip.ParsePrefix(strings.TrimSpace(f.MeshRange))
if err != nil {
return netip.Prefix{}, fmt.Errorf("the fetch's facts name no mesh range (%q): %v", f.MeshRange, err)
}
return p, nil
}
// CheckURLScheme is what the tool side can say of an address without looking at the network: it is http or https.
func CheckURLScheme(raw string) error {
if len(raw) > MostURL {
return fmt.Errorf("url is at most %d characters", MostURL)
}
u, err := url.Parse(raw)
if err != nil || u.Host == "" {
return fmt.Errorf("url %q is not an address", raw)
}
if u.Scheme != "http" && u.Scheme != "https" {
return fmt.Errorf("url %q has the scheme %q: only http and https are fetched (novox/hq ADR 0468 clause 6)", raw, u.Scheme)
}
return nil
}
var refusedRanges = []struct {
prefix netip.Prefix
class string
}{
{netip.MustParsePrefix("0.0.0.0/8"), "an unspecified address"},
{netip.MustParsePrefix("10.0.0.0/8"), "a private address (RFC 1918)"},
{netip.MustParsePrefix("100.64.0.0/10"), "a shared address (RFC 6598)"},
{netip.MustParsePrefix("127.0.0.0/8"), "a loopback address"},
{netip.MustParsePrefix("169.254.0.0/16"), "a link-local address"},
{netip.MustParsePrefix("172.16.0.0/12"), "a private address (RFC 1918)"},
{netip.MustParsePrefix("192.168.0.0/16"), "a private address (RFC 1918)"},
{netip.MustParsePrefix("224.0.0.0/4"), "a multicast address"},
{netip.MustParsePrefix("::/128"), "an unspecified address"},
{netip.MustParsePrefix("::1/128"), "a loopback address"},
{netip.MustParsePrefix("fc00::/7"), "a private address (RFC 4193)"},
{netip.MustParsePrefix("fe80::/10"), "a link-local address"},
{netip.MustParsePrefix("ff00::/8"), "a multicast address"},
}
// Refusal says why an address may not be fetched from, or "" when it may.
func (f *Fetcher) Refusal(a netip.Addr) string {
a = a.Unmap()
for _, r := range refusedRanges {
if r.prefix.Contains(a) {
return r.class
}
}
if f.MeshRange.IsValid() && f.MeshRange.Contains(a) {
return "an address of the mesh's own network"
}
return ""
}
// Fetcher fetches a web link under the rules above. Resolve and Dial are what a test replaces.
type Fetcher struct {
MeshRange netip.Prefix
Resolve func(ctx context.Context, host string) ([]netip.Addr, error)
Dial func(ctx context.Context, network, address string) (net.Conn, error)
Timeout time.Duration
Most int64
Redirects int
}
// NewFetcher is a fetcher with the real resolver and dialer and the bounds of the ADR.
func NewFetcher(meshRange netip.Prefix) *Fetcher {
d := &net.Dialer{Timeout: 15 * time.Second}
return &Fetcher{MeshRange: meshRange, Dial: d.DialContext, Timeout: FetchTime, Most: FetchMost, Redirects: FetchRedirects,
Resolve: func(ctx context.Context, host string) ([]netip.Addr, error) {
return net.DefaultResolver.LookupNetIP(ctx, "ip", host)
}}
}
// Fetched is a document fetched: its bytes and the media type the server gave.
type Fetched struct {
Body []byte
ContentType string
}
// Fetch gets the document at the address. Every refusal names why and happens before a connection to the refused
// address is made; a body over the limit or a fetch over the time stores nothing, since nothing is returned.
func (f *Fetcher) Fetch(ctx context.Context, raw string) (*Fetched, error) {
ctx, cancel := context.WithTimeout(ctx, f.Timeout)
defer cancel()
current := raw
for hop := 0; ; hop++ {
if err := CheckURLScheme(current); err != nil {
return nil, err
}
u, _ := url.Parse(current)
addr, err := f.checked(ctx, u.Hostname())
if err != nil {
return nil, fmt.Errorf("%s: %w", current, err)
}
port := u.Port()
if port == "" {
port = map[string]string{"http": "80", "https": "443"}[u.Scheme]
}
target := net.JoinHostPort(addr.String(), port)
tr := &http.Transport{
Proxy: nil, // never the environment's proxy: the address checked is the address dialled
DisableKeepAlives: true,
DialContext: func(ctx context.Context, network, _ string) (net.Conn, error) {
return f.Dial(ctx, network, target)
},
}
client := &http.Client{Transport: tr, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, current, nil)
if err != nil {
tr.CloseIdleConnections()
return nil, err
}
resp, err := client.Do(req)
if err != nil {
tr.CloseIdleConnections()
return nil, fmt.Errorf("%s could not be fetched: %w", current, err)
}
if resp.StatusCode >= 300 && resp.StatusCode < 400 && resp.Header.Get("Location") != "" {
loc, lerr := u.Parse(resp.Header.Get("Location"))
resp.Body.Close()
tr.CloseIdleConnections()
if lerr != nil {
return nil, fmt.Errorf("%s redirects to an address that is not one: %v", current, lerr)
}
if hop >= f.Redirects {
return nil, fmt.Errorf("%s is redirected more than %d times", raw, f.Redirects)
}
current = loc.String()
continue
}
defer tr.CloseIdleConnections()
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("%s answered %s", current, resp.Status)
}
if resp.ContentLength > f.Most {
return nil, fmt.Errorf("%s is %d bytes, over the %d a document may be (novox/hq ADR 0468 clause 6)", current, resp.ContentLength, f.Most)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, f.Most+1))
if err != nil {
return nil, fmt.Errorf("%s could not be read within %s: %w", current, f.Timeout, err)
}
if int64(len(body)) > f.Most {
return nil, fmt.Errorf("%s is over the %d bytes a document may be (novox/hq ADR 0468 clause 6)", current, f.Most)
}
return &Fetched{Body: body, ContentType: resp.Header.Get("Content-Type")}, nil
}
}
// checked resolves the host once, refuses it when any address it gives is refused, and answers the address to dial.
func (f *Fetcher) checked(ctx context.Context, host string) (netip.Addr, error) {
var addrs []netip.Addr
if ip, err := netip.ParseAddr(host); err == nil {
addrs = []netip.Addr{ip}
} else {
found, err := f.Resolve(ctx, host)
if err != nil {
return netip.Addr{}, fmt.Errorf("the host %s cannot be resolved: %w", host, err)
}
addrs = found
}
if len(addrs) == 0 {
return netip.Addr{}, fmt.Errorf("the host %s resolves to no address", host)
}
for _, a := range addrs {
if class := f.Refusal(a); class != "" {
return netip.Addr{}, fmt.Errorf("refused: %s is %s, and the device process fetches nothing inside the mesh or the machine (novox/hq ADR 0468 clause 6)", a, class)
}
}
return addrs[0].Unmap(), nil
}
+262
View File
@@ -0,0 +1,262 @@
package main
import (
"context"
"errors"
"net"
"net/http"
"net/http/httptest"
"net/netip"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)
// A fetch rig: names resolve as the test says, and every dial goes to the test's server whatever address was asked,
// after the fetcher has checked the address it was about to dial and told the dial what that was.
type fetchRig struct {
f *Fetcher
mu sync.Mutex
dialed []string
lookups map[string]int
names map[string][]netip.Addr
// after is what a host answers from its second lookup on, when it answers differently (a rebinding).
after map[string][]netip.Addr
srv *httptest.Server
}
func newFetchRig(t *testing.T, h http.HandlerFunc) *fetchRig {
t.Helper()
r := &fetchRig{lookups: map[string]int{}, names: map[string][]netip.Addr{}, after: map[string][]netip.Addr{}}
r.srv = httptest.NewServer(h)
t.Cleanup(r.srv.Close)
r.f = &Fetcher{MeshRange: netip.MustParsePrefix("10.10.0.0/24"), Timeout: 5 * time.Second, Most: 1 << 20, Redirects: FetchRedirects,
Resolve: func(_ context.Context, host string) ([]netip.Addr, error) {
r.mu.Lock()
defer r.mu.Unlock()
r.lookups[host]++
a, ok := r.names[host]
if !ok {
return nil, errors.New("no such host")
}
if again, rebinds := r.after[host]; rebinds && r.lookups[host] > 1 {
return again, nil
}
return a, nil
},
Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
r.mu.Lock()
r.dialed = append(r.dialed, address)
r.mu.Unlock()
return (&net.Dialer{}).DialContext(ctx, network, r.srv.Listener.Addr().String())
}}
return r
}
func addrs(s ...string) []netip.Addr {
var out []netip.Addr
for _, a := range s {
out = append(out, netip.MustParseAddr(a))
}
return out
}
// 1599/AC1: a url with a scheme other than http or https is refused by name.
func TestAURLOfAnotherSchemeIsRefused(t *testing.T) {
r := newFetchRig(t, func(http.ResponseWriter, *http.Request) {})
for _, u := range []string{"file:///etc/passwd", "ftp://example.com/a.pdf", "gopher://example.com/", "javascript:alert(1)", "//example.com/a.pdf"} {
_, err := r.f.Fetch(t.Context(), u)
if err == nil || (!strings.Contains(err.Error(), "only http and https") && !strings.Contains(err.Error(), "is not an address")) {
t.Errorf("%s: error = %v", u, err)
}
if err := CheckURLScheme(u); err == nil {
t.Errorf("%s: the tool side took it", u)
}
}
if len(r.dialed) != 0 {
t.Errorf("dialled %v", r.dialed)
}
}
// 1599/AC2: a host that is, or resolves to, a loopback, private, link-local, shared, unspecified or multicast address,
// or one in the mesh's range, is refused naming the class, and no connection is made.
func TestAddressesInsideTheMeshAndTheMachineAreRefused(t *testing.T) {
r := newFetchRig(t, func(http.ResponseWriter, *http.Request) { t.Error("a request reached the server") })
cases := map[string]string{
"127.0.0.1": "loopback", "::1": "loopback", "10.0.0.1": "private address (RFC 1918)", "192.168.1.1": "private address (RFC 1918)",
"172.16.0.1": "private address (RFC 1918)", "fd00::1": "private address (RFC 4193)", "169.254.1.1": "link-local", "fe80::1": "link-local",
"100.64.0.1": "shared address (RFC 6598)", "0.0.0.0": "unspecified", "::": "unspecified", "224.0.0.1": "multicast", "ff02::1": "multicast",
"10.10.0.7": "private address (RFC 1918)", "::ffff:127.0.0.1": "loopback",
}
for ip, class := range cases {
host := ip
if strings.Contains(ip, ":") {
host = "[" + ip + "]"
}
_, err := r.f.Fetch(t.Context(), "http://"+host+"/a.pdf")
if err == nil || !strings.Contains(err.Error(), class) {
t.Errorf("%s: error = %v, want the class %q", ip, err, class)
}
// The same address behind a name.
r.names["inside.test"] = addrs("203.0.113.9", ip)
if _, err := r.f.Fetch(t.Context(), "http://inside.test/a.pdf"); err == nil || !strings.Contains(err.Error(), class) {
t.Errorf("inside.test -> %s: error = %v", ip, err)
}
}
// The mesh's range from fetch.json, when it is not a private range.
r.f.MeshRange = netip.MustParsePrefix("203.0.113.0/24")
if _, err := r.f.Fetch(t.Context(), "http://203.0.113.5/a.pdf"); err == nil || !strings.Contains(err.Error(), "the mesh's own network") {
t.Errorf("the mesh range: error = %v", err)
}
if len(r.dialed) != 0 {
t.Errorf("a connection was made to %v", r.dialed)
}
}
// 1599/AC3: a redirect to such an address is refused; a sixth redirect is refused; a redirect to a public address is followed.
func TestRedirectsAreCheckedAndBounded(t *testing.T) {
hops := 0
r := newFetchRig(t, func(w http.ResponseWriter, req *http.Request) {
switch req.URL.Path {
case "/to-loopback":
http.Redirect(w, req, "http://127.0.0.1/secret", http.StatusFound)
case "/to-name":
http.Redirect(w, req, "http://inside.test/x", http.StatusFound)
case "/loop":
hops++
http.Redirect(w, req, "/loop", http.StatusFound)
case "/ok":
http.Redirect(w, req, "http://public.test/final.pdf", http.StatusMovedPermanently)
default:
w.Write([]byte("%PDF-1.7 final"))
}
})
r.names["public.test"] = addrs("203.0.113.9")
r.names["inside.test"] = addrs("192.168.0.9")
if _, err := r.f.Fetch(t.Context(), "http://public.test/to-loopback"); err == nil || !strings.Contains(err.Error(), "loopback") {
t.Errorf("redirect to loopback: error = %v", err)
}
if _, err := r.f.Fetch(t.Context(), "http://public.test/to-name"); err == nil || !strings.Contains(err.Error(), "private address (RFC 1918)") {
t.Errorf("redirect to a name: error = %v", err)
}
if _, err := r.f.Fetch(t.Context(), "http://public.test/loop"); err == nil || !strings.Contains(err.Error(), "redirected more than 5 times") {
t.Errorf("a loop: error = %v", err)
}
if hops != 6 {
t.Errorf("the loop was followed %d times, want the first request and 5 redirects (6 requests)", hops)
}
got, err := r.f.Fetch(t.Context(), "http://public.test/ok")
if err != nil || string(got.Body) != "%PDF-1.7 final" {
t.Errorf("a redirect to a public address: %v, %v", got, err)
}
}
// 1599/AC4: a body over the limit and a fetch over the time are refused, and nothing is returned to be stored.
func TestABodyOverTheLimitAndAFetchOverTheTimeAreRefused(t *testing.T) {
r := newFetchRig(t, func(w http.ResponseWriter, req *http.Request) {
switch req.URL.Path {
case "/big":
w.Write([]byte(strings.Repeat("x", 2000)))
case "/chunked":
w.(http.Flusher).Flush() // no length is declared
w.Write([]byte(strings.Repeat("x", 2000)))
case "/slow":
time.Sleep(500 * time.Millisecond)
w.Write([]byte("late"))
}
})
r.names["public.test"] = addrs("203.0.113.9")
r.f.Most = 1000
for _, p := range []string{"/big", "/chunked"} {
if got, err := r.f.Fetch(t.Context(), "http://public.test"+p); err == nil || got != nil || !strings.Contains(err.Error(), "over the") {
t.Errorf("%s: %v, %v", p, got, err)
}
}
r.f.Timeout = 100 * time.Millisecond
if got, err := r.f.Fetch(t.Context(), "http://public.test/slow"); err == nil || got != nil {
t.Errorf("a slow fetch: %v, %v", got, err)
}
}
// 1599/AC5: the connection goes to the address that was checked: a name that answers differently on a second lookup
// is not dialled again, and each host is looked up once.
func TestTheCheckedAddressIsTheOneDialled(t *testing.T) {
r := newFetchRig(t, func(w http.ResponseWriter, req *http.Request) { w.Write([]byte("%PDF-1.7 x")) })
r.names["rebind.test"] = addrs("203.0.113.9")
r.after["rebind.test"] = addrs("127.0.0.1") // a second lookup would say loopback
if _, err := r.f.Fetch(t.Context(), "http://rebind.test:8080/a.pdf"); err != nil {
t.Fatal(err)
}
if len(r.dialed) != 1 || r.dialed[0] != "203.0.113.9:8080" {
t.Errorf("dialled %v, want the checked address and the url's port", r.dialed)
}
if r.lookups["rebind.test"] != 1 {
t.Errorf("the host was looked up %d times", r.lookups["rebind.test"])
}
}
// 1599/AC6: a fetched PDF is stored under its SHA-256 and printed as that object.
func TestAFetchedPDFIsStoredUnderItsHashAndPrinted(t *testing.T) {
fake := newIPPFake(t, 41, 5)
r := newRig(t, `{"printer":"192.0.2.10"}`, "pw")
doc := pdfOf(4000)
web := newFetchRig(t, func(w http.ResponseWriter, req *http.Request) {
w.Header().Set("Content-Type", "application/pdf")
w.Write(doc)
})
web.names["public.test"] = addrs("203.0.113.9")
r.worker.Fetcher = func() (*Fetcher, error) { return web.f, nil }
got, err := r.call(t, "printer.print", map[string]any{"url": "http://public.test/letter.pdf"})
if err != nil {
t.Fatal(err)
}
m := got.(map[string]any)
key := sha256Hex(doc)
if m["object"] != key || m["job-id"] != float64(41) || m["format"] != "application/pdf" {
t.Errorf("answer = %v", m)
}
if stored, err := r.objects.Get(t.Context(), key); err != nil || string(stored) != string(doc) {
t.Errorf("stored under the hash: %v", err)
}
if _, sent := fake.request(t); string(sent) != string(doc) {
t.Error("the printer was not sent the fetched document")
}
queued := r.bus.published[0]
if queued.URL != "http://public.test/letter.pdf" || queued.Object != "" {
t.Errorf("queued %+v", queued)
}
if err := CheckURLScheme("https://public.test/letter.pdf"); err != nil {
t.Errorf("https refused: %v", err)
}
// A refusal inside the mesh is answered, and nothing is stored nor printed.
fake.body = nil
before := len(r.objects.keys())
if _, err := r.call(t, "printer.print", map[string]any{"url": "http://127.0.0.1:9000/mesh-shanks-brother/x"}); err == nil || !strings.Contains(err.Error(), "loopback") {
t.Errorf("a loopback address: error = %v", err)
}
if len(r.objects.keys()) != before || fake.body != nil {
t.Error("something was stored or printed for a refused address")
}
}
// The fetch's facts are the file the mesh writes (files/fetch.json) from the `mesh-range` machine fact, and the device
// process reads them.
func TestTheFetchFactsAreTheMeshRange(t *testing.T) {
raw, err := os.ReadFile("../../files/fetch.json")
if err != nil || !strings.Contains(string(raw), `"mesh-range": "${machine:mesh-range}"`) {
t.Fatalf("files/fetch.json = %s, %v", raw, err)
}
file := filepath.Join(t.TempDir(), "fetch.json")
os.WriteFile(file, []byte(`{"mesh-range": "10.10.0.0/24"}`), 0o600)
p, err := ReadFetchFacts(file)
if err != nil || p.String() != "10.10.0.0/24" {
t.Errorf("prefix %v, %v", p, err)
}
os.WriteFile(file, []byte(`{}`), 0o600)
if _, err := ReadFetchFacts(file); err == nil {
t.Error("facts with no range were taken")
}
}
+1 -1
View File
@@ -264,7 +264,7 @@ func TestAnOpenDocumentIsFoundAgainAfterARestartAndItsPagesAreDeletedWhenClosed(
docExpiry = 200 * time.Millisecond
t.Cleanup(func() { docExpiry = old })
s := &Service{Settings: Settings{Scanner: f.base(), Folder: folder, Resolution: 200, ColorMode: "color"}, Now: func() time.Time { return fixedNow },
Asks: askBus, AnswerWait: 5 * time.Second, ScanWait: 5 * time.Second, AnswerPoll: 5 * time.Millisecond, Jobs: NewJobs()}
Asks: askBus, AnswerWait: 5 * time.Second, ScanWait: 5 * time.Second, AnswerPoll: 5 * time.Millisecond, Jobs: NewJobs(), Fetch: fetchClient, DocExpiry: docExpiry}
if err := s.Recover(context.Background()); err != nil {
t.Fatal(err)
}
+33
View File
@@ -245,6 +245,39 @@ func PrinterStateOf(ctx context.Context, host string) (PrinterState, error) {
return ans.State()
}
// formatAttributes is what the format check asks for: the document formats the printer takes (RFC 8011 §5.4.22).
var formatAttributes = []string{"document-format-supported"}
// FormatsOf asks the printer, over IPP, which document formats it lists in document-format-supported.
func FormatsOf(ctx context.Context, host string) ([]string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, ippURL(host),
bytes.NewReader(EncodeGetPrinterAttributes(ippPrinterURI(host), formatAttributes)))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/ipp")
resp, err := (&http.Client{Timeout: 15 * time.Second}).Do(req)
if err != nil {
return nil, fmt.Errorf("the printer's IPP service does not answer: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return nil, fmt.Errorf("the printer's IPP answer cannot be read: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("the printer's IPP service answered %s", resp.Status)
}
ans, err := DecodeIPP(body)
if err != nil {
return nil, err
}
if ans.Status >= 0x0100 {
return nil, fmt.Errorf("the printer refused the IPP request with status 0x%04x", ans.Status)
}
return ans.texts("document-format-supported"), nil
}
// PrintOptions are what a Print-Job asks of the printer.
type PrintOptions struct {
Format string // document-format, application/pdf or image/jpeg
+21 -19
View File
@@ -11,6 +11,9 @@ import (
stdio "git.novox.be/novox/mesh-sdk/go"
)
// uploadVia is how the tool side asks a machine's node-files for an upload; a test puts its own in its place.
var uploadVia = sdkUpload
func main() {
if len(os.Args) > 1 && os.Args[1] == "device" {
os.Exit(device())
@@ -84,7 +87,7 @@ func askFrom(args map[string]any) (Ask, error) {
var a Ask
var err error
for name, dst := range map[string]*string{"page": &a.Page, "object": &a.Object, "upload": &a.Upload, "sha256": &a.SHA256,
"data": &a.Data, "sides": &a.Sides, "media": &a.Media} {
"data": &a.Data, "sides": &a.Sides, "media": &a.Media, "path": &a.Path, "node": &a.Node, "url": &a.URL, "ask": &a.Of} {
if *dst, err = strArg(args, name); err != nil {
return a, err
}
@@ -109,7 +112,8 @@ func tools(settingsFile string, now func() time.Time) []stdio.Tool {
}
return &Service{Settings: s, Node: os.Getenv("MESH_NODE"), Now: now, Asks: askBus,
AnswerWait: answerWait, ScanWait: scanWait, AnswerPoll: answerPoll,
QuickWait: quickWait, FeederWait: feederWait, Jobs: jobs}, nil
QuickWait: quickWait, FeederWait: feederWait, Jobs: jobs, Upload: uploadVia, Fetch: fetchClient, DocExpiry: docExpiry, DocRetry: docRetry,
Bucket: func() (string, error) { return ReadBucket(os.Getenv("BROTHER_BUCKET")) }}, nil
}
// ask is a tool that reads its arguments, and answers what the service gives.
ask := func(run func(s *Service, ctx context.Context, a Ask) (map[string]any, error), bound time.Duration) func(map[string]any) (any, error) {
@@ -147,39 +151,37 @@ func tools(settingsFile string, now func() time.Time) []stdio.Tool {
},
{
Name: "printer.print",
Description: "Print a document on the printer: a file under the home folder of the account the tool runs as, on the machine it runs on (path: reads it, uploads it in parts " +
"and prints it, in one call), a scan kept in the bucket (object), or a completed upload (upload; for a module that sends " +
"bytes over the bus). Only PDF and JPEG, told by the file's bytes and not its name. The device " +
"process sends it as an IPP Print-Job in its own format and answers the job's id and state (with path, the upload id and the size).",
Description: "Print a document on the printer, in one call: a key in the bucket (object: a scan, or a document stored there), a file on any " +
"machine of the mesh (path with node: that machine's node-files sends it into the bucket, a place it keeps a secret is " +
"refused there by name and nothing is printed), or a web link (url). Name exactly one. A Word, spreadsheet or slide " +
"document is turned into a PDF first; otherwise only PDF and JPEG, told by the bytes and not the name, and only a format " +
"the printer lists in document-format-supported. The device process sends it as an IPP Print-Job and answers the job's " +
"id and state. No document crosses the bus. A call answers within 25 s (the tool runner cuts a call at 30 s): a print not done by then goes on and is answered as started with its ask id, and printer.print with only that ask reads it.",
Input: map[string]any{"type": "object", "properties": map[string]any{
"path": str("an absolute path of a PDF or JPEG file under the tool account's home folder (symbolic links are resolved and the real path must lie there too; any other path is refused in one fixed way)"),
"object": str("a scan's key, as scan.added names it"),
"upload": str("a completed upload's id"),
"object": str("a stored document's key: a scan's key, as scan.added names it, or the key node-files.upload answered"),
"path": str("an absolute path of a file on the machine node names"),
"node": str("the machine path is on, such as g14"),
"url": str("an http or https address of a document or a page"),
"ask": str("the ask id of an earlier print that was answered as started: alone, it reads that print's outcome"),
"copies": num("1 to 99 (default 1)"),
"sides": str("one-sided (default), two-sided-long-edge or two-sided-short-edge"),
"media": str("the paper's size name (default " + DefaultMedia + ")"),
}},
Run: func(args map[string]any) (any, error) {
path, err := strArg(args, "path")
if err != nil {
return nil, err
}
a, err := askFrom(args)
if err != nil {
return nil, err
}
if a.Of != "" && len(args) > 1 {
return nil, fmt.Errorf("ask is read on its own: give the ask id alone to read an earlier print")
}
s, err := service()
if err != nil {
return nil, err
}
if path == "" {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
return s.PrinterPrint(ctx, a)
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
defer cancel()
return s.PrintFile(ctx, path, a)
return s.PrinterPrint(ctx, a)
},
},
{
+30 -3
View File
@@ -13,7 +13,8 @@ import (
)
type manifest struct {
Requires []string `json:"requires"`
Requires []requirement `json:"requires"`
Invokes []string `json:"invokes"`
Binds map[string]string `json:"binds"`
Secrets map[string]string `json:"secrets"`
Emits []string `json:"emits"`
@@ -28,6 +29,31 @@ type manifest struct {
} `json:"build"`
}
// requirement is an entry of `requires`: a name, or an object with the name and the shares of the bucket.
type requirement struct {
Name string `json:"name"`
Shares []struct {
To string `json:"to"`
Methods []string `json:"methods"`
} `json:"shares"`
}
func (r *requirement) UnmarshalJSON(b []byte) error {
if err := json.Unmarshal(b, &r.Name); err == nil {
return nil
}
type plain requirement
return json.Unmarshal(b, (*plain)(r))
}
func (m manifest) requireNames() []string {
var out []string
for _, r := range m.Requires {
out = append(out, r.Name)
}
return out
}
type seat struct {
Name string `json:"name"`
Scope string `json:"scope"`
@@ -265,6 +291,7 @@ func TestTheAdminProcessIsDeclaredAsMeshMailsSenderIs(t *testing.T) {
"BROTHER_ADMIN_PASSWORD": m.OwnSecrets["admin-password"]["path"],
"BROTHER_BROKER": m.OwnSecrets["broker-device"]["path"],
"BROTHER_STORE": "${dir:state}/store.json",
"BROTHER_FETCH": "${dir:state}/fetch.json", // 1599: the tunnel's range, which the fetch never reaches
}
if !reflect.DeepEqual(env, want) {
t.Errorf("env = %v, want %v", env, want)
@@ -330,8 +357,8 @@ func TestThePrinterAsksStreamIsAWorkQueue(t *testing.T) {
// written into store.json, a file only the device account can read; the tools' bundle is not told where it is.
func TestTheBucketIsRequiredAndItsKeyIsOnlyTheDeviceAccounts(t *testing.T) {
m := readManifest(t)
if !reflect.DeepEqual(m.Requires, []string{"s3-bucket"}) {
t.Errorf("requires = %v", m.Requires)
if !reflect.DeepEqual(m.requireNames(), []string{"s3-bucket"}) {
t.Errorf("requires = %v", m.requireNames())
}
if m.Binds["s3-bucket"] != "${dir:state}/store-binding.json" || m.Secrets["s3-bucket"] != "${dir:state}/store.secret" {
t.Errorf("binds %v, secrets %v", m.Binds, m.Secrets)
+19
View File
@@ -24,6 +24,8 @@ import (
"sort"
"strings"
"time"
"git.novox.be/novox/mesh-sdk/go/objectlink"
)
// Objects is the module's bucket.
@@ -55,6 +57,13 @@ func Store(ctx context.Context, o Objects, key, mediaType string, data []byte) e
return o.Put(ctx, key, mediaType, data)
}
// Presigner is a store that signs a link to one of its objects (novox/hq ADR 0468 §3): the device process's, with the
// binding's key, which reaches this module's bucket only.
type Presigner interface {
// Link is a link for GET or PUT of one key that lives for the given time (at most ten minutes).
Link(method, key string, expires time.Duration) (string, error)
}
// StoreFacts is the file the mesh writes from the s3-bucket binding and its secret.
type StoreFacts struct {
Scheme string `json:"scheme"`
@@ -114,6 +123,16 @@ func NewS3(f StoreFacts) *S3 {
func (s *S3) Bucket() string { return s.Name }
// Link signs a link for one object of the bucket with the binding's key, for the host the binding gives.
func (s *S3) Link(method, key string, expires time.Duration) (string, error) {
now := time.Now
if s.Now != nil {
now = s.Now
}
return objectlink.Presign(objectlink.Request{Method: method, Endpoint: s.Endpoint, Bucket: s.Name, Key: key,
AccessKey: s.Access, SecretKey: s.Secret, Region: s.Region, Expires: expires, Now: now()})
}
func (s *S3) do(ctx context.Context, method, key, mediaType string, body []byte) (*http.Response, error) {
u, err := url.Parse(s.Endpoint + "/" + s.Name + "/" + key)
if err != nil {
@@ -1,6 +1,7 @@
package main
import (
"bytes"
"context"
"fmt"
"io"
@@ -9,9 +10,11 @@ import (
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"testing"
"time"
)
// memObjects is the bucket in memory, for the tests of what the device process keeps.
@@ -19,6 +22,10 @@ type memObjects struct {
mu sync.Mutex
kept map[string][]byte
puts int
// corrupt, when set, makes a GET of a link answer bytes that are not the kept ones.
corrupt bool
// links records each link signed: "<method> <key> <seconds>".
links []string
}
func newMemObjects() *memObjects { return &memObjects{kept: map[string][]byte{}} }
@@ -68,6 +75,32 @@ func (m *memObjects) Delete(_ context.Context, key string) error {
return nil
}
// Link is a link the bucket in memory serves itself (RoundTrip): the key in the path and the life in the query, as a
// presigned link says them (X-Amz-Expires).
func (m *memObjects) Link(method, key string, expires time.Duration) (string, error) {
m.mu.Lock()
m.links = append(m.links, method+" "+key+" "+strconv.Itoa(int(expires/time.Second)))
m.mu.Unlock()
return "http://" + m.Bucket() + ".objects.test/" + key + "?X-Amz-Expires=" + strconv.Itoa(int(expires/time.Second)), nil
}
// RoundTrip answers a GET of a link made by Link with the object kept under its key.
func (m *memObjects) RoundTrip(r *http.Request) (*http.Response, error) {
m.mu.Lock()
b, ok := m.kept[strings.TrimPrefix(r.URL.Path, "/")]
corrupt := m.corrupt
m.mu.Unlock()
if corrupt && ok {
b = append(append([]byte(nil), b...), 0)
}
status := 200
if !ok {
status = 404
}
return &http.Response{StatusCode: status, Body: io.NopCloser(bytes.NewReader(b)), ContentLength: int64(len(b)),
Header: http.Header{}, Request: r}, nil
}
func (m *memObjects) keys() []string {
m.mu.Lock()
defer m.mu.Unlock()
+160 -46
View File
@@ -36,6 +36,11 @@ const (
// DeviceProcessID is the device process's id as module.json's resource "device" names it: the controller
// delivers its bus account as the own secret broker-device.
DeviceProcessID = "device"
// PrintTakeUp is how long a print ask may wait on the queue before the device process takes it up, and PrintFollow
// how long the tool side follows it to its end: the device process may fetch and convert before the job. A call
// answers within QuickWait whatever these are, and the print goes on (the tool runner cuts every call at 30 s).
PrintTakeUp = 3 * time.Minute
PrintFollow = 6 * time.Minute
// AnswerWait is how long a verb waits for the device process; ScanWait how long a scan does.
AnswerWait = 60 * time.Second
ScanWait = 180 * time.Second
@@ -88,6 +93,14 @@ type Ask struct {
Document string `json:"document,omitempty"` // scan: start, or an open document's id: a glass page into it
Finish bool `json:"finish,omitempty"` // scan: close the document named in Document into one PDF
// Path with Node are what printer.print is asked for a file of a machine: the tool side turns them into an Object
// before anything is queued, so the queued ask carries a key and the options and never these (ADR 0468 clause 5).
// A URL is queued as given: the device process alone fetches it (clause 6).
Path string `json:"-"` // print: a file on the machine Node
Of string `json:"-"` // print: the ask id of an earlier print, whose outcome is read
Node string `json:"-"` // print: the machine Path is on
URL string `json:"url,omitempty"` // print: a web document the device process fetches (issue 1599)
}
// AskBus is how the tools reach the device process: they queue an ask and read an answer back.
@@ -145,7 +158,10 @@ func (s *Service) call(ctx context.Context, ask Ask, wait time.Duration) (map[st
// queue puts the ask on the work queue and answers its id, under which the answer is written. The asker waits
// startBy for the device process to take the ask up (0 is for as long as it is waited for).
func (s *Service) queue(ask Ask, startBy time.Duration) (string, error) {
ask.ID, ask.At = newAskID(), s.Now().UTC()
if ask.ID == "" {
ask.ID = newAskID()
}
ask.At = s.Now().UTC()
if startBy > 0 {
ask.Deadline = ask.At.Add(startBy)
}
@@ -211,9 +227,12 @@ func validPage(page string) bool {
var (
scanKeyPattern = regexp.MustCompile(`^[0-9a-f]{64}\.pdf$`)
uploadPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`)
mediaPattern = regexp.MustCompile(`^[a-z0-9_.-]{1,64}$`)
sha256Pattern = regexp.MustCompile(`^[0-9a-f]{64}$`)
// printKeyPattern is a document the bucket keeps under its SHA-256: a scan's key, or the key node-files.upload
// answers for a file sent from a machine (the bare SHA-256).
printKeyPattern = regexp.MustCompile(`^[0-9a-f]{64}(\.pdf)?$`)
uploadPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`)
mediaPattern = regexp.MustCompile(`^[a-z0-9_.-]{1,64}$`)
sha256Pattern = regexp.MustCompile(`^[0-9a-f]{64}$`)
)
// Sides are the values the printer's sides take.
@@ -259,14 +278,23 @@ func checkUpload(a Ask) error {
// checkPrint is what is wrong with an ask to print, or nil, after the defaults are laid in.
func checkPrint(a *Ask) error {
if (a.Object == "") == (a.Upload == "") {
return fmt.Errorf("name either object (a scan's key) or upload (a completed upload's id), not both and not neither")
if a.Upload != "" {
return fmt.Errorf("upload is no longer a source of printer.print: send the file with path and node, or name its key in object")
}
if a.Object != "" && !scanKeyPattern.MatchString(a.Object) {
return fmt.Errorf("object %q is not a scan's key: 64 hex digits and .pdf", a.Object)
if (a.Path == "") != (a.Node == "") {
return fmt.Errorf("path and node go together: path is a file on the machine node names")
}
if a.Upload != "" && !uploadPattern.MatchString(a.Upload) {
return fmt.Errorf("upload %q is not an upload id: letters, digits, - and _, at most 64", a.Upload)
sources := 0
for _, given := range []bool{a.Object != "", a.Path != "", a.URL != ""} {
if given {
sources++
}
}
if sources != 1 {
return fmt.Errorf("name exactly one of object (a key in the bucket), path with node (a file on that machine) or url, not %s", map[bool]string{true: "none", false: "two or more"}[sources == 0])
}
if a.Object != "" && !printKeyPattern.MatchString(a.Object) {
return fmt.Errorf("object %q is not a stored document's key: 64 hex digits, with .pdf for a scan", a.Object)
}
if a.Copies == 0 {
a.Copies = 1
@@ -307,50 +335,42 @@ func (s *Service) PrinterUpload(ctx context.Context, a Ask) (map[string]any, err
return s.call(ctx, a, s.AnswerWait)
}
// PrintFile prints a PDF or a JPEG that lies on the machine the tool runs on: it reads the file, uploads it in
// parts under an upload id of its own and prints that, in one call (issue 1551). The type is read from the
// file's first bytes, never its name; any other file is refused before anything is queued. The answer is the
// print's, with the upload id and the file's size.
func (s *Service) PrintFile(ctx context.Context, path string, a Ask) (map[string]any, error) {
if a.Object != "" || a.Upload != "" {
return nil, fmt.Errorf("name path, or object, or upload: one of the three")
}
// The path is checked first, and refused in one fixed way that tells nothing about the file.
doc, err := ReadDocument(path, MostParts*PartSize)
// sdkUpload asks node's node-files to send a file of that machine into the bucket and answers the key it was
// stored under (issue 1597). The form is the seat's verb on one machine, `seat:<seat>.<verb>@<node>`.
func sdkUpload(node, path, bucket string) (string, error) {
raw, err := stdio.Ask("seat:node-files.upload@"+node, map[string]any{"path": path, "bucket": bucket})
if err != nil {
return nil, err
return "", err
}
a.Upload = "p-" + newAskID()
if err := checkPrint(&a); err != nil {
return nil, err
var ans struct {
Key string `json:"key"`
Answer json.RawMessage `json:"answer"`
}
if s.Settings.Printer == "" {
return nil, unsetError("printer")
if err := json.Unmarshal(raw, &ans); err != nil {
return "", fmt.Errorf("node-files' answer is not JSON: %w", err)
}
if _, _, err := SniffFormat(doc); err != nil {
return nil, fmt.Errorf("file %s: %w", path, err)
if ans.Key == "" && len(ans.Answer) > 0 {
_ = json.Unmarshal(ans.Answer, &ans)
}
sum, n := sha256Hex(doc), partCount(len(doc))
for i := 1; i <= n; i++ {
part, _, _ := cutPartOf(doc, i, PartSize)
up, err := s.PrinterUpload(ctx, Ask{Upload: a.Upload, Part: i, Parts: n, SHA256: sum, Data: base64.StdEncoding.EncodeToString(part)})
if err != nil {
return nil, fmt.Errorf("part %d of %d of %s could not be uploaded: %w", i, n, path, err)
}
if i == n && up["complete"] != true {
return nil, fmt.Errorf("the upload of %s is not complete after its last part: %v", path, up)
}
if ans.Key == "" {
return "", fmt.Errorf("node-files' answer names no key")
}
out, err := s.PrinterPrint(ctx, a)
if err != nil {
return nil, fmt.Errorf("%s is uploaded as %s but could not be printed: %w", path, a.Upload, err)
}
out["path"], out["upload"], out["bytes"] = path, a.Upload, len(doc)
return out, nil
return ans.Key, nil
}
// PrinterPrint asks the device process to print a kept scan or a completed upload.
// PrinterPrint prints a stored document, a file of a machine, or a web link, in one call. A file of a machine is first
// sent into the bucket by that machine's node-files (refused there by name when it is a secret, and then nothing is
// printed); the print ask the device process takes carries the key and the options and no bytes.
//
// The tool runner cuts every call at 30 s, so the call answers within QuickWait, upload included. A print not done by
// then goes on in the background and is answered as started, with its ask id; printer.print {ask} reads its outcome.
func (s *Service) PrinterPrint(ctx context.Context, a Ask) (map[string]any, error) {
if a.Of != "" {
if a.Object != "" || a.Path != "" || a.Node != "" || a.URL != "" || a.Upload != "" {
return nil, fmt.Errorf("ask is read on its own: name exactly one of object, path with node, url, or the ask id of an earlier print")
}
return s.PrintResult(ctx, a.Of)
}
a.Op = OpPrint
if err := checkPrint(&a); err != nil {
return nil, err
@@ -358,9 +378,103 @@ func (s *Service) PrinterPrint(ctx context.Context, a Ask) (map[string]any, erro
if s.Settings.Printer == "" {
return nil, unsetError("printer")
}
return s.call(ctx, a, s.AnswerWait)
bucket := ""
switch {
case a.URL != "":
if err := CheckURLScheme(a.URL); err != nil {
return nil, err
}
case a.Path != "":
if s.Upload == nil {
return nil, fmt.Errorf("this module has no way to ask node-files for an upload")
}
var err error
if bucket, err = s.Bucket(); err != nil {
return nil, err
}
}
id := newAskID()
j := s.Jobs.put(id)
go func() {
j.body, j.err = s.printRun(id, a, bucket)
close(j.done)
}()
return s.printWait(ctx, id, j)
}
// printRun is a print followed to its end, however long the caller waits: the upload, the ask, the device's answer.
func (s *Service) printRun(id string, a Ask, bucket string) (map[string]any, error) {
from := map[string]any{}
if a.Path != "" {
key, err := s.Upload(a.Node, a.Path, bucket)
if err != nil {
return nil, fmt.Errorf("%s on %s could not be sent to the bucket, and nothing is printed: %w", a.Path, a.Node, err)
}
from["path"], from["node"] = a.Path, a.Node
a.Object, a.Path, a.Node = key, "", ""
}
a.ID = id
if _, err := s.queue(a, PrintTakeUp); err != nil {
return nil, err
}
ctx, cancel := context.WithTimeout(context.Background(), PrintFollow)
defer cancel()
out, err := s.await(ctx, id, PrintFollow)
if err != nil {
return nil, err
}
for k, v := range from {
out[k] = v
}
return out, nil
}
// printWait gives the print's outcome if it comes within QuickWait, and started with the ask id otherwise.
func (s *Service) printWait(ctx context.Context, id string, j *job) (map[string]any, error) {
quick := s.QuickWait
if quick <= 0 {
quick = QuickWait
}
select {
case <-j.done:
return j.body, j.err
case <-time.After(quick):
return map[string]any{"state": "started", "ask": id,
"note": "the print goes on; read its outcome with printer.print {ask}"}, nil
case <-ctx.Done():
return nil, fmt.Errorf("the caller went away; the print %s goes on: %w", id, ctx.Err())
}
}
// PrintResult is the outcome of the print with this ask id: the device's answer when it is done (a failure is the
// error), and started, still printing, while it is not. A print this process followed is read from it, one it did not
// (it was restarted) from the device's answers, which are kept for 10 minutes.
func (s *Service) PrintResult(ctx context.Context, id string) (map[string]any, error) {
if !askIDPattern.MatchString(id) {
return nil, fmt.Errorf("ask %q is not an ask id: the 24 hex digits printer.print answered", id)
}
if j := s.Jobs.get(id); j != nil {
return s.printWait(ctx, id, j)
}
raw, err := s.Asks.Answer(id)
if err != nil {
return nil, fmt.Errorf("the device process's answers cannot be read: %w", err)
}
if raw == nil {
return map[string]any{"state": "started", "ask": id, "note": "still printing, or this ask is not known"}, nil
}
var out map[string]any
if err := json.Unmarshal(raw, &out); err != nil {
return nil, fmt.Errorf("the device process's answer is not JSON: %w", err)
}
if msg, bad := out["error"].(string); bad {
return nil, fmt.Errorf("%s", msg)
}
return out, nil
}
var askIDPattern = regexp.MustCompile(`^[0-9a-f]{24}$`)
// PrinterStatus is the printer's state over IPP and the scanner's over eSCL, none of it behind a login
// (ADR 0467 clause 5). A part that does not answer is an answer, with why, not an error. Which of printer and
// scanner is not set is said by name, and nothing is contacted.
@@ -0,0 +1,318 @@
package main
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)
// uploadFake stands for node-files.upload on another machine: it records the ask and answers a key, or refuses.
type uploadFake struct {
mu sync.Mutex
calls []string
key string
err error
}
// withUpload puts a fake node-files in the tool side's place, and the bucket file the mesh writes from the binding
// (files/bucket.json) where the tool reads it.
// seen is the uploads asked so far, read under the fake's lock (the print runs in the background).
func (f *uploadFake) seen() []string {
f.mu.Lock()
defer f.mu.Unlock()
return append([]string(nil), f.calls...)
}
func withUpload(t *testing.T, key string, err error) *uploadFake {
t.Helper()
f := &uploadFake{key: key, err: err}
old := uploadVia
uploadVia = func(node, path, bucket string) (string, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.calls = append(f.calls, node+"|"+path+"|"+bucket)
return f.key, f.err
}
t.Cleanup(func() { uploadVia = old })
file := filepath.Join(t.TempDir(), "bucket.json")
if err := os.WriteFile(file, []byte(`{"bucket": "brother-test"}`), 0o644); err != nil {
t.Fatal(err)
}
t.Setenv("BROTHER_BUCKET", file)
return f
}
// 1598/AC1: none, or two, of object, path+node and url is refused naming the rule; path without node is refused.
func TestPrintTakesExactlyOneSource(t *testing.T) {
fake := newIPPFake(t, 1, 3)
up := withUpload(t, "k", nil)
r := newRig(t, `{"printer":"192.0.2.10"}`, "pw")
key := sha256Hex([]byte("x")) + ".pdf"
for name, args := range map[string]map[string]any{
"none": {},
"only options": {"copies": float64(2)},
"object and path": {"object": key, "path": "/home/ops/a.pdf", "node": "g14"},
"object and url": {"object": key, "url": "https://example.com/a.pdf"},
"path+node and url": {"path": "/home/ops/a.pdf", "node": "g14", "url": "https://example.com/a.pdf"},
"all three": {"object": key, "path": "/home/ops/a.pdf", "node": "g14", "url": "https://example.com/a.pdf"},
"path without node": {"path": "/home/ops/a.pdf"},
"node without path": {"node": "g14", "object": key},
"the retired upload": {"upload": "u1"},
"object and upload": {"object": key, "upload": "u1"},
"path and its object": {"path": "/home/ops/a.pdf", "node": "g14", "object": key},
} {
_, err := r.call(t, "printer.print", args)
if err == nil {
t.Errorf("%s: accepted", name)
continue
}
rule := strings.Contains(err.Error(), "exactly one of object") || strings.Contains(err.Error(), "path and node go together") ||
strings.Contains(err.Error(), "upload is no longer")
if !rule {
t.Errorf("%s: the refusal does not name the rule: %v", name, err)
}
}
if r.bus.asks() != 0 || len(up.seen()) != 0 || fake.body != nil {
t.Errorf("%d asks queued, %d uploads asked, printer sent %d bytes", r.bus.asks(), len(up.seen()), len(fake.body))
}
}
// 1598/AC2: with path and node g14, printer.print asks g14's node-files to upload the path into brother's bucket, and
// queues a print ask that carries the returned key and the options: under 4 KiB, and no path, node or bytes.
func TestPrintOfAFileOfAnotherMachineUploadsThenPrintsTheKey(t *testing.T) {
fake := newIPPFake(t, 31, 5)
doc := pdfOf(6000)
key := sha256Hex(doc)
up := withUpload(t, key, nil)
r := newRig(t, `{"printer":"192.0.2.10"}`, "pw")
r.objects.Put(t.Context(), key, "application/pdf", doc) // what g14's upload stored
got, err := r.call(t, "printer.print", map[string]any{"path": "/home/ops/letter.pdf", "node": "g14", "copies": float64(2)})
if err != nil {
t.Fatal(err)
}
if len(up.seen()) != 1 || up.seen()[0] != "g14|/home/ops/letter.pdf|brother-test" {
t.Fatalf("uploads asked: %v", up.seen())
}
if len(r.bus.published) != 1 {
t.Fatalf("%d asks queued", len(r.bus.published))
}
queued := r.bus.published[0]
raw, _ := json.Marshal(queued)
var fields map[string]any
json.Unmarshal(raw, &fields)
if queued.Op != OpPrint || queued.Object != key || queued.Copies != 2 || len(raw) >= 4096 {
t.Errorf("queued %s", raw)
}
for _, banned := range []string{"path", "node", "url", "data"} {
if _, has := fields[banned]; has {
t.Errorf("the queued ask carries %q: %s", banned, raw)
}
}
m := got.(map[string]any)
if m["job-id"] != float64(31) || m["node"] != "g14" || m["path"] != "/home/ops/letter.pdf" {
t.Errorf("answer = %v", m)
}
if _, sent := fake.request(t); string(sent) != string(doc) {
t.Errorf("the printer was not sent the stored document")
}
}
// 1598/AC3: an upload refused by node-files is answered with node-files' reason, and nothing is printed.
func TestARefusedUploadIsAnsweredWithItsReasonAndNothingIsPrinted(t *testing.T) {
fake := newIPPFake(t, 1, 3)
up := withUpload(t, "", errors.New("/home/ops/.ssh/id_ed25519 is refused by name: it is in .ssh, which holds an account's credentials"))
r := newRig(t, `{"printer":"192.0.2.10"}`, "pw")
_, err := r.call(t, "printer.print", map[string]any{"path": "/home/ops/.ssh/id_ed25519", "node": "g14"})
if err == nil || !strings.Contains(err.Error(), "is refused by name: it is in .ssh") {
t.Fatalf("error = %v", err)
}
if len(up.seen()) != 1 || r.bus.asks() != 0 || fake.body != nil {
t.Errorf("%d uploads, %d asks queued, printer sent %d bytes", len(up.seen()), r.bus.asks(), len(fake.body))
}
}
// 1598/AC4: the device reads document-format-supported before the job and refuses a format the printer does not
// list, naming the list; a printer that cannot be asked is sent nothing.
func TestTheDeviceChecksTheFormatsThePrinterListsBeforeTheJob(t *testing.T) {
fake := newIPPFake(t, 5, 3)
r := newRig(t, `{"printer":"192.0.2.10"}`, "pw")
doc := pdfOf(300)
key := sha256Hex(doc) + ".pdf"
r.objects.Put(t.Context(), key, "application/pdf", doc)
if _, err := r.call(t, "printer.print", map[string]any{"object": key}); err != nil {
t.Fatal(err)
}
if fake.asked != 1 || fake.body == nil {
t.Fatalf("the formats were asked %d times, the job was sent: %v", fake.asked, fake.body != nil)
}
fake.body, fake.formats = nil, []string{"image/urf", "image/jpeg"}
_, err := r.call(t, "printer.print", map[string]any{"object": key})
if err == nil || !strings.Contains(err.Error(), "application/pdf") || !strings.Contains(err.Error(), "image/urf, image/jpeg") {
t.Fatalf("a format not listed: error = %v", err)
}
if fake.body != nil {
t.Error("a job was sent in a format the printer does not list")
}
fake.formats = nil
if _, err := r.call(t, "printer.print", map[string]any{"object": key}); err == nil || fake.body != nil {
t.Errorf("a printer that lists nothing: error = %v, sent %v", err, fake.body != nil)
}
}
// 1598/AC5: brother's s3-bucket requirement shares put with seat:node-files; brother invokes seat:node-files.upload;
// the bucket's name reaches the tool side through a file the mesh writes from the binding.
func TestManifestSharesPutWithNodeFilesAndInvokesItsUpload(t *testing.T) {
m := readManifest(t)
if len(m.Requires) != 1 || m.Requires[0].Name != "s3-bucket" || len(m.Requires[0].Shares) != 1 ||
m.Requires[0].Shares[0].To != "seat:node-files" || strings.Join(m.Requires[0].Shares[0].Methods, ",") != "put" {
t.Errorf("requires = %+v", m.Requires)
}
if len(m.Invokes) != 1 || m.Invokes[0] != "seat:node-files.upload" {
t.Errorf("invokes = %v", m.Invokes)
}
var bucket map[string]any
for _, r := range m.Resources {
if r["id"] == "bucket" {
bucket = r
}
}
if bucket == nil || bucket["path"] != "${dir:state}/bucket.json" || bucket["template"] != "files/bucket.json" {
t.Fatalf("bucket resource = %v", bucket)
}
raw, _ := os.ReadFile("../../files/bucket.json")
if !strings.Contains(string(raw), "${bound:s3-bucket:bucket}") || strings.Contains(string(raw), "secret") {
t.Errorf("files/bucket.json = %s", raw)
}
for _, a := range m.Build.Artifacts {
if a["name"] == "tools" && a["env"].(map[string]any)["BROTHER_BUCKET"] != "${dir:state}/bucket.json" {
t.Errorf("the tools bundle is not given BROTHER_BUCKET: %v", a["env"])
}
}
}
// quickly makes a call wait this long before it answers started, for the test.
func quickly(t *testing.T, d time.Duration) {
t.Helper()
old := quickWait
quickWait = d
t.Cleanup(func() { quickWait = old })
}
// 1598/AC7: a device answer slower than the call's bound is answered as started with the ask id within the bound, and
// printer.print {ask} then returns the device's answer.
func TestASlowPrintIsAnsweredAsStartedAndReadWithItsAsk(t *testing.T) {
fake := newIPPFake(t, 71, 5)
fake.delay = 400 * time.Millisecond
quickly(t, 60*time.Millisecond)
r := newRig(t, `{"printer":"192.0.2.10"}`, "pw")
doc := pdfOf(300)
key := sha256Hex(doc) + ".pdf"
r.objects.Put(t.Context(), key, "application/pdf", doc)
began := time.Now()
got, err := r.call(t, "printer.print", map[string]any{"object": key})
if err != nil {
t.Fatal(err)
}
m := got.(map[string]any)
id, _ := m["ask"].(string)
if m["state"] != "started" || id == "" || time.Since(began) > 300*time.Millisecond {
t.Fatalf("answer %v after %s", m, time.Since(began))
}
if QuickWait != 25*time.Second {
t.Errorf("a call waits %s, want 25s", QuickWait)
}
// Read while it goes on, and after: the same id.
deadline := time.Now().Add(5 * time.Second)
for {
read, err := r.call(t, "printer.print", map[string]any{"ask": id})
if err != nil {
t.Fatal(err)
}
rm := read.(map[string]any)
if rm["state"] == nil {
if rm["job-id"] != float64(71) || rm["object"] != key {
t.Errorf("the outcome = %v", rm)
}
break
}
if time.Now().After(deadline) {
t.Fatalf("never done: %v", rm)
}
}
// A restarted tool process has no job: the device's answers are read.
r2 := newRig(t, `{"printer":"192.0.2.10"}`, "pw")
r2.answers.m = map[string]json.RawMessage{"0123456789abcdef01234567": json.RawMessage(`{"job-id": 5}`)}
if read, err := r2.call(t, "printer.print", map[string]any{"ask": "0123456789abcdef01234567"}); err != nil || read.(map[string]any)["job-id"] != float64(5) {
t.Errorf("read from the answers: %v, %v", read, err)
}
if _, err := r.call(t, "printer.print", map[string]any{"ask": id, "copies": float64(2)}); err == nil {
t.Error("ask with another argument was accepted")
}
if _, err := r.call(t, "printer.print", map[string]any{"ask": "../x"}); err == nil {
t.Error("a made-up ask id was accepted")
}
}
// 1598/AC8: printer.print {path, node} whose upload and print together pass the bound answers started with the ask id,
// never a timeout, and the print follows.
func TestASlowUploadAndPrintAnswersStartedNeverATimeout(t *testing.T) {
fake := newIPPFake(t, 72, 5)
fake.delay = 100 * time.Millisecond
quickly(t, 60*time.Millisecond)
doc := pdfOf(300)
key := sha256Hex(doc)
up := withUpload(t, key, nil)
old := uploadVia
uploadVia = func(node, path, bucket string) (string, error) {
time.Sleep(150 * time.Millisecond) // node-files is slow
return old(node, path, bucket)
}
t.Cleanup(func() { uploadVia = old })
r := newRig(t, `{"printer":"192.0.2.10"}`, "pw")
r.objects.Put(t.Context(), key, "application/pdf", doc)
got, err := r.call(t, "printer.print", map[string]any{"path": "/home/ops/letter.pdf", "node": "g14"})
if err != nil {
t.Fatalf("a slow upload failed the call: %v", err)
}
m := got.(map[string]any)
id, _ := m["ask"].(string)
if m["state"] != "started" || id == "" || len(up.seen()) > 1 {
t.Fatalf("answer %v", m)
}
for deadline := time.Now().Add(5 * time.Second); ; {
read, err := r.call(t, "printer.print", map[string]any{"ask": id})
if err != nil {
t.Fatal(err)
}
if rm := read.(map[string]any); rm["state"] == nil {
if rm["job-id"] != float64(72) || rm["path"] != "/home/ops/letter.pdf" || rm["node"] != "g14" {
t.Errorf("the outcome = %v", rm)
}
break
}
if time.Now().After(deadline) {
t.Fatal("never done")
}
}
if len(up.seen()) != 1 || len(r.bus.published) != 1 || r.bus.published[0].Object != key {
t.Errorf("uploads %v, asks %v", up.seen(), r.bus.published)
}
// An upload that fails after the call answered is the outcome, read with the ask.
uploadVia = func(node, path, bucket string) (string, error) {
time.Sleep(150 * time.Millisecond)
return "", errors.New("/home/ops/.ssh/id_ed25519 is refused by name")
}
got, _ = r.call(t, "printer.print", map[string]any{"path": "/home/ops/.ssh/id_ed25519", "node": "g14"})
id2, _ := got.(map[string]any)["ask"].(string)
time.Sleep(300 * time.Millisecond)
if _, err := r.call(t, "printer.print", map[string]any{"ask": id2}); err == nil || !strings.Contains(err.Error(), "refused by name") {
t.Errorf("the failed upload's outcome: %v", err)
}
}
+7 -1
View File
@@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"github.com/nats-io/nats.go/jetstream"
"net/http"
"os"
"path/filepath"
"sync"
@@ -144,7 +145,12 @@ func newRig(t *testing.T, settings, password string) *printerRig {
bus := &fakeAskBus{worker: w, answers: answers}
old, oldWait, oldScan, oldPoll := askBus, answerWait, scanWait, answerPoll
askBus, answerWait, scanWait, answerPoll = bus, 5*time.Second, 5*time.Second, 5*time.Millisecond
t.Cleanup(func() { askBus, answerWait, scanWait, answerPoll = old, oldWait, oldScan, oldPoll })
oldFetch := fetchClient
fetchClient = &http.Client{Transport: objects}
t.Cleanup(func() {
askBus, answerWait, scanWait, answerPoll = old, oldWait, oldScan, oldPoll
fetchClient = oldFetch
})
tl := tools(file, func() time.Time { return fixedNow })
return &printerRig{tl: tl, run: tool(t, tl, "printer.settings").Run, bus: bus, worker: w, answers: answers, objects: objects, said: &out, mu: &mu}
}
+61 -35
View File
@@ -1,12 +1,15 @@
package main
import (
"bytes"
"context"
"encoding/base64"
"fmt"
"net/http"
"strings"
"sync"
"time"
"git.novox.be/novox/mesh-sdk/go/objectlink"
)
// EventAdded is the module's event: a scan was kept in the bucket (ADR 0464, ADR 0468 clause 2), said by the device
@@ -29,6 +32,15 @@ type Service struct {
QuickWait, FeederWait time.Duration
// Jobs are the scans this process follows.
Jobs *Jobs
// Upload asks a machine's node-files to send one of its files into the bucket and answers the key; Bucket is
// brother's bucket. Both are the tool side's (printer.print with path and node).
// Fetch reads the links the device process signs; DocExpiry and DocRetry are the open documents' waits. They are
// copied into the Service when it is made, because background work reads them while a test (or a reload) changes the
// package's own, which would race.
Fetch *http.Client
DocExpiry, DocRetry time.Duration
Upload func(node, path, bucket string) (string, error)
Bucket func() (string, error)
}
// Jobs are the scans this tool process has started and not yet been asked the outcome of. A scan runs in the
@@ -229,7 +241,7 @@ func (s *Service) finishScan(ctx context.Context, id string, wait time.Duration,
}
if doc, _ := ans["document"].(string); doc != "" && ans["key"] == nil {
// A page was added to an open document: nothing to file yet, and the document is closed if it is left open.
s.armDocument(doc, docExpiry, 0)
s.armDocument(doc, s.docExpiry(), 0)
return ans, nil
}
if ask.Document != "" && ask.Finish {
@@ -280,26 +292,19 @@ func (s *Service) fileAnswer(ctx context.Context, ans map[string]any) (map[strin
func (s *Service) fileOne(ctx context.Context, ans map[string]any) (map[string]any, error) {
key, _ := ans["key"].(string)
sum, _ := ans["sha256"].(string)
parts, _ := ans["parts"].(float64)
if key == "" || sum == "" || parts < 1 || parts > MostAnswerParts {
return nil, fmt.Errorf("the device process's answer to the scan names no stored scan: %v", ans)
link, _ := ans["link"].(string)
if key == "" || sum == "" || link == "" {
return nil, fmt.Errorf("the device process's answer to the scan names no stored scan or no link to it: %v", withoutLink(ans))
}
got := make([][]byte, 0, int(parts))
for i := 1; i <= int(parts); i++ {
p, err := s.fetchPart(ctx, key, i)
if err != nil && ctx.Err() == nil {
// A part fetch is a read, safe to repeat: one retry rides out a short bus outage (issue 1536).
p, err = s.fetchPart(ctx, key, i)
}
if err != nil {
return nil, fmt.Errorf("the scan is kept as %s, but its part %d could not be fetched: %w", key, i, err)
}
got = append(got, p)
}
pdf, err := joinParts(got, sum)
if err != nil {
return nil, fmt.Errorf("the scan is kept as %s: %w", key, err)
// The scan is read from the store by the link the device process signed, never through the bus (ADR 0468 §4), and
// must hash to the scan's SHA-256 before anything is written.
var buf bytes.Buffer
gctx, cancel := context.WithTimeout(ctx, 5*time.Minute)
defer cancel()
if _, err := objectlink.Get(gctx, s.fetchClient(), link, &buf, objectlink.MaxDocument, sum); err != nil {
return nil, fmt.Errorf("the scan is kept as %s, but it could not be read from the store: %w", key, err)
}
pdf := buf.Bytes()
at, err := time.Parse(time.RFC3339, fmt.Sprint(ans["scanned-at"]))
if err != nil {
at = s.Now()
@@ -310,7 +315,7 @@ func (s *Service) fileOne(ctx context.Context, ans map[string]any) (map[string]a
}
body := map[string]any{}
for k, v := range ans {
if k != "key" && k != "size" && k != "parts" {
if k != "key" && k != "size" && k != "link" && k != "link-seconds" {
body[k] = v
}
}
@@ -318,20 +323,41 @@ func (s *Service) fileOne(ctx context.Context, ans map[string]any) (map[string]a
return body, nil
}
// fetchPart is one part of a stored object, asked of the device process and decoded.
func (s *Service) fetchPart(ctx context.Context, object string, part int) ([]byte, error) {
ans, err := s.call(ctx, Ask{Op: OpDocument, Object: object, Part: part}, s.AnswerWait)
if err != nil {
return nil, err
// withoutLink is an answer said in an error without its link, which carries a signature.
func withoutLink(ans map[string]any) map[string]any {
out := map[string]any{}
for k, v := range ans {
if k != "link" {
out[k] = v
}
}
data, _ := ans["data"].(string)
raw, err := base64.StdEncoding.DecodeString(data)
if err != nil {
return nil, fmt.Errorf("the part is not base64: %w", err)
}
return raw, nil
return out
}
func (s *Service) fetchClient() *http.Client {
if s.Fetch != nil {
return s.Fetch
}
return &http.Client{}
}
func (s *Service) docExpiry() time.Duration {
if s.DocExpiry > 0 {
return s.DocExpiry
}
return 30 * time.Minute
}
func (s *Service) docRetry() time.Duration {
if s.DocRetry > 0 {
return s.DocRetry
}
return 5 * time.Minute
}
// fetchClientDefault is what a Service made by the tools reads the links with; a test puts a client of its own in its place before it makes the tools.
var fetchClient = &http.Client{}
// ScanStatus says whether the scanner answers, and what it is doing. A scanner that does not answer is an
// answer, not an error: that is how the module's health is read.
func (s *Service) ScanStatus(ctx context.Context) (map[string]any, error) {
@@ -370,7 +396,7 @@ func (s *Service) armDocument(doc string, d time.Duration, attempts int) {
if err == nil || attempts >= 10 || strings.Contains(err.Error(), "is closed already") || strings.Contains(err.Error(), "its id is wrong") {
return
}
s.armDocument(doc, docRetry, attempts+1)
s.armDocument(doc, s.docRetry(), attempts+1)
})
}
@@ -388,9 +414,9 @@ func (s *Service) Recover(ctx context.Context) error {
if id == "" {
continue
}
left := docExpiry
left := s.docExpiry()
if at, err := time.Parse(time.RFC3339, fmt.Sprint(m["updated"])); err == nil {
left = max(docExpiry-s.Now().Sub(at), time.Second)
left = max(s.docExpiry()-s.Now().Sub(at), time.Second)
}
s.armDocument(id, left, 0)
}
@@ -0,0 +1,157 @@
package main
import (
"encoding/json"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
)
// 1601/AC1: scanner.scan reads the stored PDF by the GET link the device's answer carries (never by parts) and writes it
// into the scan folder; the answer is the event's body plus the path.
func TestTheScanIsReadFromTheStoreByTheLinkAndFiled(t *testing.T) {
f := newFakeScanner(t, fixture(t, "scan.jpg"))
r, folder := scanRig(t, f, Settings{Crop: false})
body, err := r.scan(t)
if err != nil {
t.Fatal(err)
}
if len(*r.said) != 1 {
t.Fatalf("%d events", len(*r.said))
}
stored, err := r.objects.Get(t.Context(), (*r.said)[0].body["object"].(string))
if err != nil {
t.Fatal(err)
}
filed, err := os.ReadFile(body["path"].(string))
if err != nil || string(filed) != string(stored) {
t.Fatalf("the filed scan is not the stored one: %v", err)
}
if filepath.Dir(body["path"].(string)) != folder {
t.Errorf("filed in %s, want %s", filepath.Dir(body["path"].(string)), folder)
}
for _, a := range r.bus.published {
if a.Op == OpDocument {
t.Errorf("a part of the scan was asked of the device process over the bus")
}
}
for k := range (*r.said)[0].body {
if body[k] == nil && k != "path" {
t.Errorf("the event's %s is not in the answer", k)
}
}
if body["link"] != nil || body["parts"] != nil || body["key"] != nil {
t.Errorf("the answer carries the transport's own fields: %v", body)
}
}
// 1601/AC2: the device's answer carries a link that expires within ten minutes and no bytes of the document; the real
// store signs it with the binding's key for the host the binding gives.
func TestTheDeviceAnswerCarriesALinkAndNoBytes(t *testing.T) {
f := newFakeScanner(t, fixture(t, "scan.jpg"))
r, _ := scanRig(t, f, Settings{Crop: false})
if _, err := r.scan(t); err != nil {
t.Fatal(err)
}
var ans map[string]any
for _, raw := range r.answers.m {
json.Unmarshal(raw, &ans)
}
link, _ := ans["link"].(string)
u, err := url.Parse(link)
if err != nil || link == "" {
t.Fatalf("answer %v", ans)
}
secs, _ := strconv.Atoi(u.Query().Get("X-Amz-Expires"))
if secs < 1 || time.Duration(secs)*time.Second > 10*time.Minute {
t.Errorf("the link lives %d s", secs)
}
raw, _ := json.Marshal(ans)
if ans["data"] != nil || len(raw) > 4096 {
t.Errorf("the answer is %d bytes and carries data %v", len(raw), ans["data"] != nil)
}
// The real store: the binding's key signs, for the binding's host and port, in the module's bucket.
s3 := NewS3(StoreFacts{Scheme: "http", At: "novox.internal", Port: "20001", Bucket: "mesh-shanks-brother", Region: "eu-west",
Key: "binding-key", Secret: "binding-secret"})
s3.Now = func() time.Time { return fixedNow }
got, err := s3.Link("GET", "abc.pdf", 10*time.Minute)
if err != nil {
t.Fatal(err)
}
gu, _ := url.Parse(got)
if gu.Host != "novox.internal:20001" || gu.Path != "/mesh-shanks-brother/abc.pdf" ||
!strings.HasPrefix(gu.Query().Get("X-Amz-Credential"), "binding-key/") || gu.Query().Get("X-Amz-Expires") != "600" {
t.Errorf("link %s", got)
}
if _, err := s3.Link("GET", "abc.pdf", 11*time.Minute); err == nil {
t.Error("a link over ten minutes was signed")
}
}
// 1601/AC3: a body whose SHA-256 differs from the scan's writes no file and answers the mismatch.
func TestABodyOfAnotherHashWritesNoFile(t *testing.T) {
f := newFakeScanner(t, fixture(t, "scan.jpg"))
r, folder := scanRig(t, f, Settings{Crop: false})
r.objects.corrupt = true
_, err := r.scan(t)
if err == nil || !strings.Contains(err.Error(), "hashes to") {
t.Fatalf("error = %v", err)
}
if e, _ := os.ReadDir(folder); len(e) != 0 {
t.Errorf("a scan of the wrong hash was filed: %v", e)
}
}
// 1601/AC4: brother.scan.added is said before the file is written, once the scan is stored, and is under 4 KiB.
func TestTheEventIsSaidBeforeTheFileIsWritten(t *testing.T) {
f := newFakeScanner(t, fixture(t, "scan.jpg"))
r, folder := scanRig(t, f, Settings{Crop: false})
var filedWhenSaid = -1
emit := r.worker.Emit
r.worker.Emit = func(body map[string]any, id string) error {
e, _ := os.ReadDir(folder)
filedWhenSaid = len(e)
return emit(body, id)
}
if _, err := r.scan(t); err != nil {
t.Fatal(err)
}
if filedWhenSaid != 0 {
t.Errorf("%d files in the folder when the event was said, want 0", filedWhenSaid)
}
said := (*r.said)[0]
if !said.seen {
t.Error("the event was said before the scan was stored")
}
raw, _ := json.Marshal(said.body)
if len(raw) >= 4096 || said.body["path"] != nil {
t.Errorf("the event is %d bytes, path %v", len(raw), said.body["path"])
}
}
// 1601/AC5: no code of the device process writes a file outside its state directory: the only writes to the disk in the
// module's code are the filing of a scan (file.go), made by the tool side (scan.go).
func TestOnlyTheFilingWritesFiles(t *testing.T) {
writes := []string{"os.WriteFile", "os.Create(", "os.OpenFile", "os.MkdirAll", "os.Mkdir(", "os.Rename", "os.CreateTemp", "File(s.Settings.Folder"}
files, _ := filepath.Glob("*.go")
for _, name := range files {
if strings.HasSuffix(name, "_test.go") || name == "file.go" {
continue
}
raw, _ := os.ReadFile(name)
for _, w := range writes {
if strings.Contains(string(raw), w) && !(name == "scan.go" && w == "File(s.Settings.Folder") {
t.Errorf("%s writes with %s: only file.go does, and scan.go (the tool side) calls it", name, w)
}
}
}
raw, _ := os.ReadFile("deviceproc.go")
if strings.Contains(string(raw), "File(") {
t.Error("deviceproc.go files a scan")
}
}
@@ -362,38 +362,3 @@ func (b *flakyBus) Publish(id string, body any) error {
}
return b.fakeAskBus.Publish(id, body)
}
// 1536: a part fetch that fails once is asked again, and the scan is filed; one that fails twice is a failure
// naming the stored object, so it can be filed later.
func TestAFailedPartFetchIsAskedOnceMoreBeforeTheScanFails(t *testing.T) {
f := newFakeScanner(t, fixture(t, "scan.jpg"))
r, _ := scanRig(t, f, Settings{Crop: false})
r.bus = nil
flaky := &flakyBus{fakeAskBus: &fakeAskBus{worker: r.worker, answers: r.answers}, fails: 1}
askBus = flaky
body, err := r.scan(t)
if err != nil {
t.Fatalf("one failed part fetch ended the scan: %v", err)
}
if _, err := os.Stat(body["path"].(string)); err != nil {
t.Errorf("the scan is not filed: %v", err)
}
f2 := newFakeScanner(t, fixture(t, "scan.jpg"))
r2, folder := scanRig(t, f2, Settings{Crop: false})
askBus = &flakyBus{fakeAskBus: &fakeAskBus{worker: r2.worker, answers: r2.answers}, fails: 2}
_, err = r2.scan(t)
if err == nil {
t.Fatal("two failed part fetches did not fail the scan")
}
key := ""
if got := *r2.said; len(got) == 1 {
key, _ = got[0].body["object"].(string)
}
if key == "" || !strings.Contains(err.Error(), key) {
t.Errorf("the failure %q does not name the stored object %q", err, key)
}
if entries, _ := os.ReadDir(folder); len(entries) != 0 {
t.Errorf("a scan whose parts failed was filed: %v", entries)
}
}
+23
View File
@@ -108,3 +108,26 @@ func (s Settings) MissingPrinter() error {
return fmt.Errorf("config value %s is not set: the operator gives it with the config verb for the brother module on this node",
strings.Join(unset, " and "))
}
// bucketFile is the file the mesh writes from the s3-bucket binding (files/bucket.json): the bucket's name and no secret.
// BucketFile is where the tool side reads brother's bucket from: a file the mesh writes from the s3-bucket binding
// (files/bucket.json), which holds the bucket's name and no secret.
type bucketFile struct {
Bucket string `json:"bucket"`
}
// ReadBucket is brother's bucket, as the mesh named it.
func ReadBucket(path string) (string, error) {
if path == "" {
return "", fmt.Errorf("the module was started without the bucket's name (BROTHER_BUCKET)")
}
raw, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("the bucket's name cannot be read: %w", err)
}
var f bucketFile
if err := json.Unmarshal(raw, &f); err != nil || f.Bucket == "" {
return "", fmt.Errorf("the bucket file %s names no bucket", path)
}
return f.Bucket, nil
}
+3
View File
@@ -0,0 +1,3 @@
{
"bucket": "${bound:s3-bucket:bucket}"
}
+3
View File
@@ -0,0 +1,3 @@
{
"mesh-range": "${machine:mesh-range}"
}
+1 -1
View File
@@ -3,7 +3,7 @@ module brother
go 1.26.0
require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261020060201-88581d0acaef
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021122348-6c857e1c025f
github.com/nats-io/nats-server/v2 v2.11.17
github.com/nats-io/nats.go v1.54.0
)
+2
View File
@@ -1,5 +1,7 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261020060201-88581d0acaef h1:xy3wuGWJpqMAXYdXolocYG6w9xnWqBltVUffoaziIKo=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261020060201-88581d0acaef/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021122348-6c857e1c025f h1:7J/9jOfSfjfYpf8MOj96HwQRViQUkBNMb71RHP2mjn8=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021122348-6c857e1c025f/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
+38 -4
View File
@@ -2,7 +2,20 @@
"module": "brother",
"version": "1",
"requires": [
"s3-bucket"
{
"name": "s3-bucket",
"shares": [
{
"to": "seat:node-files",
"methods": [
"put"
]
}
]
}
],
"invokes": [
"seat:node-files.upload"
],
"binds": {
"s3-bucket": "${dir:state}/store-binding.json"
@@ -199,6 +212,21 @@
"mode": "0600",
"template": "files/store.json"
},
{
"id": "bucket",
"type": "file",
"path": "${dir:state}/bucket.json",
"mode": "0644",
"template": "files/bucket.json"
},
{
"id": "fetch",
"type": "file",
"path": "${dir:state}/fetch.json",
"owner": "brother-device",
"mode": "0600",
"template": "files/fetch.json"
},
{
"id": "device",
"type": "process",
@@ -213,11 +241,13 @@
"BROTHER_SETTINGS": "${dir:state}/settings.json",
"BROTHER_ADMIN_PASSWORD": "${dir:state}/admin-password.secret",
"BROTHER_BROKER": "${dir:state}/broker-device.secret",
"BROTHER_STORE": "${dir:state}/store.json"
"BROTHER_STORE": "${dir:state}/store.json",
"BROTHER_FETCH": "${dir:state}/fetch.json"
},
"restart-on": [
"settings",
"store"
"store",
"fetch"
],
"bus": {
"queues": [
@@ -228,6 +258,9 @@
],
"emits": [
"scan.added"
],
"invokes": [
"seat:document-converter.convert"
]
},
"health": {
@@ -267,7 +300,8 @@
"brother"
],
"env": {
"BROTHER_SETTINGS": "${dir:state}/settings.json"
"BROTHER_SETTINGS": "${dir:state}/settings.json",
"BROTHER_BUCKET": "${dir:state}/bucket.json"
}
},
{
+28 -3
View File
@@ -476,10 +476,13 @@ type PluginFile struct {
// what it is given. Outside hooks/, so no registered hook's name can be it.
const GuardDir = "guard"
// GuardFiles are the guard as the plugin carries it: the binary to copy and the data it judges with.
// GuardFiles are the guard as the plugin carries it: the binary to copy, what it judges with, and — the same
// binary again — the waits directory the SessionStart and Stop hooks are given (novox/hq ADR 0489, issues
// 1744, 1743). Empty WaitsDir places no such hook.
type GuardFiles struct {
Exe string
Data GuardData
Exe string
Data GuardData
WaitsDir string
}
// GuardMatcher is what the guard is asked about: every tool. Its own rules judge the shell and the agent's file
@@ -495,6 +498,20 @@ func GuardCommand() string {
return `"` + filepath.Join(dir, "guard") + `" guard "` + filepath.Join(dir, "guard.json") + `"`
}
// SessionStartCommand is the plugin's SessionStart hook: the guard's own binary, told the session's open
// waits live under waitsDir (novox/hq ADR 0489 §3, issue 1744).
func SessionStartCommand(waitsDir string) string {
dir := filepath.Join(ManagedDir, MarketplaceDir, "plugins", Plugin, GuardDir)
return `"` + filepath.Join(dir, "guard") + `" session-start "` + waitsDir + `"`
}
// StopCommand is the plugin's Stop hook: the guard's own binary, refusing to end the turn while a wait under
// waitsDir is open and unwatched, or past its bound and untold (novox/hq ADR 0489 §3-§6, issue 1743).
func StopCommand(waitsDir string) string {
dir := filepath.Join(ManagedDir, MarketplaceDir, "plugins", Plugin, GuardDir)
return `"` + filepath.Join(dir, "guard") + `" stop "` + waitsDir + `"`
}
// Marketplace is the marketplace directory's whole content, by path inside it. A node item of a name laid
// over a mesh item of the same kind and name: the node's wins.
func Marketplace(c Config) map[string]PluginFile { return MarketplaceWith(c, nil) }
@@ -533,6 +550,14 @@ func MarketplaceWith(c Config, guard *GuardFiles) map[string]PluginFile {
out[root+GuardDir+"/guard.json"] = PluginFile{Content: jsonFile(guard.Data)}
hooks["PreToolUse"] = append(hooks["PreToolUse"], map[string]any{"matcher": GuardMatcher,
"hooks": []any{map[string]any{"type": "command", "command": GuardCommand(), "timeout": 15}}})
if guard.WaitsDir != "" {
// A session's wait list (novox/hq ADR 0489): the SessionStart hook names the session's id and its open
// waits (issue 1744); the Stop hook keeps the turn open while one is unwatched or untold (issue 1743).
hooks["SessionStart"] = append(hooks["SessionStart"], map[string]any{"matcher": "startup|resume|clear|compact",
"hooks": []any{map[string]any{"type": "command", "command": SessionStartCommand(guard.WaitsDir), "timeout": 10}}})
hooks["Stop"] = append(hooks["Stop"], map[string]any{
"hooks": []any{map[string]any{"type": "command", "command": StopCommand(guard.WaitsDir), "timeout": 5}}})
}
}
for _, k := range keys {
it := chosen[k]
@@ -0,0 +1,139 @@
package main
// Every call a hook prints for a session to copy must be accepted by claude_code_wait's own argument check
// (issue 1757): the Stop hook's suggested watched and said calls, and the SessionStart hook's suggested
// adopt call.
import (
"encoding/json"
"strings"
"testing"
"time"
)
// suggestedCall extracts the one JSON object embedded in a hook's message, right after "claude_code_wait "
// — what a session is expected to copy verbatim — and parses it into the arguments a tool call takes.
func suggestedCall(t *testing.T, msg string) map[string]any {
t.Helper()
i := strings.Index(msg, "claude_code_wait ")
if i < 0 {
t.Fatalf("no suggested call in: %s", msg)
}
rest := msg[i+len("claude_code_wait "):]
start := strings.Index(rest, "{")
if start < 0 {
t.Fatalf("no JSON object in: %s", msg)
}
depth, end := 0, -1
for j, c := range rest[start:] {
switch c {
case '{':
depth++
case '}':
depth--
if depth == 0 {
end = start + j + 1
}
}
if end >= 0 {
break
}
}
if end < 0 {
t.Fatalf("unbalanced JSON object in: %s", msg)
}
var args map[string]any
if err := json.Unmarshal([]byte(rest[start:end]), &args); err != nil {
t.Fatalf("the suggested call is not valid JSON: %v (%s)", err, rest[start:end])
}
return args
}
// Issue 1757: the Stop hook's suggested watched call carries `by`, and the tool accepts it as printed.
func TestTheStopHooksSuggestedWatchedCallIsAcceptedByTheTool(t *testing.T) {
p, _ := node(t, "g14")
store := newMemWaits()
waiting = &Waiting{Store: store, Now: func() time.Time { return t0 }}
t.Cleanup(func() { waiting = nil })
s := serve(t, p, &bus{kept: map[string]map[string]any{}}, memConfig{})
text, isErr := s.call(t, "claude_code_wait", map[string]any{"op": "add", "session": "verify-0489-test", "by": "fix-1757@g14",
"what": "the operator's Approve", "read": map[string]any{"address": "mesh-delivery.recheck"}, "bound_minutes": 10})
if isErr {
t.Fatalf("add: %s", text)
}
f, err := readWaitsFile(waitsFilePath(p, "verify-0489-test"))
if err != nil || len(f.Waits) != 1 {
t.Fatalf("%+v %v", f, err)
}
_, reason := waitBlocksStop("verify-0489-test", f.Waits[0], t0.Add(time.Minute))
if !strings.Contains(reason, "\"by\":\"fix-1757\"") {
t.Fatalf("the suggested call does not carry by: %s", reason)
}
args := suggestedCall(t, reason)
text, isErr = s.call(t, "claude_code_wait", args)
if isErr {
t.Fatalf("the suggested watched call, as printed, was refused: %s", text)
}
}
// Issue 1757: the Stop hook's suggested said call (past its bound) carries `by` too.
func TestTheStopHooksSuggestedSaidCallIsAcceptedByTheTool(t *testing.T) {
p, _ := node(t, "g14")
store := newMemWaits()
clock := t0
waiting = &Waiting{Store: store, Now: func() time.Time { return clock }}
t.Cleanup(func() { waiting = nil })
s := serve(t, p, &bus{kept: map[string]map[string]any{}}, memConfig{})
text, isErr := s.call(t, "claude_code_wait", map[string]any{"op": "add", "session": "verify-0489-test", "by": "fix-1757@g14",
"what": "the operator's Approve", "read": map[string]any{"address": "mesh-delivery.recheck"}, "bound_minutes": 1})
if isErr {
t.Fatalf("add: %s", text)
}
clock = t0.Add(2 * time.Minute) // past its 1-minute bound, for the live tool call too
f, err := readWaitsFile(waitsFilePath(p, "verify-0489-test"))
if err != nil || len(f.Waits) != 1 {
t.Fatalf("%+v %v", f, err)
}
_, reason := waitBlocksStop("verify-0489-test", f.Waits[0], clock)
if !strings.Contains(reason, "\"by\":\"fix-1757\"") {
t.Fatalf("the suggested call does not carry by: %s", reason)
}
args := suggestedCall(t, reason)
text, isErr = s.call(t, "claude_code_wait", args)
if isErr {
t.Fatalf("the suggested said call, as printed, was refused: %s", text)
}
}
// Issue 1757: the SessionStart hook's suggested adopt call already carries `by` — checked, as asked, so a
// regression here is caught the same way as the Stop hook's.
func TestTheSessionStartHooksSuggestedAdoptCallIsAcceptedByTheTool(t *testing.T) {
p, _ := node(t, "g14")
store := newMemWaits()
waiting = &Waiting{Store: store, Now: func() time.Time { return t0 }}
t.Cleanup(func() { waiting = nil })
s := serve(t, p, &bus{kept: map[string]map[string]any{}}, memConfig{})
text, isErr := s.call(t, "claude_code_wait", map[string]any{"op": "add", "session": "sess-old", "by": "fix-1757@g14",
"what": "the operator's Approve", "read": map[string]any{"address": "mesh-delivery.recheck"}, "bound_minutes": 10})
if isErr {
t.Fatalf("add: %s", text)
}
msg := SessionStartMessage("sess-new", WaitsDirPath(p), t0.Add(time.Minute))
if !strings.Contains(msg, "\"by\":\"fix-1757@g14\"") {
t.Fatalf("the suggested adopt call does not carry by: %s", msg)
}
args := suggestedCall(t, msg)
text, isErr = s.call(t, "claude_code_wait", args)
if isErr {
t.Fatalf("the suggested adopt call, as printed, was refused: %s", text)
}
}
@@ -0,0 +1,23 @@
package main
// Shared by the plugin's hooks (novox/hq ADR 0489 §5): a notice on exit 0 — the stop hook failing open or
// letting a wait through, the session-start hook unable to use a session id — reaches the operator's screen,
// not only the log. Claude Code shows a hook's top-level "systemMessage" field to the person; stderr alone,
// on an allowed (exit 0) outcome, is never shown there.
import (
"encoding/json"
"fmt"
"io"
)
// writeSystemMessage prints message as the one JSON object Claude Code shows the operator, falling back to
// the plain words on the one error that cannot happen (a string always marshals).
func writeSystemMessage(w io.Writer, message string) {
raw, err := json.Marshal(map[string]string{"systemMessage": message})
if err != nil {
fmt.Fprintln(w, message)
return
}
fmt.Fprintln(w, string(raw))
}
@@ -388,6 +388,7 @@ func tools(p Paths, servers ServerState, view *ServerView, config ConfigState, c
Description: "Read-only: what this node carries of the agent's memory between nodes (novox/hq ADR 0343) — the home kind it carries for (the operator's own homes, or an agent account's), each project's memory files with their fingerprint and the node and time of their last version, the files not carried and why, the conflicts still open (a file changed on two nodes, kept twice in one file, the newer first), and how many replaced or removed versions are kept. Fingerprints only, never the text; it changes nothing.",
Run: func(map[string]any) (any, error) { return memoryCarrier.Status(), nil }})
out = append(out, proposalTools(p)...)
out = append(out, waitTools(p)...)
judge := NewJudge(p)
out = append(out, judgeTool(judge), judgedTool(judge))
return append(out, configTools(p, config, configView)...)
@@ -416,6 +417,15 @@ func main() {
if len(os.Args) == 3 && os.Args[1] == "guard" {
os.Exit(runGuard(os.Args[2], os.Stdin, os.Stderr))
}
// The plugin's SessionStart hook (novox/hq ADR 0489 §1, issue 1744): names the session's id and its open waits.
if len(os.Args) == 3 && os.Args[1] == "session-start" {
os.Exit(runSessionStart(os.Args[2], os.Stdin, os.Stdout))
}
// The plugin's Stop hook (novox/hq ADR 0489 §3-§6, issue 1743): keeps the turn open while a wait is unwatched
// or untold.
if len(os.Args) == 3 && os.Args[1] == "stop" {
os.Exit(runStop(os.Args[2], os.Stdin, os.Stdout, os.Stderr))
}
// The helper that touches the agent's home as the agent's account (novox/hq ADR 0266), started by this
// module through sudo: one answer per request on stdin, until it ends.
if len(os.Args) == 2 && os.Args[1] == HelperArg {
@@ -463,6 +473,7 @@ func main() {
proposing = &Proposing{Store: stateOf{stdio.State(ProposalsState)}, Bus: sdkAskBus{}, Record: recordWarrant,
Now: time.Now, Write: writeManaged}
go proposing.watch(p)
waiting = &Waiting{Store: keptWaits{stdio.State(WaitsState)}, Now: time.Now}
memoryCarrier = NewMemoryCarrier(p, memoryStateOf{stdio.State(MemoryState)}, notifyOperator)
go memoryCarrier.Run(func(handle func(key, op string, value json.RawMessage, revision uint64) error) error {
return stdio.State(MemoryState).Watch("", func(c stdio.StateChange) error {
+1 -1
View File
@@ -278,7 +278,7 @@ func guardFiles(p Paths, m *MeshTools) *GuardFiles {
if resolved, err := filepath.EvalSymlinks(exe); err == nil {
exe = resolved
}
return &GuardFiles{Exe: exe, Data: GuardDataOf(p, m)}
return &GuardFiles{Exe: exe, Data: GuardDataOf(p, m), WaitsDir: WaitsDirPath(p)}
}
// MeshToolsEvery is how often the controller is asked again.
@@ -0,0 +1,86 @@
package main
// The plugin's SessionStart hook (novox/hq ADR 0489 §1, ADR 0490 §2, issue 1744): `claude-code session-start
// <waits-dir>`, run by the agent's own account on every start, resume, /clear and /compact. The tool runner
// never tells a session its own id (only a hook's stdin carries session_id), so a session cannot name itself
// to claude_code_wait until this hook has told it. It also lists this session id's own still-open waits (if
// any name is already bound to it) and, always, every name on this node that still holds an open wait, so a
// session with a brand new id (a /clear gives one) can see a name it may want to adopt.
//
// It reads the session's mirrored waits file and the node-wide index directly — the same local reads the
// Stop hook and claude_code_wait make (readWaitsFile, readNodeIndex, waits.go) — never calling back into
// this module: this hook runs on every start, and must not wait on the bus to do it. A missing or malformed
// file is not an error here: there is simply nothing open to report.
import (
"encoding/json"
"fmt"
"io"
"strings"
"time"
)
// SessionStartInput is what the hook is given on stdin.
type SessionStartInput struct {
SessionID string `json:"session_id"`
}
// SessionStartMessage is the line the hook prints: the session's id and how to give it to claude_code_wait,
// then each still-open wait already bound to this session id (1744/AC1, AC2), then every name on this node
// that still holds an open wait, with how to adopt it (ADR 0490 §2). Pure of IO beyond the two file reads.
func SessionStartMessage(sessionID, waitsDir string, now time.Time) string {
var b strings.Builder
fmt.Fprintf(&b, "This session's id is %s; give it as `session` to claude_code_wait.\n", sessionID)
if f, err := readWaitsFile(waitsFileIn(waitsDir, sessionID)); err == nil && len(f.Waits) > 0 {
b.WriteString("Waits already bound to this session's own id:\n")
for _, w := range f.Waits {
age := now.Sub(w.At).Round(time.Second)
state := "no watcher yet"
if w.Watcher != "" {
state = "watched"
}
if w.PastBound(now) {
state += ", past its bound"
if w.Said {
state += ", said"
}
}
fmt.Fprintf(&b, " %s — %s (%s ago, %s)\n", w.ID, w.What, age, state)
}
}
if idx, err := readNodeIndex(nodeIndexPathIn(waitsDir)); err == nil && len(idx.Names) > 0 {
b.WriteString("Open waits on this node, by name:\n")
for _, n := range idx.Names {
fmt.Fprintf(&b, " %s: %d open wait(s), last called %s. If this is you: claude_code_wait "+
"{\"op\":\"adopt\",\"by\":%q,\"session\":%q}\n", n.Name, n.Open, n.LastCall.Round(time.Second).Format(time.RFC3339),
n.Name+"@"+idx.Node, sessionID)
}
}
return b.String()
}
// runSessionStart is the hook: the session's id and its open waits out, exit 0 always (1744/AC3).
func runSessionStart(waitsDir string, stdin io.Reader, stdout io.Writer) (code int) {
defer func() {
if recover() != nil {
code = 0 // never fails the session's start
}
}()
var in SessionStartInput
if err := json.NewDecoder(stdin).Decode(&in); err != nil {
return 0
}
sessionID := strings.TrimSpace(in.SessionID)
if sessionID == "" {
return 0
}
// Review round 1, item 1: a session id is never used to build a path until it is checked against
// waitSessionID — no "/" in it reaches the filesystem, so it can never name a file outside waitsDir.
if !waitSessionID.MatchString(sessionID) {
writeSystemMessage(stdout, fmt.Sprintf("this session's id (%q) could not be used to find its waits "+
"(letters, digits, '.', '_' and '-' only)", sessionID))
return 0
}
fmt.Fprint(stdout, SessionStartMessage(sessionID, waitsDir, time.Now()))
return 0
}
@@ -0,0 +1,151 @@
package main
// The plugin's SessionStart hook (novox/hq ADR 0489 §1, issue 1744).
import (
"bytes"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// 1744/AC1: the hook prints the session's id and how to give it to claude_code_wait.
func TestSessionStartPrintsTheSessionsIDAndHowToGiveIt(t *testing.T) {
waitsDir := t.TempDir()
stdin := strings.NewReader(`{"session_id":"sess-9","hook_event_name":"SessionStart","source":"startup"}`)
var out bytes.Buffer
if code := runSessionStart(waitsDir, stdin, &out); code != 0 {
t.Fatalf("code %d, stdout %s", code, out.String())
}
if !strings.Contains(out.String(), "sess-9") || !strings.Contains(out.String(), "claude_code_wait") {
t.Fatalf("stdout does not say how to name the session: %s", out.String())
}
}
// 1744/AC2: when the waits file holds open waits, it prints each (what, age, past bound or not).
func TestSessionStartListsOpenWaitsAfterAClear(t *testing.T) {
waitsDir := t.TempDir()
at := time.Date(2026, 10, 21, 10, 0, 0, 0, time.UTC)
f := WaitsFile{Session: "sess-10", At: at, Waits: []Wait{
{ID: "w-open", What: "the operator's Approve", Bound: 10, At: at},
{ID: "w-past", What: "a delivery to finish", Bound: 1, At: at},
}}
writeWaitsFixture(t, filepath.Join(waitsDir, "sess-10.json"), f)
now := at.Add(5 * time.Minute)
msg := SessionStartMessage("sess-10", waitsDir, now)
if !strings.Contains(msg, "w-open") || !strings.Contains(msg, "the operator's Approve") || !strings.Contains(msg, "no watcher") {
t.Fatalf("the open, unwatched wait is not described:\n%s", msg)
}
if !strings.Contains(msg, "w-past") || !strings.Contains(msg, "past its bound") {
t.Fatalf("the past-bound wait is not described:\n%s", msg)
}
if !strings.Contains(msg, "5m0s ago") {
t.Fatalf("the age is not said:\n%s", msg)
}
}
// 1744/AC3: a missing or malformed file prints only the id line, and the hook never fails the session start.
func TestSessionStartNeverFailsOnAMissingOrBadFile(t *testing.T) {
waitsDir := t.TempDir()
if msg := SessionStartMessage("sess-11", waitsDir, time.Now()); !strings.Contains(msg, "sess-11") || strings.Contains(msg, "open") {
t.Fatalf("a missing file printed more than the id line: %q", msg)
}
writeFile(t, filepath.Join(waitsDir, "sess-12.json"), "{not json")
if msg := SessionStartMessage("sess-12", waitsDir, time.Now()); !strings.Contains(msg, "sess-12") || strings.Contains(msg, "open") {
t.Fatalf("a malformed file printed more than the id line: %q", msg)
}
var out bytes.Buffer
if code := runSessionStart(waitsDir, strings.NewReader("not json at all"), &out); code != 0 {
t.Fatalf("unreadable stdin failed the session start: code %d", code)
}
var out2 bytes.Buffer
if code := runSessionStart(waitsDir, panicReader{}, &out2); code != 0 {
t.Fatalf("a panic was not recovered: code %d", code)
}
}
// panicReader panics on Read, as a stuck pipe or a bug reading stdin might.
type panicReader struct{}
func (panicReader) Read([]byte) (int, error) { panic("boom") }
// Review round 1, item 1 (1743/1744, ADR 0489 §5): a session id that is not a plain one is never used to
// build a file path — reading outside the waits dir never happens — and the hook prints only a line saying
// the id could not be used.
func TestSessionStartRejectsAnUnsafeSessionID(t *testing.T) {
root := t.TempDir()
waitsDir := filepath.Join(root, "agent", "waits")
if err := os.MkdirAll(waitsDir, 0o700); err != nil {
t.Fatal(err)
}
// What "../../x" resolves to from waitsDir: root/x.json, outside waitsDir, with content that must never be
// read through it.
outside := filepath.Join(waitsDir, "../../x.json")
writeWaitsFixture(t, outside, WaitsFile{Session: "x", At: time.Now(),
Waits: []Wait{{ID: "w-secret", What: "SECRET-DATA", Bound: 10, At: time.Now()}}})
t.Cleanup(func() { _ = os.Remove(outside) })
for _, id := range []string{"../../x", "a/b"} {
var out bytes.Buffer
stdin := strings.NewReader(fmt.Sprintf(`{"session_id":%q}`, id))
if code := runSessionStart(waitsDir, stdin, &out); code != 0 {
t.Fatalf("id %q: code %d", id, code)
}
if strings.Contains(out.String(), "SECRET-DATA") {
t.Fatalf("id %q: read a file outside the waits dir: %s", id, out.String())
}
if !strings.Contains(out.String(), "could not be used") {
t.Fatalf("id %q: did not say the id could not be used: %s", id, out.String())
}
// Item (a): the notice reaches the screen as the one JSON object Claude Code shows.
if !strings.Contains(out.String(), "systemMessage") {
t.Fatalf("id %q: the notice is not a systemMessage: %s", id, out.String())
}
}
var out bytes.Buffer
if code := runSessionStart(waitsDir, strings.NewReader(`{"session_id":""}`), &out); code != 0 || out.Len() != 0 {
t.Fatalf("blank id: code %d, out %q", code, out.String())
}
}
// 1744/AC4 is checked together with 1743/AC7 in terminal_only_test.go's
// TestThePluginsOnlyHooksAreTheGuardTheSessionStartHookAndTheStopHook.
// ADR 0490 §2: every session start lists every name on this node with an open wait, from the node-wide
// index, with the exact adopt call to make if it is yours — read directly, never by calling back into this
// module.
func TestSessionStartListsEveryNameOnTheNodeWithOpenWaits(t *testing.T) {
waitsDir := t.TempDir()
lastCall := time.Date(2026, 10, 21, 9, 55, 0, 0, time.UTC)
idx := NodeIndex{Node: "g14", At: lastCall, Names: []NodeIndexEntry{
{Name: "fix-1629", Open: 2, CurrentSession: "sess-old", LastCall: lastCall},
}}
raw, err := indented(idx)
if err != nil {
t.Fatal(err)
}
writeFile(t, filepath.Join(waitsDir, "_index.json"), string(raw))
msg := SessionStartMessage("sess-brand-new", waitsDir, lastCall.Add(5*time.Minute))
if !strings.Contains(msg, "fix-1629") || !strings.Contains(msg, "2 open wait") {
t.Fatalf("the name and its count are not listed:\n%s", msg)
}
want := `claude_code_wait {"op":"adopt","by":"fix-1629@g14","session":"sess-brand-new"}`
if !strings.Contains(msg, want) {
t.Fatalf("the exact adopt call is not given:\n%s\nwant it to contain:\n%s", msg, want)
}
}
func writeWaitsFixture(t *testing.T, path string, f WaitsFile) {
t.Helper()
raw, err := indented(f)
if err != nil {
t.Fatal(err)
}
writeFile(t, path, string(raw))
}
+195
View File
@@ -0,0 +1,195 @@
package main
// The plugin's Stop hook (novox/hq ADR 0489 §3-§6, issue 1743): `claude-code stop <waits-dir>`, run by the
// agent's own account before a session's turn is let end. It reads the session's mirrored waits file
// directly (readWaitsFile, waits.go) — never the bus, never calling back into this module — so the decision
// is local, under 5 s, and needs no network.
//
// Within its bound, a wait needs a watcher: refused while one is open and nothing follows it yet. Past its
// bound, a wait needs a word to the operator, watched or not: refused until it is marked said. Refused at
// most three times running for the same wait (a counter file beside the waits file, reset once the blocking
// wait changes or clears); the fourth time it is let through, saying the wait was left unwatched. A missing,
// unreadable or malformed waits file, or a panic, always allows.
import (
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"strings"
"time"
)
// StopInput is what the hook is given on stdin.
type StopInput struct {
SessionID string `json:"session_id"`
TranscriptPath string `json:"transcript_path"`
StopHookActive bool `json:"stop_hook_active"`
}
// MostStopRefusals is how many times in a row the Stop hook refuses for the same wait before it lets the
// turn end anyway (1743/AC5).
const MostStopRefusals = 3
// StopDecision is the Stop hook's verdict, and the refusal counters it leaves behind for the next call.
type StopDecision struct {
Refuse bool
Reason string
Counters map[string]int
}
// waitBlocksStop says whether w, on its own, keeps the turn from ending, and why (ADR 0489 §3-§4).
func waitBlocksStop(session string, w Wait, now time.Time) (bool, string) {
if w.Said {
return false, ""
}
if !w.PastBound(now) {
if w.Watcher != "" {
return false, ""
}
return true, fmt.Sprintf("wait %s (%s) is open and nothing follows it yet. Read where it stands with %s, then "+
"%s, and mark it watched: claude_code_wait "+
"{\"op\":\"watched\",\"session\":%q,\"by\":%q,\"id\":%q,\"watcher\":\"...\"}.", w.ID, w.What, w.Read.Address,
watcherAdvice(w), session, w.By, w.ID)
}
return true, fmt.Sprintf("wait %s (%s) is past its bound and the operator has not been told. Tell the operator what "+
"waits, since when, and what you are doing about it, then mark it: claude_code_wait "+
"{\"op\":\"said\",\"session\":%q,\"by\":%q,\"id\":%q}.", w.ID, w.What, session, w.By, w.ID)
}
// watcherAdvice is what a session is told to do to follow a wait (ADR 0489 §3, review round 1 item 3): a
// family is a candidate for mesh_wait only when the tool runner is granted it (ADR 0367 §2), which this hook
// cannot know, so it is offered conditionally; a watcher that rereads the read address always works.
func watcherAdvice(w Wait) string {
if w.Family != "" {
return fmt.Sprintf("mesh_wait on %s if this session's tool runner is granted it, otherwise start a watcher "+
"that rereads %s", w.Family, w.Read.Address)
}
return fmt.Sprintf("start a watcher that rereads %s", w.Read.Address)
}
// DecideStop is the Stop hook's decision, pure of IO: f and readErr are what reading the session's waits
// file gave, previous the refusal counters read beside it, and now the time. Only the first wait that still
// blocks the stop is named; the others are read again next time.
//
// A missing file (review round 2, item 1) is the normal state for a session with no waits — every stop of
// every such session, not an exceptional one — so it says nothing and simply allows, the same as a file that
// reads back with no open waits. Only a file that exists but cannot be read, or does not parse, says so.
func DecideStop(session string, f WaitsFile, readErr error, previous map[string]int, now time.Time) StopDecision {
if readErr != nil && !errors.Is(readErr, fs.ErrNotExist) {
return StopDecision{Reason: fmt.Sprintf("the wait list could not be read (%v); allowed", readErr), Counters: map[string]int{}}
}
for _, w := range f.Waits {
blocks, reason := waitBlocksStop(session, w, now)
if !blocks {
continue
}
count := previous[w.ID] + 1
if count > MostStopRefusals {
return StopDecision{Reason: fmt.Sprintf("wait %s was refused %d times running and is let through: it was left "+
"unwatched (%s)", w.ID, MostStopRefusals, reason), Counters: map[string]int{}}
}
return StopDecision{Refuse: true, Reason: reason, Counters: map[string]int{w.ID: count}}
}
return StopDecision{Counters: map[string]int{}}
}
func refusalCountersPath(waitsDir, session string) string {
return filepath.Join(waitsDir, session+".refusals.json")
}
func readRefusalCounters(waitsDir, session string) map[string]int {
raw, err := os.ReadFile(refusalCountersPath(waitsDir, session))
if err != nil {
return map[string]int{}
}
var m map[string]int
if json.Unmarshal(raw, &m) != nil {
return map[string]int{}
}
return m
}
// writeRefusalCounters keeps the counter file beside the waits file in step with m, through fsys like every
// other write into the agent's home (novox/hq ADR 0266, issue 1749): called from the Stop hook, which
// already runs as the agent's own account (fsys is then its zero value, a plain write or remove, as
// before), and from the tool's AdoptName, which runs as the operator's account and must reach a confined
// node's agent home through the sudo helper fsys carries such a write through. Its caller decides what an
// error here means: harmless when the stop is already allowed, but when the stop was about to be refused, a
// cap that cannot be written is a cap that cannot take effect (ADR 0489 §5 "never loops", review round 1
// item 2).
//
// Empty, m is removed rather than written as "{}" (issue 1749, follow-up): a stop with nothing to record —
// every stop of every session with no waits, the ordinary case — must leave neither the file nor, the first
// time, the waits folder itself; removing a file that was never there is not an error.
func writeRefusalCounters(waitsDir, session string, m map[string]int) error {
path := refusalCountersPath(waitsDir, session)
if len(m) == 0 {
if err := fsys.Remove(path); err != nil && !errors.Is(err, fs.ErrNotExist) {
return err
}
return nil
}
raw, err := json.Marshal(m)
if err != nil {
return err
}
if err := fsys.MkdirAll(waitsDir, 0o700); err != nil {
return err
}
return fsys.WriteFile(path, raw, 0o600)
}
// notice says a fail-open or let-through reason on both the log (stderr) and the screen: Claude Code shows a
// hook's "systemMessage" on stdout, but not stderr, when the hook allows (review round 1, item a).
func notice(stdout, stderr io.Writer, reason string) {
fmt.Fprintln(stderr, reason)
writeSystemMessage(stdout, reason)
}
// runStop is the hook: exit 2 with the reason on stderr refuses the stop, exit 0 allows (1743).
func runStop(waitsDir string, stdin io.Reader, stdout, stderr io.Writer) (code int) {
defer func() {
if recover() != nil {
code = 0 // a panic always allows (1743/AC6)
}
}()
var in StopInput
if err := json.NewDecoder(stdin).Decode(&in); err != nil {
return 0
}
sessionID := strings.TrimSpace(in.SessionID)
if sessionID == "" {
return 0
}
// Review round 1, item 1: a session id is never used to build a path until it is checked against
// waitSessionID — no "/" in it reaches the filesystem, so it can never name a file outside waitsDir.
if !waitSessionID.MatchString(sessionID) {
notice(stdout, stderr, fmt.Sprintf("the session id %q could not be used to find its waits (letters, digits, "+
"'.', '_' and '-' only); this stop is allowed", sessionID))
return 0
}
f, err := readWaitsFile(waitsFileIn(waitsDir, sessionID))
counters := readRefusalCounters(waitsDir, sessionID)
d := DecideStop(sessionID, f, err, counters, time.Now())
if werr := writeRefusalCounters(waitsDir, sessionID, d.Counters); werr != nil && d.Refuse {
// Item 2: writing the counter failed on the very call that would have incremented it toward the cap;
// refusing anyway could hold the turn forever, so this call is let through instead.
notice(stdout, stderr, fmt.Sprintf("this wait's refusal counter could not be written (%v); allowed so it "+
"cannot hold the turn forever", werr))
return 0
}
if d.Reason != "" {
fmt.Fprintln(stderr, d.Reason)
if !d.Refuse {
writeSystemMessage(stdout, d.Reason) // item a: a fail-open or let-through notice also reaches the screen
}
}
if d.Refuse {
return 2
}
return 0
}
@@ -0,0 +1,362 @@
package main
// The plugin's Stop hook (novox/hq ADR 0489 §3-§6, issue 1743).
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
var stopT0 = time.Date(2026, 10, 21, 12, 0, 0, 0, time.UTC)
func openWait(id string, bound int) Wait {
return Wait{ID: id, What: "the operator's Approve", Bound: bound, At: stopT0}
}
// 1743/AC1: a wait within its bound and unwatched refuses the stop, naming the wait, its read address and
// what the session does about it (mesh_wait on its family, conditionally — review round 1 item 3 — or a
// watcher that rereads the address).
func TestStopRefusesAnUnwatchedWaitWithinItsBound(t *testing.T) {
w := openWait("w-1", 10)
w.Read = WaitRead{Address: "mesh-delivery.recheck"}
w.Family = "mesh-delivery.decided"
d := DecideStop("sess-1", WaitsFile{Waits: []Wait{w}}, nil, map[string]int{}, stopT0.Add(time.Minute))
if !d.Refuse || !strings.Contains(d.Reason, "w-1") || !strings.Contains(d.Reason, "mesh-delivery.recheck") ||
!strings.Contains(d.Reason, "mesh_wait") || !strings.Contains(d.Reason, "watcher") {
t.Fatalf("%+v", d)
}
}
// 1743/AC2: a watched wait within its bound, a done wait (absent from the file), or no file entry: allowed.
func TestStopAllowsAWatchedOrDoneOrAbsentWait(t *testing.T) {
watched := openWait("w-2", 10)
watched.Watcher = "mesh_wait on mesh-delivery.decided"
if d := DecideStop("sess-2", WaitsFile{Waits: []Wait{watched}}, nil, map[string]int{}, stopT0.Add(time.Minute)); d.Refuse {
t.Fatalf("a watched wait within its bound was refused: %+v", d)
}
if d := DecideStop("sess-2", WaitsFile{Waits: []Wait{}}, nil, map[string]int{}, stopT0); d.Refuse {
t.Fatalf("no open waits was refused: %+v", d)
}
}
// 1743/AC3: a wait past its bound, watched or not, is refused until said, then allowed.
func TestStopRefusesPastBoundUntilSaidWatchedOrNot(t *testing.T) {
unwatched := openWait("w-3", 5)
if d := DecideStop("sess-3", WaitsFile{Waits: []Wait{unwatched}}, nil, map[string]int{}, stopT0.Add(10*time.Minute)); !d.Refuse ||
!strings.Contains(d.Reason, "w-3") || !strings.Contains(d.Reason, "said") {
t.Fatalf("unwatched past bound: %+v", d)
}
watched := unwatched
watched.Watcher = "mesh_wait on mesh-delivery.decided"
if d := DecideStop("sess-3", WaitsFile{Waits: []Wait{watched}}, nil, map[string]int{}, stopT0.Add(10*time.Minute)); !d.Refuse {
t.Fatalf("watched past bound was allowed: %+v", d)
}
said := watched
said.Said = true
if d := DecideStop("sess-3", WaitsFile{Waits: []Wait{said}}, nil, map[string]int{}, stopT0.Add(10*time.Minute)); d.Refuse {
t.Fatalf("said past bound was still refused: %+v", d)
}
}
// 1743/AC4: an unreadable or malformed waits file is allowed, saying so on stderr and as a systemMessage on
// stdout; the decision does not touch the network and answers at once.
func TestStopAllowsAndSaysSoWhenTheWaitsFileIsUnreadableOrMalformed(t *testing.T) {
d := DecideStop("sess-4", WaitsFile{}, errors.New("no such file"), map[string]int{}, stopT0)
if d.Refuse || !strings.Contains(d.Reason, "could not be read") {
t.Fatalf("%+v", d)
}
waitsDir := t.TempDir()
if err := os.WriteFile(filepath.Join(waitsDir, "sess-4b.json"), []byte("{not json"), 0o600); err != nil {
t.Fatal(err)
}
var stdout, stderr bytes.Buffer
stdin := strings.NewReader(`{"session_id":"sess-4b","stop_hook_active":false}`)
start := time.Now()
code := runStop(waitsDir, stdin, &stdout, &stderr)
if code != 0 || !strings.Contains(stderr.String(), "could not be read") {
t.Fatalf("code %d, stderr %s", code, stderr.String())
}
// Item (a): a fail-open notice also reaches the screen, as the one JSON object Claude Code shows.
if !strings.Contains(stdout.String(), "systemMessage") || !strings.Contains(stdout.String(), "could not be read") {
t.Fatalf("the fail-open notice did not reach stdout: %s", stdout.String())
}
if took := time.Since(start); took > 5*time.Second {
t.Fatalf("took %s; the Stop hook has no network and is bounded under 5s", took)
}
}
// Review round 2, item 1 (1743/AC2, ADR 0490 §2): a missing waits file is the normal state for a session
// with no waits — every stop of every such session — so it must print nothing, on either stream, and
// simply allow; a notice there would fire constantly for nothing wrong.
func TestStopPrintsNothingWhenTheWaitsFileIsSimplyMissing(t *testing.T) {
d := DecideStop("sess-4c", WaitsFile{}, fs.ErrNotExist, map[string]int{}, stopT0)
if d.Refuse || d.Reason != "" {
t.Fatalf("a missing file (fs.ErrNotExist) said something: %+v", d)
}
waitsDir := t.TempDir() // no file at all for sess-4d
var stdout, stderr bytes.Buffer
stdin := strings.NewReader(`{"session_id":"sess-4d","stop_hook_active":false}`)
if code := runStop(waitsDir, stdin, &stdout, &stderr); code != 0 {
t.Fatalf("code %d", code)
}
if stdout.Len() != 0 || stderr.Len() != 0 {
t.Fatalf("a missing waits file printed something: stdout %q, stderr %q", stdout.String(), stderr.String())
}
}
// 1743/AC5: the same stop refused three times running for the same wait is allowed the fourth time, saying
// it was left unwatched; the counter resets when the wait changes.
func TestStopAllowsTheFourthRefusalInARow(t *testing.T) {
w := openWait("w-5", 10)
counters := map[string]int{}
for i := 1; i <= MostStopRefusals; i++ {
d := DecideStop("sess-5", WaitsFile{Waits: []Wait{w}}, nil, counters, stopT0.Add(time.Minute))
if !d.Refuse {
t.Fatalf("refusal %d was allowed: %+v", i, d)
}
counters = d.Counters
}
d := DecideStop("sess-5", WaitsFile{Waits: []Wait{w}}, nil, counters, stopT0.Add(time.Minute))
if d.Refuse || !strings.Contains(d.Reason, "unwatched") {
t.Fatalf("the fourth refusal was not let through: %+v", d)
}
if len(d.Counters) != 0 {
t.Fatalf("the counter was not reset once let through: %+v", d.Counters)
}
// A different wait starts its own count from zero.
w2 := openWait("w-5b", 10)
d2 := DecideStop("sess-5", WaitsFile{Waits: []Wait{w2}}, nil, counters, stopT0.Add(time.Minute))
if !d2.Refuse || d2.Counters["w-5b"] != 1 {
t.Fatalf("a different wait did not start its own count: %+v", d2)
}
}
// 1743/AC6: a panic answers allow.
func TestStopAPanicAllows(t *testing.T) {
waitsDir := t.TempDir()
var stdout, stderr bytes.Buffer
if code := runStop(waitsDir, panicReader{}, &stdout, &stderr); code != 0 {
t.Fatalf("a panic was not recovered: code %d", code)
}
}
// runStop through its counter file: three refusals in a row for the one open wait, then let through.
func TestRunStopKeepsItsCounterBesideTheWaitsFile(t *testing.T) {
waitsDir := t.TempDir()
f := WaitsFile{Session: "sess-6", At: stopT0, Waits: []Wait{openWait("w-6", 10)}}
writeWaitsFixture(t, filepath.Join(waitsDir, "sess-6.json"), f)
stdin := func() *strings.Reader { return strings.NewReader(`{"session_id":"sess-6","stop_hook_active":false}`) }
for i := 1; i <= MostStopRefusals; i++ {
var stdout, stderr bytes.Buffer
if code := runStop(waitsDir, stdin(), &stdout, &stderr); code != 2 {
t.Fatalf("refusal %d: code %d, stderr %s", i, code, stderr.String())
}
}
var stdout, stderr bytes.Buffer
if code := runStop(waitsDir, stdin(), &stdout, &stderr); code != 0 || !strings.Contains(stderr.String(), "unwatched") {
t.Fatalf("the fourth call: code %d, stderr %s", code, stderr.String())
}
// Item (a): the let-through notice reaches the screen too, not only the log.
if !strings.Contains(stdout.String(), "systemMessage") || !strings.Contains(stdout.String(), "unwatched") {
t.Fatalf("the let-through notice did not reach stdout: %s", stdout.String())
}
// Once let through, the counters are empty again: the file is removed, not left holding "{}" (issue
// 1749, follow-up).
if _, err := os.Stat(refusalCountersPath(waitsDir, "sess-6")); !os.IsNotExist(err) {
t.Fatalf("the counter file was not removed once its counters emptied: %v", err)
}
}
// Issue 1749, follow-up: a stop for a session with no waits at all leaves nothing behind — not the counter
// file, not even the waits folder a node that never added a wait would otherwise gain on every stop of
// every session.
func TestStopWithNoWaitsLeavesNoFolderAndNoFile(t *testing.T) {
waitsDir := filepath.Join(t.TempDir(), "waits") // not created: this is what the hook is handed cold
var stdout, stderr bytes.Buffer
stdin := strings.NewReader(`{"session_id":"sess-none","stop_hook_active":false}`)
if code := runStop(waitsDir, stdin, &stdout, &stderr); code != 0 {
t.Fatalf("code %d, stderr %s", code, stderr.String())
}
if _, err := os.Stat(waitsDir); !os.IsNotExist(err) {
t.Fatalf("a stop with no waits created the waits folder: %v", err)
}
}
// Issue 1749, follow-up: a refusal writes the counter file (there is something to record).
func TestStopWithARefusalWritesTheCounterFile(t *testing.T) {
waitsDir := t.TempDir()
f := WaitsFile{Session: "sess-7", At: stopT0, Waits: []Wait{openWait("w-7", 10)}}
writeWaitsFixture(t, filepath.Join(waitsDir, "sess-7.json"), f)
var stdout, stderr bytes.Buffer
stdin := strings.NewReader(`{"session_id":"sess-7","stop_hook_active":false}`)
if code := runStop(waitsDir, stdin, &stdout, &stderr); code != 2 {
t.Fatalf("code %d, stderr %s", code, stderr.String())
}
raw, err := os.ReadFile(refusalCountersPath(waitsDir, "sess-7"))
if err != nil {
t.Fatal(err)
}
var counters map[string]int
if json.Unmarshal(raw, &counters) != nil || counters["w-7"] != 1 {
t.Fatalf("the counter file: %s", raw)
}
}
// Issue 1749, follow-up: once the wait is done (no longer in the waits file), the next stop removes the
// counter file rather than leaving a stale "{}" behind.
func TestStopAfterTheWaitIsDoneRemovesTheCounterFile(t *testing.T) {
waitsDir := t.TempDir()
path := filepath.Join(waitsDir, "sess-8.json")
writeWaitsFixture(t, path, WaitsFile{Session: "sess-8", At: stopT0, Waits: []Wait{openWait("w-8", 10)}})
stdin := strings.NewReader(`{"session_id":"sess-8","stop_hook_active":false}`)
var stdout, stderr bytes.Buffer
if code := runStop(waitsDir, stdin, &stdout, &stderr); code != 2 {
t.Fatalf("refusal: code %d, stderr %s", code, stderr.String())
}
if _, err := os.Stat(refusalCountersPath(waitsDir, "sess-8")); err != nil {
t.Fatalf("the counter file was not written: %v", err)
}
// The wait is done: it is no longer in the waits file (DoneWait removes it, waits.go).
writeWaitsFixture(t, path, WaitsFile{Session: "sess-8", At: stopT0, Waits: []Wait{}})
var stdout2, stderr2 bytes.Buffer
stdin2 := strings.NewReader(`{"session_id":"sess-8","stop_hook_active":false}`)
if code := runStop(waitsDir, stdin2, &stdout2, &stderr2); code != 0 {
t.Fatalf("after done: code %d, stderr %s", code, stderr2.String())
}
if _, err := os.Stat(refusalCountersPath(waitsDir, "sess-8")); !os.IsNotExist(err) {
t.Fatalf("the counter file was not removed after the wait was done: %v", err)
}
}
// Review round 1, item 1 (1743/1744, ADR 0489 §5): a session id that is not a plain one (path separators,
// ".."), is never used to build a file path — the counter file stays inside the waits directory — and the
// Stop hook allows, saying why.
func TestStopRejectsAnUnsafeSessionID(t *testing.T) {
for _, id := range []string{"../../x", "a/b"} {
root := t.TempDir()
waitsDir := filepath.Join(root, "agent", "waits")
if err := os.MkdirAll(waitsDir, 0o700); err != nil {
t.Fatal(err)
}
var stdout, stderr bytes.Buffer
stdin := strings.NewReader(fmt.Sprintf(`{"session_id":%q,"stop_hook_active":false}`, id))
if code := runStop(waitsDir, stdin, &stdout, &stderr); code != 0 {
t.Fatalf("id %q: code %d, stderr %s", id, code, stderr.String())
}
if !strings.Contains(stderr.String(), "could not be used") {
t.Fatalf("id %q: did not say why it allowed: %s", id, stderr.String())
}
if !strings.Contains(stdout.String(), "systemMessage") {
t.Fatalf("id %q: the notice did not reach stdout: %s", id, stdout.String())
}
escaped := filepath.Join(waitsDir, id+".refusals.json")
if _, err := os.Stat(escaped); !os.IsNotExist(err) {
t.Fatalf("id %q: a counter file was written outside the waits dir at %s", id, escaped)
}
}
// A blank id still just allows, quietly: it never reaches the point of building a path at all.
waitsDir := t.TempDir()
var stdout, stderr bytes.Buffer
if code := runStop(waitsDir, strings.NewReader(`{"session_id":""}`), &stdout, &stderr); code != 0 {
t.Fatalf("blank id: code %d", code)
}
}
// Review round 1, item 2 (ADR 0489 §5 "never loops", 1743/AC5): when the decision is to refuse and the
// refusal counter cannot be written, the hook allows rather than risk refusing forever with a cap that
// never takes effect.
func TestStopAllowsWhenTheRefusalCounterCannotBeWritten(t *testing.T) {
if os.Geteuid() == 0 {
t.Skip("running as root: a directory's write bit cannot be taken away")
}
waitsDir := t.TempDir()
writeWaitsFixture(t, filepath.Join(waitsDir, "sess-7.json"),
WaitsFile{Session: "sess-7", At: stopT0, Waits: []Wait{openWait("w-7", 10)}})
if err := os.Chmod(waitsDir, 0o500); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chmod(waitsDir, 0o700) })
var stdout, stderr bytes.Buffer
stdin := strings.NewReader(`{"session_id":"sess-7","stop_hook_active":false}`)
if code := runStop(waitsDir, stdin, &stdout, &stderr); code != 0 {
t.Fatalf("code %d, stderr %s", code, stderr.String())
}
if !strings.Contains(stderr.String(), "counter") {
t.Fatalf("did not say the counter could not be written: %s", stderr.String())
}
if !strings.Contains(stdout.String(), "systemMessage") {
t.Fatalf("the notice did not reach stdout: %s", stdout.String())
}
}
// Review round 1, item 3 (1743/AC1, §3 with ADR 0367 §2): a family is offered only conditionally (the tool
// runner may not be granted it); without one, the advice is only to start a watcher.
func TestWaitBlocksStopAdvisesMeshWaitOnlyConditionallyOtherwiseAWatcher(t *testing.T) {
withFamily := openWait("w-f", 10)
withFamily.Read = WaitRead{Address: "mesh-delivery.recheck"}
withFamily.Family = "mesh-delivery.decided"
_, reason := waitBlocksStop("sess-f", withFamily, stopT0.Add(time.Minute))
if !strings.Contains(reason, "mesh_wait on mesh-delivery.decided if this session's tool runner is granted it") ||
!strings.Contains(reason, "otherwise start a watcher that rereads mesh-delivery.recheck") {
t.Fatalf("%s", reason)
}
noFamily := openWait("w-nf", 10)
noFamily.Read = WaitRead{Address: "mesh-delivery.recheck"}
_, reason2 := waitBlocksStop("sess-nf", noFamily, stopT0.Add(time.Minute))
if strings.Contains(reason2, "mesh_wait") || !strings.Contains(reason2, "start a watcher that rereads mesh-delivery.recheck") {
t.Fatalf("%s", reason2)
}
}
// 1743/AC7's rendering half (hooks.json holds the guard's PreToolUse entry and the Stop entry) is checked
// together with 1744/AC4 in terminal_only_test.go's
// TestThePluginsOnlyHooksAreTheGuardTheSessionStartHookAndTheStopHook. claude_code_hook_register still
// refuses a Stop hook (or any other) at every scope.
func TestHookRegisterStillRefusesEveryHook(t *testing.T) {
p, _ := node(t, "laptop")
s := serve(t, p, &bus{kept: map[string]map[string]any{}}, memConfig{})
text, isError := s.call(t, "claude_code_hook_register", map[string]any{"name": "x", "event": "Stop", "command": "true"})
if !isError || !strings.Contains(text, "issue 340") {
t.Fatalf("a Stop hook was not refused: %v %s", isError, text)
}
}
// Issue 1749: adopt's refusal-counter reset writes into the agent's home (beside the waits file) and must
// go through fsys like every other such write (novox/hq ADR 0266) — on a confined node the module runs as
// the operator's account, which cannot write there directly; only the helper, as the agent's own account,
// can. The confined-node fake (agentFiles, agent_account_test.go) proves it: a write that reaches the real
// filesystem only through the helper's request/response protocol bumps its count.
func TestWriteRefusalCountersGoesThroughTheHelper(t *testing.T) {
f, home, counted := agentFiles(t)
was := fsys
fsys = f
t.Cleanup(func() { fsys = was })
waitsDir := filepath.Join(home, ".claude", "nox-mesh", "waits")
if err := writeRefusalCounters(waitsDir, "sess-1", map[string]int{"w-1": 2}); err != nil {
t.Fatal(err)
}
if counted.asked.Load() == 0 {
t.Fatal("the counter file was written directly, never through the helper")
}
raw, err := os.ReadFile(filepath.Join(waitsDir, "sess-1.refusals.json"))
if err != nil {
t.Fatal(err)
}
var m map[string]int
if json.Unmarshal(raw, &m) != nil || m["w-1"] != 2 {
t.Fatalf("the written counters: %s", raw)
}
}
@@ -261,12 +261,12 @@ func TestTheOperatorsManagedSettingsAreWhatSessionsObey(t *testing.T) {
}
}
// The plugin's hooks are the mesh's own guard on the shell (novox/hq ADR 0245) and nothing else, whatever the
// configuration given holds (novox/hq issue 340).
func TestThePluginsOnlyHookIsTheGuard(t *testing.T) {
// The plugin's only hooks are the guard, the session-start hook and the Stop hook (novox/hq ADR 0245, ADR
// 0489) — never a hook a registration named, whatever the configuration given holds (novox/hq issue 340).
func TestThePluginsOnlyHooksAreTheGuardTheSessionStartHookAndTheStopHook(t *testing.T) {
c := Config{Mesh: []Item{{Kind: KindHook, Name: "planted", Scope: ScopeMesh, Event: "PreToolUse", Matcher: "Bash",
Command: "${HOOK_DIR}/x.sh", Files: map[string]string{"x.sh": "#!/bin/sh\n"}}}}
plugin := MarketplaceWith(c, &GuardFiles{Exe: "/bin/true"})
plugin := MarketplaceWith(c, &GuardFiles{Exe: "/bin/true", WaitsDir: "/home/agent/.claude/nox-mesh/waits"})
root := "plugins/" + Plugin + "/"
var hooks struct {
Hooks map[string][]struct {
@@ -279,9 +279,16 @@ func TestThePluginsOnlyHookIsTheGuard(t *testing.T) {
if err := json.Unmarshal([]byte(plugin[root+"hooks/hooks.json"].Content), &hooks); err != nil {
t.Fatal(err)
}
if len(hooks.Hooks) != 1 || len(hooks.Hooks["PreToolUse"]) != 1 || hooks.Hooks["PreToolUse"][0].Matcher != GuardMatcher ||
if len(hooks.Hooks) != 3 || len(hooks.Hooks["PreToolUse"]) != 1 || hooks.Hooks["PreToolUse"][0].Matcher != GuardMatcher ||
hooks.Hooks["PreToolUse"][0].Hooks[0].Command != GuardCommand() {
t.Fatalf("the plugin's hooks: %s", plugin[root+"hooks/hooks.json"].Content)
t.Fatalf("the guard's hook: %s", plugin[root+"hooks/hooks.json"].Content)
}
if len(hooks.Hooks["SessionStart"]) != 1 || hooks.Hooks["SessionStart"][0].Matcher != "startup|resume|clear|compact" ||
hooks.Hooks["SessionStart"][0].Hooks[0].Command != SessionStartCommand("/home/agent/.claude/nox-mesh/waits") {
t.Fatalf("the session-start hook: %s", plugin[root+"hooks/hooks.json"].Content)
}
if len(hooks.Hooks["Stop"]) != 1 || hooks.Hooks["Stop"][0].Hooks[0].Command != StopCommand("/home/agent/.claude/nox-mesh/waits") {
t.Fatalf("the Stop hook: %s", plugin[root+"hooks/hooks.json"].Content)
}
for path := range plugin {
if strings.HasPrefix(path, root+"hooks/planted") {
@@ -0,0 +1,776 @@
package main
// A session's wait list (novox/hq ADR 0489, as changed by ADR 0490): what a session still expects an answer
// to — an Approve, a delivery's end, anything it started and must act on when it ends
// (follow-what-you-start) — so the plugin's Stop hook can refuse to end its turn while one is open and
// nothing watches it.
//
// Kept in the module's `waits` state under the session's name and this node (ADR 0490 §1) — `by`, as
// claude_code_ask takes it, never the session id, which Claude Code hands a session a new one of at a
// /clear. Every call also gives `session`, the caller's current id; the module keeps the binding of a name
// to the session that last called for it, with that call's time, so the same name's waits are found again
// across a /clear once the new session has adopted it.
//
// The waits file a hook reads is kept per session id, not per name, and holds the waits of whichever name is
// currently bound to that id (empty for an id nothing is bound to): written whole, atomically, on every
// change, and rewritten for both ids when a binding moves (AdoptName). A second file, the node-wide index,
// lists every name on this node that still holds an open wait, so the SessionStart hook can tell a session
// with a brand new id what it might want to adopt, without calling back into this module.
//
// Every file lives under the agent's home (the manifest's `agent-home` resource), which this process —
// launched as the operator's account (novox/hq ADR 0193) — cannot itself write when the node names an agent
// account of its own (novox/hq ADR 0266): every write below goes through `fsys` (homefs.go), which already
// carries such a write through the sudo helper the guard's binary also serves, exactly as grant.go's
// WriteCredentials does for the agent's own credentials file. Where the node has no agent account of its
// own, fsys is the plain filesystem and the write reaches the operator's own home directly.
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
)
// WaitsState is the module's state the wait lists are kept in.
const WaitsState = "waits"
// WaitsDir is where the mirrored files live under the agent's home.
const WaitsDir = "nox-mesh/waits"
// WaitPrefix starts every wait's id.
const WaitPrefix = "w-"
// MostWaits is how many open waits one name may hold at once (novox/hq ADR 0489, 1629/AC5).
const MostWaits = 50
// The bounds of a wait's bound, in minutes (1629/AC5).
const (
ShortestWaitBound = 1
LongestWaitBound = 1440
)
// mostWaitAttempts bounds the compare-and-set retry loop over the `waits` state.
const mostWaitAttempts = 20
var waitSessionID = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$`)
// requireSessionID trims session and refuses it unless it is a plain id matching waitSessionID — every op
// holds to this (review round 1, item b), so a session id is never used to build a path from anything but a
// safe name (no "/", so it cannot name a file outside the waits directory).
func requireSessionID(session string) (string, error) {
session = strings.TrimSpace(session)
if !waitSessionID.MatchString(session) {
return "", errors.New("session is required: the id the SessionStart hook named")
}
return session, nil
}
// WaitRead is the verb a session reads to see where one wait stands.
type WaitRead struct {
Address string `json:"address"`
Arguments map[string]any `json:"arguments,omitempty"`
}
// Wait is one answer a session wrote down that it expects (novox/hq ADR 0489 §1-§2). By is the name it was
// added under.
type Wait struct {
ID string `json:"id"`
By string `json:"by"`
What string `json:"what"`
Read WaitRead `json:"read"`
Family string `json:"family,omitempty"`
Bound int `json:"bound"` // minutes
At time.Time `json:"at"`
Watcher string `json:"watcher,omitempty"`
Said bool `json:"said,omitempty"`
}
// PastBound says whether now is past this wait's bound.
func (w Wait) PastBound(now time.Time) bool {
return now.Sub(w.At) > time.Duration(w.Bound)*time.Minute
}
// NamedWaits is what the `waits` state keeps for one name on this node (novox/hq ADR 0490 §1): its open
// waits, and the binding to whichever session currently answers to that name — empty until the first call
// names it, by add or by adopt.
type NamedWaits struct {
Name string `json:"name"`
Node string `json:"node"`
CurrentSession string `json:"currentSession,omitempty"`
LastCall time.Time `json:"lastCall,omitempty"`
Waits []Wait `json:"waits"`
}
// WaitsFile is what one session id's mirrored file holds, whole, on every change (1629/AC3): the open waits
// of whichever name is currently bound to this session, and the time they were written.
type WaitsFile struct {
Session string `json:"session"`
At time.Time `json:"at"`
Waits []Wait `json:"waits"`
}
// NodeIndexEntry is one name's open waits, as the node-wide index lists it for the SessionStart hook
// (novox/hq ADR 0490 §2).
type NodeIndexEntry struct {
Name string `json:"name"`
Open int `json:"open"`
CurrentSession string `json:"currentSession,omitempty"`
LastCall time.Time `json:"lastCall"`
}
// NodeIndex is the node-wide index file's whole content (novox/hq ADR 0490 §2): every name on this node that
// still holds an open wait, written by the tool on every change a name's waits or binding takes, so the
// SessionStart hook can say "if this is you, adopt" without calling back into this module.
type NodeIndex struct {
Node string `json:"node"`
At time.Time `json:"at"`
Names []NodeIndexEntry `json:"names"`
}
// WaitStore is the `waits` state: a value with its revision, created only when absent, updated only at the
// revision read; Keys lists every name kept, for the node-wide index.
type WaitStore interface {
Get(key string) (json.RawMessage, uint64, bool, error)
Create(key string, value any) error
Update(key string, value any, revision uint64) error
Keys() ([]string, error)
}
// keptWaits is the `waits` state on the bus.
type keptWaits struct{ s stdio.KeptState }
func (k keptWaits) Get(key string) (json.RawMessage, uint64, bool, error) {
e, err := k.s.Get(key)
if err != nil || e == nil {
return nil, 0, false, err
}
return e.Value, e.Revision, true, nil
}
func (k keptWaits) Create(key string, value any) error {
_, err := k.s.Create(key, value)
return err
}
func (k keptWaits) Update(key string, value any, revision uint64) error {
_, err := k.s.Update(key, value, revision)
return err
}
func (k keptWaits) Keys() ([]string, error) { return k.s.Keys() }
// Waiting is what claude_code_wait reaches: the `waits` state and the clock. Nil outside a launch.
type Waiting struct {
Store WaitStore
Now func() time.Time
}
var waiting *Waiting
// waitsKey is the state's key for one name's waits: its name and this node (novox/hq ADR 0490 §1), so two
// nodes never collide over the same name.
func waitsKey(p Paths, name string) string { return p.Node + "/" + name }
// WaitsDirPath is where this node's sessions' waits are mirrored, under the agent's home: the directory the
// plugin's SessionStart and Stop hooks (1744, 1743) are given on their command line.
func WaitsDirPath(p Paths) string {
return filepath.Join(p.Home, ".claude", filepath.FromSlash(WaitsDir))
}
// waitsFilePath is where one session id's mirrored waits are.
func waitsFilePath(p Paths, session string) string {
return filepath.Join(WaitsDirPath(p), session+".json")
}
// nodeIndexPath is the node-wide index's file, beside the per-session ones; its name can never collide with
// a session id, since waitSessionID never allows a leading "_".
func nodeIndexPath(p Paths) string { return filepath.Join(WaitsDirPath(p), "_index.json") }
func newWaitID() string {
var b [8]byte
_, _ = rand.Read(b[:])
return WaitPrefix + hex.EncodeToString(b[:])
}
// addressShaped says whether s could be an address `mesh_call` takes: not blank, no whitespace, and a dot
// between two names (`<seat>.<verb>`, `<node>/<seat>.<verb>`, `<node>/<module>.<tool>`).
func addressShaped(s string) bool {
s = strings.TrimSpace(s)
if s == "" || strings.ContainsAny(s, " \t\n\r") {
return false
}
dot := strings.LastIndex(s, ".")
return dot > 0 && dot < len(s)-1
}
func readNamedWaits(store WaitStore, key string) (NamedWaits, uint64, bool, error) {
raw, rev, found, err := store.Get(key)
if err != nil || !found {
return NamedWaits{}, 0, found, err
}
var nw NamedWaits
_ = json.Unmarshal(raw, &nw)
return nw, rev, true, nil
}
// updateNamedWaits reads one name's waits, lets mutate change them, and writes the result back at the
// revision read — created when there was none yet — retried when another write won the race.
func updateNamedWaits(store WaitStore, key string, mutate func(NamedWaits) (NamedWaits, error)) (NamedWaits, error) {
for i := 0; i < mostWaitAttempts; i++ {
nw, rev, found, err := readNamedWaits(store, key)
if err != nil {
return NamedWaits{}, fmt.Errorf("the wait list could not be read: %w", err)
}
next, err := mutate(nw)
if err != nil {
return NamedWaits{}, err
}
if !found {
err = store.Create(key, next)
} else {
err = store.Update(key, next, rev)
}
if err == nil {
return next, nil
}
if errors.Is(err, stdio.ErrChanged) {
continue
}
return NamedWaits{}, fmt.Errorf("the wait list could not be written: %w", err)
}
return NamedWaits{}, errors.New("the wait list kept changing elsewhere; try again")
}
// tempSuffix is a short random name for a temp file, so two writes racing for the same path never share one
// (review round 2, item a): each gets its own, and only the rename is a single atomic step.
func tempSuffix() string {
var b [8]byte
_, _ = rand.Read(b[:])
return hex.EncodeToString(b[:])
}
// writeSessionFile rewrites one session id's mirrored file whole, atomically (its own temp file, then
// rename): it holds exactly the open waits given and the time of this write (1629/AC3). waits is always the
// union of every name currently bound to sessionID (sessionWaitsUnion), never only the name an op acted on:
// a second name bound to the same id must stay enforced (ADR 0490 §1, review round 2 item 2).
func writeSessionFile(p Paths, sessionID string, waits []Wait, now time.Time) error {
path := waitsFilePath(p, sessionID)
if err := fsys.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return err
}
if waits == nil {
waits = []Wait{}
}
raw, err := indented(WaitsFile{Session: sessionID, At: now.UTC(), Waits: waits})
if err != nil {
return err
}
tmp := path + ".mesh-tmp-" + tempSuffix()
if err := fsys.WriteFile(tmp, raw, 0o600); err != nil {
return err
}
return fsys.Rename(tmp, path)
}
// sessionWaitsUnion is every open wait of every name on this node currently bound to session (ADR 0490 §1):
// what that session id's mirrored file must hold, whole — never only the one name an op just acted on, or a
// second name bound to the same id would stop being enforced the moment any op touched the first.
func sessionWaitsUnion(store WaitStore, node, session string) ([]Wait, error) {
keys, err := store.Keys()
if err != nil {
return nil, err
}
prefix := node + "/"
var out []Wait
names := make([]string, 0, len(keys))
for _, k := range keys {
if strings.HasPrefix(k, prefix) {
names = append(names, k)
}
}
sort.Strings(names) // deterministic order: tests, and a session reading its own file, see the same thing
for _, k := range names {
nw, _, found, err := readNamedWaits(store, k)
if err != nil || !found || nw.CurrentSession != session {
continue
}
out = append(out, nw.Waits...)
}
return out, nil
}
// writeSessionWaits rewrites session's mirrored file with sessionWaitsUnion: every call that changes a
// name's waits or its binding goes through this, never writeSessionFile directly with one name's waits
// alone.
func writeSessionWaits(p Paths, store WaitStore, session string, now time.Time) error {
union, err := sessionWaitsUnion(store, p.Node, session)
if err != nil {
return fmt.Errorf("the session's other names could not be read: %w", err)
}
return writeSessionFile(p, session, union, now)
}
// writeNodeIndex rewrites the node-wide index whole, atomically, from every name's current state on this
// node (novox/hq ADR 0490 §2): a full scan of the `waits` state, bounded by how many names are ever used on
// one node at once. Best-effort for its caller: a name's own file is already correct by the time this runs.
func writeNodeIndex(p Paths, store WaitStore, now time.Time) error {
keys, err := store.Keys()
if err != nil {
return fmt.Errorf("the node-wide wait index could not be read: %w", err)
}
prefix := p.Node + "/"
idx := NodeIndex{Node: p.Node, At: now.UTC(), Names: []NodeIndexEntry{}}
for _, k := range keys {
name, ok := strings.CutPrefix(k, prefix)
if !ok {
continue
}
nw, _, found, err := readNamedWaits(store, k)
if err != nil || !found || len(nw.Waits) == 0 {
continue
}
idx.Names = append(idx.Names, NodeIndexEntry{Name: name, Open: len(nw.Waits),
CurrentSession: nw.CurrentSession, LastCall: nw.LastCall.UTC()})
}
sort.Slice(idx.Names, func(i, j int) bool { return idx.Names[i].Name < idx.Names[j].Name })
path := nodeIndexPath(p)
if err := fsys.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return err
}
raw, err := indented(idx)
if err != nil {
return err
}
tmp := path + ".mesh-tmp-" + tempSuffix()
if err := fsys.WriteFile(tmp, raw, 0o600); err != nil {
return err
}
return fsys.Rename(tmp, path)
}
// callerName reads `by` as claude_code_ask does: who@node, where node is this one — the name a wait list is
// kept under (novox/hq ADR 0490 §1), never the session id.
func callerName(p Paths, by string) (string, error) {
by = strings.TrimSpace(by)
if who, node, found := strings.Cut(by, "@"); found {
if node != p.Node {
return "", fmt.Errorf("by names the node %q, and this is %s: call on the node the session runs on", node, p.Node)
}
return who, nil
}
if by == "" {
return "", errors.New("by is required: your session's name, as who@node or who")
}
return by, nil
}
// bindCaller checks the binding for name against session, refusing when another, more recently bound
// session holds it (novox/hq ADR 0490 §3) — naming that session and when it last called, and how to take the
// name back with adopt — and otherwise (re)binds it to session at now, so a name nothing has bound yet is
// claimed by the first session that calls for it, add included.
func bindCaller(nw *NamedWaits, name, node, by, session string, now time.Time) error {
if nw.CurrentSession != "" && nw.CurrentSession != session {
return fmt.Errorf("the name %q now answers to session %s (it last called at %s); adopt it again if this "+
"is you: claude_code_wait {\"op\":\"adopt\",\"by\":%q,\"session\":%q}", name, nw.CurrentSession,
nw.LastCall.UTC().Format(time.RFC3339), by, session)
}
nw.Name, nw.Node, nw.CurrentSession, nw.LastCall = name, node, session, now.UTC()
return nil
}
// findWait is the index of id in waits, or -1.
func findWait(waits []Wait, id string) int {
for i, w := range waits {
if w.ID == id {
return i
}
}
return -1
}
// WaitInput is what claude_code_wait's add takes.
type WaitInput struct {
Session string
By string
What string
Read WaitRead
Family string
Bound int
}
// AddWait keeps one more wait for a name, refusing outside ADR 0489's bounds (1629/AC5) or when another
// session has since taken the name over (ADR 0490 §3), and rewrites the caller's mirrored file (1629/AC3).
func AddWait(p Paths, in WaitInput, store WaitStore, now time.Time) (map[string]any, error) {
session, err := requireSessionID(in.Session)
if err != nil {
return nil, err
}
name, err := callerName(p, in.By)
if err != nil {
return nil, err
}
what := strings.TrimSpace(in.What)
if what == "" {
return nil, errors.New("what is required: say what you are waiting for")
}
if in.Bound < ShortestWaitBound || in.Bound > LongestWaitBound {
return nil, fmt.Errorf("bound_minutes is %d; it is %d to %d", in.Bound, ShortestWaitBound, LongestWaitBound)
}
address := strings.TrimSpace(in.Read.Address)
if !addressShaped(address) {
return nil, fmt.Errorf("read.address %q is not an address: the verb that reads where this wait stands, as mesh_call takes it", in.Read.Address)
}
w := Wait{ID: newWaitID(), By: name, What: what, Read: WaitRead{Address: address, Arguments: in.Read.Arguments},
Family: strings.TrimSpace(in.Family), Bound: in.Bound, At: now.UTC()}
key := waitsKey(p, name)
nw, err := updateNamedWaits(store, key, func(nw NamedWaits) (NamedWaits, error) {
if err := bindCaller(&nw, name, p.Node, in.By, session, now); err != nil {
return nw, err
}
if len(nw.Waits) >= MostWaits {
return nw, fmt.Errorf("%d waits already wait for %s; at most %d open waits per name", len(nw.Waits), name, MostWaits)
}
nw.Waits = append(append([]Wait(nil), nw.Waits...), w)
return nw, nil
})
if err != nil {
return nil, err
}
if err := writeSessionWaits(p, store, session, now); err != nil {
return nil, fmt.Errorf("the wait was kept, but its file could not be written: %w", err)
}
if err := writeNodeIndex(p, store, now); err != nil {
say("writing the node-wide wait index after %s added for %s: %v", w.ID, name, err)
}
say("wait %s added for %s (session %s): %s", w.ID, name, session, what)
return map[string]any{"id": w.ID, "name": name, "session": session, "open": len(nw.Waits)}, nil
}
// WatchedWait marks a wait as followed: by mesh_wait on its family, or a watcher started for it (1629/AC2).
func WatchedWait(p Paths, session, by, id, watcher string, store WaitStore, now time.Time) (map[string]any, error) {
session, err := requireSessionID(session)
if err != nil {
return nil, err
}
name, err := callerName(p, by)
if err != nil {
return nil, err
}
id, watcher = strings.TrimSpace(id), strings.TrimSpace(watcher)
if id == "" {
return nil, errors.New("id is required: the one add answered")
}
if watcher == "" {
return nil, errors.New("watcher is required: what follows this wait (mesh_wait on its family, or a watcher agent)")
}
key := waitsKey(p, name)
_, err = updateNamedWaits(store, key, func(nw NamedWaits) (NamedWaits, error) {
if err := bindCaller(&nw, name, p.Node, by, session, now); err != nil {
return nw, err
}
i := findWait(nw.Waits, id)
if i < 0 {
return nw, fmt.Errorf("no open wait %s for %s", id, name)
}
nw.Waits[i].Watcher = watcher
return nw, nil
})
if err != nil {
return nil, err
}
if err := writeSessionWaits(p, store, session, now); err != nil {
return nil, fmt.Errorf("the wait was watched, but its file could not be written: %w", err)
}
_ = writeNodeIndex(p, store, now)
return map[string]any{"id": id, "watcher": watcher}, nil
}
// DoneWait removes a wait from the open list: it ended, with outcome (1629/AC2).
func DoneWait(p Paths, session, by, id, outcome string, store WaitStore, now time.Time) (map[string]any, error) {
session, err := requireSessionID(session)
if err != nil {
return nil, err
}
name, err := callerName(p, by)
if err != nil {
return nil, err
}
id, outcome = strings.TrimSpace(id), strings.TrimSpace(outcome)
if id == "" {
return nil, errors.New("id is required: the one add answered")
}
key := waitsKey(p, name)
_, err = updateNamedWaits(store, key, func(nw NamedWaits) (NamedWaits, error) {
if err := bindCaller(&nw, name, p.Node, by, session, now); err != nil {
return nw, err
}
i := findWait(nw.Waits, id)
if i < 0 {
return nw, fmt.Errorf("no open wait %s for %s", id, name)
}
nw.Waits = append(append([]Wait(nil), nw.Waits[:i]...), nw.Waits[i+1:]...)
return nw, nil
})
if err != nil {
return nil, err
}
if err := writeSessionWaits(p, store, session, now); err != nil {
return nil, fmt.Errorf("the wait was done, but its file could not be written: %w", err)
}
_ = writeNodeIndex(p, store, now)
say("wait %s for %s (session %s) done: %s", id, name, session, outcome)
return map[string]any{"id": id, "done": true, "outcome": outcome}, nil
}
// SaidWait marks a wait past its bound as told to the operator: refused while it is still within its bound
// (1629/AC2).
func SaidWait(p Paths, session, by, id string, store WaitStore, now time.Time) (map[string]any, error) {
session, err := requireSessionID(session)
if err != nil {
return nil, err
}
name, err := callerName(p, by)
if err != nil {
return nil, err
}
id = strings.TrimSpace(id)
if id == "" {
return nil, errors.New("id is required: the one add answered")
}
key := waitsKey(p, name)
_, err = updateNamedWaits(store, key, func(nw NamedWaits) (NamedWaits, error) {
if err := bindCaller(&nw, name, p.Node, by, session, now); err != nil {
return nw, err
}
i := findWait(nw.Waits, id)
if i < 0 {
return nw, fmt.Errorf("no open wait %s for %s", id, name)
}
if !nw.Waits[i].PastBound(now) {
return nw, fmt.Errorf("wait %s is not past its bound yet: said is for telling the operator about one that is", id)
}
nw.Waits[i].Said = true
return nw, nil
})
if err != nil {
return nil, err
}
if err := writeSessionWaits(p, store, session, now); err != nil {
return nil, fmt.Errorf("the wait was said, but its file could not be written: %w", err)
}
_ = writeNodeIndex(p, store, now)
return map[string]any{"id": id, "said": true}, nil
}
// ListWaits answers a name's open waits, with their age and whether they are past their bound (1629/AC1),
// and who currently holds the name. Read-only (review round 2, item c): unlike every other op it never
// claims an unbound name, and never refuses a session that is not (or not yet) the one bound to it — a
// session may want to look before adopting.
func ListWaits(p Paths, session, by string, store WaitStore, now time.Time) (map[string]any, error) {
session, err := requireSessionID(session)
if err != nil {
return nil, err
}
name, err := callerName(p, by)
if err != nil {
return nil, err
}
nw, _, _, err := readNamedWaits(store, waitsKey(p, name))
if err != nil {
return nil, fmt.Errorf("the wait list could not be read: %w", err)
}
out := []map[string]any{}
for _, w := range nw.Waits {
entry := map[string]any{"id": w.ID, "what": w.What, "by": w.By, "address": w.Read.Address,
"bound_minutes": w.Bound, "at": w.At.Format(time.RFC3339), "age_seconds": int(now.Sub(w.At).Seconds()),
"past_bound": w.PastBound(now), "watcher": w.Watcher, "said": w.Said}
if len(w.Read.Arguments) > 0 {
entry["arguments"] = w.Read.Arguments
}
if w.Family != "" {
entry["family"] = w.Family
}
out = append(out, entry)
}
answer := map[string]any{"session": session, "name": name, "open": out}
if nw.CurrentSession != "" {
answer["held-by"] = map[string]any{"session": nw.CurrentSession, "last-called": nw.LastCall.UTC().Format(time.RFC3339)}
}
return answer, nil
}
// AdoptName binds name to session, always taking it over from whoever held it (novox/hq ADR 0490 §3): the
// mirrored files of both the session it is taken from and the one adopting it are rewritten at once, so a
// session that no longer owns a name never finds its old waits in its own file, and the new one does right
// away — no waiting on a bound, unlike the Stop and SessionStart hooks' own.
func AdoptName(p Paths, by, session string, store WaitStore, now time.Time) (map[string]any, error) {
session, err := requireSessionID(session)
if err != nil {
return nil, err
}
name, err := callerName(p, by)
if err != nil {
return nil, err
}
key := waitsKey(p, name)
var oldSession string
var oldLastCall time.Time
nw, err := updateNamedWaits(store, key, func(nw NamedWaits) (NamedWaits, error) {
oldSession, oldLastCall = nw.CurrentSession, nw.LastCall
nw.Name, nw.Node, nw.CurrentSession, nw.LastCall = name, p.Node, session, now.UTC()
return nw, nil
})
if err != nil {
return nil, err
}
tookOver := oldSession != "" && oldSession != session
if tookOver {
// oldSession's file is rewritten from the union of whatever names remain bound to it (ADR 0490 §1,
// review round 2 item 2): name no longer is one of them, but another might still be — never simply
// emptied.
if err := writeSessionWaits(p, store, oldSession, now); err != nil {
return nil, fmt.Errorf("%s was adopted, but the session it was taken from could not be told: %w", name, err)
}
}
if err := writeSessionWaits(p, store, session, now); err != nil {
return nil, fmt.Errorf("the name was adopted, but its file could not be written: %w", err)
}
if err := writeNodeIndex(p, store, now); err != nil {
say("writing the node-wide wait index after %s was adopted: %v", name, err)
}
// Review round 2, item b: a fresh three-strikes count starts under the new binding, on both ids.
waitsDir := WaitsDirPath(p)
_ = writeRefusalCounters(waitsDir, session, map[string]int{})
if tookOver {
_ = writeRefusalCounters(waitsDir, oldSession, map[string]int{})
}
answer := map[string]any{"name": name, "session": session, "open": len(nw.Waits)}
if tookOver {
answer["took-over-from"] = map[string]any{"session": oldSession, "last-called": oldLastCall.UTC().Format(time.RFC3339)}
say("%s adopted by session %s, taken over from %s (last called %s)", name, session, oldSession,
oldLastCall.UTC().Format(time.RFC3339))
} else {
say("%s adopted by session %s", name, session)
}
return answer, nil
}
// waitsFileIn is where one session's mirrored waits are, given a hook's own waits-dir argument: the hooks
// have no launch environment, only their command line, so they never recompute this from Paths.
func waitsFileIn(waitsDir, session string) string { return filepath.Join(waitsDir, session+".json") }
// nodeIndexPathIn is the node-wide index file, given a hook's own waits-dir argument.
func nodeIndexPathIn(waitsDir string) string { return filepath.Join(waitsDir, "_index.json") }
// readWaitsFile reads one session's mirrored file, as the hooks do: a plain local read, as the session's own
// account (never through fsys or the module's state), and never calling back into this module.
func readWaitsFile(path string) (WaitsFile, error) {
raw, err := os.ReadFile(path)
if err != nil {
return WaitsFile{}, err
}
var f WaitsFile
if err := json.Unmarshal(raw, &f); err != nil {
return WaitsFile{}, err
}
return f, nil
}
// readNodeIndex reads the node-wide index, as the SessionStart hook does: a plain local read, never through
// fsys or the module's state, and never calling back into this module.
func readNodeIndex(path string) (NodeIndex, error) {
raw, err := os.ReadFile(path)
if err != nil {
return NodeIndex{}, err
}
var idx NodeIndex
if err := json.Unmarshal(raw, &idx); err != nil {
return NodeIndex{}, err
}
return idx, nil
}
// waitTools is claude_code_wait: add, watched, done, said, list and adopt, over a name's wait list (novox/hq
// ADR 0489, ADR 0490).
func waitTools(p Paths) []stdio.Tool {
notLaunched := errors.New("this module was not launched with the bus; no wait can be kept")
return []stdio.Tool{
{Name: "claude_code_wait",
Description: "Your wait list, kept under your session's name (novox/hq ADR 0489, ADR 0490): what you " +
"still expect an answer to — an Approve, a delivery's end, anything you started and must act on when " +
"it ends. The plugin's Stop hook refuses to end your turn while a wait here is open and nothing " +
"follows it. op is add, watched, done, said, list or adopt; session (your current session id, named " +
"by the SessionStart hook) and by (your session's name, as who@node or who) are always required. A " +
"name is bound to whichever session last called for it; if your own id changed (a /clear) you must " +
"adopt your name again before it answers to you — the SessionStart hook lists every name on this " +
"node with an open wait and how. adopt takes the name over at once, however recently another " +
"session called for it; that session is told so, naming yours, the next time it calls. add takes " +
"what (what you are waiting for), read (the verb that answers where it stands: {address, " +
"arguments}), family (optional, the event mesh_wait can wait on) and bound_minutes (1 to 1440), and " +
"answers an id; at most 50 waits are open per name. watched takes id and watcher (what follows it: " +
"mesh_wait on its family, or a watcher agent you started). done takes id and outcome, and removes it " +
"from the open list. said takes id and marks a wait past its bound as told to the operator — " +
"refused while it is still within its bound. list answers the open waits, with their age and " +
"whether each is past its bound.",
Input: map[string]any{
"op": str("add, watched, done, said, list or adopt"),
"session": str("required: your session's current id"),
"by": str("required: your session's name, as who@node or who"),
"what": str("add: what you are waiting for, in plain words"),
"read": map[string]any{"type": "object", "description": "add: the verb that reads where this wait stands",
"properties": map[string]any{
"address": str("the address, as mesh_call takes it"),
"arguments": map[string]any{"type": "object", "description": "its arguments"},
}},
"family": str("add: the event family mesh_wait can wait on, when there is one"),
"bound_minutes": map[string]any{"type": "integer", "description": "add: minutes until this wait's bound, 1 to 1440"},
"id": str("watched, done or said: the id add answered"),
"watcher": str("watched: what follows this wait"),
"outcome": str("done: what the wait ended with"),
},
Run: func(a map[string]any) (any, error) {
if waiting == nil {
return nil, notLaunched
}
session, by := strArg(a, "session"), strArg(a, "by")
now := time.Now()
if waiting.Now != nil {
now = waiting.Now()
}
switch op := strArg(a, "op"); op {
case "add":
read := WaitRead{}
if m, ok := a["read"].(map[string]any); ok {
read.Address = strArg(m, "address")
if args, ok := m["arguments"].(map[string]any); ok {
read.Arguments = args
}
}
bound := 0
if n, ok := a["bound_minutes"].(float64); ok {
bound = int(n)
}
return AddWait(p, WaitInput{Session: session, By: by, What: strArg(a, "what"), Read: read,
Family: strArg(a, "family"), Bound: bound}, waiting.Store, now)
case "watched":
return WatchedWait(p, session, by, strArg(a, "id"), strArg(a, "watcher"), waiting.Store, now)
case "done":
return DoneWait(p, session, by, strArg(a, "id"), strArg(a, "outcome"), waiting.Store, now)
case "said":
return SaidWait(p, session, by, strArg(a, "id"), waiting.Store, now)
case "list":
return ListWaits(p, session, by, waiting.Store, now)
case "adopt":
return AdoptName(p, by, session, waiting.Store, now)
default:
return nil, fmt.Errorf("op is add, watched, done, said, list or adopt, not %q", op)
}
}},
}
}
@@ -0,0 +1,577 @@
package main
// A session's wait list (novox/hq ADR 0489, ADR 0490, issues 1629, 1744).
import (
"encoding/json"
"os"
"strings"
"sync"
"testing"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
)
// memWaits is the `waits` state: values with revisions, create-only and compare-and-set, as the bus keeps it.
type memWaits struct {
mu sync.Mutex
m map[string]json.RawMessage
rev map[string]uint64
n uint64
}
func newMemWaits() *memWaits {
return &memWaits{m: map[string]json.RawMessage{}, rev: map[string]uint64{}}
}
func (s *memWaits) Get(key string) (json.RawMessage, uint64, bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
raw, ok := s.m[key]
return raw, s.rev[key], ok, nil
}
func (s *memWaits) put(key string, value any) error {
raw, err := json.Marshal(value)
s.n++
s.m[key], s.rev[key] = raw, s.n
return err
}
func (s *memWaits) Create(key string, value any) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.m[key]; ok {
return stdio.ErrChanged
}
return s.put(key, value)
}
func (s *memWaits) Update(key string, value any, revision uint64) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.rev[key] != revision {
return stdio.ErrChanged
}
return s.put(key, value)
}
func (s *memWaits) Keys() ([]string, error) {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]string, 0, len(s.m))
for k := range s.m {
out = append(out, k)
}
return out, nil
}
// addApproveWait adds the wait fixture the operator's Approve is kept under, for name on session.
func addApproveWait(t *testing.T, p Paths, store WaitStore, name, session string, at time.Time) string {
t.Helper()
out, err := AddWait(p, WaitInput{Session: session, By: name + "@" + p.Node, What: "the operator's Approve of pull request 42",
Read: WaitRead{Address: "mesh-delivery.recheck", Arguments: map[string]any{"pr": 42}}, Family: "mesh-delivery.decided",
Bound: 10}, store, at)
if err != nil {
t.Fatalf("add: %v", err)
}
return out["id"].(string)
}
// 1629/AC1: add answers an id; list answers the open waits with their age and whether each is past its bound.
func TestAddAnswersAnIDAndListShowsItsAgeAndPastBound(t *testing.T) {
p, _ := node(t, "g14")
store := newMemWaits()
id := addApproveWait(t, p, store, "fix-1629", "sess-1", t0)
if !strings.HasPrefix(id, WaitPrefix) {
t.Fatalf("id %q does not start with %q", id, WaitPrefix)
}
out, err := ListWaits(p, "sess-1", "fix-1629@g14", store, t0.Add(time.Minute))
if err != nil {
t.Fatal(err)
}
open := out["open"].([]map[string]any)
if len(open) != 1 || open[0]["id"] != id || open[0]["past_bound"] != false || open[0]["age_seconds"] != 60 {
t.Fatalf("%+v", open)
}
out, err = ListWaits(p, "sess-1", "fix-1629@g14", store, t0.Add(11*time.Minute))
if err != nil {
t.Fatal(err)
}
open = out["open"].([]map[string]any)
if open[0]["past_bound"] != true {
t.Fatalf("past its 10-minute bound after 11 minutes: %+v", open[0])
}
}
// 1629/AC2: watched, done and said change the wait; said is refused for a wait not past its bound; done removes
// it from the open list.
func TestWatchedDoneAndSaidChangeTheWait(t *testing.T) {
p, _ := node(t, "g14")
store := newMemWaits()
by := "fix-1629@g14"
id := addApproveWait(t, p, store, "fix-1629", "sess-2", t0)
if _, err := SaidWait(p, "sess-2", by, id, store, t0.Add(time.Minute)); err == nil || !strings.Contains(err.Error(), "not past its bound") {
t.Fatalf("said before the bound: %v", err)
}
if _, err := WatchedWait(p, "sess-2", by, id, "mesh_wait on mesh-delivery.decided", store, t0.Add(time.Minute)); err != nil {
t.Fatal(err)
}
out, _ := ListWaits(p, "sess-2", by, store, t0.Add(2*time.Minute))
open := out["open"].([]map[string]any)
if open[0]["watcher"] != "mesh_wait on mesh-delivery.decided" {
t.Fatalf("watcher not kept: %+v", open[0])
}
if _, err := SaidWait(p, "sess-2", by, id, store, t0.Add(11*time.Minute)); err != nil {
t.Fatalf("said past the bound: %v", err)
}
out, _ = ListWaits(p, "sess-2", by, store, t0.Add(12*time.Minute))
open = out["open"].([]map[string]any)
if open[0]["said"] != true {
t.Fatalf("said not kept: %+v", open[0])
}
if _, err := DoneWait(p, "sess-2", by, id, "approved", store, t0.Add(13*time.Minute)); err != nil {
t.Fatal(err)
}
out, _ = ListWaits(p, "sess-2", by, store, t0.Add(14*time.Minute))
if len(out["open"].([]map[string]any)) != 0 {
t.Fatalf("done did not remove it from the open list: %v", out)
}
if _, err := DoneWait(p, "sess-2", by, id, "approved", store, t0.Add(15*time.Minute)); err == nil {
t.Fatal("done on an already-done wait was not refused")
}
}
// 1629/AC3: every change rewrites the session's waits file whole, atomically; it holds exactly the open waits
// and the write time.
func TestEveryChangeRewritesTheWaitsFileWhole(t *testing.T) {
p, _ := node(t, "g14")
store := newMemWaits()
by := "fix-1629@g14"
id1 := addApproveWait(t, p, store, "fix-1629", "sess-3", t0)
f, err := readWaitsFile(waitsFilePath(p, "sess-3"))
if err != nil {
t.Fatal(err)
}
if f.Session != "sess-3" || len(f.Waits) != 1 || f.Waits[0].ID != id1 || !f.At.Equal(t0.UTC()) {
t.Fatalf("%+v", f)
}
// Written whole: no .mesh-tmp left beside it.
if _, err := os.Stat(waitsFilePath(p, "sess-3") + ".mesh-tmp"); !os.IsNotExist(err) {
t.Fatalf("a temp file was left beside it: %v", err)
}
id2, err := AddWait(p, WaitInput{Session: "sess-3", By: by, What: "a second wait",
Read: WaitRead{Address: "mesh-delivery.recheck"}, Bound: 5}, store, t0.Add(time.Minute))
if err != nil {
t.Fatal(err)
}
f, err = readWaitsFile(waitsFilePath(p, "sess-3"))
if err != nil || len(f.Waits) != 2 || !f.At.Equal(t0.Add(time.Minute).UTC()) {
t.Fatalf("%+v %v", f, err)
}
if _, err := DoneWait(p, "sess-3", by, id1, "approved", store, t0.Add(2*time.Minute)); err != nil {
t.Fatal(err)
}
f, err = readWaitsFile(waitsFilePath(p, "sess-3"))
if err != nil || len(f.Waits) != 1 || f.Waits[0].ID != id2["id"] {
t.Fatalf("the file does not hold exactly the open waits: %+v %v", f, err)
}
}
// 1629/AC4: the waits are read again after the module "restarts" (the state is kept) and for the same name
// after a /clear that keeps calling with the same session id; a different name lists none of them.
func TestWaitsSurviveARestartAndAreNeverSeenByAnotherName(t *testing.T) {
p, _ := node(t, "g14")
store := newMemWaits()
by := "fix-1629@g14"
addApproveWait(t, p, store, "fix-1629", "sess-4", t0)
// A restart: a fresh Waiting over the same store reads the same waits.
restarted := &Waiting{Store: store, Now: func() time.Time { return t0.Add(time.Minute) }}
_ = restarted
out, err := ListWaits(p, "sess-4", by, store, t0.Add(time.Minute))
if err != nil || len(out["open"].([]map[string]any)) != 1 {
t.Fatalf("not read again after a restart: %v %v", out, err)
}
// The same session id, called again: still lists it.
out, err = ListWaits(p, "sess-4", by, store, t0.Add(2*time.Minute))
if err != nil || len(out["open"].([]map[string]any)) != 1 {
t.Fatalf("not read again for the same name: %v %v", out, err)
}
// A different name, same node: none of them.
out, err = ListWaits(p, "sess-other", "fix-other@g14", store, t0.Add(2*time.Minute))
if err != nil || len(out["open"].([]map[string]any)) != 0 {
t.Fatalf("another name saw them: %v %v", out, err)
}
}
// 1629/AC5: add refuses an empty what, a bound under 1 or over 1440 minutes, a read address that is not an
// address, and more than 50 open waits per name, naming the argument.
func TestAddRefusesOutsideItsBounds(t *testing.T) {
p, _ := node(t, "g14")
base := func() WaitInput {
return WaitInput{Session: "sess-5", By: "fix-1629@" + p.Node, What: "something", Read: WaitRead{Address: "mesh-delivery.recheck"}, Bound: 10}
}
for name, change := range map[string]struct {
mutate func(*WaitInput)
want string
}{
"empty what": {func(in *WaitInput) { in.What = "" }, "what is required"},
"bound zero": {func(in *WaitInput) { in.Bound = 0 }, "bound_minutes"},
"bound under 1": {func(in *WaitInput) { in.Bound = 0 }, "bound_minutes"},
"bound over 1440": {func(in *WaitInput) { in.Bound = 1441 }, "bound_minutes"},
"empty address": {func(in *WaitInput) { in.Read.Address = "" }, "read.address"},
"address no dot": {func(in *WaitInput) { in.Read.Address = "notanaddress" }, "read.address"},
"address has spaces": {func(in *WaitInput) { in.Read.Address = "not an address" }, "read.address"},
} {
in := base()
change.mutate(&in)
store := newMemWaits()
if _, err := AddWait(p, in, store, t0); err == nil || !strings.Contains(err.Error(), change.want) {
t.Errorf("%s: %v", name, err)
}
}
// More than 50 open waits per name.
store := newMemWaits()
for i := 0; i < MostWaits; i++ {
if _, err := AddWait(p, base(), store, t0); err != nil {
t.Fatalf("wait %d: %v", i, err)
}
}
if _, err := AddWait(p, base(), store, t0); err == nil || !strings.Contains(err.Error(), "at most 50 open waits") {
t.Fatalf("the 51st wait was not refused: %v", err)
}
}
// Review round 1 item (b): list, watched, done and said refuse a session id that is not a plain one (the
// same waitSessionID add already holds to), naming "session" — not silently treating it as just-another-key.
func TestListWatchedDoneAndSaidRefuseABadSessionID(t *testing.T) {
p, _ := node(t, "g14")
by := "fix-1629@g14"
for _, bad := range []string{"", "a/b", "../x"} {
store := newMemWaits()
if _, err := ListWaits(p, bad, by, store, t0); err == nil || !strings.Contains(err.Error(), "session") {
t.Errorf("list %q: %v", bad, err)
}
if _, err := WatchedWait(p, bad, by, "w-1", "watcher", store, t0); err == nil || !strings.Contains(err.Error(), "session") {
t.Errorf("watched %q: %v", bad, err)
}
if _, err := DoneWait(p, bad, by, "w-1", "outcome", store, t0); err == nil || !strings.Contains(err.Error(), "session") {
t.Errorf("done %q: %v", bad, err)
}
if _, err := SaidWait(p, bad, by, "w-1", store, t0); err == nil || !strings.Contains(err.Error(), "session") {
t.Errorf("said %q: %v", bad, err)
}
}
}
// 1629/AC6: the tool is served through the module's own dispatch.
func TestTheWaitToolIsServedThroughTheModulesDispatch(t *testing.T) {
p, _ := node(t, "g14")
store := newMemWaits()
waiting = &Waiting{Store: store, Now: func() time.Time { return t0 }}
t.Cleanup(func() { waiting = nil })
s := serve(t, p, &bus{kept: map[string]map[string]any{}}, memConfig{})
text, isErr := s.call(t, "claude_code_wait", map[string]any{"op": "add", "session": "sess-6", "by": "fix-1629@g14",
"what": "the operator's Approve", "read": map[string]any{"address": "mesh-delivery.recheck"}, "bound_minutes": 10})
if isErr {
t.Fatalf("add: %s", text)
}
var added map[string]any
if err := json.Unmarshal([]byte(text), &added); err != nil {
t.Fatal(err)
}
id, _ := added["id"].(string)
if id == "" {
t.Fatalf("no id answered: %s", text)
}
text, isErr = s.call(t, "claude_code_wait", map[string]any{"op": "list", "session": "sess-6", "by": "fix-1629@g14"})
if isErr || !strings.Contains(text, id) {
t.Fatalf("list: %v %s", isErr, text)
}
text, isErr = s.call(t, "claude_code_wait", map[string]any{"op": "done", "session": "sess-6", "by": "fix-1629@g14",
"id": id, "outcome": "approved"})
if isErr {
t.Fatalf("done: %s", text)
}
}
// ---- ADR 0490: a name's waits survive a session's id changing, by adopting -----------------------------
// ADR 0490 §1: a name nothing has bound yet is claimed by the first session that calls for it (add), without
// a separate adopt first.
func TestAFirstAddBindsAnUnboundNameAtOnce(t *testing.T) {
p, _ := node(t, "g14")
store := newMemWaits()
id := addApproveWait(t, p, store, "fix-1629", "sess-a", t0)
out, err := ListWaits(p, "sess-a", "fix-1629@g14", store, t0)
if err != nil || len(out["open"].([]map[string]any)) != 1 || out["open"].([]map[string]any)[0]["id"] != id {
t.Fatalf("%v %v", out, err)
}
}
// ADR 0490 §1/§3: once a name is bound to a session, a different, non-adopted session calling for the same
// name (any op) is refused, naming the session that holds it and when it last called.
func TestADifferentSessionIsRefusedWithoutAdopting(t *testing.T) {
p, _ := node(t, "g14")
store := newMemWaits()
by := "fix-1629@g14"
addApproveWait(t, p, store, "fix-1629", "sess-old", t0)
if _, err := AddWait(p, WaitInput{Session: "sess-new", By: by, What: "another", Read: WaitRead{Address: "mesh-delivery.recheck"},
Bound: 10}, store, t0.Add(time.Minute)); err == nil || !strings.Contains(err.Error(), "sess-old") {
t.Fatalf("add from a different session was not refused: %v", err)
}
// list, read-only (review round 2 item c), never refuses: it just says who holds the name.
out, err := ListWaits(p, "sess-new", by, store, t0.Add(time.Minute))
if err != nil {
t.Fatalf("list from a different session was refused: %v", err)
}
held, _ := out["held-by"].(map[string]any)
if held == nil || held["session"] != "sess-old" {
t.Fatalf("list did not say who holds the name: %v", out)
}
}
// ADR 0490 §2/§3: adopt right after a simulated /clear binds the name to the new session id at once; the
// mirrored file of the old session id is emptied and the new one holds the waits, with no delay.
func TestAdoptRightAfterAClearBindsAtOnce(t *testing.T) {
p, _ := node(t, "g14")
store := newMemWaits()
by := "fix-1629@g14"
id := addApproveWait(t, p, store, "fix-1629", "sess-old", t0)
out, err := AdoptName(p, by, "sess-new", store, t0.Add(time.Minute))
if err != nil {
t.Fatal(err)
}
if out["session"] != "sess-new" || out["open"] != 1 {
t.Fatalf("%v", out)
}
took, _ := out["took-over-from"].(map[string]any)
if took == nil || took["session"] != "sess-old" {
t.Fatalf("the answer did not name the earlier id: %v", out)
}
oldFile, err := readWaitsFile(waitsFilePath(p, "sess-old"))
if err != nil || len(oldFile.Waits) != 0 {
t.Fatalf("the old session's file still holds waits: %+v %v", oldFile, err)
}
newFile, err := readWaitsFile(waitsFilePath(p, "sess-new"))
if err != nil || len(newFile.Waits) != 1 || newFile.Waits[0].ID != id {
t.Fatalf("the new session's file does not hold the waits: %+v %v", newFile, err)
}
// The new session can act on the name at once: no 10-minute rule.
if _, err := ListWaits(p, "sess-new", by, store, t0.Add(time.Minute)); err != nil {
t.Fatalf("the new session could not act right after adopting: %v", err)
}
}
// ADR 0490 §3: adopt of a name bound to another id always takes it over, however recently that id called —
// no bound protects it.
func TestAdoptAlwaysTakesOverEvenJustCalled(t *testing.T) {
p, _ := node(t, "g14")
store := newMemWaits()
by := "fix-1629@g14"
addApproveWait(t, p, store, "fix-1629", "sess-fresh", t0)
// sess-fresh calls again right before being adopted away from.
if _, err := ListWaits(p, "sess-fresh", by, store, t0.Add(10*time.Second)); err != nil {
t.Fatal(err)
}
out, err := AdoptName(p, by, "sess-taker", store, t0.Add(11*time.Second))
if err != nil {
t.Fatalf("adopt was refused even though it always takes over: %v", err)
}
if out["session"] != "sess-taker" {
t.Fatalf("%v", out)
}
}
// ADR 0490 §3: the earlier id's next claude_code_wait call (any op) answers that its name now answers to
// another session, naming it.
func TestTheEarlierIDIsToldOnItsNextCall(t *testing.T) {
p, _ := node(t, "g14")
store := newMemWaits()
by := "fix-1629@g14"
addApproveWait(t, p, store, "fix-1629", "sess-old", t0)
if _, err := AdoptName(p, by, "sess-new", store, t0.Add(time.Minute)); err != nil {
t.Fatal(err)
}
_, err := AddWait(p, WaitInput{Session: "sess-old", By: by, What: "another", Read: WaitRead{Address: "mesh-delivery.recheck"},
Bound: 10}, store, t0.Add(2*time.Minute))
if err == nil || !strings.Contains(err.Error(), "sess-new") || !strings.Contains(err.Error(), "adopt") {
t.Fatalf("the earlier id's next call did not name the new session: %v", err)
}
}
// ADR 0490 §2: the node-wide index lists every name on this node with an open wait, for the SessionStart
// hook to read directly.
func TestTheNodeWideIndexListsEveryNameWithOpenWaits(t *testing.T) {
p, _ := node(t, "g14")
store := newMemWaits()
addApproveWait(t, p, store, "fix-1629", "sess-x", t0)
addApproveWait(t, p, store, "fix-1744", "sess-y", t0)
if _, err := DoneWait(p, "sess-x", "fix-1629@g14", wantsOnlyWait(t, store, p, "fix-1629", "sess-x"), "done",
store, t0.Add(time.Minute)); err != nil {
t.Fatal(err)
}
idx, err := readNodeIndex(nodeIndexPath(p))
if err != nil {
t.Fatal(err)
}
if idx.Node != "g14" || len(idx.Names) != 1 || idx.Names[0].Name != "fix-1744" || idx.Names[0].Open != 1 ||
idx.Names[0].CurrentSession != "sess-y" {
t.Fatalf("%+v", idx)
}
}
// wantsOnlyWait is the id of the one wait a fixture added, read back through list.
func wantsOnlyWait(t *testing.T, store WaitStore, p Paths, name, session string) string {
t.Helper()
out, err := ListWaits(p, session, name+"@"+p.Node, store, t0)
if err != nil {
t.Fatal(err)
}
open := out["open"].([]map[string]any)
if len(open) != 1 {
t.Fatalf("%v", open)
}
return open[0]["id"].(string)
}
// claude_code_wait op=adopt, served through the module's own dispatch (1629/AC6, ADR 0490).
func TestAdoptIsServedThroughTheModulesDispatch(t *testing.T) {
p, _ := node(t, "g14")
store := newMemWaits()
waiting = &Waiting{Store: store, Now: func() time.Time { return t0 }}
t.Cleanup(func() { waiting = nil })
s := serve(t, p, &bus{kept: map[string]map[string]any{}}, memConfig{})
text, isErr := s.call(t, "claude_code_wait", map[string]any{"op": "add", "session": "sess-old", "by": "fix-1629@g14",
"what": "the operator's Approve", "read": map[string]any{"address": "mesh-delivery.recheck"}, "bound_minutes": 10})
if isErr {
t.Fatalf("add: %s", text)
}
text, isErr = s.call(t, "claude_code_wait", map[string]any{"op": "adopt", "session": "sess-new", "by": "fix-1629@g14"})
if isErr || !strings.Contains(text, "sess-old") {
t.Fatalf("adopt: %v %s", isErr, text)
}
}
// ---- Review round 2: a session id's mirrored file is the union of every name bound to it -----------------
// Review round 2, item 2 (ADR 0490 §1): one session id with two different names bound to it holds both
// names' waits in its one mirrored file, not only the name an op last acted on.
func TestASessionsFileHoldsEveryNameBoundToIt(t *testing.T) {
p, _ := node(t, "g14")
store := newMemWaits()
idX := addApproveWait(t, p, store, "fix-x", "sess-shared", t0)
idY := addApproveWait(t, p, store, "fix-y", "sess-shared", t0.Add(time.Minute))
f, err := readWaitsFile(waitsFilePath(p, "sess-shared"))
if err != nil {
t.Fatal(err)
}
ids := map[string]bool{}
for _, w := range f.Waits {
ids[w.ID] = true
}
if len(f.Waits) != 2 || !ids[idX] || !ids[idY] {
t.Fatalf("the session's file does not hold both names' waits: %+v", f)
}
}
// Review round 2, item 2: adopting one of two names bound to the same session away keeps that session's
// file holding the other name's waits — it is never emptied wholesale.
func TestAdoptingOneNameAwayKeepsTheSessionsOtherNamesWaits(t *testing.T) {
p, _ := node(t, "g14")
store := newMemWaits()
idX := addApproveWait(t, p, store, "fix-x", "sess-shared", t0)
idY := addApproveWait(t, p, store, "fix-y", "sess-shared", t0.Add(time.Minute))
if _, err := AdoptName(p, "fix-y@g14", "sess-y-new", store, t0.Add(2*time.Minute)); err != nil {
t.Fatal(err)
}
sharedFile, err := readWaitsFile(waitsFilePath(p, "sess-shared"))
if err != nil || len(sharedFile.Waits) != 1 || sharedFile.Waits[0].ID != idX {
t.Fatalf("sess-shared should still hold fix-x's wait: %+v %v", sharedFile, err)
}
newFile, err := readWaitsFile(waitsFilePath(p, "sess-y-new"))
if err != nil || len(newFile.Waits) != 1 || newFile.Waits[0].ID != idY {
t.Fatalf("sess-y-new should hold fix-y's wait: %+v %v", newFile, err)
}
}
// Review round 2, item b: adopt clears the refusal counter of both the earlier id and the new one, so a
// fresh three-strikes count starts under the new binding.
func TestAdoptClearsTheRefusalCounterOfBothIDs(t *testing.T) {
p, _ := node(t, "g14")
store := newMemWaits()
by := "fix-1629@g14"
addApproveWait(t, p, store, "fix-1629", "sess-old", t0)
waitsDir := WaitsDirPath(p)
if err := writeRefusalCounters(waitsDir, "sess-old", map[string]int{"w-1": 2}); err != nil {
t.Fatal(err)
}
if err := writeRefusalCounters(waitsDir, "sess-new", map[string]int{"w-2": 3}); err != nil {
t.Fatal(err)
}
if _, err := AdoptName(p, by, "sess-new", store, t0.Add(time.Minute)); err != nil {
t.Fatal(err)
}
if c := readRefusalCounters(waitsDir, "sess-old"); len(c) != 0 {
t.Errorf("sess-old's counter was not cleared: %v", c)
}
if c := readRefusalCounters(waitsDir, "sess-new"); len(c) != 0 {
t.Errorf("sess-new's counter was not cleared: %v", c)
}
}
// Review round 2, item c: list is read-only — it never binds an unbound name, and never refuses a
// mismatched session; it answers the name's waits and who currently holds it.
func TestListNeverBindsAndAnswersWhoHoldsTheName(t *testing.T) {
p, _ := node(t, "g14")
store := newMemWaits()
by := "fix-1629@g14"
// An unbound name: list answers no waits, and binds nothing (a later add from a different session still
// succeeds, as the first caller for an unbound name).
out, err := ListWaits(p, "sess-reader", by, store, t0)
if err != nil || len(out["open"].([]map[string]any)) != 0 {
t.Fatalf("an unbound name: %v %v", out, err)
}
if _, found, _ := readSessionBinding(store, waitsKey(p, "fix-1629")); found {
t.Fatal("list bound an unbound name")
}
if _, err := AddWait(p, WaitInput{Session: "sess-first", By: by, What: "x", Read: WaitRead{Address: "mesh-delivery.recheck"},
Bound: 10}, store, t0); err != nil {
t.Fatalf("add after a read-only list: %v", err)
}
// A name bound to another session: list still answers (never refused), and says who holds it.
out, err = ListWaits(p, "sess-other", by, store, t0.Add(time.Minute))
if err != nil {
t.Fatalf("list for a name held by another session was refused: %v", err)
}
if len(out["open"].([]map[string]any)) != 1 {
t.Fatalf("list did not answer the name's waits: %v", out)
}
held, _ := out["held-by"].(map[string]any)
if held == nil || held["session"] != "sess-first" {
t.Fatalf("list did not say who holds the name: %v", out)
}
}
// readSessionBinding is a thin test helper reading the raw NamedWaits at key, without claiming it.
func readSessionBinding(store WaitStore, key string) (NamedWaits, bool, error) {
nw, _, found, err := readNamedWaits(store, key)
return nw, found, err
}
+3 -1
View File
@@ -28,7 +28,8 @@
"config",
"proposals",
"questions",
"memory"
"memory",
"waits"
],
"reads": [
"claude-licence-manager.bindings"
@@ -67,6 +68,7 @@
"claude_code_proposals",
"claude_code_ask",
"claude_code_answer",
"claude_code_wait",
"claude_code_settings_get",
"claude_code_settings_set",
"claude_code_settings_clear",
+3 -3
View File
@@ -5,16 +5,16 @@ machine: an agent working on a change builds it and runs its tests there, with t
pull request. The lab is the QA step after that: it tests the work in a sandbox, through its seat at the mesh
scope, and is not where development happens.
Assign it to every node.
Assign it to every node, with the `go` and `nodejs` modules: it requires their provisions `go-toolchain` and `node-toolchain` (novox/hq ADR 0477 clause 4).
## Owns
| what | package | class (ADR 0182) |
|---|---|---|
| the Go toolchain, for `go build` and `go test` in every Go module | `go` | package |
| the Go toolchain, for `go build` and `go test` in every Go module | the `go` module's package `go` | package |
| a C compiler, for `go test -race` and cgo | `gcc` | package |
| make | `make` | package |
| Node.js and npm, for the TypeScript modules' `npm ci` and `npm test` | `nodejs`, `npm` | package |
| Node.js and npm, for the TypeScript modules' `npm ci` and `npm test` | the `nodejs` module's packages `nodejs`, `npm` | package |
| Python, for the scripts some checks run | `python` | package |
| git and jq | `git`, `jq` | package |
+4 -15
View File
@@ -4,12 +4,11 @@
"capabilities": [
"package-manager"
],
"requires": [
"go-toolchain",
"node-toolchain"
],
"resources": [
{
"id": "go",
"type": "package",
"package": "go"
},
{
"id": "c-compiler",
"type": "package",
@@ -20,16 +19,6 @@
"type": "package",
"package": "make"
},
{
"id": "nodejs",
"type": "package",
"package": "nodejs"
},
{
"id": "npm",
"type": "package",
"package": "npm"
},
{
"id": "python",
"type": "package",
+432
View File
@@ -0,0 +1,432 @@
package main
import (
"encoding/base64"
"fmt"
"regexp"
"sort"
"strconv"
"strings"
"unicode/utf8"
)
// A unified diff applied by the tool itself (novox/hq issue 1604). The forge's diffpatch answers 500 with an empty
// message when an unchanged context line holds a non-ASCII character, so a patch the forge did not take is applied
// here: each touched file is read at the commit the commit goes on, its hunks are applied, and the resulting
// files are committed through the same call a files commit uses. The forge's files call carries no file mode, so
// a patch that creates an executable file, changes a mode or touches a symlink is not applied here: the tool says so.
// fileSource reads a file at the commit the patch is applied to; ok is false when there is no such file.
type fileSource func(path string) (content []byte, mode string, ok bool, err error)
// patchFile is one file section of a patch.
type patchFile struct {
oldPath, newPath string // "" when /dev/null
create, remove bool
renamed bool
oldSeen bool
hunks []hunk
modeBlock string // why the files call cannot carry this section, "" when it can
}
type hunk struct {
oldStart int
old, new []string
oldNoNL, newNoNL bool
leading int // unchanged lines before the first change
trailing int // unchanged lines after the last change
}
var hunkHeader = regexp.MustCompile(`^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@`)
// gitPath reads a path of a diff header: git quotes one holding unusual bytes in C style.
func gitPath(s string) string {
s = strings.TrimRight(s, "\t ")
if strings.HasPrefix(s, `"`) {
if u, err := strconv.Unquote(s); err == nil {
return u
}
} else if i := strings.IndexByte(s, '\t'); i >= 0 {
s = s[:i]
}
return s
}
func stripPrefix(p string) string {
if p == "/dev/null" {
return ""
}
if i := strings.IndexByte(p, '/'); i >= 0 && (p[:i] == "a" || p[:i] == "b") {
return p[i+1:]
}
return p
}
func nonRegular(mode string) bool { return mode != "" && mode != "100644" }
// parsePatch reads a unified diff into its file sections.
func parsePatch(text string) ([]*patchFile, error) {
lines := strings.Split(text, "\n")
if n := len(lines); n > 0 && lines[n-1] == "" {
lines = lines[:n-1]
}
var files []*patchFile
var cur *patchFile
block := func(f *patchFile, what string) {
if f.modeBlock == "" {
f.modeBlock = what
}
}
for i := 0; i < len(lines); i++ {
l := strings.TrimSuffix(lines[i], "\r")
switch {
case strings.HasPrefix(l, "diff --git "):
cur = &patchFile{}
files = append(files, cur)
rest := strings.TrimPrefix(l, "diff --git ")
if k := strings.Index(rest, " b/"); k > 0 && !strings.HasPrefix(rest, `"`) {
cur.oldPath, cur.newPath = stripPrefix(rest[:k]), stripPrefix(rest[k+1:])
}
case cur != nil && strings.HasPrefix(l, "new file mode "):
cur.create = true
if m := strings.TrimPrefix(l, "new file mode "); nonRegular(m) {
block(cur, "creates a file of mode "+m)
}
case cur != nil && strings.HasPrefix(l, "deleted file mode "):
cur.remove = true
case cur != nil && strings.HasPrefix(l, "old mode "):
block(cur, "changes the file mode ("+l+")")
case cur != nil && strings.HasPrefix(l, "new mode "):
block(cur, "changes the file mode ("+l+")")
case cur != nil && strings.HasPrefix(l, "index "):
if f := strings.Fields(l); len(f) == 3 && nonRegular(f[2]) {
block(cur, "touches a file of mode "+f[2])
}
case cur != nil && strings.HasPrefix(l, "rename from "):
cur.oldPath, cur.renamed = gitPath(strings.TrimPrefix(l, "rename from ")), true
case cur != nil && strings.HasPrefix(l, "rename to "):
cur.newPath, cur.renamed = gitPath(strings.TrimPrefix(l, "rename to ")), true
case strings.HasPrefix(l, "copy from "), strings.HasPrefix(l, "copy to "):
return nil, fmt.Errorf("a copy in a patch (%s) is not applied by the tool", l)
case strings.HasPrefix(l, "GIT binary patch"), strings.HasPrefix(l, "Binary files "):
return nil, fmt.Errorf("a binary patch is not applied by the tool")
case strings.HasPrefix(l, "--- "):
if cur == nil || cur.oldSeen {
cur = &patchFile{}
files = append(files, cur)
}
p := stripPrefix(gitPath(strings.TrimPrefix(l, "--- ")))
cur.oldPath = p
if p == "" {
cur.create = true
}
cur.oldSeen = true
if i+1 >= len(lines) || !strings.HasPrefix(lines[i+1], "+++ ") {
return nil, fmt.Errorf("the line %d, %q, is not followed by a +++ line", i+1, l)
}
i++
q := stripPrefix(gitPath(strings.TrimPrefix(strings.TrimSuffix(lines[i], "\r"), "+++ ")))
cur.newPath = q
if q == "" {
cur.remove = true
}
case cur != nil && hunkHeader.MatchString(l):
m := hunkHeader.FindStringSubmatch(l)
atoi := func(s string, def int) int {
if s == "" {
return def
}
n, _ := strconv.Atoi(s)
return n
}
h := hunk{oldStart: atoi(m[1], 0)}
oldN, newN := atoi(m[2], 1), atoi(m[4], 1)
startLine := i + 1
noNL := func() { // "\ No newline at end of file" is about the line before it
prev := lines[i-1]
switch {
case prev != "" && prev[0] == '-':
h.oldNoNL = true
case prev != "" && prev[0] == '+':
h.newNoNL = true
default:
h.oldNoNL, h.newNoNL = true, true
}
}
changed := false
for oldN > 0 || newN > 0 {
i++
if i >= len(lines) {
return nil, fmt.Errorf("the hunk of %s at patch line %d ends before its %d old and %d new lines are all there", cur.name(), startLine, oldN, newN)
}
b := strings.TrimSuffix(lines[i], "\r")
if b == "" {
b = " " // a blank context line some tools strip to nothing, or a bare CR
}
switch b[0] {
case '\\':
noNL()
case ' ':
text := lines[i]
if strings.HasPrefix(text, " ") {
text = text[1:] // a bare "" or "\r" is the whole blank line, its leading space stripped
}
h.old, h.new = append(h.old, text), append(h.new, text)
oldN--
newN--
if changed {
h.trailing++
} else {
h.leading++
}
case '-':
h.old = append(h.old, lines[i][1:])
oldN--
changed, h.trailing = true, 0
case '+':
h.new = append(h.new, lines[i][1:])
newN--
changed, h.trailing = true, 0
default:
return nil, fmt.Errorf("the hunk of %s at patch line %d has a line %d that is neither context, - nor +: %q", cur.name(), startLine, i+1, lines[i])
}
}
for i+1 < len(lines) && strings.HasPrefix(lines[i+1], `\`) {
i++
noNL()
}
cur.hunks = append(cur.hunks, h)
}
}
for _, f := range files {
if f.newPath == "" && f.oldPath == "" {
return nil, fmt.Errorf("a file section of the patch names no file")
}
}
if len(files) == 0 {
return nil, fmt.Errorf("the patch holds no file section")
}
return files, nil
}
func (f *patchFile) name() string {
if f.newPath != "" {
return f.newPath
}
return f.oldPath
}
// textFile is a file's content as lines, and whether its last line ends with a newline.
type textFile struct {
lines []string
endNL bool
}
func splitText(b []byte) textFile {
if len(b) == 0 {
return textFile{endNL: true}
}
s := string(b)
endNL := strings.HasSuffix(s, "\n")
s = strings.TrimSuffix(s, "\n")
return textFile{lines: strings.Split(s, "\n"), endNL: endNL}
}
func (t textFile) bytes() []byte {
if len(t.lines) == 0 {
return nil
}
s := strings.Join(t.lines, "\n")
if t.endNL {
s += "\n"
}
return []byte(s)
}
// apply puts one hunk on the file, at where the hunk says or, as git apply does, at the nearest place its old
// lines are found exactly; delta is how far earlier hunks of this file moved lines.
func (t textFile) apply(h hunk, delta int) (textFile, int, error) {
want := h.oldStart - 1 + delta
if len(h.old) == 0 && h.oldStart > 0 {
want = h.oldStart + delta // a pure insertion is "after" the line it names
}
if want < 0 {
want = 0
}
matches := func(at int) bool {
if at < 0 || at+len(h.old) > len(t.lines) {
return false
}
for k, l := range h.old {
if t.lines[at+k] != l {
return false
}
}
// the old side's last line lacks a newline only when the file's does
if at+len(h.old) == len(t.lines) && len(h.old) > 0 && h.oldNoNL == t.endNL {
return false
}
return true
}
// As git apply does: a hunk that starts at line 1 (or 0) begins the file, and a hunk with context but none
// after its last change ends it; a hunk whose old side lacks the final newline ends it too.
atStart := h.oldStart <= 1
atEnd := (h.trailing == 0 && h.leading > 0) || h.oldNoNL
at := -1
switch {
case atStart && atEnd:
if len(t.lines) == len(h.old) && matches(0) {
at = 0
}
case atStart:
if matches(0) {
at = 0
}
case atEnd:
if e := len(t.lines) - len(h.old); matches(e) {
at = e
}
}
for d := 0; !atStart && !atEnd && d <= len(t.lines)+1 && at < 0; d++ {
switch {
case matches(want - d):
at = want - d
case matches(want + d):
at = want + d
}
}
if at < 0 {
return t, delta, fmt.Errorf("the hunk at line %d does not apply: its context and removed lines are not in the file (first of them: %q)", h.oldStart, firstOf(h.old))
}
out := append(append(append([]string{}, t.lines[:at]...), h.new...), t.lines[at+len(h.old):]...)
endNL := t.endNL
if at+len(h.old) == len(t.lines) && (len(h.new) > 0 || len(h.old) > 0) {
endNL = !h.newNoNL
}
return textFile{lines: out, endNL: endNL}, delta + len(h.new) - len(h.old), nil
}
func firstOf(l []string) string {
if len(l) == 0 {
return ""
}
return l[0]
}
// patchAsFiles applies the patch to the files read through read, and says the files to commit.
func patchAsFiles(text string, read fileSource) ([]FileChange, error) {
sections, err := parsePatch(text)
if err != nil {
return nil, err
}
for _, s := range sections {
if s.modeBlock != "" {
return nil, fmt.Errorf("%s %s, and the forge's files call carries no file mode", s.name(), s.modeBlock)
}
}
work := map[string][]byte{} // the files as they are being changed; nil means absent
orig := map[string]bool{} // which paths existed
load := func(p string) error {
if _, seen := work[p]; seen {
return nil
}
b, mode, ok, err := read(p)
if err != nil {
return err
}
if ok && mode != "" && mode != "100644" {
return fmt.Errorf("%s is a file of mode %s, and the forge's files call carries no file mode, so committing it that way would make it 100644", p, mode)
}
orig[p] = ok
if ok {
if b == nil {
b = []byte{}
}
work[p] = b
} else {
work[p] = nil
}
return nil
}
for _, s := range sections {
from, to := s.oldPath, s.newPath
if s.create {
from = ""
}
var t textFile
if from != "" {
if err := load(from); err != nil {
return nil, err
}
if work[from] == nil {
return nil, fmt.Errorf("%s: the file is not there at the commit the patch goes on, so the patch does not apply", from)
}
if !utf8.Valid(work[from]) && len(s.hunks) > 0 {
return nil, fmt.Errorf("%s is not UTF-8 text: the tool applies text patches only", from)
}
t = splitText(work[from])
} else {
if err := load(to); err != nil {
return nil, err
}
if work[to] != nil {
return nil, fmt.Errorf("%s: the patch creates it, but it is already there at the commit the patch goes on", to)
}
t = textFile{endNL: true}
}
delta := 0
for _, h := range s.hunks {
if t, delta, err = t.apply(h, delta); err != nil {
return nil, fmt.Errorf("%s: %w", s.name(), err)
}
}
switch {
case s.remove:
if len(t.lines) != 0 {
return nil, fmt.Errorf("%s: the patch deletes the file, but lines are left after its hunks", from)
}
work[from] = nil
default:
if s.renamed && from != to {
work[from] = nil
if err := load(to); err != nil {
return nil, err
}
}
body := t.bytes()
if body == nil {
body = []byte{}
}
work[to] = body
}
}
var paths []string
for p := range work {
paths = append(paths, p)
}
sort.Strings(paths)
var out []FileChange
for _, p := range paths {
switch {
case work[p] == nil && !orig[p]:
case work[p] == nil:
out = append(out, FileChange{Path: p, Delete: true})
default:
fc := FileChange{Path: p, size: len(work[p])}
if utf8.Valid(work[p]) {
s := string(work[p])
fc.Content = &s
} else {
s := base64Std(work[p])
fc.ContentBase64 = &s
}
out = append(out, fc)
}
}
if len(out) == 0 {
return nil, fmt.Errorf("the patch changes no file")
}
return out, nil
}
func base64Std(b []byte) string { return base64.StdEncoding.EncodeToString(b) }
+189
View File
@@ -0,0 +1,189 @@
package main
import (
"context"
"strings"
"testing"
)
// novox/hq issue 1604: the forge's diffpatch answers 500 when an unchanged context line holds a non-ASCII
// character; the tool then applies the patch itself.
func sectionFake(t *testing.T) *fakeForge {
f := newFake(t)
f.trees["c0"]["doc.md"] = "# Title\n\nSee § 4 for the rule.\nold line\nend § here\n"
f.trees["c0"]["run.sh"] = "#!/bin/sh\necho a\n"
f.patchStatus = 500
return f
}
const sectionPatch = "diff --git a/doc.md b/doc.md\n--- a/doc.md\n+++ b/doc.md\n@@ -2,4 +2,4 @@\n \n See § 4 for the rule.\n-old line\n+new line\n end § here\n"
func TestAPatchWhoseContextHoldsANonASCIICharacterIsAppliedByTheToolWhenTheForgeAnswers500(t *testing.T) {
f := sectionFake(t)
got, err := commit(context.Background(), f.serve(), ask(t, map[string]any{"branch": "fix/1604", "patch": sectionPatch}))
if err != nil {
t.Fatal(err)
}
want := "# Title\n\nSee § 4 for the rule.\nnew line\nend § here\n"
if tree := f.trees[got.Commit]; tree["doc.md"] != want || tree["README.md"] != "hello\n" {
t.Fatalf("tree %v", tree)
}
if len(f.patches) != 1 || len(f.writes) != 1 || f.writes[0]["new_branch"] != "fix/1604" || f.writes[0]["branch"] != "main" {
t.Fatalf("patches %v writes %v", f.patches, f.writes)
}
if len(got.Files) != 1 || got.Files[0].Operation != "update" || got.Parent != "c0" {
t.Fatalf("%+v", got)
}
if !strings.Contains(strings.Join(got.Said, " "), "applied it") {
t.Errorf("not said: %v", got.Said)
}
}
func TestAPatchTheForgeTakesIsStillTheForgesToApply(t *testing.T) {
f := sectionFake(t)
f.patchStatus = 0
if _, err := commit(context.Background(), f.serve(), ask(t, map[string]any{"branch": "fix/1604", "patch": sectionPatch})); err != nil {
t.Fatal(err)
}
if len(f.writes) != 0 {
t.Fatal("the tool applied a patch the forge took")
}
}
func TestAPatchThatCreatesDeletesAndRenamesIsApplied(t *testing.T) {
f := sectionFake(t)
patch := "diff --git a/new.txt b/new.txt\nnew file mode 100644\n--- /dev/null\n+++ b/new.txt\n@@ -0,0 +1,2 @@\n+one §\n+two\n" +
"diff --git a/docs/a.md b/docs/a.md\ndeleted file mode 100644\n--- a/docs/a.md\n+++ /dev/null\n@@ -1 +0,0 @@\n-a\n" +
"diff --git a/README.md b/README.txt\nsimilarity index 80%\nrename from README.md\nrename to README.txt\n--- a/README.md\n+++ b/README.txt\n@@ -1 +1 @@\n-hello\n+hello §\n"
got, err := commit(context.Background(), f.serve(), ask(t, map[string]any{"branch": "fix/r", "patch": patch}))
if err != nil {
t.Fatal(err)
}
tree := f.trees[got.Commit]
if tree["new.txt"] != "one §\ntwo\n" || tree["README.txt"] != "hello §\n" {
t.Fatalf("tree %v", tree)
}
if _, has := tree["README.md"]; has {
t.Error("the renamed file is still there")
}
if _, has := tree["docs/a.md"]; has {
t.Error("the deleted file is still there")
}
}
func TestAHunkMovedByEarlierLinesIsFoundAndNoNewlineIsKept(t *testing.T) {
f := sectionFake(t)
f.trees["c0"]["x.txt"] = "pre\npre\nabc §\nd\ne"
// the hunk below starts at line 2, though its lines are at 3: the tool finds them one line on
patch := "--- a/x.txt\n+++ b/x.txt\n@@ -2,3 +2,3 @@\n abc §\n d\n-e\n\\ No newline at end of file\n+f\n\\ No newline at end of file\n"
got, err := commit(context.Background(), f.serve(), ask(t, map[string]any{"branch": "fix/n", "patch": patch}))
if err != nil {
t.Fatal(err)
}
if g := f.trees[got.Commit]["x.txt"]; g != "pre\npre\nabc §\nd\nf" {
t.Fatalf("%q", g)
}
}
func TestAPatchThatDoesNotApplyIsRefusedNamingTheFileAndLine(t *testing.T) {
f := sectionFake(t)
patch := strings.Replace(sectionPatch, "-old line", "-other line", 1)
_, err := commit(context.Background(), f.serve(), ask(t, map[string]any{"branch": "fix/1604", "patch": patch}))
if err == nil || !strings.Contains(err.Error(), "doc.md") || !strings.Contains(err.Error(), "at line 2 does not apply") {
t.Fatalf("%v", err)
}
if len(f.writes) != 0 {
t.Fatal("something was written")
}
}
func TestAPatchWithAnExecutableFileIsNotAppliedByTheToolAndSaysWhy(t *testing.T) {
f := sectionFake(t)
for _, patch := range []string{
"diff --git a/s.sh b/s.sh\nnew file mode 100755\n--- /dev/null\n+++ b/s.sh\n@@ -0,0 +1 @@\n+echo §\n",
"diff --git a/run.sh b/run.sh\nold mode 100644\nnew mode 100755\n",
"diff --git a/run.sh b/run.sh\nindex 1111111..2222222 100755\n--- a/run.sh\n+++ b/run.sh\n@@ -1,2 +1,2 @@\n #!/bin/sh\n-echo a\n+echo b\n",
} {
_, err := commit(context.Background(), f.serve(), ask(t, map[string]any{"branch": "fix/x", "patch": patch}))
if err == nil || !strings.Contains(err.Error(), "the forge did not take the patch") || !strings.Contains(err.Error(), "carries no file mode") {
t.Errorf("%v", err)
}
}
if len(f.writes) != 0 {
t.Fatal("something was written")
}
}
func TestAClientErrorOfTheForgeIsNotRetriedByTheTool(t *testing.T) {
f := sectionFake(t)
f.patchStatus = 422
_, err := commit(context.Background(), f.serve(), ask(t, map[string]any{"branch": "fix/1604", "patch": sectionPatch}))
if err == nil || len(f.writes) != 0 {
t.Fatalf("%v", err)
}
}
func TestADefaultBranchIsStillRefusedForAPatch(t *testing.T) {
f := sectionFake(t)
_, err := commit(context.Background(), f.serve(), ask(t, map[string]any{"branch": "main", "patch": sectionPatch}))
if err == nil || !strings.Contains(err.Error(), "ADR 0266") || len(f.patches)+len(f.writes) != 0 {
t.Fatalf("%v", err)
}
}
// Review round 1.
func appliedTo(t *testing.T, content, patch string) (string, error) {
t.Helper()
f := sectionFake(t)
f.trees["c0"]["y.txt"] = content
got, err := commit(context.Background(), f.serve(), ask(t, map[string]any{"branch": "fix/y", "patch": patch}))
if err != nil {
return "", err
}
return f.trees[got.Commit]["y.txt"], nil
}
func TestAHunkAtLineOneMustMatchAtTheStartOfTheFile(t *testing.T) {
patch := "--- a/y.txt\n+++ b/y.txt\n@@ -1,3 +1,3 @@\n-a\n+A\n b\n c\n"
if got, err := appliedTo(t, "X\nb\nc\nzzz\na\nb\nc\n", patch); err == nil {
t.Fatalf("applied in the middle: %q", got)
}
if got, err := appliedTo(t, "a\nb\nc\nzzz\n", patch); err != nil || got != "A\nb\nc\nzzz\n" {
t.Fatalf("%q %v", got, err)
}
}
func TestAHunkWithNoTrailingContextMustMatchAtTheEndOfTheFile(t *testing.T) {
patch := "--- a/y.txt\n+++ b/y.txt\n@@ -1,3 +1,4 @@\n a\n b\n c\n+NEW\n"
if got, err := appliedTo(t, "a\nb\nc\nd\ne\n", patch); err == nil {
t.Fatalf("added in the middle: %q", got)
}
patch = "--- a/y.txt\n+++ b/y.txt\n@@ -3,3 +3,4 @@\n a\n b\n c\n+NEW\n"
if got, err := appliedTo(t, "x\ny\na\nb\nc\n", patch); err != nil || got != "x\ny\na\nb\nc\nNEW\n" {
t.Fatalf("%q %v", got, err)
}
}
func TestAnExistingExecutableFileIsNotSilentlyMadeNonExecutable(t *testing.T) {
for _, patch := range []string{
"diff --git a/run.sh b/run.sh2\nsimilarity index 100%\nrename from run.sh\nrename to run.sh2\n",
"--- a/run.sh\n+++ b/run.sh\n@@ -1,2 +1,2 @@\n #!/bin/sh\n-echo a\n+echo b\n",
} {
f := sectionFake(t)
f.modes = map[string]string{"run.sh": "100755"}
_, err := commit(context.Background(), f.serve(), ask(t, map[string]any{"branch": "fix/m", "patch": patch}))
if err == nil || !strings.Contains(err.Error(), "100755") || len(f.writes) != 0 {
t.Errorf("%v (%d writes)", err, len(f.writes))
}
}
}
func TestAPatchWithCRLFLinesAndABareCRContextLineDoesNotPanic(t *testing.T) {
patch := "--- a/y.txt\r\n+++ b/y.txt\r\n@@ -1,3 +1,3 @@\r\n a\r\n\r\n-c\r\n+C\r\n"
got, err := appliedTo(t, "a\r\n\r\nc\r\n", patch)
if err != nil || got != "a\r\n\r\nC\r\n" {
t.Fatalf("%q %v", got, err)
}
}
+119 -5
View File
@@ -326,14 +326,49 @@ func commit(ctx context.Context, f *Forge, a CommitAsk) (*Committed, error) {
var made struct {
Commit madeCommit `json:"commit"`
}
// A patch goes to the forge's diffpatch first. When the forge answers a server error (it does so, with an
// empty message, when an unchanged context line holds a non-ASCII character: novox/hq issue 1604) the tool
// applies the patch itself and commits the resulting files the way a files commit does.
files := a.Files
forgeApplied := false
if a.Patch != "" {
on["content"] = a.Patch
if err := f.SendJSON(ctx, http.MethodPost, repo+"/diffpatch", on, &made); err != nil {
err := f.SendJSON(ctx, http.MethodPost, repo+"/diffpatch", on, &made)
var status *forgehttp.StatusError
switch {
case err == nil:
forgeApplied = true
case errors.As(err, &status) && status.Status >= 500:
delete(on, "content")
read := func(p string) ([]byte, string, bool, error) {
b, ok, err := f.contentAt(ctx, repo, p, ref)
if err != nil || !ok {
return nil, "", false, err
}
mode, err := f.modeAt(ctx, repo, ref, p)
return b, mode, true, err
}
var perr error
if files, perr = patchAsFiles(a.Patch, read); perr != nil {
return nil, fmt.Errorf("the forge did not take the patch: %w; the tool could not apply it itself: %v", err, perr)
}
total := 0
for _, fc := range files {
if fc.size > maxFileBytes {
return nil, refuse("%s would be %s, more than the %s one file of a commit may be. Nothing was sent", fc.Path, mib(fc.size), mib(maxFileBytes))
}
total += fc.size
}
if total > maxCommitBytes {
return nil, refuse("the patch's files are %s together, more than the %s one commit carries. Nothing was sent", mib(total), mib(maxCommitBytes))
}
out.Said = append(out.Said, fmt.Sprintf("the forge answered %d to the patch, so the tool applied it to the files at %s and committed them", status.Status, short(ref)))
default:
return nil, fmt.Errorf("the forge did not take the patch: %w", err)
}
} else {
files := a.Files
for _, fc := range a.Files {
}
if !forgeApplied {
for _, fc := range files {
if fc.CopyFrom != nil {
// Read, and judged against the limits, before anything is sent.
var err error
@@ -391,7 +426,7 @@ func commit(ctx context.Context, f *Forge, a CommitAsk) (*Committed, error) {
moved = "base " + out.Base
}
how := "each file it changes was checked against " + short(read) + " by its blob, so none of them changed in between"
if a.Patch != "" {
if forgeApplied {
how = "the forge applied the patch there, and it refuses a patch whose context changed"
}
out.Said = append(out.Said, fmt.Sprintf("the %s moved from %s to %s between the read and the commit, so the "+
@@ -478,3 +513,82 @@ func short(sha string) string {
}
return sha
}
// contentAt is the content of a file at a ref; ok is false when there is no file there.
func (f *Forge) contentAt(ctx context.Context, repo, path, ref string) ([]byte, bool, error) {
seg, err := segments("file path", path)
if err != nil {
return nil, false, err
}
body, err := f.Do(ctx, http.MethodGet, repo+"/contents/"+seg+"?ref="+queryEscape(ref), nil, maxBody)
if err != nil {
if errors.Is(err, errNotFound) {
return nil, false, nil
}
return nil, false, err
}
var entry struct {
Type string `json:"type"`
Size int `json:"size"`
Content string `json:"content"`
Encoding string `json:"encoding"`
}
if err := jsonInto(body, &entry); err != nil {
return nil, false, fmt.Errorf("the forge's answer about %s is not readable: %w", path, err)
}
if entry.Type != "file" {
return nil, false, refuse("%s is a %s, not a file. Nothing was sent", path, entry.Type)
}
if entry.Encoding == "base64" && (entry.Content != "" || entry.Size == 0) {
raw, err := base64.StdEncoding.DecodeString(strings.ReplaceAll(entry.Content, "\n", ""))
if err != nil {
return nil, false, fmt.Errorf("the content of %s is not base64: %w", path, err)
}
return raw, true, nil
}
raw, err := f.Do(ctx, http.MethodGet, repo+"/raw/"+seg+"?ref="+queryEscape(ref), nil, maxFileBytes+1)
if err != nil {
return nil, false, err
}
return raw, true, nil
}
// modeAt is the git mode of a file at a commit, read through the git trees API: the contents API carries none.
func (f *Forge) modeAt(ctx context.Context, repo, ref, path string) (string, error) {
parts := strings.Split(path, "/")
tree := ref
for depth, name := range parts {
found := false
for page := 1; ; page++ {
var got struct {
Tree []struct {
Path string `json:"path"`
Mode string `json:"mode"`
Type string `json:"type"`
SHA string `json:"sha"`
} `json:"tree"`
Total int `json:"total_count"`
}
if err := f.GetJSON(ctx, fmt.Sprintf("%s/git/trees/%s?per_page=1000&page=%d", repo, url.PathEscape(tree), page), &got); err != nil {
return "", fmt.Errorf("the mode of %s could not be read: %w", path, err)
}
for _, e := range got.Tree {
if e.Path != name {
continue
}
if depth == len(parts)-1 {
return e.Mode, nil
}
tree, found = e.SHA, true
break
}
if found || len(got.Tree) == 0 || page*1000 >= got.Total {
break
}
}
if !found && depth < len(parts)-1 {
return "", fmt.Errorf("the mode of %s could not be read: %s is not a folder at %s", path, name, short(ref))
}
}
return "", fmt.Errorf("the mode of %s could not be read: not in the tree of %s", path, short(ref))
}
+48 -1
View File
@@ -46,6 +46,8 @@ type fakeForge struct {
parentsBroken bool // and GET git/commits fails
parents map[string]string // commit -> parent, as GET git/commits says it
sources map[string]*sourceRepo
modes map[string]string // path -> git mode when not 100644
patchStatus int // the status diffpatch answers instead of taking the patch (issue 1604)
}
type move struct {
@@ -128,7 +130,48 @@ func (f *fakeForge) handle(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
return
}
reply(map[string]any{"type": "file", "path": p, "sha": blob(content)})
reply(map[string]any{"type": "file", "path": p, "sha": blob(content), "size": len(content), "encoding": "base64",
"content": base64.StdEncoding.EncodeToString([]byte(content))})
case r.Method == "GET" && strings.HasPrefix(path, repo+"/git/trees/"):
id := strings.TrimPrefix(path, repo+"/git/trees/")
commitID, dir := id, ""
if strings.HasPrefix(id, "tree:") {
parts := strings.SplitN(id, ":", 3)
commitID, dir = parts[1], parts[2]
}
if c, isBranch := f.branches[commitID]; isBranch {
commitID = c
}
var entries []map[string]any
seen := map[string]bool{}
for p := range f.trees[commitID] {
if dir != "" {
if !strings.HasPrefix(p, dir+"/") {
continue
}
p = strings.TrimPrefix(p, dir+"/")
}
name, rest, nested := strings.Cut(p, "/")
if seen[name] {
continue
}
seen[name] = true
full := name
if dir != "" {
full = dir + "/" + name
}
if nested {
_ = rest
entries = append(entries, map[string]any{"path": name, "mode": "040000", "type": "tree", "sha": "tree:" + commitID + ":" + full})
continue
}
mode := "100644"
if m, ok := f.modes[full]; ok {
mode = m
}
entries = append(entries, map[string]any{"path": name, "mode": mode, "type": "blob", "sha": blob(f.trees[commitID][full])})
}
reply(map[string]any{"tree": entries, "total_count": len(entries)})
case r.Method == "POST" && path == repo+"/contents":
var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body)
@@ -176,6 +219,10 @@ func (f *fakeForge) handle(w http.ResponseWriter, r *http.Request) {
var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body)
f.patches = append(f.patches, body)
if f.patchStatus != 0 {
http.Error(w, `{"message":""}`, f.patchStatus)
return
}
f.next++
made := fmt.Sprintf("p%d", f.next)
from := f.branches[body["branch"].(string)]
+7 -7
View File
@@ -1,22 +1,22 @@
module gitea
go 1.25.0
go 1.26.0
require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261018003858-f185aedbf5d6
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021114014-66e8fdaa62e2
github.com/nats-io/nats-server/v2 v2.11.17
github.com/nats-io/nats.go v1.51.0
golang.org/x/crypto v0.50.0
github.com/nats-io/nats.go v1.54.0
golang.org/x/crypto v0.57.0
)
require (
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op // indirect
github.com/google/go-tpm v0.9.8 // indirect
github.com/klauspost/compress v1.18.5 // indirect
github.com/klauspost/compress v1.20.0 // indirect
github.com/minio/highwayhash v1.0.4 // indirect
github.com/nats-io/jwt/v2 v2.8.1 // indirect
github.com/nats-io/nkeys v0.4.15 // indirect
github.com/nats-io/nkeys v0.4.16 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/sys v0.48.0 // indirect
golang.org/x/time v0.15.0 // indirect
)
+12 -14
View File
@@ -1,29 +1,27 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261016174426-a4e7f480b3b0 h1:EK84so3Y8JdFT3BHrzj9cPQQrDUPa/d+IdOGnerlmEI=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261016174426-a4e7f480b3b0/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261018003858-f185aedbf5d6 h1:Sv1Qzix+U1RpaUkw5IbB0IJEyZ+FqgzHmbZrCZkcWf0=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261018003858-f185aedbf5d6/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021114014-66e8fdaa62e2 h1:Cdl5N56QPfmFBoLHrDfvvyc5Q+PUnTjf3fgYD7imcco=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021114014-66e8fdaa62e2/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/minio/highwayhash v1.0.4 h1:asJizugGgchQod2ja9NJlGOWq4s7KsAWr5XUc9Clgl4=
github.com/minio/highwayhash v1.0.4/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ=
github.com/nats-io/jwt/v2 v2.8.1 h1:V0xpGuD/N8Mi+fQNDynXohVvp7ZztevW5io8CUWlPmU=
github.com/nats-io/jwt/v2 v2.8.1/go.mod h1:nWnOEEiVMiKHQpnAy4eXlizVEtSfzacZ1Q43LIRavZg=
github.com/nats-io/nats-server/v2 v2.11.17 h1:GKEghcFK6A+aFx11Yf1LjgLC3txAwvyhnYzhBIQZA8I=
github.com/nats-io/nats-server/v2 v2.11.17/go.mod h1:B1sFVz4StNosQ903ak4N1G01Fl/9f8e06mXpFIE2K24=
github.com/nats-io/nats.go v1.51.0 h1:ByW84XTz6W03GSSsygsZcA+xgKK8vPGaa/FCAAEHnAI=
github.com/nats-io/nats.go v1.51.0/go.mod h1:26HypzazeOkyO3/mqd1zZd53STJN0EjCYF9Uy2ZOBno=
github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4=
github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs=
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k=
github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg=
github.com/nats-io/nkeys v0.4.16/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
+6 -9
View File
@@ -1,14 +1,11 @@
# go
The Go toolchain as a module (novox/hq ADR 0477 §4, issue 1573).
The Go toolchain as a module (novox/hq ADR 0477 clause 4, issue 1573).
- **Declares the package `go`.** Today dev-tools, lab and mesh-build-go declare it too, which the resolver allows
(ADR 0303); once they require `go-toolchain` instead (issue 1573's second step), this is the only module that
declares it, and `checks/toolchains` refuses another.
- **Provides `go-toolchain` with the machine's reach**: a module that requires it composes only on a machine where
this module is assigned. It is never pulled in for whatever asked, so `go` is assigned to a node on purpose.
Nothing requires it yet; dev-tools, lab and mesh-build-go will, in the second step.
- **The only module that declares the package `go`.** `checks/toolchains` refuses any other that does.
- **Provides `go-toolchain` with the machine's reach**: dev-tools, lab and mesh-build-go require it, so each
composes only on a machine where this module is assigned. It is never pulled in for whatever asked, so `go` is
assigned to a node on purpose, and a node without it refuses those modules at resolution.
- Holds no long-running resource, so it adds nothing to `health-undeclared`.
The node-engine never uninstalls a package, so when the users stop declaring `go`, nothing leaves a node that has
this module.
The node-engine never uninstalls a package, so moving the declaration here removed nothing from a node.
+2 -2
View File
@@ -1,5 +1,5 @@
module keycloak
go 1.25.0
go 1.26.0
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261018003858-f185aedbf5d6
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021114014-66e8fdaa62e2
+2 -4
View File
@@ -1,4 +1,2 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261016174426-a4e7f480b3b0 h1:EK84so3Y8JdFT3BHrzj9cPQQrDUPa/d+IdOGnerlmEI=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261016174426-a4e7f480b3b0/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261018003858-f185aedbf5d6 h1:Sv1Qzix+U1RpaUkw5IbB0IJEyZ+FqgzHmbZrCZkcWf0=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261018003858-f185aedbf5d6/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021114014-66e8fdaa62e2 h1:Cdl5N56QPfmFBoLHrDfvvyc5Q+PUnTjf3fgYD7imcco=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021114014-66e8fdaa62e2/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
+1 -1
View File
@@ -1,6 +1,6 @@
# lab
The `lab` module of the mesh catalogue: installs the package `git`; installs the package `make`; installs the package `python`; installs the package `file`; installs the package `iproute2`; installs the package `npm`; installs the package `go`; installs the package `incus`.
The `lab` module of the mesh catalogue: installs the package `git`; installs the package `make`; installs the package `python`; installs the package `file`; installs the package `iproute2`; installs the package `incus`; requires `go-toolchain` and `node-toolchain`, so the `go` module and the `nodejs` module are assigned to a machine running it (novox/hq ADR 0477 clause 4).
## Files it does not know
+3 -11
View File
@@ -6,7 +6,9 @@
"virtualisation"
],
"requires": [
"npm-package-registry"
"npm-package-registry",
"go-toolchain",
"node-toolchain"
],
"contributes": {
"npm-package-registry": {
@@ -84,16 +86,6 @@
"type": "package",
"package": "iproute2"
},
{
"id": "npm",
"type": "package",
"package": "npm"
},
{
"id": "go",
"type": "package",
"package": "go"
},
{
"id": "incus",
"type": "package",
+87
View File
@@ -0,0 +1,87 @@
// What the autoconfiguration module (automx2) holds, as stored: its provider, server, domain and server_domain
// rows, read from its SQLite file inside its container with the sqlite3 program the image carries, opened
// read-only. Nothing is written and nothing is interpreted: the rows are the evidence for what automx answers
// to mail clients (novox/hq issue 1630, ADR 0480), and the reason the answer may be empty.
import type { Exec } from "./health.js";
export const AUTOMX_DATABASE = "/data/db.sqlite";
/** The tables read, each with its columns and its ordering. Fixed text: the tool takes no query from its caller. */
export const AUTOMX_TABLES: Record<string, { columns: string[]; order: string }> = {
provider: { columns: ["id", "name", "short_name"], order: "id" },
server: { columns: ["id", "prio", "name", "port", "type", "socket_type", "user_name", "authentication"], order: "id" },
domain: { columns: ["id", "name", "provider_id", "ldapserver_id"], order: "id" },
server_domain: { columns: ["server_id", "domain_id"], order: "domain_id, server_id" },
};
/**
* The one statement that reads a table. The sqlite3 in the mailu-automx image does not know `-json` (it answers
* "sqlite3: Error: unknown option: -json", issue 1630), so each column is printed as `<type>:<hex of its bytes>`, which
* the oldest sqlite3 prints in its default list mode: no quoting, no separator or newline inside a cell, and a NULL
* (`null:`) apart from an empty text (`text:`).
*/
export function selectOf(table: string): string {
const t = AUTOMX_TABLES[table];
return `SELECT ${t.columns.map((c) => `typeof(${c}) || ':' || hex(${c})`).join(", ")} FROM ${table} ORDER BY ${t.order}`;
}
export type Row = Record<string, string | number | null>;
export type AutomxRows = {
database: string;
tables: Record<string, Row[]>;
/** servers whose name is empty: the client is told an empty host name for them */
empty_names: { id: number; type: string }[];
/** tables that could not be read, each with what sqlite3 or docker said on one line */
unreadable: Record<string, string>;
};
/** One cell as selectOf prints it: `<type>:<hex>`; text is the bytes read as UTF-8, a number is read from its text. */
function cellOf(cell: string): string | number | null {
const at = cell.indexOf(":");
if (at < 0 || cell.length - at - 1 > 0 && !/^([0-9A-Fa-f]{2})+$/.test(cell.slice(at + 1))) throw new Error(`sqlite3 printed ${JSON.stringify(cell.slice(0, 40))}, not a cell`);
const type = cell.slice(0, at);
const bytes = Buffer.from(cell.slice(at + 1), "hex").toString("utf8");
switch (type) {
case "null": return null;
case "integer": case "real": return Number(bytes);
default: return bytes;
}
}
/** sqlite3 prints one line per row, cells apart by `|`, and nothing at all for a table without rows. */
export function parseRows(table: string, output: string): Row[] {
const columns = AUTOMX_TABLES[table].columns;
const rows: Row[] = [];
for (const line of output.split("\n")) {
if (line.trim() === "") continue;
const cells = line.replace(/\r$/, "").split("|");
if (cells.length !== columns.length) throw new Error(`sqlite3 printed ${cells.length} cells for ${columns.length} columns`);
rows.push(Object.fromEntries(columns.map((c, i) => [c, cellOf(cells[i])])));
}
return rows;
}
/** The command that reads one table: read-only, one fixed statement. */
export function readCommand(table: string): string[] {
return ["sqlite3", "-readonly", AUTOMX_DATABASE, selectOf(table)];
}
/** Read every table; one that fails is said in `unreadable` and does not hide the others. */
export async function readAutomx(exec: Exec, container: string): Promise<AutomxRows> {
const answer: AutomxRows = { database: AUTOMX_DATABASE, tables: {}, empty_names: [], unreadable: {} };
for (const table of Object.keys(AUTOMX_TABLES)) {
try {
answer.tables[table] = parseRows(table, await exec(container, readCommand(table)));
} catch (e) {
const err = e as { stderr?: string; message?: string };
const text = (err?.stderr ?? "").trim() || (err?.message ?? String(e)).trim();
answer.unreadable[table] = text.split("\n").map((l) => l.trim()).filter(Boolean)[0] ?? "it failed and said nothing";
}
}
for (const s of answer.tables.server ?? []) {
if (String(s.name ?? "").trim() === "") answer.empty_names.push({ id: Number(s.id), type: String(s.type) });
}
return answer;
}
@@ -0,0 +1 @@
<clientConfig version="1.1"><emailProvider id="automx2-1"><identity /><domain>novox.be</domain><displayName>novox.be</displayName><displayShortName>novox.be</displayShortName><incomingServer type="imap"><hostname /><port>143</port><socketType>STARTTLS</socketType><username>%EMAILADDRESS%</username><authentication>password-cleartext</authentication></incomingServer><outgoingServer type="smtp"><hostname>mail.novox.be</hostname><port>587</port><socketType>STARTTLS</socketType><username>%EMAILADDRESS%</username><authentication>password-cleartext</authentication></outgoingServer></emailProvider></clientConfig>
@@ -0,0 +1,3 @@
Content-Length: 612
Content-Type: application/xml
Server: Werkzeug/3.1.9 Python/3.9.5
@@ -0,0 +1,3 @@
GET https://autoconfig.novox.be/mail/config-v1.1.xml?emailaddress=jochen@novox.be
User-Agent: Thunderbird
Accept: */*
@@ -0,0 +1 @@
200 OK
@@ -0,0 +1 @@
<clientConfig version="1.1"><emailProvider id="automx2-1"><identity /><domain>novox.be</domain><displayName>novox.be</displayName><displayShortName>novox.be</displayShortName><incomingServer type="imap"><hostname /><port>143</port><socketType>STARTTLS</socketType><username>%EMAILADDRESS%</username><authentication>password-cleartext</authentication></incomingServer><outgoingServer type="smtp"><hostname>mail.novox.be</hostname><port>587</port><socketType>STARTTLS</socketType><username>%EMAILADDRESS%</username><authentication>password-cleartext</authentication></outgoingServer></emailProvider></clientConfig>
@@ -0,0 +1,3 @@
Content-Length: 612
Content-Type: application/xml
Server: Werkzeug/3.1.9 Python/3.9.5
@@ -0,0 +1,2 @@
GET https://automx.novox.be/mail/config-v1.1.xml?emailaddress=jochen@novox.be
User-Agent: Thunderbird
@@ -0,0 +1 @@
200 OK
@@ -0,0 +1 @@
<clientConfig version="1.1"><emailProvider id="automx2-1"><identity /><domain>novox.be</domain><displayName>novox.be</displayName><displayShortName>novox.be</displayShortName><incomingServer type="imap"><hostname /><port>143</port><socketType>STARTTLS</socketType><username>%EMAILADDRESS%</username><authentication>password-cleartext</authentication></incomingServer><outgoingServer type="smtp"><hostname>mail.novox.be</hostname><port>587</port><socketType>STARTTLS</socketType><username>%EMAILADDRESS%</username><authentication>password-cleartext</authentication></outgoingServer></emailProvider></clientConfig>
@@ -0,0 +1,3 @@
Content-Length: 612
Content-Type: application/xml
Server: Werkzeug/3.1.9 Python/3.9.5
@@ -0,0 +1,2 @@
GET https://autodiscover.novox.be/mail/config-v1.1.xml?emailaddress=jochen@novox.be
User-Agent: Thunderbird
@@ -0,0 +1 @@
200 OK
@@ -0,0 +1,2 @@
Content-Type: text/html; charset=utf-8
Server: Werkzeug/3.1.9 Python/3.9.5
@@ -0,0 +1,2 @@
GET https://autoconfig.novox.be/mail/config-v1.1.xml?emailaddress=nobody@unknown.example
User-Agent: Thunderbird
@@ -0,0 +1 @@
204 No Content
@@ -0,0 +1 @@
Missing request argument "emailaddress"
@@ -0,0 +1,3 @@
Content-Length: 39
Content-Type: text/html; charset=utf-8
Server: Werkzeug/3.1.9 Python/3.9.5
@@ -0,0 +1,2 @@
GET https://autoconfig.novox.be/mail/config-v1.1.xml
User-Agent: Thunderbird
@@ -0,0 +1 @@
400 Bad Request
@@ -0,0 +1 @@
<html><body>Show Thunderbird-style <a href="/mail/config-v1.1.xml?emailaddress=abc@example.com">XML configuration</a> for abc@example.com.</body></html>
@@ -0,0 +1,3 @@
Content-Length: 152
Content-Type: text/html; charset=utf-8
Server: Werkzeug/3.1.9 Python/3.9.5
@@ -0,0 +1,2 @@
GET https://autoconfig.novox.be/
User-Agent: Mozilla/5.0
@@ -0,0 +1 @@
200 OK
@@ -0,0 +1,5 @@
<!doctype html>
<html lang=en>
<title>404 Not Found</title>
<h1>Not Found</h1>
<p>The requested URL was not found on the server. If you entered the URL manually please check your spelling and try again.</p>
@@ -0,0 +1,3 @@
Content-Length: 207
Content-Type: text/html; charset=utf-8
Server: Werkzeug/3.1.9 Python/3.9.5
@@ -0,0 +1 @@
GET https://autoconfig.novox.be/.well-known/autoconfig/mail/config-v1.1.xml?emailaddress=jochen@novox.be
@@ -0,0 +1 @@
404 Not Found
@@ -0,0 +1,5 @@
<!doctype html>
<html lang=en>
<title>500 Internal Server Error</title>
<h1>Internal Server Error</h1>
<p>The server encountered an internal error and was unable to complete your request. Either the server is overloaded or there is an error in the application.</p>
@@ -0,0 +1,3 @@
Content-Length: 265
Content-Type: text/html; charset=utf-8
Server: Werkzeug/3.1.9 Python/3.9.5
@@ -0,0 +1,12 @@
POST https://autodiscover.novox.be/autodiscover/autodiscover.xml
User-Agent: Microsoft Office/16.0 (Windows NT 10.0; Microsoft Outlook 16.0.14326; Pro)
Content-Type: text/xml
Accept: */*
<?xml version="1.0" encoding="utf-8"?>
<Autodiscover xmlns="http://schemas.microsoft.com/exchange/autodiscover/outlook/requestschema/2006">
<Request>
<EMailAddress>jochen@novox.be</EMailAddress>
<AcceptableResponseSchema>http://schemas.microsoft.com/exchange/autodiscover/outlook/responseschema/2006a</AcceptableResponseSchema>
</Request>
</Autodiscover>
@@ -0,0 +1 @@
500 Internal Server Error
@@ -0,0 +1,2 @@
Content-Type: text/html; charset=utf-8
Server: Werkzeug/3.1.9 Python/3.9.5
@@ -0,0 +1,6 @@
POST https://autodiscover.novox.be/autodiscover/autodiscover.xml
User-Agent: Microsoft Office/16.0
Content-Type: text/xml
<?xml version="1.0" encoding="utf-8"?>
<Autodiscover xmlns="http://schemas.microsoft.com/exchange/autodiscover/outlook/requestschema/2006"><Request><EMailAddress>nobody@unknown.example</EMailAddress><AcceptableResponseSchema>http://schemas.microsoft.com/exchange/autodiscover/outlook/responseschema/2006a</AcceptableResponseSchema></Request></Autodiscover>
@@ -0,0 +1 @@
204 No Content
@@ -0,0 +1,5 @@
<!doctype html>
<html lang=en>
<title>500 Internal Server Error</title>
<h1>Internal Server Error</h1>
<p>The server encountered an internal error and was unable to complete your request. Either the server is overloaded or there is an error in the application.</p>
@@ -0,0 +1,3 @@
Content-Length: 265
Content-Type: text/html; charset=utf-8
Server: Werkzeug/3.1.9 Python/3.9.5
@@ -0,0 +1,5 @@
POST https://autodiscover.novox.be/autodiscover/autodiscover.xml
User-Agent: Microsoft Office/16.0
Content-Type: text/xml
this is not xml
@@ -0,0 +1 @@
500 Internal Server Error
@@ -0,0 +1 @@
Missing request argument "EMailAddress"
@@ -0,0 +1,3 @@
Content-Length: 39
Content-Type: text/html; charset=utf-8
Server: Werkzeug/3.1.9 Python/3.9.5
@@ -0,0 +1,6 @@
POST https://autodiscover.novox.be/autodiscover/autodiscover.xml
User-Agent: Microsoft Office/16.0
Content-Type: text/xml
<?xml version="1.0" encoding="utf-8"?>
<Autodiscover xmlns="http://schemas.microsoft.com/exchange/autodiscover/outlook/requestschema/2006"><Request><AcceptableResponseSchema>http://schemas.microsoft.com/exchange/autodiscover/outlook/responseschema/2006a</AcceptableResponseSchema></Request></Autodiscover>
@@ -0,0 +1 @@
400 Bad Request

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