Compare commits

..
Author SHA1 Message Date
mesh-admin 1a2c917abf Merge pull request '57/V1: mesh-vault in Go, holding secrets sealed and answering get sealed to the asker (issue 674)' (#286) from pkg/57-v1-vault-in-go into feat/57-config-and-provisioning 2026-10-14 18:27:49 +00:00
mesh-admin b807b1d211 Merge pull request '57/K2: The list of predefined keys, and a merge check refusing a key removed while a module names it (issue 661)' (#291) from pkg/57-k2-predefined-keys into feat/57-config-and-provisioning 2026-10-14 06:52:29 +00:00
mesh-adminandClaude Opus 5.5 ff3f8ca192 Check that a change to predefined.json removes no key a module still names
mesh/merge-gate head ff3f8ca192a4 merged into trunk dae760224526 — pass: the change touches no module of the mesh's graph
mesh/repo-check head ff3f8ca192a4 merged into trunk dae760224526 — pass: THE CHANGE ALTERS ITS OWN CHECK (merge-check.sh): feat/57-config-and-provisioning'…
mesh/delivery delivered
mesh/delivery-group group pkg/57-k2-predefined-keys delivered: every member is delivered
Part 2 of 2: the check merge-check.sh runs, and its tests with a planted removal (hq ADR 0348 clause 7, issue 661).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 05:51:54 +00:00
mesh-adminandClaude Opus 5.5 9ae9e321a8 Add the list of predefined keys, and refuse removing a key a module still names
Mesh and node config may hold values only for keys on a fixed, reviewed list (hq ADR 0348 clause 7, design 57 package K2, issue 661). predefined.json at the root starts with font-size (a number of points, 6 to 72) and agent-managed-settings (a trusted object). The merge check refuses a change that removes a key a manifest still names, against the trunk's list at the merge base; whether a removed key still holds a value is mesh-config's to refuse (package K3). Part 1 of 2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 05:51:15 +00:00
mesh-admin dae7602245 Merge pull request '57/F2b: influxdb serves its org and bucket from its settings (issue 696)' (#290) from pkg/57-f2b-served-keys into feat/57-config-and-provisioning 2026-10-14 05:15:53 +00:00
mesh-adminandClaude Opus 5.5 c0d55ee27d mesh-vault: ADR 0230's rules pinned one at a time, and the words held to the controller's types (review 255)
mesh/merge-gate head c0d55ee27dae merged into trunk b807b1d21102 — pass: [release feat/57-config-and-provisioning, judged by its controller fcdecb436117] t…
mesh/repo-check head c0d55ee27dae merged into trunk b807b1d21102 — pass: [release feat/57-config-and-provisioning, judged by its controller fcdecb436117] i…
mesh/delivery delivered
mesh/delivery-group group pkg/57-v1-vault-in-go delivered: every member is delivered
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 01:41:01 +00:00
mesh-adminandClaude Opus 5.5 dadf824c3e mesh-vault: tests of the bound, the retry's real path and a hand-out without its record; the README corrected (review 255)
mesh/delivery superseded: a newer head of the same pull request
mesh/merge-gate head dadf824c3e33 merged into trunk 8aa37e8b835d — pass: the change touches no module of the mesh's graph
mesh/repo-check head dadf824c3e33 merged into trunk 8aa37e8b835d — fail: its merge-check.sh failed: files asking for a setting without saying whether it is…
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 01:39:49 +00:00
mesh-adminandClaude Opus 5.5 8c5d70a186 mesh-vault: the retirement tools take the controller's consumers, confirm, by and via; vault_status; the export's fingerprint (review 255)
mesh/merge-gate head 8c5d70a18677 merged into trunk 8aa37e8b835d — pass: the change touches no module of the mesh's graph
mesh/repo-check head 8c5d70a18677 merged into trunk 8aa37e8b835d — fail: its merge-check.sh failed: files asking for a setting without saying whether it is…
mesh/delivery superseded: a newer head of the same pull request
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 01:39:15 +00:00
mesh-adminandClaude Opus 5.5 958018e696 mesh-vault: the ledger keeps the ledger; retirement moves to retirement.go (review 255)
mesh/merge-gate head 958018e696e4 merged into trunk 8aa37e8b835d — pass: the change touches no module of the mesh's graph
mesh/repo-check head 958018e696e4 merged into trunk 8aa37e8b835d — fail: its merge-check.sh failed: files asking for a setting without saying whether it is…
mesh/delivery superseded: a newer head of the same pull request
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 01:38:27 +00:00
mesh-adminandClaude Opus 5.5 b71e696993 mesh-vault: retirement in the controller's words, held to its types by golden files (review 255)
mesh/merge-gate head b71e696993b3 merged into trunk 8aa37e8b835d — pass: the change touches no module of the mesh's graph
mesh/repo-check head b71e696993b3 merged into trunk 8aa37e8b835d — fail: its merge-check.sh failed: files asking for a setting without saying whether it is…
mesh/delivery superseded: a newer head of the same pull request
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 01:37:27 +00:00
mesh-adminandClaude Opus 5.5 2dd6510ded influxdb: serve org and bucket from its settings (issue 696)
mesh/merge-gate head 2dd6510ded29 merged into trunk 8aa37e8b835d — pass: the change touches no module of the mesh's graph
mesh/repo-check head 2dd6510ded29 merged into trunk 8aa37e8b835d — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
mesh/delivery-group group pkg/57-f2b-served-keys delivered: every member is delivered
A settings layer will stop replacing what a provider serves (ADR 0348
clause 11). influxdb on ace relies on that today: its layer sets org and
bucket over the served literals, and grafana writes the bucket into its
data source. Serving ${setting:org} and ${setting:bucket}, with today's
literals as defaults, keeps every consumer's binding the same before and
after the controller change. Declaring settings means runtime-config names
the keys it takes; it keeps every key its client reads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 01:36:59 +00:00
mesh-adminandClaude Opus 5.5 6ab38066c9 mesh-vault: bound the record of hand-outs and record only a well-formed ask (review 255)
mesh/merge-gate head 6ab38066c910 merged into trunk 8aa37e8b835d — pass: the change touches no module of the mesh's graph
mesh/repo-check head 6ab38066c910 merged into trunk 8aa37e8b835d — fail: its merge-check.sh failed: files asking for a setting without saying whether it is…
mesh/delivery superseded: a newer head of the same pull request
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 01:36:11 +00:00
mesh-adminandClaude Opus 5.5 c63f7d6e5c mesh-vault: the ledger's tests say ADR 0230's rules, not a withdrawal on the first pass (review 250)
mesh/merge-gate head c63f7d6e5cea merged into trunk 8aa37e8b835d — pass: the change touches no module of the mesh's graph
mesh/repo-check head c63f7d6e5cea merged into trunk 8aa37e8b835d — fail: its merge-check.sh failed: files asking for a setting without saying whether it is…
mesh/delivery superseded: a newer head of the same pull request
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 01:22:30 +00:00
mesh-adminandClaude Opus 5.5 39c38f3745 mesh-vault: tests for the review's fixes, and the README saying what at-rest encryption protects (review 250)
mesh/merge-gate head 39c38f37459a merged into trunk 8aa37e8b835d — pass: the change touches no module of the mesh's graph
mesh/repo-check head 39c38f37459a merged into trunk 8aa37e8b835d — fail: its merge-check.sh failed: files asking for a setting without saying whether it is…
mesh/delivery superseded: a newer head of the same pull request
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 01:21:30 +00:00
mesh-adminandClaude Opus 5.5 da889d421d mesh-vault: finish a new operator key after a failure partway, record every hand-out, reach the vault's copies from secret recover (review 250)
mesh/merge-gate head da889d421d71 merged into trunk 8aa37e8b835d — pass: the change touches no module of the mesh's graph
mesh/delivery superseded: a newer head of the same pull request
mesh/repo-check head da889d421d71 merged into trunk 8aa37e8b835d — fail: its merge-check.sh failed: files asking for a setting without saying whether it is…
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 01:20:25 +00:00
mesh-adminandClaude Opus 5.5 b67fe1ffb1 mesh-vault: sync before rename, record hand-outs, refuse a new key beside secrets, take the operator key as PEM only (review 250)
mesh/delivery-group group pkg/57-v1-vault-in-go delivering: 1 of 2 delivered
mesh/delivery superseded: a newer head of the same pull request
mesh/merge-gate head b67fe1ffb16e merged into trunk 8aa37e8b835d — pass: the change touches no module of the mesh's graph
mesh/repo-check head b67fe1ffb16e merged into trunk 8aa37e8b835d — fail: its merge-check.sh failed: files asking for a setting without saying whether it is…
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 01:19:36 +00:00
mesh-adminandClaude Opus 5.5 2000c02559 mesh-vault: retire a holder by ADR 0230, never delete it on a pass; refuse a contributions file that says nothing (review 250)
mesh/delivery superseded: a newer head of the same pull request
mesh/delivery-group group pkg/57-v1-vault-in-go checking: 1 of 2 member(s) ready
mesh/merge-gate head 2000c02559d8 merged into trunk 8aa37e8b835d — pass: the change touches no module of the mesh's graph
mesh/repo-check head 2000c02559d8 merged into trunk 8aa37e8b835d — fail: its merge-check.sh failed: files asking for a setting without saying whether it is…
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 01:18:51 +00:00
mesh-adminandClaude Opus 5.5 84d8198f15 mesh-vault: ADR 0349's vault tests (issue 674)
mesh/merge-gate head 84d8198f1540 merged into trunk 8aa37e8b835d — pass: the change touches no module of the mesh's graph
mesh/repo-check head 84d8198f1540 merged into trunk 8aa37e8b835d — fail: its merge-check.sh failed: files asking for a setting without saying whether it is…
mesh/delivery-group group pkg/57-v1-vault-in-go rejected: a member's own check failed
mesh/delivery superseded: a newer head of the same pull request
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 00:57:54 +00:00
mesh-adminandClaude Opus 5.5 329fc420c9 mesh-vault: the ledger's and the tools' tests, and the README settling the key formats (issue 674)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 00:56:44 +00:00
mesh-adminandClaude Opus 5.5 da04ad62ae mesh-vault: the tools the TypeScript vault offered, in Go (issue 674)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 00:55:34 +00:00
mesh-adminandClaude Opus 5.5 11fc3258b4 mesh-vault: get, secret-changed and the custody ledger (issue 674)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 00:54:22 +00:00
mesh-adminandClaude Opus 5.5 44d6a18e43 mesh-vault: its store, its keys and its rules (issue 674)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 00:53:38 +00:00
mesh-adminandClaude Opus 5.5 71c611b768 Rewrite mesh-vault in Go, holding secrets sealed and handing them out on request (issue 674)
The vault was read-only TypeScript and held no secret the mesh uses (hq ADR
0349 §7, §11; ADR 0113 §1, §2, §22). It now keeps each secret in its own store,
sealed to its own key and to the operator key, answers the seat's verb get
sealed to the asker's key and only to an allowed asker (nobody yet: V4 and P3
bring the rule), raises secret-changed without a value, and takes no private
key. The custody ledger and its three tools carry over unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 00:52:59 +00:00
mesh-admin 8aa37e8b83 Open the integration branch of release 57 (config and provisioning)
So the packages of hq design 57 meet here, reviewed one by one, and reach main as one delivery group.
2026-10-13 23:39:24 +00:00
88 changed files with 4413 additions and 3698 deletions
+6
View File
@@ -0,0 +1,6 @@
# Release 57: config and provisioning
This branch carries the work of hq design 57 (ADR 0348, ADR 0349) in this repository. Each package
merges here through its own reviewed pull request; the branch reaches main as one pull request, in one
delivery group with the branch of the same name in mesh-controller, mesh-host, mesh-sdk, mesh-catalog
and hq, after a rehearsal in the lab. This file is deleted by the last commit before that pull request.
+3
View File
@@ -0,0 +1,3 @@
module git.novox.be/novox/mesh-catalog/checks/predefined
go 1.22
+153
View File
@@ -0,0 +1,153 @@
// Command predefined refuses a change to predefined.json that removes a key a module still names (novox/hq ADR
// 0348 clause 7, design 57 package K2, issue 661).
//
// Mesh config and node config hold values only for the keys on the catalogue's list of predefined keys, at the
// root of the repository; a module's config value may fall back to one, `"predefined": "<key>"`. A key removed
// while a manifest still names it would leave that module falling back to nothing: the controller refuses its
// build then, at the next merge that touches it. So the removal is refused here, in the pull request that makes
// it, naming each module that names the key.
//
// **What it cannot know.** Whether a key still holds a value at the mesh or a node is mesh-config's to say (design
// 57 package K3): it holds the values, and the catalogue's check reads no store. Until mesh-config refuses that
// itself, removing a key nobody names is let through here, and said.
//
// The list it compares with is the trunk's at the merge base, given with -base; without one, nothing was removed.
// The controller the mesh runs reads the list itself (type, validation, every manifest against it) in the
// module check; this check reads only its keys.
//
// go run . [-base <the trunk's predefined.json>] <catalogue root>
//
// Exits 1 on a finding.
package main
import (
"encoding/json"
"errors"
"flag"
"fmt"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
)
// File is the list's file at the root of the catalogue.
const File = "predefined.json"
// Keys is the keys of a list, read as a JSON object; an absent file has none.
func Keys(path string) (map[string]bool, error) {
raw, err := os.ReadFile(path)
if errors.Is(err, fs.ErrNotExist) {
return map[string]bool{}, nil
}
if err != nil {
return nil, err
}
var keys map[string]json.RawMessage
if err := json.Unmarshal(raw, &keys); err != nil {
return nil, fmt.Errorf("%s is not a JSON object of keys: %w", path, err)
}
out := map[string]bool{}
for k := range keys {
out[k] = true
}
return out, nil
}
// Named is, per predefined key, the modules whose manifests name it: under `config`, or its alias `settings`.
func Named(root string) (map[string][]string, error) {
manifests, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
if err != nil {
return nil, err
}
sort.Strings(manifests)
named := map[string][]string{}
for _, path := range manifests {
raw, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var m struct {
Module string `json:"module"`
Config map[string]json.RawMessage `json:"config"`
Settings map[string]json.RawMessage `json:"settings"`
}
if err := json.Unmarshal(raw, &m); err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
for _, declared := range []map[string]json.RawMessage{m.Config, m.Settings} {
for _, d := range declared {
var value struct {
Predefined string `json:"predefined"`
}
if json.Unmarshal(d, &value) == nil && value.Predefined != "" {
named[value.Predefined] = append(named[value.Predefined], m.Module)
}
}
}
}
return named, nil
}
// Removed is each key the base list holds and the tree's does not, with the modules still naming it; a removed
// key nobody names is answered with none.
func Removed(base, tree map[string]bool, named map[string][]string) map[string][]string {
out := map[string][]string{}
for k := range base {
if !tree[k] {
out[k] = named[k]
}
}
return out
}
func main() {
basePath := flag.String("base", "", "the trunk's predefined.json at the merge base")
flag.Parse()
if flag.NArg() != 1 {
fmt.Fprintln(os.Stderr, "usage: predefined [-base <the trunk's predefined.json>] <catalogue root>")
os.Exit(2)
}
root := flag.Arg(0)
tree, err := Keys(filepath.Join(root, File))
if err != nil {
fmt.Println(err)
os.Exit(1)
}
if *basePath == "" {
fmt.Printf("%s: %d key(s); no trunk list given, so none was removed\n", File, len(tree))
return
}
base, err := Keys(*basePath)
if err != nil {
fmt.Println(err)
os.Exit(1)
}
named, err := Named(root)
if err != nil {
fmt.Println(err)
os.Exit(1)
}
removed := Removed(base, tree, named)
keys := make([]string, 0, len(removed))
for k := range removed {
keys = append(keys, k)
}
sort.Strings(keys)
failed := false
for _, k := range keys {
if modules := removed[k]; len(modules) > 0 {
failed = true
fmt.Printf("%s: the key %q is removed and still named by %s: move each off it first (novox/hq ADR 0348 "+
"clause 7)\n", File, k, strings.Join(modules, ", "))
} else {
fmt.Printf("%s: the key %q is removed and no module names it; whether the mesh or a node still holds a "+
"value for it is mesh-config's to refuse, not this check's\n", File, k)
}
}
if failed {
os.Exit(1)
}
fmt.Printf("%s: %d key(s), none removed that a module names\n", File, len(tree))
}
+100
View File
@@ -0,0 +1,100 @@
package main
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
// A catalogue whose dunst falls back to font-size, and the trunk's list holding font-size and
// agent-managed-settings.
func aCatalogue(t *testing.T, list string) (root, base string) {
t.Helper()
root = t.TempDir()
write := func(path, text string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(text), 0o644); err != nil {
t.Fatal(err)
}
}
write(filepath.Join(root, "modules", "dunst", "module.json"), `{"module": "dunst", "config": {"font-size":
{"type": "number", "description": "points", "default": 10, "predefined": "font-size"}}}`)
write(filepath.Join(root, "modules", "claude-code", "module.json"), `{"module": "claude-code", "settings": {
"managed_settings": {"kind": "preference", "default": "{}", "why": "none"}}}`)
write(filepath.Join(root, "modules", "plain", "module.json"), `{"module": "plain"}`)
if list != "" {
write(filepath.Join(root, File), list)
}
base = filepath.Join(t.TempDir(), "base.json")
write(base, `{"font-size": {"type": "number"}, "agent-managed-settings": {"type": "object"}}`)
return root, base
}
func run(t *testing.T, args ...string) (string, error) {
t.Helper()
out, err := exec.Command("go", append([]string{"run", "."}, args...)...).CombinedOutput()
return string(out), err
}
func TestARemovalOfAKeyAModuleStillNamesIsRefused(t *testing.T) {
// The planted removal: font-size goes while dunst still falls back to it.
root, base := aCatalogue(t, `{"agent-managed-settings": {"type": "object"}}`)
out, err := run(t, "-base", base, root)
if err == nil || !strings.Contains(out, `the key "font-size" is removed and still named by dunst`) {
t.Fatalf("a removal of a named key passed:\n%s", out)
}
}
func TestARemovalNobodyNamesPassesAndSaysWhatItCannotKnow(t *testing.T) {
root, base := aCatalogue(t, `{"font-size": {"type": "number"}}`)
out, err := run(t, "-base", base, root)
if err != nil || !strings.Contains(out, `"agent-managed-settings" is removed and no module names it`) ||
!strings.Contains(out, "mesh-config's to refuse") {
t.Fatalf("a removal nobody names: %v\n%s", err, out)
}
}
func TestAListUnchangedOrGrownPasses(t *testing.T) {
for _, list := range []string{
`{"font-size": {"type": "number"}, "agent-managed-settings": {"type": "object"}}`,
`{"font-size": {"type": "number"}, "agent-managed-settings": {"type": "object"}, "accent": {"type": "string"}}`,
} {
root, base := aCatalogue(t, list)
if out, err := run(t, "-base", base, root); err != nil || !strings.Contains(out, "none removed") {
t.Fatalf("%s: %v\n%s", list, err, out)
}
}
// No trunk list given: nothing was removed.
root, _ := aCatalogue(t, `{}`)
if out, err := run(t, root); err != nil || !strings.Contains(out, "none was removed") {
t.Fatalf("without a base: %v\n%s", err, out)
}
}
func TestTheFileRemovedWholeIsARemovalOfEveryKey(t *testing.T) {
root, base := aCatalogue(t, "")
out, err := run(t, "-base", base, root)
if err == nil || !strings.Contains(out, `"font-size" is removed and still named by dunst`) {
t.Fatalf("the list removed whole passed:\n%s", out)
}
}
func TestNamedReadsBothForms(t *testing.T) {
root, _ := aCatalogue(t, "")
if err := os.WriteFile(filepath.Join(root, "modules", "plain", "module.json"), []byte(`{"module": "plain",
"settings": {"size": {"predefined": "font-size"}}}`), 0o644); err != nil {
t.Fatal(err)
}
named, err := Named(root)
if err != nil {
t.Fatal(err)
}
if got := strings.Join(named["font-size"], ","); got != "dunst,plain" {
t.Fatalf("font-size named by %s", got)
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
62
66
The long-running resources of this catalogue that do not say how they are ready (`health`, novox/hq ADR 0240
rule 8), as `module check` counts them. It may only go down: merge-check.sh fails a change that raises it, and
one that lowers it writes the new number on the first line. From 2026-11-18, or once it is 0, a long-running
+16
View File
@@ -33,6 +33,10 @@
# 6. the secrets (novox/hq issue 342): no mergeable file fills a key with a vault secret that a setting could
# replace — the key is `protected`, or left out of the file's `takes-settings`. checks/secrets/known names
# the nested ones the controller cannot protect alone yet, and only shrinks.
# 7. the list of predefined keys (novox/hq ADR 0348 clause 7): a change to predefined.json removes no key a
# module still names, judged against the trunk's copy at the merge base. Whether a removed key still holds a
# value at the mesh or a node is mesh-config's to refuse; this check reads no store. The list itself and
# every manifest against it are judged by the controller's module check in step 1.
set -eu
(cd checks/words && go test -count=1 . && go run . ../..)
@@ -51,6 +55,18 @@ else
echo "NOT COMPARED WITH THE TRUNK: no merge base with $secrets_ref; checks/secrets/known is held to the list it was first written with"
fi
(cd checks/secrets && go test -count=1 . && if [ -n "$secrets_base" ]; then go run . -base "$secrets_base" ../..; else go run . ../..; fi)
# The list of predefined keys (novox/hq ADR 0348 clause 7): no key removed that a module still names. The trunk's
# list at the merge base is what it is judged against; a trunk without the file had none.
predefined_base=""
if merge_base=$(git merge-base HEAD "origin/${MESH_CHECK_BASE:-main}" 2>/dev/null); then
predefined_base=$(mktemp)
if ! git show "$merge_base:predefined.json" >"$predefined_base" 2>/dev/null; then
echo '{}' >"$predefined_base"
fi
else
echo "NOT COMPARED WITH THE TRUNK: no merge base with origin/${MESH_CHECK_BASE:-main}; no removal from predefined.json can be seen"
fi
(cd checks/predefined && go test -count=1 . && if [ -n "$predefined_base" ]; then go run . -base "$predefined_base" ../..; else go run . ../..; fi)
# The lists that narrow the paths check may not grow unseen: the base branch's copies are what they are
# judged against (MESH_CHECK_BASE, main when unset), read from the clone the check runs in.
base_lists=""
+1 -1
View File
@@ -14,7 +14,7 @@ WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -buildvcs=false -ldflags=-buildid= -o /mesh-builder ./cmd/mesh-builder
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -o /mesh-builder ./cmd/mesh-builder
# Unlike mesh-controller's own FROM scratch (ADR 0006: nothing to audit but one binary), the build
# machine's whole job is shelling out to git and docker — it needs a real userland to do that in,
-54
View File
@@ -115,57 +115,3 @@ func TestTheGenesisDocumentIsDeclaredAndIssuedOutside(t *testing.T) {
}
t.Fatal("the build agent has no environment file")
}
// **The judge cache is the agent's own, beside its workspace and never in it** (novox/hq ADR 0358 §6, issue 704): a
// directory resource of its own, kept as the agent's data, mounted into the agent at the same path as on the host —
// the container runtime resolves a judge build's mounts on the host — and handed to the agent as MESH_JUDGE_CACHE.
func TestTheJudgeCacheIsADirectoryOfItsOwnMountedAtTheSamePath(t *testing.T) {
raw, err := os.ReadFile("module.json")
if err != nil {
t.Fatal(err)
}
var m struct {
Data struct {
Own []struct {
ID string `json:"id"`
Path string `json:"path"`
Class string `json:"class"`
} `json:"own"`
} `json:"data"`
Resources []struct {
ID string `json:"id"`
Type string `json:"type"`
Content string `json:"content"`
Volumes []string `json:"volumes"`
} `json:"resources"`
}
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
dir, data, env, mounted := false, false, false, false
for _, d := range m.Data.Own {
if d.ID == "judge-cache" && d.Path == "${dir:judge-cache}" && d.Class == "cache" {
data = true
}
}
for _, r := range m.Resources {
switch {
case r.ID == "judge-cache" && r.Type == "directory":
dir = true
case r.ID == "agent-env":
env = strings.Contains(r.Content, "MESH_JUDGE_CACHE=${dir:judge-cache}\n")
case r.ID == "server":
for _, v := range r.Volumes {
if v == "${dir:judge-cache}:${dir:judge-cache}" {
mounted = true
}
if strings.HasPrefix(v, "${dir:workspace}") && strings.Contains(v, "judge-cache") {
t.Errorf("the judge cache is mounted inside the workspace: %s", v)
}
}
}
}
if !dir || !data || !env || !mounted {
t.Fatalf("the judge cache: directory %v, data %v, env %v, mounted at the same path %v", dir, data, env, mounted)
}
}
+1 -13
View File
@@ -39,12 +39,6 @@
"path": "${dir:workspace}",
"class": "cache",
"why": "a build's working copy, cloned again for every build"
},
{
"id": "judge-cache",
"path": "${dir:judge-cache}",
"class": "cache",
"why": "the Go cache every merge check's judge is built with and the release-branch judges kept per head; mounted into judge builds only, never into a check's containers (novox/hq ADR 0358 §6)"
}
]
},
@@ -60,17 +54,12 @@
"type": "directory",
"mode": "0700"
},
{
"id": "judge-cache",
"type": "directory",
"mode": "0700"
},
{
"id": "agent-env",
"type": "file",
"path": "${dir:mesh-state}/build-agent.env",
"mode": "0600",
"content": "MESH_BROKER_FILE=/run/mesh/broker\nMESH_NODE=${machine:name}\nMESH_REGISTRY=${bound:artifact-store:at}:${bound:artifact-store:port}\nMESH_PACKAGE_BINDING=/run/mesh/package-registry.json\nMESH_NPM_TOKEN_FILE=/run/mesh/package-registry.secret\nMESH_GENESIS_PACKAGE_REGISTRY=/run/mesh/genesis-package-registry.json\nMESH_WORKSPACE=${dir:workspace}\nMESH_JUDGE_CACHE=${dir:judge-cache}\n"
"content": "MESH_BROKER_FILE=/run/mesh/broker\nMESH_NODE=${machine:name}\nMESH_REGISTRY=${bound:artifact-store:at}:${bound:artifact-store:port}\nMESH_PACKAGE_BINDING=/run/mesh/package-registry.json\nMESH_NPM_TOKEN_FILE=/run/mesh/package-registry.secret\nMESH_GENESIS_PACKAGE_REGISTRY=/run/mesh/genesis-package-registry.json\nMESH_WORKSPACE=${dir:workspace}\n"
},
{
"id": "server",
@@ -83,7 +72,6 @@
"volumes": [
"${dir:mesh-state}:/run/mesh:ro",
"${dir:workspace}:${dir:workspace}",
"${dir:judge-cache}:${dir:judge-cache}",
"/var/run/docker.sock:/var/run/docker.sock"
],
"restart-on": [
+5 -121
View File
@@ -101,92 +101,6 @@ export interface OpenPull {
/** The head's statuses by context, the newest of each, with its state (novox/hq issue 438): what tells a check
* that started and never answered — pending — from one that answered. */
checks: Record<string, HeadCheck>;
/** The pull request's labels, by name. */
labels?: string[];
/** Its ready mark (novox/hq issue 722): the label `mesh/ready` and the newest comment "ready at <full head sha>", the
* head a reviewer approved. Absent without the label; `head` empty when the label names no head in a comment. */
ready?: ReadyMark;
}
/** The label that marks a pull request ready to merge at the head a comment names (novox/hq issue 722). */
export const READY_LABEL = "mesh/ready";
/** A comment that names the head a pull request is marked ready at: "ready at <full sha>", on a line of its own. */
const READY_AT = /^\s*ready at ([0-9a-f]{40}|[0-9a-f]{64})\b/im;
/** A pull request's ready mark: the head it names, who said it and when, and the label's id, to take it off. */
export interface ReadyMark {
head: string;
by?: string;
at?: string;
label_id: number;
}
/** **The accounts whose "ready at" comment counts** (novox/hq issue 722): the trusted setting ready-authors, which
* the mesh writes to the file MESH_GITEA_READY_AUTHORS_FILE names as a line `authors=<account> <account> …`. A file
* that cannot be read leaves the mesh's own forge account alone, said; never an empty list read as everyone. */
export function readyAuthors(file = process.env.MESH_GITEA_READY_AUTHORS_FILE, own = process.env.MESH_GITEA_ADMIN_USER,
read: (f: string) => string = (f) => readFileSync(f, "utf8")): string[] {
const fallback = own ? [own] : [];
if (!file) return fallback;
try {
const line = read(file).split("\n").find((l) => l.startsWith("authors="));
const authors = (line ?? "").slice("authors=".length).split(/[\s,]+/).filter(Boolean);
if (authors.length) return authors;
console.error(`[gitea] ${file} names no account that may mark a pull request ready; only ${fallback.join(", ") || "none"} may`);
} catch (err) {
console.error(`[gitea] ${file} cannot be read (${err instanceof Error ? err.message : err}); only ` +
`${fallback.join(", ") || "none"} may mark a pull request ready`);
}
return fallback;
}
/** One event of an issue's or pull request's timeline, as the forge keeps it, oldest first: a comment, a push to a
* pull request's branch (`pull_push`), a label added (body "1") or taken off (body ""), and the rest. */
export interface TimelineEvent {
id: number;
type: string;
body: string;
created_at?: string;
user?: string;
label?: { id: number; name: string };
}
/** The comments that wrote "ready at" without counting, by id: each said once in the log (novox/hq issue 722). */
const ignoredSaid = new Set<number>();
/** **The ready mark a pull request's timeline makes** (novox/hq issue 722), read in the forge's order — every event is
* one row of one table, numbered as it happened, so the order is exact where times share a second. A "ready at"
* comment counts only when its author is one of the accounts given, and only when it was written after the label
* mesh/ready was last added and after the last push to the pull request's branch: a push or the label taken off
* undoes every mark before it, so a mark older than the latest push never counts, even with the label still on. A
* comment by any other account is ignored, and said once. */
export function readyMarkOf(labelID: number, events: TimelineEvent[], authors: string[],
say: (line: string) => void = (l) => console.error(l)): ReadyMark {
const listed = new Set(authors.map((a) => a.toLowerCase()));
let labelled = false;
let mark: ReadyMark | undefined;
for (const e of [...events].sort((x, y) => x.id - y.id)) {
if (e.type === "pull_push") {
mark = undefined;
} else if (e.type === "label" && e.label?.name === READY_LABEL) {
labelled = e.body === "1";
mark = undefined;
} else if (e.type === "comment") {
const m = READY_AT.exec(e.body);
if (!m) continue;
if (!listed.has(String(e.user ?? "").toLowerCase())) {
if (!ignoredSaid.has(e.id)) {
ignoredSaid.add(e.id);
say(`[gitea] a "ready at ${m[1].slice(0, 12)}" comment (${e.id}) by ${e.user ?? "an unknown account"} is ignored: ` +
`only ${authors.join(", ") || "no account"} may mark a pull request ready (novox/hq issue 722)`);
}
continue;
}
if (labelled) mark = { head: m[1].toLowerCase(), by: e.user, at: e.created_at, label_id: labelID };
}
}
return mark ?? { head: "", label_id: labelID };
}
/** One status on a head, the newest of its context: its state, what it said, and when it was set. */
@@ -418,9 +332,8 @@ export class GiteaClient {
/** The open pull requests of every repository the mesh's account sees, updated since a moment when one is given,
* page by page (novox/hq issue 347): each with its repository and its own update time. */
async searchOpenPulls(since?: string): Promise<{ owner: string; name: string; number: number; updated_at?: string;
labels?: { id: number; name: string }[] }[]> {
const out: { owner: string; name: string; number: number; updated_at?: string; labels?: { id: number; name: string }[] }[] = [];
async searchOpenPulls(since?: string): Promise<{ owner: string; name: string; number: number; updated_at?: string }[]> {
const out: { owner: string; name: string; number: number; updated_at?: string }[] = [];
for (let page = 1; page <= 20; page++) {
const qs = new URLSearchParams({ type: "pulls", state: "open", limit: "50", page: String(page) });
if (since) qs.set("since", since);
@@ -430,8 +343,7 @@ export class GiteaClient {
const [fo, fn] = String(r.full_name ?? "").split("/");
const owner = String(r.owner ?? fo ?? "");
const name = String(r.name ?? fn ?? "");
const labels = (Array.isArray(i?.labels) ? i.labels : []).map((l: any) => ({ id: Number(l?.id ?? 0), name: String(l?.name ?? "") }));
if (owner && name && i?.number) out.push({ owner, name, number: Number(i.number), updated_at: i.updated_at, labels });
if (owner && name && i?.number) out.push({ owner, name, number: Number(i.number), updated_at: i.updated_at });
}
if (!found || found.length < 50) break;
}
@@ -442,7 +354,7 @@ export class GiteaClient {
* requires and the contexts its head has a status of, in any state. What mesh-delivery reads to say a pull
* request the mesh was never asked to check; asked of the forge, never of what the announcer remembers, so a
* fault in the announcer cannot hide itself. Reads only. */
async openPulls(readyAuthors: string[] = []): Promise<OpenPull[]> {
async openPulls(): Promise<OpenPull[]> {
const out: OpenPull[] = [];
const rules = new Map<string, BranchProtection[] | undefined>();
for (const found of await this.searchOpenPulls()) {
@@ -452,14 +364,9 @@ export class GiteaClient {
if (!rules.has(full)) rules.set(full, await this.branchProtections(found.owner, found.name).catch(() => undefined));
const rule = rules.get(full)?.find((p) => (p.rule_name ?? p.branch_name) === (pull.base || "main"));
const checks = await this.headChecks(found.owner, found.name, pull.head_sha);
const labels = found.labels ?? [];
// The ready mark (novox/hq issue 722): its timeline is read, whole, only for a pull request that carries the label.
const label = labels.find((l) => l.name === READY_LABEL);
const ready = label ? readyMarkOf(label.id, await this.timeline(found.owner, found.name, pull.number), readyAuthors)
: undefined;
out.push({ owner: found.owner, repo: found.name, number: pull.number, base: pull.base ?? "", head_sha: pull.head_sha,
html_url: pull.html_url, required: rule?.enable_status_check ? (rule.status_check_contexts ?? []) : [],
statuses: Object.keys(checks), checks, labels: labels.map((l) => l.name), ...(ready ? { ready } : {}) });
statuses: Object.keys(checks), checks });
}
return out;
}
@@ -732,24 +639,6 @@ export class GiteaClient {
return this.requestText(`${repoPath(owner, repo)}/pulls/${index}.diff`);
}
/** **Every event of an issue's or pull request's timeline**, oldest first, page by page (novox/hq issue 722): a long
* thread hides nothing. Bounded at 100 pages of 50; a longer one is said, and its first 5000 events read. */
async timeline(owner: string, repo: string, index: number): Promise<TimelineEvent[]> {
indexOf(index);
const out: TimelineEvent[] = [];
for (let page = 1; page <= 100; page++) {
const raw = await this.request<any[]>(`${repoPath(owner, repo)}/issues/${index}/timeline?page=${page}&limit=50`);
for (const e of raw ?? []) {
out.push({ id: Number(e?.id ?? 0), type: String(e?.type ?? ""), body: String(e?.body ?? ""),
created_at: e?.created_at, user: e?.user?.login,
...(e?.label ? { label: { id: Number(e.label.id ?? 0), name: String(e.label.name ?? "") } } : {}) });
}
if (!raw || raw.length < 50) return out;
}
console.error(`[gitea] ${owner}/${repo}#${index} has more than 5000 timeline events; only the first 5000 are read`);
return out;
}
/** Every comment on an issue or pull request, oldest first. */
async listComments(owner: string, repo: string, index: number): Promise<GiteaComment[]> {
indexOf(index);
@@ -914,11 +803,6 @@ export class GiteaAdmin {
return { status: res.status, body };
}
/** One admin-API call with a JSON body, answering the status and body (the system webhook, novox/hq issue 729). */
call(method: string, path: string, body?: unknown): Promise<AdminResponse> {
return this.request(path, { method, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
}
/** Fail with the forge's own message when a status the caller did not expect comes back. */
private static fail(path: string, res: AdminResponse): never {
const detail = typeof res.body === "string" ? res.body : JSON.stringify(res.body);
+64 -152
View File
@@ -40,11 +40,10 @@ import { emit, on } from "@novox/mesh-sdk/events";
import { registerModuleTools } from "@novox/mesh-sdk/tools";
import { commitStatusTool } from "./delivery.js";
import { mergeTool } from "./merge.js";
import { GiteaAdmin, GiteaClient, movedSince } from "./client.js";
import { GiteaClient, movedSince } from "./client.js";
import { CHECK_CONTEXT, REPO_CHECK_CONTEXT, commentFor, headsToAnnounce, statusesFor, type Announced, type Checked,
type RepoCheckFacts, reposToLook } from "./pulls.js";
import { GateWriter, clonedAs, type BuildStarted } from "./gate.js";
import { HOOK_PATH, HOOK_PORT, Pace, claimHead, ensureSystemHook, keptSecret, receiver, type ForgeEvent } from "./webhook.js";
import { announcementOf, keepTook, lateVerdictComment, outOfDateWords, sameCommit, tookFile, weighTrunks } from "./trunk.js";
// Without a way to a token — configured, or mintable with the admin account (token.ts) — there is
@@ -146,9 +145,57 @@ async function pollMerged(client: GiteaClient): Promise<void> {
// at once.
const fresh = !!pull.merged_at && !!since && pull.merged_at > since;
if (primedMerges && fresh) {
if (await announceMerge(client, repo, pull)) pace.miss(`the merge of ${repo.full_name}#${pull.number}`);
// What it changed, asked for only now: a module is rebuilt because a file inside its own
// directory moved, and without this every module built from a repository is rebuilt for a
// change to any of them (novox/hq 04-ISSUES/131).
const changed = await client.listPullFiles(repo.owner, repo.name, pull.number);
// Which of the directories they are in hold a module at the merge commit (novox/hq issue 278): a
// change inside one is that module's, held or not, and only a file in none is shared code. Not
// said when the list is cut or the forge could not be asked; the mesh then keeps its old rule.
let moduleDirs: string[] | null = null;
if (!changed.truncated) {
try {
moduleDirs = await client.moduleDirsAt(repo.owner, repo.name, pull.merge_commit_sha, changed.paths);
} catch (err) {
console.error(`[gitea] ${repo.full_name}#${pull.number}: which directories hold a module could not be read, ` +
`so the mesh reads its files by the old rule — ${err instanceof Error ? err.message : String(err)}`);
}
}
// The head it merged, and what its head was checked as (novox/hq ADR 0239): the delivery it was, made
// from the forge's word when its owner never heard the head.
let headChecks: Record<string, string> | null = null;
if (pull.head_sha) {
try {
headChecks = await client.commitStatuses(repo.owner, repo.name, pull.head_sha);
} catch (err) {
console.error(`[gitea] ${repo.full_name}#${pull.number}: its head's statuses could not be read — ${err instanceof Error ? err.message : err}`);
}
}
await emit("pull.merged", {
owner: repo.owner,
repo: repo.name,
number: pull.number,
title: pull.title,
body: pull.body,
head_sha: pull.head_sha,
...(headChecks ? { head_checks: headChecks } : {}),
head: pull.head,
base: pull.base,
merge_commit_sha: pull.merge_commit_sha,
merged_at: pull.merged_at,
clone_url: repo.clone_url,
html_url: pull.html_url,
paths: changed.paths,
paths_truncated: changed.truncated,
// Which of them the merge deleted (novox/hq ADR 0236): a module whose manifest went is forgotten,
// not built.
removed: changed.removed,
...(moduleDirs ? { module_dirs: moduleDirs, module_dirs_said: true } : {}),
});
// Said, because a trigger that fires silently is indistinguishable from one that did not
// fire (novox/hq 04-ISSUES/131) — this line is how an operator knows the mesh was told.
console.log(`[gitea] announced merge ${repo.full_name}#${pull.number} (${pull.merge_commit_sha.slice(0, 8)}) into ${pull.base}`);
}
else announced.add(pull.merge_commit_sha);
announced.add(pull.merge_commit_sha);
changed = true;
}
@@ -159,73 +206,6 @@ async function pollMerged(client: GiteaClient): Promise<void> {
lastLook = began;
}
type RepoOf = { owner: string; name: string; full_name: string; clone_url?: string };
type PullOf = Awaited<ReturnType<GiteaClient["listPullRequests"]>>[number];
/** **A merge, announced once, whichever path saw it first** (novox/hq issue 729): the poll or the forge's event. */
async function announceMerge(client: GiteaClient, repo: RepoOf, pull: PullOf): Promise<boolean> {
if (!pull.merged || !pull.merge_commit_sha || announced.has(pull.merge_commit_sha)) return false;
// Recorded before the first wait, so the other path finds it taken while this one is still asking the forge.
announced.add(pull.merge_commit_sha);
try {
// What it changed, asked for only now: a module is rebuilt because a file inside its own
// directory moved, and without this every module built from a repository is rebuilt for a
// change to any of them (novox/hq 04-ISSUES/131).
const changed = await client.listPullFiles(repo.owner, repo.name, pull.number);
// Which of the directories they are in hold a module at the merge commit (novox/hq issue 278): a
// change inside one is that module's, held or not, and only a file in none is shared code. Not
// said when the list is cut or the forge could not be asked; the mesh then keeps its old rule.
let moduleDirs: string[] | null = null;
if (!changed.truncated) {
try {
moduleDirs = await client.moduleDirsAt(repo.owner, repo.name, pull.merge_commit_sha, changed.paths);
} catch (err) {
console.error(`[gitea] ${repo.full_name}#${pull.number}: which directories hold a module could not be read, ` +
`so the mesh reads its files by the old rule — ${err instanceof Error ? err.message : String(err)}`);
}
}
// The head it merged, and what its head was checked as (novox/hq ADR 0239): the delivery it was, made
// from the forge's word when its owner never heard the head.
let headChecks: Record<string, string> | null = null;
if (pull.head_sha) {
try {
headChecks = await client.commitStatuses(repo.owner, repo.name, pull.head_sha);
} catch (err) {
console.error(`[gitea] ${repo.full_name}#${pull.number}: its head's statuses could not be read — ${err instanceof Error ? err.message : err}`);
}
}
await emit("pull.merged", {
owner: repo.owner,
repo: repo.name,
number: pull.number,
title: pull.title,
body: pull.body,
head_sha: pull.head_sha,
...(headChecks ? { head_checks: headChecks } : {}),
head: pull.head,
base: pull.base,
merge_commit_sha: pull.merge_commit_sha,
merged_at: pull.merged_at,
clone_url: repo.clone_url,
html_url: pull.html_url,
paths: changed.paths,
paths_truncated: changed.truncated,
// Which of them the merge deleted (novox/hq ADR 0236): a module whose manifest went is forgotten,
// not built.
removed: changed.removed,
...(moduleDirs ? { module_dirs: moduleDirs, module_dirs_said: true } : {}),
});
// Said, because a trigger that fires silently is indistinguishable from one that did not
// fire (novox/hq 04-ISSUES/131) — this line is how an operator knows the mesh was told.
console.log(`[gitea] announced merge ${repo.full_name}#${pull.number} (${pull.merge_commit_sha.slice(0, 8)}) into ${pull.base}`);
} catch (err) {
announced.delete(pull.merge_commit_sha);
throw err;
}
keepAnnounced();
return true;
}
// **Every new head of an open pull request is announced, once** (novox/hq to-be 45 §9): the mesh checks it
// against every machine of its facts before it merges. Kept beside the merges' record, so a restart
// announces nothing twice; the first look on a machine with no record announces only what moved in the
@@ -248,31 +228,6 @@ function keepHeads(): void {
writeFileSync(tmp, JSON.stringify({ heads }));
renameSync(tmp, pullsRecord);
}
/** **A head, announced once, whichever path saw it first** (novox/hq issue 729): the poll or the forge's event. */
async function announceHead(client: GiteaClient, repo: RepoOf, pull: OpenOf): Promise<boolean> {
const key = `${repo.full_name}#${pull.number}`;
const sha = String(pull.head_sha ?? "");
const before = heads[key];
// Claimed before the first wait, so the other path finds it taken while this one is still announcing.
if (!claimHead(heads, key, sha)) return false;
try {
// The announcement the merge tool makes too when it asks a check (trunk.ts, novox/hq ADR 0323 §5).
await emit("pull.updated", await announcementOf(client, repo.owner, repo.name, repo.clone_url ?? "", pull));
} catch (err) {
if (before === undefined) delete heads[key];
else heads[key] = before;
throw err;
}
keepHeads();
// Said, so an operator knows the mesh was asked to check it.
console.log(`[gitea] announced ${key} at ${sha.slice(0, 8)} to be checked before it merges`);
// Pending until the verdict comes, so the pull request says a check is running rather than nothing — and
// where it is: in the build queue from now, until a build machine takes it (novox/hq issue 446).
await gates.pending(repo.owner, repo.name, sha);
return true;
}
type OpenOf = Awaited<ReturnType<GiteaClient["listOpenPullsSince"]>>[number];
let lastPullLook = "";
// **A sweep now and then looks at every repository** (novox/hq issue 347), whatever moved: the floor is a
// heuristic, and a heuristic that misses is caught by the next sweep rather than never.
@@ -304,8 +259,15 @@ async function pollPulls(client: GiteaClient): Promise<void> {
const key = `${repo.full_name}#${pull.number}`;
const fresh = primedPulls || (!!pull.updated_at && pull.updated_at > dayAgo);
if (fresh) {
if (await announceHead(client, repo, pull)) pace.miss(`${key} at ${String(pull.head_sha).slice(0, 8)}`);
} else heads[key] = String(pull.head_sha);
// The announcement the merge tool makes too when it asks a check (trunk.ts, novox/hq ADR 0323 §5).
await emit("pull.updated", await announcementOf(client, repo.owner, repo.name, repo.clone_url, pull));
// Said, so an operator knows the mesh was asked to check it.
console.log(`[gitea] announced ${key} at ${String(pull.head_sha).slice(0, 8)} to be checked before it merges`);
// Pending until the verdict comes, so the pull request says a check is running rather than nothing — and
// where it is: in the build queue from now, until a build machine takes it (novox/hq issue 446).
await gates.pending(repo.owner, repo.name, String(pull.head_sha));
}
heads[key] = String(pull.head_sha);
changed = true;
}
if (await trunksMoved(client, repo.owner, repo.name)) changedTrunks = true;
@@ -451,59 +413,12 @@ async function buildStarted(event: { body: unknown }): Promise<void> {
}
}
/** How fast the polls look: 30 s until the forge's events are heard, five minutes while they are (webhook.ts). */
const pace = new Pace();
/** What the forge's event asks of the announcer at once (issue 729): a new head is announced, and a merge. */
async function actOn(client: GiteaClient, event: ForgeEvent): Promise<void> {
const b = event.body as { owner?: string; repo?: string; number?: number };
if (!b.owner || !b.repo || !b.number) return;
const head = ["forge.pull-request.opened", "forge.pull-request.synchronized", "forge.pull-request.reopened"].includes(event.name);
const merged = event.name === "forge.pull-request.merged";
if (!head && !merged) return;
const [repo, pull] = await Promise.all([client.getRepo(b.owner, b.repo), client.getPullRequest(b.owner, b.repo, b.number)]);
if (head && pull.state === "open") await announceHead(client, repo, pull);
if (merged) await announceMerge(client, repo, pull);
}
/**
* **The receiver, and the forge's one system webhook pointing at it** (issue 729). The secret is minted and kept beside
* the token; the webhook is made or corrected with the admin account (ADR 0266: the tools' token holds no admin scope).
* Answers whether events are heard; a forge that refuses is said, and the polls keep their pace.
*/
async function listenForForge(client: GiteaClient): Promise<boolean> {
const stateDir = process.env.MESH_GITEA_STATE_DIR;
const url = process.env.MESH_GITEA_HOOK_URL;
if (!stateDir || !url) {
console.log("[gitea] no webhook receiver: MESH_GITEA_STATE_DIR or MESH_GITEA_HOOK_URL unset, so the polls alone watch the forge");
return false;
}
try {
const secret = keptSecret(join(stateDir, "webhook.secret"));
const port = Number(process.env.MESH_GITEA_HOOK_PORT ?? HOOK_PORT);
const server = receiver({ secret, port, heard: () => pace.heard(),
publish: (event) => emit(event.name, event.body),
act: (event) => actOn(client, event) });
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(port, process.env.MESH_GITEA_HOOK_HOST ?? "0.0.0.0", () => resolve());
});
const did = await ensureSystemHook(GiteaAdmin.fromEnv(), url.replace(/\/+$/, "") + HOOK_PATH, secret);
console.log(`[gitea] listening for the forge's events on port ${port}; its system webhook ${did}, every event, signed`);
return true;
} catch (err) {
console.error(`[gitea] the forge's events are not heard, so the polls keep their pace — ${err instanceof Error ? err.message : err}`);
return false;
}
}
if (gitea) {
const client = gitea;
// A poll that fails says so once, not once a minute: the same reason repeating (the forge not up
// yet, the admin account refused on a restored forge) is one fact, and a recovery is worth a line.
let failing: string | null = null;
const tick = (fn: () => Promise<void>, every: number | (() => number)): void => {
const everyMs = (): number => (typeof every === "number" ? every : every());
const tick = (fn: () => Promise<void>, everyMs: number): void => {
// **One pass at a time** (novox/hq issue 250): the next pass is scheduled when this one has ended, so a
// pass that outlasts its interval delays the next instead of running beside it — two passes at once
// could each announce the same merge before either recorded it.
@@ -518,15 +433,12 @@ if (gitea) {
if (why !== failing) console.error(`[gitea] not watching until this clears — ${why}`);
failing = why;
})
.finally(() => setTimeout(run, everyMs()));
.finally(() => setTimeout(run, everyMs));
run();
};
// **The forge's events first, the polls as the safety net** (novox/hq issue 729): while verified deliveries arrive the
// polls slow to every five minutes and announce only what no event said (Pace); otherwise they keep 30 s.
await listenForForge(client);
tick(() => pollRepos(client), 60_000);
tick(() => pollMerged(client), () => pace.every());
tick(() => pollPulls(client), () => pace.every());
tick(() => pollMerged(client), 30_000);
tick(() => pollPulls(client), 30_000);
await on("mesh-controller.checked", (event) => {
const c = (event.body ?? {}) as Checked;
// A head's own verdict takes its turn after every write of that head's gate already asked for (gate.ts).
+5 -79
View File
@@ -42,47 +42,7 @@
"issue.opened",
"pull.merged",
"pull.updated",
"pull.closed",
"forge.push",
"forge.create",
"forge.delete",
"forge.fork",
"forge.status",
"forge.pull-request.opened",
"forge.pull-request.reopened",
"forge.pull-request.synchronized",
"forge.pull-request.edited",
"forge.pull-request.closed",
"forge.pull-request.merged",
"forge.pull-request.labeled",
"forge.pull-request.unlabeled",
"forge.pull-request.assigned",
"forge.pull-request.unassigned",
"forge.pull-request.reviewed",
"forge.pull-request.review-requested",
"forge.pull-request.commented",
"forge.pull-request.changed",
"forge.issue-comment.created",
"forge.issue-comment.edited",
"forge.issue-comment.deleted",
"forge.issues.opened",
"forge.issues.reopened",
"forge.issues.closed",
"forge.issues.edited",
"forge.issues.labeled",
"forge.issues.unlabeled",
"forge.issues.assigned",
"forge.issues.unassigned",
"forge.issues.changed",
"forge.release.published",
"forge.release.updated",
"forge.release.deleted",
"forge.release.changed",
"forge.repository.created",
"forge.repository.deleted",
"forge.repository.changed",
"forge.wiki.changed",
"forge.other"
"pull.closed"
],
"consumes": [
"mesh-controller.checked",
@@ -101,14 +61,7 @@
"port": 22,
"protocol": "tcp",
"from": "mesh",
"why": "git over ssh, gitea's own unmodified sshd. Published on the machine's own side at 222, the mesh's fixed public convention — not 22, which the machine's own daemon holds and a module does not take"
},
{
"name": "forge-events",
"port": 3047,
"protocol": "tcp",
"from": "machine",
"why": "the forge's system webhook, every event signed with a secret the module keeps, from the forge's container on the docker bridge to the module's receiver at the bridge's gateway 172.17.0.1 (novox/hq issue 729)"
"why": "git over ssh, gitea's own unmodified sshd. Published on the machine's own side at 222, the mesh's fixed public convention \u2014 not 22, which the machine's own daemon holds and a module does not take"
}
],
"serves": {
@@ -156,13 +109,6 @@
}
}
},
"settings": {
"ready-authors": {
"kind": "preference",
"default": "mesh-admin jschoubben",
"why": "the forge accounts whose comment \"ready at <full head sha>\" marks a pull request ready for mesh-delivery to merge, separated by spaces: the mesh's own forge account and the operator's (novox/hq issue 722). A comment by any other account is ignored and said in the forge's log. Trusted: who may have a change merged is the operator's word"
}
},
"resources": [
{
"id": "mesh-state",
@@ -176,14 +122,6 @@
"path": "${dir:mesh-state}/state",
"mode": "0700"
},
{
"id": "ready-authors",
"type": "file",
"path": "${dir:mesh-state}/ready-authors",
"mode": "0600",
"trusted": true,
"content": "# The accounts whose \"ready at <sha>\" comment marks a pull request ready (novox/hq issue 722), from the setting\n# ready-authors, written by the mesh. Replaced on every push; change the setting, never this file.\nauthors=${setting:ready-authors}\n"
},
{
"id": "state",
"type": "directory",
@@ -200,7 +138,7 @@
"type": "file",
"path": "${dir:state}/server.env",
"mode": "0600",
"content": "GITEA__security__INTERNAL_TOKEN=${secret:internal-token}\nGITEA__database__DB_TYPE=postgres\nGITEA__database__HOST=${bound:postgres-database:at}:${bound:postgres-database:port}\nGITEA__database__NAME=${bound:postgres-database:as}\nGITEA__database__USER=${bound:postgres-database:as}\nGITEA__database__PASSWD=${secret:postgres-database}\nGITEA__webhook__ALLOWED_HOST_LIST=172.17.0.1\n"
"content": "GITEA__security__INTERNAL_TOKEN=${secret:internal-token}\nGITEA__database__DB_TYPE=postgres\nGITEA__database__HOST=${bound:postgres-database:at}:${bound:postgres-database:port}\nGITEA__database__NAME=${bound:postgres-database:as}\nGITEA__database__USER=${bound:postgres-database:as}\nGITEA__database__PASSWD=${secret:postgres-database}\n"
},
{
"id": "data",
@@ -211,13 +149,6 @@
{
"id": "server",
"type": "container",
"health": {
"kind": "http",
"endpoint": "web",
"path": "/api/healthz",
"status": 200,
"needs": "postgres-database"
},
"name": "gitea",
"image": "gitea/gitea@sha256:87a67ee09d3ae0d1df5fda5dcda3e2a1f9236a45b0a59025d6e00e46adc43bef",
"env": {
@@ -269,8 +200,7 @@
"path": "${dir:mesh-state}/config.json",
"mode": "0600",
"content": "{}\n",
"merge": "json",
"takes-settings": []
"merge": "json"
}
],
"provides": [
@@ -309,11 +239,7 @@
"MESH_GITEA_ADMIN_USER": "mesh-admin",
"MESH_GITEA_ADMIN_PASSWORD_FILE": "${dir:state}/admin.secret",
"MESH_GITEA_STATE_DIR": "${dir:runtime-state}",
"MESH_RECEIVES": "${dir:grants}/npm.json",
"MESH_GITEA_READY_AUTHORS_FILE": "${dir:mesh-state}/ready-authors",
"MESH_GITEA_HOOK_URL": "http://172.17.0.1:3047",
"MESH_GITEA_HOOK_PORT": "3047",
"MESH_GITEA_HOOK_HOST": "172.17.0.1"
"MESH_RECEIVES": "${dir:grants}/npm.json"
}
},
{
-117
View File
@@ -137,120 +137,3 @@ test("a merged pull request whose branch was deleted is announced with its branc
assert.equal(headBranchOf({ ref: "refs/pull/7/head" }), "refs/pull/7/head");
assert.equal(headBranchOf(undefined), undefined);
});
// **A pull request marked ready says the head it is marked at** (novox/hq issue 722): the label mesh/ready and the
// newest comment "ready at <full head sha>" by a listed account, written after the label was added and after the last
// push. Read from the pull request's whole timeline, page by page; without the label nothing is read.
const head = "c".repeat(40), older = "d".repeat(40);
const ev = (id: number, type: string, body: string, user = "mesh-admin", label?: { id: number; name: string }) =>
({ id, type, body, user: { login: user }, created_at: `2026-10-14T10:${String(id % 60).padStart(2, "0")}:00Z`,
...(label ? { label } : {}) });
const readyLabel = { id: 31, name: "mesh/ready" };
function forgeWith(timeline: unknown[]) {
const asked: string[] = [];
const server = createServer((req, res) => {
const url = req.url ?? "";
asked.push(url);
const send = (v: unknown) => res.end(JSON.stringify(v));
if (url.startsWith("/api/v1/repos/issues/search")) {
return send(url.includes("page=1") ? [
{ number: 7, repository: { owner: "novox", name: "hq", full_name: "novox/hq" },
labels: [{ id: 31, name: "mesh/ready" }, { id: 2, name: "bug" }] },
{ number: 8, repository: { owner: "novox", name: "hq", full_name: "novox/hq" }, labels: [] },
] : []);
}
if (url.startsWith("/api/v1/repos/novox/hq/pulls/7")) {
return send({ number: 7, state: "open", base: { ref: "main" }, head: { ref: "x", sha: head }, html_url: "h" });
}
if (url.startsWith("/api/v1/repos/novox/hq/pulls/8")) {
return send({ number: 8, state: "open", base: { ref: "main" }, head: { ref: "y", sha: older }, html_url: "h" });
}
const t = /\/issues\/7\/timeline\?page=(\d+)&limit=50/.exec(url);
if (t) {
const page = Number(t[1]);
return send(timeline.slice((page - 1) * 50, page * 50));
}
if (url.includes("/branch_protections")) return send([]);
if (url.includes("/status")) return send({ statuses: [] });
res.statusCode = 404;
res.end("{}");
});
return { server, asked };
}
async function readOpen(timeline: unknown[], authors = ["mesh-admin", "jschoubben"]) {
const { GiteaClient } = await import("../client.ts");
const { server, asked } = forgeWith(timeline);
await new Promise<void>((r) => server.listen(0, r));
try {
const client = new GiteaClient(`http://127.0.0.1:${(server.address() as any).port}`, "t");
return { open: await client.openPulls(authors), asked };
} finally {
server.close();
}
}
test("a pull request's ready mark is read: the label and the head the newest listed ready comment names", async () => {
const { open, asked } = await readOpen([
ev(1, "pull_push", JSON.stringify({ commit_ids: [head] })),
ev(2, "label", "1", "mesh-admin", readyLabel),
ev(3, "comment", `ready at ${older}`),
ev(4, "comment", `Reviewed: approve.\nready at ${head}\n`),
ev(5, "comment", "thanks", "jschoubben"),
]);
assert.deepEqual(open[0].labels, ["mesh/ready", "bug"]);
assert.deepEqual(open[0].ready, { head, by: "mesh-admin", at: "2026-10-14T10:04:00Z", label_id: 31 });
assert.equal(open[1].ready, undefined, "no label, no mark");
assert.ok(!asked.some((u) => u.includes("/issues/8/")), "no timeline read without the label");
});
test("a ready comment by an account not listed is ignored, and said", async () => {
const { readyMarkOf } = await import("../client.ts");
const said: string[] = [];
const mark = readyMarkOf(31, [
ev(1, "pull_push", "{}") as any, ev(2, "label", "1", "mesh-admin", readyLabel) as any,
{ ...ev(3, "comment", `ready at ${head}`, "outsider"), user: "outsider" },
].map((e: any) => ({ ...e, user: typeof e.user === "string" ? e.user : e.user.login })), ["mesh-admin"], (l) => said.push(l));
assert.equal(mark.head, "", "an outsider's mark counts");
assert.equal(said.length, 1);
assert.match(said[0], /by outsider is ignored/);
});
test("a ready comment older than the latest push, or than the label, never counts", async () => {
const pushedAfter = await readOpen([
ev(1, "label", "1", "mesh-admin", readyLabel),
ev(2, "comment", `ready at ${head}`),
ev(3, "pull_push", JSON.stringify({ commit_ids: [head] })),
]);
assert.equal(pushedAfter.open[0].ready?.head, "", "a mark written before the latest push counts, the label still on");
const beforeLabel = await readOpen([
ev(1, "pull_push", "{}"),
ev(2, "comment", `ready at ${head}`),
ev(3, "label", "1", "mesh-admin", readyLabel),
]);
assert.equal(beforeLabel.open[0].ready?.head, "", "a mark written before the label was added counts");
const takenOff = await readOpen([
ev(1, "label", "1", "mesh-admin", readyLabel),
ev(2, "comment", `ready at ${head}`),
ev(3, "label", "", "mesh-admin", readyLabel),
]);
assert.equal(takenOff.open[0].ready?.head, "", "a mark whose label was taken off since counts");
});
test("a long thread hides nothing: every page of the timeline is read", async () => {
const events: unknown[] = [ev(1, "pull_push", "{}"), ev(2, "label", "1", "mesh-admin", readyLabel),
ev(3, "comment", `ready at ${older}`)];
for (let id = 4; id < 130; id++) events.push(ev(id, "comment", "a long discussion", "jschoubben"));
events.push(ev(130, "comment", `ready at ${head}`, "jschoubben"));
const { open, asked } = await readOpen(events);
assert.equal(open[0].ready?.head, head, "the newest mark, on the third page, is not read");
assert.equal(asked.filter((u) => u.includes("/issues/7/timeline")).length, 3);
});
test("the accounts that may mark are the setting's, and only the mesh's own when it cannot be read", async () => {
const { readyAuthors } = await import("../client.ts");
assert.deepEqual(readyAuthors("f", "mesh-admin", () => "# x\nauthors=mesh-admin jschoubben\n"), ["mesh-admin", "jschoubben"]);
assert.deepEqual(readyAuthors("f", "mesh-admin", () => { throw new Error("gone"); }), ["mesh-admin"]);
assert.deepEqual(readyAuthors("f", "mesh-admin", () => "authors=\n"), ["mesh-admin"]);
});
+8 -77
View File
@@ -15,7 +15,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { collectTools } from "@novox/mesh-sdk/tools";
import { AdminRefused, MINT_RETRY_AFTER_MS, MintedToken, TOKEN_SCOPES } from "../dist/token.js";
import { AdminRefused, MintedToken, TOKEN_SCOPES } from "../dist/token.js";
import { GiteaClient } from "../dist/client.js";
import { getGiteaTools } from "../dist/tools/index.js";
@@ -29,9 +29,6 @@ interface Forge {
mints: number;
lastScopes: string[] | null;
tokens: Map<string, string>;
/** Tokens beyond the one per name `tokens` holds, by id: what two mints racing leave (novox/hq issue 739). */
twins: Map<number, { name: string; sha1: string }>;
tokenCalls: number;
scopesOf: Map<string, string[]>;
admins: Map<string, string>;
pullState: string;
@@ -45,9 +42,6 @@ function fakeForge(): Promise<Forge> {
mints: 0,
lastScopes: null as string[] | null,
tokens: new Map<string, string>(), // name -> value
twins: new Map<number, { name: string; sha1: string }>(),
idOf: new Map<string, number>(), // value -> id
tokenCalls: 0,
scopesOf: new Map<string, string[]>(), // value -> scopes, so a route can enforce them like gitea does
admins: new Map([[ADMIN, PASSWORD]]),
pullState: "open",
@@ -79,41 +73,21 @@ function fakeForge(): Promise<Forge> {
const tokens = url.pathname.match(/^\/api\/v1\/users\/([^/]+)\/tokens(?:\/([^/]+))?$/);
if (tokens) {
const user = basic(req);
forge.tokenCalls++;
if (user === null || user !== decodeURIComponent(tokens[1])) return json(res, 401, { message: "auth required" });
const named = (name: string): number =>
(forge.tokens.has(name) ? 1 : 0) + [...forge.twins.values()].filter((t) => t.name === name).length;
if (req.method === "POST") {
const { name, scopes } = await body(req);
if (named(name) > 0) return json(res, 400, { message: "token name has already been used" });
if (forge.tokens.has(name)) return json(res, 400, { message: "token name has already been used" });
forge.mints++;
forge.lastScopes = scopes;
const sha1 = `minted-${forge.mints}-${Math.random().toString(36).slice(2)}`;
forge.tokens.set(name, sha1);
forge.idOf.set(sha1, forge.mints);
forge.scopesOf.set(sha1, scopes);
return json(res, 201, { id: forge.mints, name, sha1, scopes, token_last_eight: sha1.slice(-8) });
}
if (req.method === "GET" && !tokens[2]) {
const page = Number(url.searchParams.get("page") ?? "1");
const all = [...[...forge.tokens].map(([name, v]) => ({ id: forge.idOf.get(v) ?? 0, name })),
...[...forge.twins].map(([id, t]) => ({ id, name: t.name }))];
return json(res, 200, page === 1 ? all : []);
}
if (req.method === "DELETE" && tokens[2]) {
const key = decodeURIComponent(tokens[2]);
// gitea: "identified by ID and if not available by name"
if (/^\d+$/.test(key)) {
const id = Number(key);
if (forge.twins.delete(id)) return json(res, 204, null);
const hit = [...forge.tokens].find(([, v]) => forge.idOf.get(v) === id);
if (!hit) return json(res, 404, { message: "token not found" });
forge.tokens.delete(hit[0]);
return json(res, 204, null);
}
if (named(key) > 1) return json(res, 422, { message: `multiple matches for token name '${key}'` });
if (!forge.tokens.has(key)) return json(res, 404, { message: "token not found" });
forge.tokens.delete(key);
const name = decodeURIComponent(tokens[2]);
if (!forge.tokens.has(name)) return json(res, 404, { message: "token not found" });
forge.tokens.delete(name);
return json(res, 204, null);
}
return json(res, 405, { message: "method not allowed" });
@@ -206,9 +180,7 @@ function fakeForge(): Promise<Forge> {
get pullState() { return forge.pullState; },
get pullTitle() { return forge.pullTitle; },
get branchDeleted() { return forge.branchDeleted; },
get tokenCalls() { return forge.tokenCalls; },
tokens: forge.tokens,
twins: forge.twins,
scopesOf: forge.scopesOf,
admins: forge.admins,
close: () => new Promise((r) => server.close(() => r())),
@@ -237,9 +209,8 @@ async function delivered(forge: Forge): Promise<{ env: NodeJS.ProcessEnv; file:
}
/** A client as a fresh process would build it: a new source over the kept file, its log captured. */
function minted(env: NodeJS.ProcessEnv, logs: string[], now?: () => number): GiteaClient {
function minted(env: NodeJS.ProcessEnv, logs: string[]): GiteaClient {
const source = new MintedToken({
now,
url: env.MESH_GITEA_URL!,
admin: env.MESH_GITEA_ADMIN_USER!,
passwordFile: env.MESH_GITEA_ADMIN_PASSWORD_FILE!,
@@ -354,44 +325,6 @@ test("the kept file is gone but the forge still holds a token by that name: repl
assert.ok(logs.some((l) => l.includes('already holds a token named "mesh-tools"')), logs.join("\n"));
});
test("two tokens carry the name (two mints raced): each is dropped by its id and one is minted, not a 422 for good (issue 739)", async () => {
const { env, logs } = await delivered(forge);
await minted(env, logs).listRepos();
// What two processes minting at once leave: a second token by the same name, whose value nobody kept.
// Two tokens by the name, neither of them the one kept.
forge.tokens.clear();
forge.twins.set(9001, { name: "mesh-tools", sha1: "raced-first" });
forge.twins.set(9002, { name: "mesh-tools", sha1: "raced-second" });
const repos = await minted(env, logs).listRepos();
assert.equal(repos.length, 1);
assert.equal(forge.twins.size, 0);
assert.equal([...forge.tokens.keys()].filter((n) => n === "mesh-tools").length, 1);
assert.ok(logs.some((l) => l.includes('holds 2 tokens named "mesh-tools"')), logs.join("\n"));
});
test("a mint that failed is not asked of the forge again within the retry window: the same error, no basic-auth call (issue 739)", async () => {
const { env, logs } = await delivered(forge);
forge.admins.delete(ADMIN);
try {
let clock = 5_000_000;
const client = minted(env, logs, () => clock);
await assert.rejects(client.listRepos(), AdminRefused);
const calls = forge.tokenCalls;
await assert.rejects(client.listRepos(), AdminRefused);
await assert.rejects(client.listRepos(), AdminRefused);
assert.equal(forge.tokenCalls, calls);
clock += MINT_RETRY_AFTER_MS;
await assert.rejects(client.listRepos(), AdminRefused);
assert.equal(forge.tokenCalls, calls + 1);
} finally {
forge.admins.set(ADMIN, PASSWORD);
}
});
test("concurrent first calls share one mint", async () => {
const { env, logs } = await delivered(forge);
const client = minted(env, logs);
@@ -406,8 +339,7 @@ test("the admin account is refused: said plainly, nothing kept, and the next cal
const { env, file, logs } = await delivered(forge);
forge.admins.delete(ADMIN); // the forge's data came from a predecessor; mesh-admin was never created there
try {
let clock = 1_000_000;
const client = minted(env, logs, () => clock);
const client = minted(env, logs);
const before = forge.mints;
await assert.rejects(client.listRepos(), (err: unknown) => {
@@ -422,9 +354,8 @@ test("the admin account is refused: said plainly, nothing kept, and the next cal
assert.equal(forge.mints, before);
await assert.rejects(stat(file), /ENOENT/);
// The account appears (the operator created it): the next call after the retry window mints and works.
// The account appears (the operator created it): the very next call mints and works.
forge.admins.set(ADMIN, PASSWORD);
clock += MINT_RETRY_AFTER_MS;
assert.equal((await client.listRepos()).length, 1);
assert.equal(forge.mints, before + 1);
} finally {
-26
View File
@@ -173,29 +173,3 @@ test("a verdict that landed out of date keeps its account on the pull request",
const bare = lateVerdictComment({ commit: HEAD, trunk: TRUNK.slice(0, 12), verdict: "pass" }, null, words);
assert.match(bare, /head dfb1415aaaaa merged into trunk a2c2c35bbbbb, pass/);
});
test("a release branch's move puts every open pull request into it out of date, as a trunk's does (ADR 0358 §5)", async () => {
// novox/hq ADR 0358 §5: for a pull request into a release branch, the release branch takes the trunk's place in ADR
// 0323 — the check merges the head onto the release branch's head and names it as the trunk commit, so the release
// branch's move puts its open verdicts out of date, and leaves those into main alone.
const release = "feat/57-config-and-provisioning";
const heads: Record<string, string> = { main: TRUNK, [release]: "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" };
const pulls = [{ number: 315, base: release, head: "3".repeat(40) }, { number: 1, base: "main", head: "1".repeat(40) }];
const { forge, statuses } = aForge(pulls, heads);
const gates = new GateWriter(forge);
const trunks: Record<string, string> = {};
await weighTrunks(forge, gates, trunks, "novox", "mesh-controller");
heads[release] = MOVED;
assert.equal(await weighTrunks(forge, gates, trunks, "novox", "mesh-controller"), true);
const s = statuses.get("3".repeat(40))!;
assert.equal(s["mesh/merge-gate"].state, "pending");
assert.match(s["mesh/merge-gate"].description!, /judged against trunk eeeeeeeeeeee, the trunk is now d28ca95ccccc/);
assert.equal(s["mesh/repo-check"].state, "pending");
assert.equal(statuses.get("1".repeat(40))!["mesh/merge-gate"].state, "success", "a pull request into main is left");
assert.equal(trunks[`novox/mesh-controller:${release}`], MOVED);
// The merge tool's rule reads the same: a verdict judged against the release branch's old head is not current.
assert.equal(verdictAtTrunk({ "mesh/merge-gate": { state: "success",
description: "head x merged into trunk eeeeeeeeeeee — pass" } }, MOVED).current, false);
});
-120
View File
@@ -1,120 +0,0 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { createHmac } from "node:crypto";
import type { AddressInfo } from "node:net";
// **The forge says every event on the bus** (novox/hq issue 729): a signed delivery is published once and acted on,
// an unsigned or unverified one is refused, and an event and the poll seeing the same head announce it once.
const secret = "s".repeat(64);
const sign = (body: string, key = secret) => createHmac("sha256", key).update(body).digest("hex");
const opened = JSON.stringify({ action: "opened", number: 7, pull_request: { number: 7, title: "t", user: { login: "jo" },
head: { ref: "feat/x", sha: "a".repeat(40) }, base: { ref: "main" }, merged: false },
repository: { name: "hq", full_name: "novox/hq", owner: { login: "novox" } }, sender: { login: "jo" } });
async function serve(published: { name: string }[], acted: { name: string }[]) {
const { receiver, HOOK_PATH } = await import("../webhook.ts");
const server = receiver({ secret, port: 0, log: () => {},
publish: async (e) => { published.push(e); }, act: async (e) => { acted.push(e); } });
await new Promise<void>((r) => server.listen(0, "127.0.0.1", () => r()));
const url = `http://127.0.0.1:${(server.address() as AddressInfo).port}${HOOK_PATH}`;
return { server, url };
}
test("a signed event is published on the bus and acted on once", async () => {
const published: { name: string }[] = [], acted: { name: string }[] = [];
const { server, url } = await serve(published, acted);
try {
const headers = { "X-Gitea-Event": "pull_request", "X-Gitea-Signature": sign(opened), "X-Gitea-Delivery": "d-1" };
const res = await fetch(url, { method: "POST", body: opened, headers });
assert.equal(res.status, 200);
// The same delivery sent again is answered and published nowhere.
const again = await fetch(url, { method: "POST", body: opened, headers });
assert.equal(again.status, 200);
await server.idle();
assert.deepEqual(published.map((e) => e.name), ["forge.pull-request.opened"]);
assert.deepEqual(acted.map((e) => e.name), ["forge.pull-request.opened"]);
} finally { server.close(); }
});
test("an unsigned event, or one whose signature does not verify, is refused and published nowhere", async () => {
const published: { name: string }[] = [], acted: { name: string }[] = [];
const { server, url } = await serve(published, acted);
try {
const unsigned = await fetch(url, { method: "POST", body: opened, headers: { "X-Gitea-Event": "pull_request" } });
assert.equal(unsigned.status, 401);
assert.match(await unsigned.text(), /unsigned/);
const forged = await fetch(url, { method: "POST", body: opened,
headers: { "X-Gitea-Event": "pull_request", "X-Gitea-Signature": sign(opened, "another secret") } });
assert.equal(forged.status, 401);
assert.match(await forged.text(), /does not verify/);
const big = "x".repeat(5 * 1024 * 1024 + 1);
const large = await fetch(url, { method: "POST", body: big,
headers: { "X-Gitea-Event": "push", "X-Gitea-Signature": sign(big) } });
assert.equal(large.status, 413);
await server.idle();
assert.deepEqual(published, []);
assert.deepEqual(acted, []);
} finally { server.close(); }
});
test("an event and the poll seeing the same head announce it once", async () => {
const { claimHead } = await import("../webhook.ts");
const heads: Record<string, string> = {};
const sha = "a".repeat(40);
assert.equal(claimHead(heads, "novox/hq#7", sha), true, "the event claims the head and announces it");
assert.equal(claimHead(heads, "novox/hq#7", sha), false, "the poll finds it taken");
assert.equal(claimHead(heads, "novox/hq#7", "b".repeat(40)), true, "a new head is announced again");
});
test("each forge event is named in the module's stream and trimmed", async () => {
const { eventOf } = await import("../webhook.ts");
const merged = eventOf("pull_request", { action: "closed", pull_request: { number: 3, merged: true, merge_commit_sha: "c".repeat(40),
head: { sha: "a".repeat(40) }, base: { ref: "main" } }, repository: { name: "hq", owner: { login: "novox" } } });
assert.equal(merged.name, "forge.pull-request.merged");
assert.equal(merged.body.merge_commit_sha, "c".repeat(40));
assert.equal(eventOf("pull_request", { action: "label_updated", pull_request: { number: 3 }, label: { name: "mesh/ready" } }).name,
"forge.pull-request.labeled");
const comment = eventOf("issue_comment", { action: "created", is_pull: true, issue: { number: 3 },
comment: { id: 9, body: "ready at abc", user: { login: "jo" } } });
assert.equal(comment.name, "forge.pull-request.commented");
assert.equal(comment.body.comment, "ready at abc");
assert.equal(eventOf("push", { ref: "refs/heads/main", after: "d".repeat(40) }).name, "forge.push");
assert.equal(eventOf("pull_request_review_approved", {}).name, "forge.other");
});
test("the system webhook is made when none points at the receiver, and corrected when one does", async () => {
const { ensureSystemHook } = await import("../webhook.ts");
const calls: string[] = [];
let hooks: unknown[] = [];
const admin = { call: async (method: string, path: string, body?: any) => {
calls.push(`${method} ${path}`);
if (method === "GET") return { status: 200, body: hooks };
if (method === "POST") { assert.ok(body.events.includes("pull_request") && body.events.includes("push")); assert.equal(body.config.secret, secret); return { status: 201, body: {} }; }
return { status: 200, body: {} };
} };
assert.equal(await ensureSystemHook(admin, "http://h:3047/forge-event", secret), "created");
hooks = [{ id: 4, config: { url: "http://h:3047/forge-event" } }];
assert.equal(await ensureSystemHook(admin, "http://h:3047/forge-event", secret), "corrected");
assert.ok(calls.includes("PATCH /admin/hooks/4"));
});
test("the polls look every 30 s until events are heard, every five minutes while they are, and 30 s again on a miss", async () => {
const { Pace } = await import("../webhook.ts");
let now = 1_000_000;
const said: string[] = [];
const pace = new Pace(() => now, (l) => said.push(l));
assert.equal(pace.every(), 30_000, "no delivery yet");
pace.heard();
assert.equal(pace.every(), 300_000, "a delivery heard");
now += 11 * 60_000;
assert.equal(pace.every(), 30_000, "ten minutes without a delivery");
pace.heard();
now += 1000;
pace.miss("novox/hq#7 at aaaaaaaa");
assert.equal(pace.every(), 30_000, "the poll found what no event said");
assert.match(said.join("\n"), /MISSED novox\/hq#7/);
now += 1000;
pace.heard();
assert.equal(pace.every(), 300_000, "the next delivery");
});
+4 -60
View File
@@ -97,25 +97,13 @@ export interface MintedTokenOptions {
readonly scopes?: readonly string[];
readonly log?: (line: string) => void;
readonly fetch?: typeof fetch;
/** The clock, in milliseconds; a test sets it. */
readonly now?: () => number;
}
/**
* **How long a failed mint is not tried again** (novox/hq issue 739). Every call that finds no token mints, and a
* mint is two or three calls over basic auth, each one hashing the admin password on the forge. A mint that keeps
* failing, once per tool call, is a loop the forge pays for in CPU: on 2026-10-14 it ran several times a minute
* on the control-node while it was already overloaded. Within this window the last failure is answered again,
* without asking the forge.
*/
export const MINT_RETRY_AFTER_MS = 30_000;
/** The token the module mints for itself, kept in its state and renewed when the forge rejects it. */
export class MintedToken implements TokenSource {
private held: string | null = null;
private readFile = false;
private inflight: Promise<string> | null = null;
private failed: { at: number; error: unknown } | null = null;
private readonly name: string;
private readonly scopes: readonly string[];
private readonly log: (line: string) => void;
@@ -126,11 +114,8 @@ export class MintedToken implements TokenSource {
this.scopes = opts.scopes ?? TOKEN_SCOPES;
this.log = opts.log ?? ((line) => console.log(`[gitea] ${line}`));
this.fetchImpl = opts.fetch ?? fetch;
this.now = opts.now ?? Date.now;
}
private readonly now: () => number;
/**
* Build from the runtime's environment: the forge's URL, the admin login and password file the
* manifest hands the runtime, and the module's state directory (MESH_GITEA_STATE_DIR, a directory
@@ -141,10 +126,7 @@ export class MintedToken implements TokenSource {
const opts = MintedToken.optionsFromEnv(url, env);
// One source per kept file in a process. The watcher and the tools entrypoint both build a
// client in the same runtime; two sources over one file would each renew on a 401 and drop the
// other's token by name, forever. Shared, a renewal is one renewal. Across processes it is not shared: the
// runtime serves the watcher (index.js) and the tools (tools/index.js) as separate bundles, so two sources over
// one file do exist. renew() reads the kept file before it mints, and a race that still leaves two tokens by
// the name is undone by dropping each by its id (doMint; novox/hq issue 739).
// other's token by name, forever. Shared, a renewal is one renewal.
const shared = MintedToken.shared.get(opts.file);
if (shared) return shared;
const source = new MintedToken(opts);
@@ -181,7 +163,7 @@ export class MintedToken implements TokenSource {
}
if (kept !== null) return this.mint("the kept token was minted with other scopes than the tools take now — minting one with them");
}
return this.mint(this.failed ? "no token held since the last mint failed — minting one" : "no token kept — minting one");
return this.mint("no token kept — minting one");
}
async renew(rejected: string): Promise<string> {
@@ -206,19 +188,8 @@ export class MintedToken implements TokenSource {
/** One mint at a time: concurrent first calls share it, rather than each minting its own. */
private mint(why: string): Promise<string> {
if (this.inflight === null) {
// A mint that failed a moment ago is not asked of the forge again yet (novox/hq issue 739): its error stands.
if (this.failed !== null && this.now() - this.failed.at < MINT_RETRY_AFTER_MS) return Promise.reject(this.failed.error);
this.log(why);
this.inflight = this.doMint().then(
(token) => {
this.failed = null;
return token;
},
(err) => {
this.failed = { at: this.now(), error: err };
throw err;
},
).finally(() => {
this.inflight = this.doMint().finally(() => {
this.inflight = null;
});
}
@@ -290,12 +261,7 @@ export class MintedToken implements TokenSource {
// went while the forge's data stayed. It is ours to replace: drop it by name and mint again.
this.log(`the forge already holds a token named "${this.name}" — replacing it`);
const dropped = await call("DELETE", `/${encodeURIComponent(this.name)}`);
if (dropped.status === 422) {
// More than one token carries the name (novox/hq issue 739): two processes minted at once, and the forge's
// check that a name is unused is not atomic. The forge refuses a delete by such a name for good, so every
// one of them is dropped by its id — all are ours, and none of their values is kept.
await this.dropEveryNamed(call, dropped);
} else if (dropped.status !== 204 && dropped.status !== 404) {
if (dropped.status !== 204 && dropped.status !== 404) {
throw new Error(`Gitea DELETE /users/${this.opts.admin}/tokens/${this.name}: ${dropped.status} ${detail(dropped.body)}`);
}
res = await call("POST", "", { name: this.name, scopes: this.scopes });
@@ -311,28 +277,6 @@ export class MintedToken implements TokenSource {
this.log(`minted a token for "${this.opts.admin}" (${this.scopes.join(", ")}), kept at ${this.opts.file}`);
return token;
}
/** Drop, by id, every token of the admin account that carries this name; the forge listed them, page by page. */
private async dropEveryNamed(call: (method: string, path?: string) => Promise<{ status: number; body: any }>,
refused: { status: number; body: any }): Promise<void> {
const ids: number[] = [];
for (let page = 1; page <= 20; page++) {
const listed = await call("GET", `?page=${page}&limit=50`);
if (listed.status !== 200 || !Array.isArray(listed.body)) {
throw new Error(`Gitea DELETE /users/${this.opts.admin}/tokens/${this.name}: ${refused.status} ${detail(refused.body)}; ` +
`and its tokens could not be listed to drop them one by one: ${listed.status} ${detail(listed.body)}`);
}
for (const t of listed.body) if (t?.name === this.name && Number.isInteger(t?.id)) ids.push(t.id);
if (listed.body.length < 50) break;
}
this.log(`the forge holds ${ids.length} tokens named "${this.name}" — dropping each by its id`);
for (const id of ids) {
const dropped = await call("DELETE", `/${id}`);
if (dropped.status !== 204 && dropped.status !== 404) {
throw new Error(`Gitea DELETE /users/${this.opts.admin}/tokens/${id}: ${dropped.status} ${detail(dropped.body)}`);
}
}
}
}
function detail(body: unknown): string {
+3 -3
View File
@@ -10,7 +10,7 @@
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
import { emit } from "@novox/mesh-sdk/events";
import { GiteaClient, readyAuthors, type BranchProtection } from "../client.js";
import { GiteaClient, type BranchProtection } from "../client.js";
import { appendNote, run, viewBody, viewComment } from "../delivery.js";
import { containedFrom, isCommit, mergeableUnder, newestByContext } from "../checks.js";
import {
@@ -514,9 +514,9 @@ function allGiteaTools(gitea: GiteaClient): ToolDefinition[] {
},
{
name: "gitea_open_pulls",
description: "Every open pull request of every repository, from the forge itself: its head, its base, the statuses the base's protection requires and the ones its head has, each with its state, description and when it was set. What mesh-delivery reads to say a pull request the mesh was never asked to check (novox/hq issue 347), or whose check started and never answered (issue 438), and each pull request's labels and ready mark — the label mesh/ready and the head named by the newest comment \"ready at <full head sha>\" that a listed account (the setting ready-authors) wrote after the label was added and after the last push — which mesh-delivery merges on (issue 722). Reads only.",
description: "Every open pull request of every repository, from the forge itself: its head, its base, the statuses the base's protection requires and the ones its head has, each with its state, description and when it was set. What mesh-delivery reads to say a pull request the mesh was never asked to check (novox/hq issue 347), or whose check started and never answered (issue 438). Reads only.",
input: {},
run: async () => ({ pulls: await gitea.openPulls(readyAuthors()) }),
run: async () => ({ pulls: await gitea.openPulls() }),
},
{
name: "gitea_contains",
-314
View File
@@ -1,314 +0,0 @@
// **The forge says every event on the bus** (novox/hq issue 729; to-be 10: an event makes it fast, nothing makes it
// necessary). The module keeps one system webhook on its forge for every event, pointing at its own receiver, signed
// with a secret it mints and keeps beside its token; the receiver refuses whatever is unsigned or does not verify, and
// publishes each event, trimmed, on the module's events as forge.<kind>.<action> (forge.pull-request.opened, forge.push, …),
// on the shared EVENTS path its other events take; its own stream (ADR 0363) is a follow-up once issue 725 ships.
// The polls stay as the safety net, slowed; the announced-heads and announced-merges records keep anything from
// being announced twice, whichever path saw it first.
//
// The secret is never logged: lines say it was minted or reused and where it is kept, never what it is.
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import { createServer, type Server } from "node:http";
import { dirname } from "node:path";
/** The name the receiver answers on, and the port it listens on unless the manifest says otherwise. */
export const HOOK_PATH = "/forge-event";
export const HOOK_PORT = 3047;
/** The secret the forge signs with: read from its 0600 file, or minted and kept there. */
export function keptSecret(file: string, log: (line: string) => void = (l) => console.log(`[gitea] ${l}`)): string {
try {
const kept = readFileSync(file, "utf8").trim();
if (kept.length >= 32) {
log(`reusing the webhook secret kept at ${file}`);
return kept;
}
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
}
const secret = randomBytes(32).toString("hex");
mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
const tmp = `${file}.tmp`;
writeFileSync(tmp, secret + "\n", { mode: 0o600 });
chmodSync(tmp, 0o600);
renameSync(tmp, file);
log(`minted a webhook secret, kept at ${file}`);
return secret;
}
/** Whether a body carries the forge's HMAC-SHA256 signature (X-Gitea-Signature, hex) under the secret. */
export function verified(secret: string, body: Buffer | string, signature: string | undefined): boolean {
if (!secret || !signature || !/^[0-9a-f]{64}$/i.test(signature.trim())) return false;
const want = createHmac("sha256", secret).update(body).digest();
const got = Buffer.from(signature.trim(), "hex");
return got.length === want.length && timingSafeEqual(got, want);
}
/** One forge event as the bus carries it: its name inside the stream, and what is needed of it — no secrets. */
export interface ForgeEvent {
name: string;
body: Record<string, unknown>;
}
/** Every event name the module emits for the forge, without its forge. prefix (module.json `emits`). */
export const EVENTS: readonly string[] = ["push", "create", "delete", "fork", "status", "pull-request.opened", "pull-request.reopened", "pull-request.synchronized", "pull-request.edited", "pull-request.closed", "pull-request.merged", "pull-request.labeled", "pull-request.unlabeled", "pull-request.assigned", "pull-request.unassigned", "pull-request.reviewed", "pull-request.review-requested", "pull-request.commented", "pull-request.changed", "issue-comment.created", "issue-comment.edited", "issue-comment.deleted", "issues.opened", "issues.reopened", "issues.closed", "issues.edited", "issues.labeled", "issues.unlabeled", "issues.assigned", "issues.unassigned", "issues.changed", "release.published", "release.updated", "release.deleted", "release.changed", "repository.created", "repository.deleted", "repository.changed", "wiki.changed", "other"];
/** The families the module's stream carries (module.json `streams`); any other kind is said as other.<kind>; underscores become hyphens. */
export const FAMILIES = ["push", "create", "delete", "fork", "status", "pull_request", "issue_comment", "issues",
"release", "repository", "wiki"] as const;
const ACTIONS: Record<string, string> = { label_updated: "labeled", label_cleared: "unlabeled", synchronized: "synchronized" };
function str(v: unknown): string | undefined {
return typeof v === "string" && v ? v : undefined;
}
function num(v: unknown): number | undefined {
return typeof v === "number" ? v : undefined;
}
function login(u: any): string | undefined {
return str(u?.login) ?? str(u?.username);
}
/**
* The event a forge delivery is: its kind (X-Gitea-Event) and the payload's action, trimmed to ids, repository, refs,
* shas, author, labels and a comment's body (a ready mark is one, ADR 0364). A merge is pull-request.merged, a comment on
* a pull request pull-request.commented.
*/
export function eventOf(kind: string, payload: any): ForgeEvent {
// The bus's event names take lower-case letters, digits and hyphens: the forge's underscores become hyphens.
const event = forgeEventOf(kind, payload);
const name = event.name.toLowerCase().replace(/_/g, "-").replace(/[^a-z0-9.-]/g, "-");
// Published on the module's events as it publishes its others, each name declared in its `emits`: one the manifest
// does not name is forge.other, saying what the forge called it. Its own stream (ADR 0363) follows issue 725.
if (EVENTS.includes(name)) return { name: `forge.${name}`, body: event.body };
return { name: "forge.other", body: { ...event.body, kind, said: name } };
}
function forgeEventOf(said: string, payload: any): ForgeEvent {
// The forge names some kinds finer than their family (pull_request_label, pull_request_sync): read by the payload.
let kind = said;
if (said.startsWith("pull_request") && payload?.pull_request) kind = "pull_request";
if ((said.startsWith("issue") || said === "pull_request_comment") && payload?.comment) kind = "issue_comment";
if (said.startsWith("issue") && !payload?.comment && payload?.issue) kind = "issues";
const repo = payload?.repository ?? {};
const base: Record<string, unknown> = {
owner: login(repo.owner), repo: str(repo.name), full_name: str(repo.full_name), clone_url: str(repo.clone_url),
sender: login(payload?.sender),
};
const family = (FAMILIES as readonly string[]).includes(kind) ? kind : `other`;
let action = str(payload?.action);
const pull = payload?.pull_request;
if (kind === "push") {
return { name: "push", body: { ...base, ref: str(payload.ref), before: str(payload.before), after: str(payload.after),
commits: Array.isArray(payload.commits) ? payload.commits.length : undefined, pusher: login(payload.pusher) } };
}
if (kind === "pull_request" && pull) {
if (action === "closed" && pull.merged) action = "merged";
action = ACTIONS[action ?? ""] ?? action ?? "changed";
return { name: `pull_request.${action}`, body: { ...base, number: num(pull.number ?? payload.number),
title: str(pull.title), author: login(pull.user), head: str(pull.head?.ref), head_sha: str(pull.head?.sha),
base: str(pull.base?.ref), merged: !!pull.merged, merge_commit_sha: str(pull.merge_commit_sha),
merged_at: str(pull.merged_at), html_url: str(pull.html_url),
labels: Array.isArray(pull.labels) ? pull.labels.map((l: any) => str(l?.name)).filter(Boolean) : undefined,
label: str(payload.label?.name) } };
}
if (kind === "issue_comment") {
const issue = payload.issue ?? {};
const onPull = !!payload.is_pull || !!issue.pull_request;
return { name: onPull ? "pull_request.commented" : `issue_comment.${action ?? "created"}`, body: { ...base,
number: num(issue.number), comment_id: num(payload.comment?.id), author: login(payload.comment?.user),
comment: str(payload.comment?.body), action } };
}
if (kind === "create" || kind === "delete") {
return { name: kind, body: { ...base, ref: str(payload.ref), ref_type: str(payload.ref_type), sha: str(payload.sha) } };
}
if (kind === "status") {
return { name: "status", body: { ...base, sha: str(payload.sha), context: str(payload.context), state: str(payload.state) } };
}
const name = family === "other" ? `other.${kind.replace(/[^a-z0-9_-]/gi, "_") || "unknown"}` :
["fork"].includes(family) ? family : `${family}.${action ?? "changed"}`;
return { name, body: { ...base, number: num(payload?.number ?? payload?.issue?.number), action } };
}
export interface ReceiverOptions {
secret: string;
port: number;
host?: string;
/** Publishes one event on the bus. */
publish: (event: ForgeEvent) => Promise<void>;
/** Acts on it at once, beside publishing it (the announcer). */
act?: (event: ForgeEvent) => Promise<void>;
/** Told of every verified delivery, so the polls know events are heard. */
heard?: () => void;
log?: (line: string) => void;
}
/** The most a delivery may carry: the forge's push of many commits stays well under it. */
export const MAX_BODY = 5 * 1024 * 1024;
/** How many deliveries' ids are remembered, so a delivery sent again is published once. */
export const REMEMBERED = 2000;
/**
* The receiver. A request without a signature is refused before its body is read, a body over MAX_BODY is refused, and
* one whose signature does not verify is refused, each with 401 or 413 and a line saying so. A verified delivery is
* answered 200 at once and queued: it is published and acted on after the reply, one at a time, so the forge's
* delivery timeout is never reached. A delivery whose X-Gitea-Delivery id was already heard is answered and dropped.
*/
export function receiver(opts: ReceiverOptions): Server & { idle(): Promise<void> } {
const log = opts.log ?? ((l) => console.log(`[gitea] ${l}`));
const seen = new Set<string>();
let queue: Promise<void> = Promise.resolve();
const server = createServer((req, res) => {
if (req.method !== "POST" || (req.url ?? "").split("?")[0] !== HOOK_PATH) {
res.statusCode = 404;
return res.end();
}
const from = req.socket.remoteAddress ?? "?";
const signature = String(req.headers["x-gitea-signature"] ?? "").trim();
if (!signature) {
log(`refused a forge event from ${from}: it is unsigned`);
res.writeHead(401, { Connection: "close" });
res.end("unsigned\n");
req.destroy();
return;
}
if (Number(req.headers["content-length"] ?? 0) > MAX_BODY) {
log(`refused a forge event from ${from}: its body is over ${MAX_BODY} bytes`);
res.writeHead(413, { Connection: "close" });
res.end("too large\n");
req.destroy();
return;
}
const chunks: Buffer[] = [];
let size = 0;
let over = false;
req.on("data", (c: Buffer) => {
size += c.length;
if (size > MAX_BODY) {
if (!over) {
over = true;
log(`refused a forge event from ${from}: its body is over ${MAX_BODY} bytes`);
res.writeHead(413, { Connection: "close" });
res.end("too large\n");
req.destroy();
}
} else chunks.push(c);
});
req.on("end", () => {
if (over) return;
const body = Buffer.concat(chunks);
if (!verified(opts.secret, body, signature)) {
log(`refused a forge event from ${from}: its signature does not verify`);
res.statusCode = 401;
return res.end("signature does not verify\n");
}
let payload: unknown;
try {
payload = JSON.parse(body.toString("utf8"));
} catch {
res.statusCode = 400;
return res.end("not JSON\n");
}
opts.heard?.();
const delivery = String(req.headers["x-gitea-delivery"] ?? "");
if (delivery && seen.has(delivery)) {
res.statusCode = 200;
return res.end("already heard\n");
}
if (delivery) {
seen.add(delivery);
if (seen.size > REMEMBERED) seen.delete(seen.values().next().value as string);
}
const event = eventOf(String(req.headers["x-gitea-event"] ?? "other"), payload);
res.statusCode = 200;
res.end("queued\n");
queue = queue.then(async () => {
try {
await opts.publish(event);
if (opts.act) await opts.act(event);
} catch (err) {
log(`a forge event ${event.name} could not be published or acted on; the polls catch it — ${err instanceof Error ? err.message : err}`);
}
});
});
});
return Object.assign(server, { idle: () => queue });
}
/** What ensuring the system webhook needs of the forge's administration (GiteaAdmin's login). */
export interface HookAdmin {
call(method: string, path: string, body?: unknown): Promise<{ status: number; body: any }>;
}
/**
* **How fast the polls look** (issue 729): every five minutes only while the forge's events are heard — a verified
* delivery within the last ten minutes, and none missed since. Until the first delivery, after ten minutes without one,
* or once the poll found something no event said, every thirty seconds.
*/
export class Pace {
private last = 0;
private missed = 0;
private readonly now: () => number;
private readonly log: (l: string) => void;
constructor(now: () => number = Date.now, log: (l: string) => void = (l) => console.error(`[gitea] ${l}`)) {
this.now = now;
this.log = log;
}
heard(): void {
this.last = this.now();
}
/** The poll found a head or merge no forge event announced. */
miss(what: string): void {
if (this.hearing()) this.log(`THE FORGE'S EVENTS MISSED ${what}: the poll found it, so the polls look every 30 s again until the next event`);
this.missed = this.now();
}
hearing(): boolean {
const t = this.now();
return this.last > 0 && t - this.last < 10 * 60_000 && this.last > this.missed;
}
every(): number {
return this.hearing() ? 5 * 60_000 : 30_000;
}
}
/** Every event family the forge (1.27) can send a webhook. */
export const HOOK_EVENTS = ["create", "delete", "fork", "push", "issues", "issue_comment", "pull_request",
"pull_request_review_request", "wiki", "repository", "release", "package", "status", "workflow_run", "workflow_job"];
/**
* **One system webhook, for every event, at the receiver** (issue 729): created when none points there, corrected
* when one does but differs (events, active, content type), and its secret set again each start — the forge never
* hands a secret back, so a corrected secret cannot be told from a kept one. Answers what it did.
*/
export async function ensureSystemHook(admin: HookAdmin, url: string, secret: string): Promise<"created" | "corrected"> {
// POST /admin/hooks makes a system webhook, and its options take no flag for it (the forge's 1.27.3 API schema). The
// forge has no "every event" word: each family is named, and a family name (issues, pull_request) takes all of it.
const want = { type: "gitea", active: true, events: HOOK_EVENTS, branch_filter: "*",
config: { url, content_type: "json", secret } };
const listed = await admin.call("GET", "/admin/hooks?type=system&limit=50");
if (listed.status !== 200) throw new Error(`the forge's system webhooks could not be listed: ${listed.status}`);
const hooks: any[] = Array.isArray(listed.body) ? listed.body : [];
const ours = hooks.filter((h) => h?.config?.url === url);
if (ours.length === 0) {
const made = await admin.call("POST", "/admin/hooks", want);
if (made.status !== 201 && made.status !== 200) throw new Error(`the forge's system webhook could not be made: ${made.status}`);
return "created";
}
for (const extra of ours.slice(1)) await admin.call("DELETE", `/admin/hooks/${extra.id}`);
const fixed = await admin.call("PATCH", `/admin/hooks/${ours[0].id}`, { active: true, events: HOOK_EVENTS, branch_filter: "*",
config: { url, content_type: "json", secret } });
if (fixed.status !== 200) throw new Error(`the forge's system webhook could not be corrected: ${fixed.status}`);
return "corrected";
}
/**
* **Claim a head for announcing, once** (issue 729): true when `sha` is not already the head recorded for `key`, which
* it then is. The poll and the forge's event both claim before they announce, so whichever comes second finds it taken.
*/
export function claimHead(heads: Record<string, string>, key: string, sha: string): boolean {
if (!sha || heads[key] === sha) return false;
heads[key] = sha;
return true;
}
+21 -3
View File
@@ -26,12 +26,24 @@
"why": "queries, writes and the web UI, over http; consumers granted influxdb-api sign in with the mesh's credential, and a name is a route grant"
}
],
"settings": {
"org": {
"kind": "preference",
"default": "mesh",
"why": "the organisation influxdb-api names to its consumers; mesh is the one influxdb makes at its first start (server.env), and a machine whose server was set up with another sets its own (novox/hq issue 696, ADR 0348 clause 11)"
},
"bucket": {
"kind": "preference",
"default": "default",
"why": "the bucket influxdb-api names to its consumers; default is the one influxdb makes at its first start (server.env), and a machine whose server was set up with another sets its own (novox/hq issue 696, ADR 0348 clause 11)"
}
},
"serves": {
"influxdb-api": {
"scheme": "http",
"port": 8086,
"org": "mesh",
"bucket": "default"
"org": "${setting:org}",
"bucket": "${setting:bucket}"
}
},
"receives": {
@@ -124,7 +136,13 @@
"path": "${dir:mesh-state}/config.json",
"mode": "0600",
"content": "{}\n",
"merge": "json"
"merge": "json",
"takes-settings": [
"bucket",
"org",
"url"
],
"trusted": true
}
],
"requires": [
-198
View File
@@ -1,198 +0,0 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
BASE_IMAGE_ALIAS, BASE_MAX_AGE_DAYS, baseImageFrom, baseProblem, baseScript, buildingBase, cleanCredentials, credentialPath,
describeBase, launchRebuild, launchRun, listBaseBuilds, listRuns, readStatus, runRefusal, running, staleBase,
type BaseImage,
} from "../tools/runs.ts";
// Defends novox/hq issue 698: the lab's base image was 44 days old, a bed machine raised from it carried a
// package database the mirrors no longer served, and genesis-keeps-its-bus stopped on a 404 for
// postgresql-libs. Nothing said how old the image was, nothing refused it, and no mesh tool rebuilt it.
const now = new Date("2026-10-14T01:00:00Z");
const listed = (uploaded: string, alias = BASE_IMAGE_ALIAS) => JSON.stringify([
{ aliases: [{ name: "images:other" }], uploaded_at: "2026-10-13T00:00:00Z" },
{ aliases: [{ name: alias }], uploaded_at: uploaded, created_at: "2026-08-01T00:00:00Z" },
]);
test("the base image's age is read from when this machine made it", () => {
const b = baseImageFrom(listed("2026-08-31T09:00:00Z"), now);
assert.equal(b.exists, true);
assert.equal(b.built, "2026-08-31T09:00:00.000Z");
assert.equal(b.ageDays, 43);
assert.match(describeBase(b), /built 2026-08-31T09:00:00.000Z \(43 day\(s\) ago; the bound is 14\)/);
});
test("an image older than the bound refuses a run, naming the tool that rebuilds it", () => {
const stale = baseImageFrom(listed("2026-08-31T09:00:00Z"), now);
const reason = runRefusal(stale);
assert.ok(reason, "a 43-day-old base image was not refused");
assert.match(reason, /older than the lab's bound of 14 days/);
assert.match(reason, /lab_base \{"rebuild": true\}/);
assert.equal(staleBase(stale), reason);
});
test("an image within the bound runs", () => {
const fresh = baseImageFrom(listed(new Date(now.getTime() - BASE_MAX_AGE_DAYS * 86_400_000).toISOString()), now);
assert.equal(fresh.ageDays, BASE_MAX_AGE_DAYS);
assert.equal(runRefusal(fresh), undefined);
});
test("a missing image, or one whose age cannot be read, is said and not refused", () => {
const none = baseImageFrom(listed("2026-10-01T00:00:00Z", "something-else"), now);
assert.equal(none.exists, false);
assert.equal(runRefusal(none), undefined);
assert.match(describeBase(none), /is not on this machine; rebuild it with lab_base/);
const unread = baseImageFrom(undefined, now);
assert.equal(runRefusal(unread), undefined);
assert.match(describeBase(unread), /its age is not known/);
});
test("no run starts while the base image is rebuilt", () => {
const fresh = baseImageFrom(listed("2026-10-13T00:00:00Z"), now);
const reason = runRefusal(fresh, { id: "base-x", started: "2026-10-14T00:59:00Z" });
assert.match(reason ?? "", /is being rebuilt \(base-x/);
});
test("a rebuild is mesh-lab's base build from the forge's main, with no secret in its script", () => {
const s = baseScript("/work/base-x", "https://forge.invalid", "/work/.base-x.git-credentials");
assert.match(s, /clone --quiet --depth 1 'https:\/\/forge.invalid\/novox\/mesh-lab.git'/);
assert.match(s, /src\/cli.ts base build/);
assert.ok(s.indexOf("rm -f '/work/.base-x.git-credentials'") < s.indexOf("npm ci"), "the credential outlives the clone");
});
test("a rebuild is known by its status, and is not a run", () => {
const work = mkdtempSync(join(tmpdir(), "lab-base-"));
try {
mkdirSync(join(work, "base-1"));
writeFileSync(join(work, "base-1", "status.json"), JSON.stringify({ id: "base-1", state: "built", started: "2026-10-13T00:00:00Z" }));
mkdirSync(join(work, "base-2"));
// A build whose process is gone while it says building was lost, and holds nothing up.
writeFileSync(join(work, "base-2", "status.json"), JSON.stringify({ id: "base-2", state: "building", started: "2026-10-14T00:00:00Z", pid: 2 ** 22 + 7 }));
assert.deepEqual(listBaseBuilds(work).map((b) => `${b.id} ${b.state}`), ["base-2 lost", "base-1 built"]);
assert.equal(buildingBase(work), undefined);
writeFileSync(join(work, "base-2", "status.json"), JSON.stringify({ id: "base-2", state: "building", started: "2026-10-14T00:00:00Z", pid: process.pid }));
assert.equal(buildingBase(work)?.id, "base-2");
assert.deepEqual(listRuns(work), []);
assert.ok(readFileSync(join(work, "base-1", "status.json"), "utf8"));
} finally {
rmSync(work, { recursive: true, force: true });
}
});
// **A run and a rebuild asked together: one starts** (review 256). Each asks the package registry, which
// waits, and each refused while the other was going; asked at once, both passed their checks before either
// wrote its status. The registry is asked first now, and the check and the start are one step.
function aLab(): string {
return mkdtempSync(join(tmpdir(), "lab-launch-"));
}
const fresh: BaseImage = { alias: BASE_IMAGE_ALIAS, exists: true, built: "2026-10-13T00:00:00.000Z", ageDays: 1 };
const slowRegistry = () => new Promise<{ registry: string }>((ok) => setTimeout(() => ok({ registry: "https://forge.invalid/r/" }), 20));
const launch = { registry: slowRegistry, base: () => fresh };
function beginRun(work: string, id: string) {
return () => {
mkdirSync(join(work, id));
const s = { id, state: "running" as const, started: new Date().toISOString(), tests: [], refs: {}, pid: process.pid };
writeFileSync(join(work, id, "status.json"), JSON.stringify(s));
return s;
};
}
function beginBuild(work: string, id: string) {
return () => {
mkdirSync(join(work, id));
const b = { id, state: "building" as const, started: new Date().toISOString(), pid: process.pid };
writeFileSync(join(work, id, "status.json"), JSON.stringify(b));
return b;
};
}
test("a run and a rebuild asked at once: exactly one starts", async () => {
for (const runFirst of [true, false]) {
const work = aLab();
try {
// Both asked before either answers, in one order and then the other.
const ask = { run: () => launchRun(work, launch, beginRun(work, "run-a")), build: () => launchRebuild(work, launch, beginBuild(work, "base-a")) };
const [r, b] = runFirst ? await Promise.all([ask.run(), ask.build()])
: (await Promise.all([ask.build(), ask.run()])).reverse() as [Awaited<ReturnType<typeof ask.run>>, Awaited<ReturnType<typeof ask.build>>];
assert.equal([r.started, b.started].filter(Boolean).length, 1, `both or neither started: run ${r.started}, rebuild ${b.started}`);
assert.equal(listRuns(work).length + listBaseBuilds(work).length, 1);
} finally {
rmSync(work, { recursive: true, force: true });
}
}
});
test("two runs, or two rebuilds, asked at once: one of each starts", async () => {
const work = aLab();
try {
const runs = await Promise.all([launchRun(work, launch, beginRun(work, "run-a")), launchRun(work, launch, beginRun(work, "run-b"))]);
assert.deepEqual(runs.map((r) => r.started).sort(), [false, true]);
const other = aLab();
const builds = await Promise.all([launchRebuild(other, launch, beginBuild(other, "base-a")), launchRebuild(other, launch, beginBuild(other, "base-b"))]);
assert.deepEqual(builds.map((b) => b.started).sort(), [false, true]);
rmSync(other, { recursive: true, force: true });
} finally {
rmSync(work, { recursive: true, force: true });
}
});
test("a run is refused on a stale base, and starts nothing", async () => {
const work = aLab();
try {
const stale: BaseImage = { alias: BASE_IMAGE_ALIAS, exists: true, built: "2026-08-31T09:00:00.000Z", ageDays: 43 };
const r = await launchRun(work, { registry: slowRegistry, base: () => stale }, beginRun(work, "run-a"));
assert.equal(r.started, false);
assert.match(r.started ? "" : r.reason, /lab_base \{"rebuild": true\}/);
assert.deepEqual(listRuns(work), []);
} finally {
rmSync(work, { recursive: true, force: true });
}
});
test("a rebuild or a run that never recorded its process is lost, and holds nothing up", () => {
const work = aLab();
try {
mkdirSync(join(work, "base-x"));
writeFileSync(join(work, "base-x", "status.json"), JSON.stringify({ id: "base-x", state: "building", started: "2026-10-14T00:00:00Z" }));
mkdirSync(join(work, "run-x"));
writeFileSync(join(work, "run-x", "status.json"), JSON.stringify({ id: "run-x", state: "running", started: "2026-10-14T00:00:00Z", tests: [], refs: {} }));
assert.equal(listBaseBuilds(work)[0]?.state, "lost");
assert.equal(buildingBase(work), undefined);
assert.equal(readStatus(work, "run-x")?.state, "lost");
assert.equal(running(work), undefined);
} finally {
rmSync(work, { recursive: true, force: true });
}
});
test("a rebuild's credential left behind is cleaned, and a running one's is kept", () => {
const work = aLab();
try {
for (const id of ["base-old", "base-now", "run-old", "run-now"]) writeFileSync(credentialPath(work, id), "x");
const removed = cleanCredentials(work, [credentialPath(work, "base-now"), credentialPath(work, "run-now")]).sort();
assert.deepEqual(removed, [credentialPath(work, "base-old"), credentialPath(work, "run-old")].sort());
} finally {
rmSync(work, { recursive: true, force: true });
}
});
test("lab_check's problem: a stale, missing or unread base image; a fresh one is none", () => {
assert.match(baseProblem({ alias: BASE_IMAGE_ALIAS, exists: false }) ?? "", /is not on this machine/);
assert.match(baseProblem({ alias: BASE_IMAGE_ALIAS, exists: false, problem: "incus could not list the lab's images" }) ?? "", /age is not known/);
assert.match(baseProblem({ alias: BASE_IMAGE_ALIAS, exists: true, built: "x", ageDays: 30 }) ?? "", /older than the lab's bound/);
assert.equal(baseProblem(fresh), undefined);
});
// The tools use the gate and the check above (index.ts registers with the SDK when imported, so it is read).
test("lab_run and lab_base start through the gate, and lab_check names what is wrong with the base", () => {
const index = readFileSync(new URL("../tools/index.ts", import.meta.url), "utf8");
assert.match(index, /await launchRun\(work,/);
assert.match(index, /await launchRebuild\(work,/);
assert.match(index, /const unfit = baseProblem\(base\);\s*if \(unfit\) problems\.push\(unfit\)/);
assert.match(index, /if \(args\.rebuild !== true\) return reading;/);
});
+1 -106
View File
@@ -5,7 +5,7 @@ import { spawnSync } from "node:child_process";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { installPlan, installStep, NPM_REPOSITORIES, outcomeOf, readStatus, REPOSITORIES, script } from "../tools/runs.ts";
import { installPlan, installStep, NPM_REPOSITORIES, REPOSITORIES, script } from "../tools/runs.ts";
// Defends novox/hq issue 624: lab_run ran `npm ci` at the root of every checkout it installs, and
// mesh-tools keeps its package in node-tools/ with no lock file at its root, so every run failed at its
@@ -381,108 +381,3 @@ test("a forge setting that is not http(s) trusts no origin of its own", async ()
packument("ftp://novox.internal/api/packages/novox/npm/x.tgz")).get, root);
assert.match(viaRoot.problem ?? "", /neither/);
});
// Defends novox/hq issue 693: a run whose only test skipped as NOT REACHED read "0 passed, 0 failed", exit 0,
// and lab_status said passed. The log's tail, as run-2026-10-14T00-21-44-014Z on shanks wrote it.
const oldLog = [
"STOPPED at step 16 of 24, base: \t\t\t\tMake sure you have all the referenced content and try again.",
"\ufe63 genesis adopts its bus as the nats module in place: one bus, its streams kept, the node connected (588152.016874ms) # ADR 0333 §5 NOT REACHED: the first run stopped at step 16 of 24, base, before the broker step:",
" \t\t\t\tMake sure you have all the referenced content and try again.",
"NotFound: content digest sha256:e2de96513ba9: not found",
"ℹ tests 1", "ℹ suites 0", "ℹ pass 0", "ℹ fail 0", "ℹ cancelled 0", "ℹ skipped 1", "ℹ todo 0",
"ℹ duration_ms 590254.163993",
"",
"recorded: test/integration/genesis-keeps-its-bus.test.ts — 0 passed, 0 failed, against mesh-lab 7ed7da1",
].join("\n");
// The same run, as the lab's suite says it once it names its skips (mesh-lab, issue 693).
const newLog = [
"ℹ tests 1", "ℹ pass 0", "ℹ fail 0", "ℹ skipped 1",
"",
"recorded: test/integration/genesis-keeps-its-bus.test.ts — 0 passed, 0 failed, 1 skipped, against mesh-lab 7ed7da1",
"NOT PASSED: no test passed: 1 skipped, 1 of them as NOT REACHED",
"skipped: genesis adopts its bus — one bus (test/integration/genesis-keeps-its-bus.test.ts) # ADR 0333 §5 NOT REACHED: the first run stopped at step 16 of 24, base: NotFound: content digest",
].join("\n");
function anEndedRun(log: string, state: "passed" | "failed", exit: number): { work: string; id: string } {
const work = mkdtempSync(join(tmpdir(), "lab-runs-"));
const id = "run-2026-10-14T00-21-44-014Z";
mkdirSync(join(work, id));
writeFileSync(join(work, id, "run.log"), log);
writeFileSync(join(work, id, "status.json"), JSON.stringify({
id, state, exit, started: "2026-10-14T00:21:44.014Z", ended: "2026-10-14T00:33:11.179Z",
tests: ["test/integration/genesis-keeps-its-bus.test.ts"], refs: {},
}));
return { work, id };
}
test("a run whose only test skipped as NOT REACHED, from a lab of before, reads as failed with the reason", () => {
const { work, id } = anEndedRun(oldLog, "passed", 0);
const s = readStatus(work, id);
assert.equal(s?.state, "failed", JSON.stringify(s));
assert.equal(s?.exit, 0);
assert.equal(s?.outcome?.passed, 0);
assert.equal(s?.outcome?.skipped, 1);
assert.equal(s?.outcome?.notPassed, "no test passed: 1 skipped, 1 of them as NOT REACHED");
assert.match(s?.outcome?.skips[0]?.reason ?? "", /^ADR 0333 §5 NOT REACHED: the first run stopped at step 16 of 24, base, before the broker step:$/);
// Kept in the run's record, not only answered.
const kept = JSON.parse(readFileSync(join(work, id, "status.json"), "utf8"));
assert.equal(kept.state, "failed");
assert.equal(kept.outcome.notPassed, "no test passed: 1 skipped, 1 of them as NOT REACHED");
rmSync(work, { recursive: true, force: true });
});
test("the lab's own NOT PASSED and skipped lines are read, a test name's punctuation and all", () => {
const { work, id } = anEndedRun(newLog, "failed", 1);
const s = readStatus(work, id);
assert.equal(s?.state, "failed");
assert.equal(s?.outcome?.notPassed, "no test passed: 1 skipped, 1 of them as NOT REACHED");
assert.deepEqual(s?.outcome?.skips, [{
file: "test/integration/genesis-keeps-its-bus.test.ts",
test: "genesis adopts its bus — one bus",
reason: "ADR 0333 §5 NOT REACHED: the first run stopped at step 16 of 24, base: NotFound: content digest",
}]);
rmSync(work, { recursive: true, force: true });
});
test("a run that passed tests and skipped one for a declared reason stays passed, its skip listed", () => {
const log = [
"✔ a module lands (12ms)",
"﹣ a builder builds (0.1ms) # set MESH_LAB_BUILDER to a built mesh-builder",
"ℹ tests 2", "ℹ pass 1", "ℹ fail 0", "ℹ skipped 1",
].join("\n");
const { work, id } = anEndedRun(log, "passed", 0);
const s = readStatus(work, id);
assert.equal(s?.state, "passed");
assert.equal(s?.outcome?.notPassed, undefined);
assert.equal(s?.outcome?.skips[0]?.reason, "set MESH_LAB_BUILDER to a built mesh-builder");
rmSync(work, { recursive: true, force: true });
});
test("a run that ran no test at all is not passed", () => {
assert.equal(outcomeOf("ℹ tests 0\nℹ pass 0\nℹ fail 0\nℹ skipped 0\n").notPassed, "no test passed: none ran");
});
// Review 254 on issue 693: a test's own output, printed before the suite reports, is not the suite's verdict.
test("a line a test printed that looks like the suite's is not read as the suite's", () => {
const log = [
"NOT PASSED: a test printed this",
"skipped: a forged skip (test/integration/x.test.ts) # FORGED NOT REACHED",
"✔ a module lands (12ms)",
"ℹ tests 1", "ℹ pass 1", "ℹ fail 0", "ℹ skipped 0",
"",
"recorded: test/integration/x.test.ts — 1 passed, 0 failed, 0 skipped, against mesh-lab 7ed7da1",
].join("\n");
const o = outcomeOf(log);
assert.equal(o.notPassed, undefined, JSON.stringify(o));
assert.deepEqual(o.skips, []);
});
test("not reached is matched in any case", () => {
const log = [
"✔ a module lands (12ms)",
"﹣ the broker step (0.1ms) # the broker step was not reached",
"ℹ tests 2", "ℹ pass 1", "ℹ fail 0", "ℹ skipped 1",
].join("\n");
assert.equal(outcomeOf(log).notPassed, "1 test(s) skipped as NOT REACHED: their subject was not reached");
});
+12 -51
View File
@@ -9,8 +9,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import {
checkDir, cleanCredentials, clearChecks, credentialArgs, forgeCredential, installPlan, listRuns, npmRegistry, NPM_REPOSITORIES, publicRootOf, readStatus, REPOSITORIES, running, start,
stop, tail, writeCredential, baseBuildLog, buildingBase, listBaseBuilds, startBaseBuild, credentialPath, launchRun,
launchRebuild, BASE_MAX_AGE_DAYS, describeBase, readBaseImage, baseProblem,
stop, tail, writeCredential,
} from "./runs.js";
export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] {
@@ -60,18 +59,12 @@ export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] {
// **And what a run would check out and install, before a run finds out** (novox/hq issue 624): every
// repository it clones answers on the forge, and every one it installs has a lock file where the run
// installs from.
// A credential a stopped, crashed or rebooted run or rebuild left behind is removed, and said; one still in
// use by a running run or rebuild is not.
const busy = [running(work)?.id, buildingBase(work)?.id].filter((id): id is string => !!id);
const left = cleanCredentials(work, busy.map((id) => credentialPath(work, id)));
const removed = left.length > 0 ? `removed ${left.length} forge credential(s) a run or rebuild left behind: ${left.join(", ")}` : "";
// **And how old the base image is** (novox/hq issue 698): past the bound, missing, or of an age that cannot
// be read is a problem here, though lab_run refuses only the first.
const base = readBaseImage();
const unfit = baseProblem(base);
if (unfit) problems.push(unfit);
const output = [`${check.stdout}${check.stderr}`.trim(), describeBase(base), removed, ...leftovers, ...problems]
.filter(Boolean).join("\n");
// A credential a stopped, crashed or rebooted run left behind is removed, and said; one still in use
// by a running run is not.
const busy = running(work);
const left = cleanCredentials(work, busy ? `${work}/.${busy.id}.git-credentials` : undefined);
const removed = left.length > 0 ? `removed ${left.length} forge credential(s) a run left behind: ${left.join(", ")}` : "";
const output = [`${check.stdout}${check.stderr}`.trim(), removed, ...leftovers, ...problems].filter(Boolean).join("\n");
return { ok: check.status === 0 && problems.length === 0, output };
},
},
@@ -103,43 +96,11 @@ export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] {
}
const stranger = Object.keys(refs).filter((r) => !REPOSITORIES.includes(r));
if (stranger.length > 0) return { started: false, reason: `the lab does not build ${stranger.join(", ")}` };
// **One job at a time, not on a base image past its bound, nor while it is rebuilt** (novox/hq issue 698,
// review 256): the registry asked first, then the check and the start with nothing awaited between.
const launched = await launchRun(work, { registry: () => registryOf(forge), base: () => readBaseImage() },
(registry) => start(work, forge, tests, refs, credentialOf(forge), registry));
if (!launched.started) return launched;
return { started: true, run: launched.job, base: describeBase(launched.base), registry: launched.registry,
...(launched.problem ? { problem: launched.problem } : {}) };
},
},
{
name: "lab_base",
description:
"The lab's base image, which every bed machine with a container runtime is raised from: when it was built, its " +
`age, the bound lab_run refuses past (${BASE_MAX_AGE_DAYS} days), and the last rebuilds with the newest one's log. ` +
"Reads only, unless rebuild is true: then it rebuilds the image with mesh-lab's `base build` from the forge's " +
"main, in the background, and answers at once; ask again for the build's state and the image's new age.",
input: {
rebuild: { type: "boolean", description: "rebuild the image (default false: only read)" },
},
run: async (args) => {
const before = readBaseImage();
const builds = listBaseBuilds(work).slice(0, 3);
const newest = builds[0];
const reading = {
base: before, said: describeBase(before), bound: BASE_MAX_AGE_DAYS,
...(baseProblem(before) ? { problem: baseProblem(before) } : {}),
builds, ...(newest ? { log: baseBuildLog(work, newest.id) } : {}),
};
if (args.rebuild !== true) return reading;
const forge = forgeOf();
if (!forge) return { ...reading, started: false, reason: "the lab's forge is not set: settings for lab, {\"forge\": \"<url>\"}" };
// Not under a run, nor beside another rebuild: the registry asked first, then the check and the start with
// nothing awaited between (review 256).
const launched = await launchRebuild(work, { registry: () => registryOf(forge), base: () => before },
(registry) => startBaseBuild(work, forge, credentialOf(forge), registry));
if (launched.started === false) return { ...reading, started: false, reason: launched.reason };
return { ...reading, started: true, build: launched.job };
const busy = running(work);
if (busy) return { started: false, reason: `${busy.id} is still ${busy.state}; one run at a time`, running: busy };
const registry = await registryOf(forge);
return { started: true, run: start(work, forge, tests, refs, credentialOf(forge), registry.registry),
registry: registry.registry, ...(registry.problem ? { problem: registry.problem } : {}) };
},
},
{
+9 -364
View File
@@ -6,7 +6,7 @@
// and that file is the whole of what the tools read back: a runtime that restarts mid-run still answers
// for it, and says it was lost rather than pretending it is still going.
import { spawn, spawnSync } from "node:child_process";
import { spawn } from "node:child_process";
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
@@ -223,18 +223,16 @@ trap ${shellQuote(`${rm}; exit 129`)} HUP`;
}
/**
* cleanCredentials removes every run's and every base rebuild's credential file left in the work directory
* but the ones named — what a crash or a reboot left behind, which no trap could remove. Answers what it
* removed.
* cleanCredentials removes every run's credential file left in the work directory but the one named — what
* a crash or a reboot left behind, which no trap could remove. Answers what it removed.
*/
export function cleanCredentials(work: string, keep?: string | string[]): string[] {
export function cleanCredentials(work: string, keep?: string): string[] {
if (!existsSync(work)) return [];
const kept = new Set([keep ?? []].flat());
const removed: string[] = [];
for (const name of readdirSync(work)) {
if (!/^\.(run|base)-.*\.git-credentials$/.test(name)) continue;
if (!/^\.run-.*\.git-credentials$/.test(name)) continue;
const path = join(work, name);
if (kept.has(path)) continue;
if (path === keep) continue;
rmSync(path, { force: true });
removed.push(path);
}
@@ -288,117 +286,6 @@ export interface RunStatus {
commits?: Record<string, string>;
exit?: number;
pid?: number;
/** What the suite said of its tests, read from the run's log once it ended (novox/hq issue 693). */
outcome?: Outcome;
}
/** One skipped test, its file and the reason it gave. */
export interface Skip {
file: string;
test: string;
reason: string;
}
/**
* What a run's suite said of its tests: its totals, each skip with its reason, and — when the run did not
* pass although its exit said 0 — why (`notPassed`). Totals the runner did not print are absent.
*/
export interface Outcome {
passed?: number;
failed?: number;
skipped?: number;
skips: Skip[];
notPassed?: string;
}
/**
* outcomeOf reads what a run's log says of its tests (novox/hq issue 693).
*
* **A run in which no test passed has proven nothing**, and the runner exits 0 when every test skipped:
* the genesis bed skipped its only test as "NOT REACHED", the suite printed "0 passed, 0 failed", and the
* lab recorded the run as passed. The lab's suite now says it itself — a "NOT PASSED: <why>" line and a
* "skipped: <test> (<file>) # <reason>" line per skip after its "recorded:" line, and an exit that is not
* 0 — and this reads those lines. For a run of a lab from before (a branch, or an old log), the same rule is
* read from the runner's own output: its summary totals, and its skip lines' first line of reason.
*
* The rule, the lab suite's (mesh-lab src/outcomes.ts): **not passed when no test passed**, or when a test
* skipped as NOT REACHED (its subject was not reached); a skip for another reason beside tests that passed
* leaves the run passed, its reason listed.
*/
export function outcomeOf(log: string): Outcome {
const plain = log.replace(/\u001b\[[0-9;]*m/g, "");
const total = (word: string) => {
// The last summary in the log: the runner's own, once per run.
const all = [...plain.matchAll(new RegExp(`^\\s*(?:\u2139|#)\\s*${word}\\s+(\\d+)\\s*$`, "gm"))];
const last = all[all.length - 1];
return last ? Number(last[1]) : undefined;
};
const outcome: Outcome = { skips: [] };
const passed = total("pass");
const failed = total("fail");
const skipped = total("skipped");
if (passed !== undefined) outcome.passed = passed;
if (failed !== undefined) outcome.failed = failed;
if (skipped !== undefined) outcome.skipped = skipped;
// **The suite's own lines are read only after its last "recorded:" line** (novox/hq issue 693, review
// 254): a test prints what it likes before the suite reports, and a line of its that looked like the
// suite's — "NOT PASSED: …", "skipped: … # NOT REACHED" — would otherwise stand for the suite's verdict.
const recorded = [...plain.matchAll(/^recorded: .*$/gm)];
const lastRecorded = recorded[recorded.length - 1];
const suiteSaid = lastRecorded ? plain.slice((lastRecorded.index ?? 0) + lastRecorded[0].length) : "";
// The suite's own line, "skipped: <test> (<file>) # <reason>": the file in brackets, a .ts, parts it.
for (const m of suiteSaid.matchAll(/^skipped: (.*?) \(([^()]+\.ts)\) # (.*)$/gm)) {
outcome.skips.push({ file: m[2] ?? "", test: m[1] ?? "", reason: m[3] === "no reason given" ? "" : m[3] ?? "" });
}
if (outcome.skips.length === 0) {
// A lab from before names no skip itself; its runner's spec line does: "﹣ <test> (<ms>ms) # <reason>".
for (const m of plain.matchAll(/^\s*\ufe63 (.*?) \([\d.]+m?s\) # (.*)$/gm)) {
const reason = (m[2] ?? "").trim();
outcome.skips.push({ file: "", test: m[1] ?? "", reason: reason === "SKIP" ? "" : reason });
}
}
const said = suiteSaid.match(/^NOT PASSED: (.*)$/m);
if (said) {
outcome.notPassed = (said[1] ?? "").trim();
} else if ((failed ?? 0) === 0) {
// In any case: "not reached" says the same as "NOT REACHED".
const unreached = outcome.skips.filter((s) => s.reason.toUpperCase().includes("NOT REACHED")).length;
if (passed === 0) {
outcome.notPassed = outcome.skips.length > 0 || (skipped ?? 0) > 0
? `no test passed: ${skipped ?? outcome.skips.length} skipped${unreached > 0 ? `, ${unreached} of them as NOT REACHED` : ""}`
: "no test passed: none ran";
} else if (unreached > 0) {
outcome.notPassed = `${unreached} test(s) skipped as NOT REACHED: their subject was not reached`;
}
}
return outcome;
}
/**
* settled is a run that ended, with its outcome read from its log: a run whose exit said 0 and whose suite
* proved nothing reads as failed, with why (novox/hq issue 693). Read once and kept in its status, so the
* record says it too; a run still going, stopped or lost is left as it is.
*/
export function settled(work: string, s: RunStatus): RunStatus {
if (s.outcome || !s.ended || (s.state !== "passed" && s.state !== "failed")) return s;
let log: string;
try {
log = readFileSync(join(runDir(work, s.id), "run.log"), "utf8");
} catch {
return s;
}
const outcome = outcomeOf(log);
const next: RunStatus = { ...s, outcome };
if (next.state === "passed" && outcome.notPassed) next.state = "failed";
try {
writeFileSync(statusPath(work, s.id), JSON.stringify(next, null, 2));
} catch {
// Read again next time: what lab_status answers is the same either way.
}
return next;
}
export function runDir(work: string, id: string): string {
@@ -413,23 +300,16 @@ export function readStatus(work: string, id: string): RunStatus | undefined {
try {
const s = JSON.parse(readFileSync(statusPath(work, id), "utf8")) as RunStatus;
// A run whose process is gone while its status still says it is going was lost — the runtime or
// the machine restarted under it. Said, rather than left reading as running for ever. One that never
// recorded a process (the runtime died between writing its status and starting it) is lost too: it
// would otherwise hold up every run after it (review 256).
if (!["passed", "failed", "stopped", "lost"].includes(s.state) && gone(s.pid)) {
// the machine restarted under it. Said, rather than left reading as running for ever.
if (!["passed", "failed", "stopped", "lost"].includes(s.state) && s.pid && !alive(s.pid)) {
s.state = "lost";
}
return settled(work, s);
return s;
} catch {
return undefined;
}
}
/** gone is whether a job's process is not running: none recorded, or none by that id. */
function gone(pid: number | undefined): boolean {
return !pid || !alive(pid);
}
function alive(pid: number): boolean {
try {
process.kill(pid, 0);
@@ -566,238 +446,3 @@ export function tail(work: string, id: string, lines: number): string {
return "";
}
}
// ---- the base image (novox/hq issue 698)
//
// Every bed machine that needs a container runtime is a clone of one base image, `mesh-lab/base`, which
// mesh-lab's `base build` makes from the distribution's current image. A machine raised from it carries the
// package database the image was built with. On a rolling distribution the mirrors stop serving what an old
// database names within weeks, and a bed that installs a package then fails on a 404 that reads as a mirror
// outage (novox/hq issue 205; seen 2026-10-14 when genesis-keeps-its-bus installed postgresql-libs from a
// database six weeks old). So the lab names the image's age wherever it is asked, refuses a run on an image
// older than the bound, and rebuilds the image when asked to.
//
// **The bound is 14 days** (BASE_MAX_AGE_DAYS). Arch's mirrors keep a package version for as long as it is
// current and drop it soon after it is replaced; an image a fortnight old has reliably installed what beds
// install, and the one that failed was 44 days old. A rebuild takes minutes, so a short bound costs little;
// a run refused is cheaper than a bed that fails an hour in for a reason that is not the change under test.
/** The image every bed machine with a runtime is raised from, as mesh-lab names it (src/lifecycle/place.ts). */
export const BASE_IMAGE_ALIAS = "mesh-lab/base";
/** How old the base image may be, in days, before a run is refused (see the head of this file). */
export const BASE_MAX_AGE_DAYS = 14;
const DAY = 24 * 60 * 60 * 1000;
export interface BaseImage {
alias: string;
/** Whether the image is there at all. */
exists: boolean;
/** When this machine made it, ISO. */
built?: string;
/** Its age in whole days. */
ageDays?: number;
/** Why its age could not be read, when it could not. */
problem?: string;
}
/**
* baseImageFrom reads `incus image list <alias> --format json`: the image whose aliases hold the base's
* name, and when it was made here (its upload, which a publish sets).
*/
export function baseImageFrom(listed: string | undefined, now: Date): BaseImage {
const alias = BASE_IMAGE_ALIAS;
if (listed === undefined) return { alias, exists: false, problem: "incus could not list the lab's images" };
let images: Array<{ aliases?: Array<{ name?: string }>; uploaded_at?: string; created_at?: string }>;
try {
images = JSON.parse(listed);
} catch {
return { alias, exists: false, problem: "incus listed the lab's images as something that is not JSON" };
}
const image = (Array.isArray(images) ? images : []).find((i) => (i.aliases ?? []).some((a) => a.name === alias));
if (!image) return { alias, exists: false };
const made = [image.uploaded_at, image.created_at].map((t) => (t ? Date.parse(t) : NaN))
.find((t) => Number.isFinite(t) && t > 0);
if (made === undefined) return { alias, exists: true, problem: "incus gives no time the image was made" };
return { alias, exists: true, built: new Date(made).toISOString(), ageDays: Math.floor((now.getTime() - made) / DAY) };
}
/** readBaseImage asks incus, on this machine, for the base image. */
export function readBaseImage(now = new Date()): BaseImage {
const listed = spawnSync("incus", ["image", "list", BASE_IMAGE_ALIAS, "--format", "json"], { encoding: "utf8" });
return baseImageFrom(listed.status === 0 ? listed.stdout : undefined, now);
}
/** The base image's age in one sentence, as lab_check and lab_run say it. */
export function describeBase(b: BaseImage): string {
if (b.problem) return `the base image ${b.alias}: its age is not known: ${b.problem}`;
if (!b.exists) return `the base image ${b.alias} is not on this machine; rebuild it with lab_base {"rebuild": true}`;
return `the base image ${b.alias} was built ${b.built} (${b.ageDays} day(s) ago; the bound is ${BASE_MAX_AGE_DAYS})`;
}
/**
* staleBase is why a run is refused for its base image, or undefined when it is not: an image older than the
* bound. An image that is missing or whose age cannot be read is not refused here — a bed that needs it says
* so itself, and a bed that does not need it runs.
*/
export function staleBase(b: BaseImage): string | undefined {
if (!b.exists || b.ageDays === undefined || b.ageDays <= BASE_MAX_AGE_DAYS) return undefined;
return `the base image ${b.alias} was built ${b.built}, ${b.ageDays} days ago, older than the lab's bound of ` +
`${BASE_MAX_AGE_DAYS} days: a bed machine raised from it carries a package database the mirrors no longer serve ` +
`(novox/hq issues 205, 698). Rebuild it with lab_base {"rebuild": true}, then run again`;
}
/**
* baseProblem is what lab_check and lab_base say is wrong with the base image, or undefined: past the bound, not
* on this machine, or of an age that cannot be read (review 256). Only the first refuses a run.
*/
export function baseProblem(b: BaseImage): string | undefined {
if (b.problem || !b.exists) return describeBase(b);
return staleBase(b);
}
/**
* runRefusal is why lab_run starts no run for the base image, or undefined: a rebuild still going, which
* replaces the image a bed raises its machines from, or an image older than the bound.
*/
export function runRefusal(base: BaseImage, rebuilding?: { id: string; started: string }): string | undefined {
if (rebuilding) return `the base image is being rebuilt (${rebuilding.id}, since ${rebuilding.started}); run once lab_base says it is built`;
return staleBase(base);
}
// ---- its rebuild: mesh-lab's `base build`, in the background, as a run is.
export interface BaseBuild {
id: string;
state: "building" | "built" | "failed" | "lost";
started: string;
ended?: string;
exit?: number;
pid?: number;
}
function buildStatusPath(work: string, id: string): string {
return join(work, id, "status.json");
}
/** The base builds, newest first. A build whose process is gone while it says building was lost. */
export function listBaseBuilds(work: string): BaseBuild[] {
if (!existsSync(work)) return [];
const builds: BaseBuild[] = [];
for (const id of readdirSync(work).filter((d) => d.startsWith("base-"))) {
try {
const b = JSON.parse(readFileSync(buildStatusPath(work, id), "utf8")) as BaseBuild;
if (b.state === "building" && gone(b.pid)) b.state = "lost";
builds.push(b);
} catch {
// A directory without a status is not a build.
}
}
return builds.sort((a, b) => b.started.localeCompare(a.started));
}
/** The base build still going, if any. */
export function buildingBase(work: string): BaseBuild | undefined {
return listBaseBuilds(work).find((b) => b.state === "building");
}
/** The last lines of a base build's log. */
export function baseBuildLog(work: string, id: string, lines = 40): string {
try {
return readFileSync(join(work, id, "build.log"), "utf8").split("\n").slice(-Math.max(1, lines)).join("\n");
} catch {
return "";
}
}
/**
* baseScript is what one rebuild runs: mesh-lab's main from the forge, installed the way a run installs it,
* then its `base build`, which replaces the image and reads back that it works before publishing it.
*/
export function baseScript(dir: string, forge: string, credential?: string, registry = `${forge}${NPM_PATH}`): string {
const lab = join(dir, "mesh-lab");
return `set -euo pipefail
export GIT_TERMINAL_PROMPT=0
${credentialGuard(credential)}
cd ${shellQuote(dir)}
git ${credentialArgs(credential)}clone --quiet --depth 1 ${shellQuote(`${forge}/novox/mesh-lab.git`)} ${shellQuote(lab)}
${credential ? `rm -f ${shellQuote(credential)}` : ""}
echo "mesh-lab $(git -C ${shellQuote(lab)} rev-parse HEAD)"
printf '%s\\n' ${shellQuote(`@novox:registry=${registry}`)} > ${shellQuote(join(dir, ".npmrc"))}
export NPM_CONFIG_USERCONFIG=${shellQuote(join(dir, ".npmrc"))}
${installStep(lab, false)}
cd ${shellQuote(lab)}
node --experimental-strip-types src/cli.ts base build
`;
}
/** startBaseBuild rebuilds the base image in the background and answers at once with the build. */
export function startBaseBuild(work: string, forge: string, credential?: string, registry?: string): BaseBuild {
const id = `base-${new Date().toISOString().replace(/[:.]/g, "-")}`;
const dir = join(work, id);
mkdirSync(dir, { recursive: true });
const build: BaseBuild = { id, state: "building", started: new Date().toISOString() };
writeFileSync(buildStatusPath(work, id), JSON.stringify(build, null, 2));
// What a crashed or rebooted run or rebuild left behind goes before this one writes its own.
cleanCredentials(work);
const credentials = writeCredential(credentialPath(work, id), credential);
writeFileSync(join(dir, "build.sh"), baseScript(dir, forge, credentials, registry), { mode: 0o700 });
const wrapper = `bash ${shellQuote(join(dir, "build.sh"))} > ${shellQuote(join(dir, "build.log"))} 2>&1; code=$?
node -e ${shellQuote(
`const f=${JSON.stringify(buildStatusPath(work, id))};const s=JSON.parse(require("fs").readFileSync(f,"utf8"));s.state=process.argv[1]==="0"?"built":"failed";s.exit=Number(process.argv[1]);s.ended=new Date().toISOString();require("fs").writeFileSync(f,JSON.stringify(s,null,2))`,
)} "$code"
rm -f ${shellQuote(credentialPath(work, id))}
rm -rf ${shellQuote(join(dir, "mesh-lab"))}`;
const child = spawn("bash", ["-c", wrapper], { detached: true, stdio: "ignore" });
child.unref();
build.pid = child.pid;
writeFileSync(buildStatusPath(work, id), JSON.stringify(build, null, 2));
return build;
}
// ---- starting a run or a rebuild: one job on the lab's machine at a time (review 256).
//
// **Everything that waits is done first; the check and the start are one step.** A run and a rebuild each
// refused while the other was going, but each asked the package registry — an await — between its check and
// writing its status, so a run and a rebuild asked together could both pass their checks and both start: a
// bed raising machines from an image being replaced under it. Here the registry is asked first, and nothing
// is awaited between reading what is going and writing the new job's status, so in the one runtime that
// serves the lab's tools no other call runs in between.
export interface Launch {
/** The address a run or a rebuild names the package registry by, asked of the forge (npmRegistry). */
registry: () => Promise<{ registry: string; problem?: string }>;
/** The base image as it is now. */
base: () => BaseImage;
}
export type Launched<T> = { started: true; registry: string; problem?: string; base: BaseImage; job: T }
| { started: false; reason: string; base: BaseImage };
/** launchRun starts a run unless a run or a rebuild is going, or the base image is past its bound. */
export async function launchRun(work: string, launch: Launch, begin: (registry: string) => RunStatus):
Promise<Launched<RunStatus>> {
const registry = await launch.registry();
// From here to begin(): nothing awaited.
const base = launch.base();
const busy = running(work);
if (busy) return { started: false, reason: `${busy.id} is still ${busy.state}; one run at a time`, base };
const refused = runRefusal(base, buildingBase(work));
if (refused) return { started: false, reason: refused, base };
return { started: true, ...registry, base, job: begin(registry.registry) };
}
/** launchRebuild starts a rebuild of the base image unless a run or another rebuild is going. */
export async function launchRebuild(work: string, launch: Launch, begin: (registry: string) => BaseBuild):
Promise<Launched<BaseBuild>> {
const registry = await launch.registry();
// From here to begin(): nothing awaited.
const base = launch.base();
const rebuilding = buildingBase(work);
if (rebuilding) return { started: false, reason: `${rebuilding.id} is still rebuilding it`, base };
// A run raises its machines from the image; replacing it under one would change what the run tests.
const busy = running(work);
if (busy) return { started: false, reason: `${busy.id} is still ${busy.state}, and raises its machines from this image`, base };
return { started: true, ...registry, base, job: begin(registry.registry) };
}
-216
View File
@@ -1,216 +0,0 @@
// Whether mail flows through mailu, as the node-engine asks it every interval (novox/hq ADR 0240, kind
// `tool`; issue 690). Every container read healthy on 2026-10-14 while postfix could not resolve a name:
// inbound mail was refused with 450 and outbound mail sat in the deferred queue for days. The parts'
// checks judge parts; this judges the flow, from inside the postfix container, the way postfix sees it:
//
// 1. postfix runs (`postfix status`, standing in for the image's own check, `! /usr/libexec/postfix/master
// -t`, which this one replaces);
// 2. the deferred queue (`postqueue -j`) shows no fault of this side: a recipient deferred longer than the
// bound because a lookup failed here, or overdue mail to three or more domains. One or two far domains
// that do not answer are theirs, and can stay deferred for days: said, never unhealthy, so one dead
// recipient does not page the operator for days;
// 3. a public mail host's name resolves through the resolver postfix is given: a resolver that
// SERVFAILs refuses inbound mail before anything reaches the queue.
//
// The answer is the health answer every health tool gives (mesh-sdk go/health): `state`, `healthy` beside
// it for an older reader, and `why` in words — counts, the oldest message's age and its first reason —
// never a bare false.
import { execFile } from "node:child_process";
import { promisify } from "node:util";
const run = promisify(execFile);
/** How long a message may wait in the deferred queue before mail is said not to flow. */
export const DEFERRED_BOUND_SECONDS = 30 * 60;
/** The public name resolved through postfix's resolver: gmail's mail exchanger, which mail needs. */
export const PUBLIC_NAME = "gmail-smtp-in.l.google.com";
// The lookup postfix's own would make, through the resolver the container is given; a failure is said on one
// line ("gaierror: [Errno -3] Try again" is what a SERVFAIL reads as), never as a traceback.
const RESOLVE = [
"import socket, sys",
"try:",
` socket.getaddrinfo(${JSON.stringify(PUBLIC_NAME)}, 25)`,
"except Exception as e:",
" sys.exit(type(e).__name__ + ': ' + str(e))",
].join("\n");
/** Overdue mail to this many recipient domains or more is our side's fault, not one far server's. */
export const SPREAD_DOMAINS = 3;
/**
* What postfix writes when its own lookups fail, as on 2026-10-14 (issue 687): "Host or domain name not
* found. Name service error for name=gmail.com type=MX: Host not found, try again", or "Temporary lookup
* failure". A far server that is down reads otherwise ("Connection timed out", "Connection refused").
*/
export const LOCAL_LOOKUP_FAILURE =
/Name service error|Temporary lookup failure|Host or domain name not found.*try again/is;
/** The domain of an address, lower-cased; "(no recipient)" when there is none. */
function domainOf(address?: string): string {
const at = address?.lastIndexOf("@") ?? -1;
return address && at >= 0 ? address.slice(at + 1).toLowerCase() : "(no recipient)";
}
/** Runs a command in a container: its stdout, or a rejection carrying stderr. */
export type Exec = (container: string, command: string[]) => Promise<string>;
/** The health answer (mesh-sdk go/health): healthy, or unhealthy with why. */
export interface HealthAnswer {
state: "healthy" | "unhealthy";
healthy: boolean;
why: string;
queue?: {
messages: number;
deferred: number;
overdue: number;
oldestDeferredSeconds?: number;
overdueDomains?: string[];
nameServiceFailures?: number;
};
}
/** One message as `postqueue -j` prints it, one JSON object a line (postfix 3.1 and later). */
interface Queued {
queue_name?: string;
queue_id?: string;
arrival_time?: number;
recipients?: Array<{ address?: string; delay_reason?: string }>;
}
/** docker exec, bounded: a hung container is an answer too. */
export function dockerExec(timeoutMs = 10_000): Exec {
return async (container, command) =>
(await run("docker", ["exec", container, ...command], { timeout: timeoutMs })).stdout;
}
/** What a failed exec said, on one line: its stderr when it has one, else its message. */
function said(e: unknown): string {
const err = e as { stderr?: string; message?: string; killed?: boolean; signal?: string };
if (err?.killed || err?.signal === "SIGTERM") return "it did not answer in time";
const text = (err?.stderr ?? "").trim() || (err?.message ?? String(e)).trim();
return text.split("\n").map((l) => l.trim()).filter(Boolean)[0] ?? "it failed and said nothing";
}
/** An age in words: "2 days 3 hours", "45 minutes". */
export function age(seconds: number): string {
const s = Math.max(0, Math.floor(seconds));
const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60);
const unit = (n: number, w: string) => `${n} ${w}${n === 1 ? "" : "s"}`;
if (d > 0) return h > 0 ? `${unit(d, "day")} ${unit(h, "hour")}` : unit(d, "day");
if (h > 0) return m > 0 ? `${unit(h, "hour")} ${unit(m, "minute")}` : unit(h, "hour");
return unit(m, "minute");
}
function parseQueue(output: string): Queued[] {
const queued: Queued[] = [];
for (const line of output.split("\n")) {
const text = line.trim();
if (!text.startsWith("{")) continue; // "Mail queue is empty" and the like
queued.push(JSON.parse(text) as Queued);
}
return queued;
}
/**
* Judge whether mail flows. `now` is in seconds since the epoch. Every part is asked even when one
* fails, so the answer says everything that is wrong at once.
*/
export async function mailFlows(
exec: Exec,
container: string,
now: number = Date.now() / 1000,
boundSeconds: number = DEFERRED_BOUND_SECONDS,
): Promise<HealthAnswer> {
const faults: string[] = [];
const [status, queue, resolve] = await Promise.allSettled([
exec(container, ["postfix", "status"]),
exec(container, ["postqueue", "-j"]),
exec(container, ["python3", "-c", RESOLVE]),
]);
if (status.status === "rejected") {
faults.push(`postfix in ${container} does not run: ${said(status.reason)}.`);
}
let summary: HealthAnswer["queue"];
const notes: string[] = [];
if (queue.status === "rejected") {
faults.push(`postfix's queue in ${container} could not be read: ${said(queue.reason)}.`);
} else {
let queued: Queued[] = [];
try {
queued = parseQueue(queue.value);
} catch (e) {
faults.push(`postfix's queue in ${container} printed a line that is not JSON: ${said(e)}.`);
}
const deferred = queued.filter((q) => q.queue_name === "deferred");
const overdue = deferred
.filter((q) => typeof q.arrival_time === "number" && now - q.arrival_time > boundSeconds)
.sort((a, b) => (a.arrival_time ?? 0) - (b.arrival_time ?? 0));
const oldest = deferred.reduce<number | undefined>(
(o, q) => (typeof q.arrival_time === "number" && (o === undefined || q.arrival_time < o) ? q.arrival_time : o),
undefined,
);
// Each overdue recipient, oldest message first: what our side is judged by, not one far host.
const late = overdue.flatMap((q) =>
(q.recipients?.length ? q.recipients : [{}]).map((r) => ({
to: r.address,
domain: domainOf(r.address),
reason: r.delay_reason?.trim() || "postfix gave no reason",
waited: now - (q.arrival_time ?? now),
})),
);
const lookups = late.filter((r) => LOCAL_LOOKUP_FAILURE.test(r.reason));
const domains = [...new Set(late.map((r) => r.domain))];
summary = {
messages: queued.length,
deferred: deferred.length,
overdue: overdue.length,
...(oldest !== undefined ? { oldestDeferredSeconds: Math.max(0, Math.floor(now - oldest)) } : {}),
...(domains.length > 0 ? { overdueDomains: domains } : {}),
...(lookups.length > 0 ? { nameServiceFailures: lookups.length } : {}),
};
const one = (r: (typeof late)[number]) =>
`the oldest, ${age(r.waited)} old${r.to ? ` to ${r.to}` : ""}, waits because: ${r.reason}`;
const bound = age(boundSeconds);
if (lookups.length > 0) {
faults.push(
`${lookups.length} deferred ${lookups.length === 1 ? "recipient has" : "recipients have"} waited longer ` +
`than ${bound} on a name-service failure, which is this server's resolver; ${one(lookups[0])}.`,
);
} else if (domains.length >= SPREAD_DOMAINS) {
faults.push(
`${overdue.length} of ${deferred.length} deferred messages have waited longer than ${bound}, to ` +
`${domains.length} domains (${domains.join(", ")}): more than one far server's fault; ${one(late[0])}.`,
);
} else if (late.length > 0) {
notes.push(
`${overdue.length} deferred ${overdue.length === 1 ? "message has" : "messages have"} waited longer than ` +
`${bound}, only to ${domains.join(" and ")}, which reads as the far side's: ${one(late[0])}.`,
);
}
}
if (resolve.status === "rejected") {
faults.push(`${container} cannot resolve ${PUBLIC_NAME} through its resolver: ${said(resolve.reason)}.`);
}
if (faults.length > 0) {
const why = [`Mail does not flow.`, ...faults, ...notes].join(" ");
return { state: "unhealthy", healthy: false, why, queue: summary };
}
const waiting = !summary || summary.deferred === 0
? " The deferred queue is empty."
: notes.length > 0
? ` ${notes.join(" ")}`
: ` ${summary.deferred} deferred, the oldest ${age(summary.oldestDeferredSeconds ?? 0)} old, within ${age(boundSeconds)}.`;
return {
state: "healthy",
healthy: true,
why: `Mail flows: postfix runs, ${PUBLIC_NAME} resolves.${waiting}`,
queue: summary,
};
}
+4 -32
View File
@@ -5,10 +5,6 @@
"policy": "record",
"why": "people's mail: any change to how its containers are declared recreates them together in one send, and the mail is down for everyone until they are up again — a person chooses the moment (hq ADR 0242, issue 295)"
},
"unhealthy": {
"severity": "urgent",
"why": "while mailu is broken, mail is refused or stuck for everyone with a mailbox here (hq ADR 0355, issue 687)"
},
"capabilities": [
"container-runtime"
],
@@ -60,19 +56,6 @@
"api-token": "${dir:state}/api-token.secret"
}
},
"tools": [
"mailu_list_users",
"mailu_create_user",
"mailu_change_password",
"mailu_delete_user",
"mailu_list_aliases",
"mailu_create_alias",
"mailu_delete_alias",
"mailu_list_domains",
"mailu_read_mail",
"mailu_search_mail",
"mailu_health"
],
"emits": [
"user.created",
"user.deleted",
@@ -388,7 +371,7 @@
"id": "resolver",
"type": "container",
"name": "mailu-resolver",
"image": "ghcr.io/mailu/unbound@sha256:2c037bc5c988b5c0ab7e1b6e6ffa762e14ba1e45c96281ac87f8b9be8eee8b6e",
"image": "ghcr.io/mailu/unbound@sha256:3a0fdfb364a63f4f9259526e013c1ef40f5f14de3621ce1560804b3a5909584a",
"health": {
"kind": "runtime"
},
@@ -460,12 +443,7 @@
"name": "mailu-smtp",
"image": "ghcr.io/mailu/postfix@sha256:e2e49f39e53b80eac9e7a2f18d9df11edeb4914fd62dbba89b3155e8e034f62e",
"health": {
"kind": "tool",
"tool": "mailu_health",
"interval": "60s",
"timeout": "30s",
"looks": 3,
"grace": "120s"
"kind": "runtime"
},
"network": "mailu",
"env-file": [
@@ -582,12 +560,7 @@
"name": "mailu-front",
"image": "ghcr.io/mailu/nginx@sha256:36f98897cd1bc9d27628bbb4e04bdf60147af2ec7507d6da77f002c4f256896d",
"health": {
"kind": "exec",
"command": "curl -m3 -skfLo /dev/null http://127.0.0.1:10204/health && kill -0 $(cat /run/dovecot/master.pid) && python3 -c \"import smtplib; s = smtplib.SMTP('127.0.0.1', 25, timeout=5); s.ehlo('health'); s.quit()\" && openssl x509 -checkend 1209600 -noout -in /certs/letsencrypt/live/mailu/nginx-chain.pem",
"interval": "60s",
"timeout": "15s",
"looks": 3,
"grace": "120s"
"kind": "runtime"
},
"network": "mailu",
"env-file": [
@@ -664,7 +637,6 @@
"MESH_MAILU_URL": "http://127.0.0.1:${port:8080}/api/v1",
"MESH_MAILU_API_KEY_FILE": "${dir:state}/api-token.secret",
"MESH_MAILU_IMAP_CONTAINER": "mailu-imap",
"MESH_MAILU_SMTP_CONTAINER": "mailu-smtp",
"MESH_MAILU_CONFIG_FILE": "${dir:mesh-state}/config.json",
"MESH_RECEIVES": "${dir:grants}/mesh.json"
}
@@ -701,7 +673,7 @@
"jails": [
{
"name": "mailu-front",
"failregex": "^.*(?:imap|pop3|submission|managesieve)-login: .*\\(auth failed, \\d+ attempts(?: in \\d+ secs)?\\):.*rip=<HOST>(?:,|$)\n ^.*client login failed: \"AUTH not supported\" while in http auth state, client: <HOST>, server: \\S+:25,",
"failregex": "^.*(?:imap|pop3|submission|managesieve)-login: .*\\(auth failed, \\d+ attempts(?: in \\d+ secs)?\\):.*rip=<HOST>(?:,|$)",
"jail": "backend = systemd\njournalmatch = CONTAINER_NAME=mailu-front\nport = smtp,submission,submissions,imap,imaps,pop3,pop3s\nmaxretry = 3\nfindtime = 1d\nbantime = 1d"
}
]
-140
View File
@@ -1,140 +0,0 @@
// Whether mail flows (novox/hq issue 690): the health tool is unhealthy, in words, when a message waits in
// postfix's deferred queue longer than the bound, when postfix does not run, when a public name does not
// resolve, and when the container cannot be reached — and healthy otherwise. Run against the compiled
// module, the way the runtime loads it.
import { test } from "node:test";
import assert from "node:assert/strict";
import { mailFlows, DEFERRED_BOUND_SECONDS } from "../dist/health.js";
const NOW = 1_800_000_000;
type Answers = { status?: string | Error; queue?: string | Error; resolve?: string | Error };
/** A container that answers each command as told; an Error is a failed exec, with its stderr. */
function container(a: Answers) {
const asked: string[][] = [];
const exec = async (_name: string, command: string[]) => {
asked.push(command);
const answer = command[0] === "postfix" ? a.status ?? "" : command[0] === "postqueue" ? a.queue ?? "" : a.resolve ?? "";
if (answer instanceof Error) throw answer;
return answer;
};
return { exec, asked };
}
function failed(stderr: string): Error {
return Object.assign(new Error("Command failed: docker exec"), { stderr });
}
function deferred(id: string, ageSeconds: number, reason: string, to = "friend@example.com"): string {
return JSON.stringify({
queue_name: "deferred",
queue_id: id,
arrival_time: NOW - ageSeconds,
message_size: 1234,
sender: "someone@example.org",
recipients: [{ address: to, delay_reason: reason }],
});
}
test("an empty queue is healthy, and says so", async () => {
const c = container({ queue: "" });
const a = await mailFlows(c.exec, "mailu-smtp", NOW);
assert.equal(a.state, "healthy");
assert.equal(a.healthy, true);
assert.match(a.why, /deferred queue is empty/);
assert.deepEqual(a.queue, { messages: 0, deferred: 0, overdue: 0 });
assert.equal(c.asked.length, 3, "postfix, its queue and a lookup were each asked");
});
test("a recipient overdue on a name-service failure is unhealthy, with counts, its age and its reason", async () => {
const reason = "Host or domain name not found. Name service error for name=gmail.com type=MX: Host not found, try again";
const queue = [
deferred("AAA111", 3 * 86400 + 2 * 3600, reason),
deferred("BBB222", 5 * 60, "connect to mx.example.net[192.0.2.1]:25: Connection timed out"),
JSON.stringify({ queue_name: "active", queue_id: "CCC333", arrival_time: NOW - 10, recipients: [] }),
].join("\n");
const a = await mailFlows(container({ queue }).exec, "mailu-smtp", NOW);
assert.equal(a.state, "unhealthy");
assert.equal(a.healthy, false);
assert.match(a.why, /^Mail does not flow\./);
assert.match(a.why, /1 deferred recipient has waited longer than 30 minutes on a name-service failure/);
assert.match(a.why, /3 days 2 hours old to friend@example\.com/);
assert.ok(a.why.includes(reason), "the reason is said");
assert.deepEqual(a.queue, {
messages: 3, deferred: 2, overdue: 1, oldestDeferredSeconds: 3 * 86400 + 2 * 3600,
overdueDomains: ["example.com"], nameServiceFailures: 1,
});
});
test("one dead far domain overdue is healthy, and said", async () => {
const queue = [
deferred("AAA111", 2 * 86400, "connect to mx.dead.example[192.0.2.9]:25: Connection timed out", "a@dead.example"),
deferred("BBB222", 3600, "connect to mx.dead.example[192.0.2.9]:25: Connection timed out", "b@dead.example"),
].join("\n");
const a = await mailFlows(container({ queue }).exec, "mailu-smtp", NOW);
assert.equal(a.state, "healthy");
assert.equal(a.healthy, true);
assert.match(a.why, /2 deferred messages have waited longer than 30 minutes, only to dead\.example, which reads as the far side's/);
assert.match(a.why, /2 days old to a@dead\.example, waits because: connect to mx\.dead\.example/);
assert.deepEqual(a.queue?.overdueDomains, ["dead.example"]);
});
test("overdue mail to three domains is unhealthy, whatever the reason", async () => {
const timedOut = "connect to mx[192.0.2.1]:25: Connection timed out";
const queue = [
deferred("AAA111", 7200, timedOut, "a@one.example"),
deferred("BBB222", 3600, timedOut, "b@two.example"),
deferred("CCC333", 3600, timedOut, "c@three.example"),
].join("\n");
const a = await mailFlows(container({ queue }).exec, "mailu-smtp", NOW);
assert.equal(a.state, "unhealthy");
assert.match(a.why, /3 of 3 deferred messages have waited longer than 30 minutes, to 3 domains \(one\.example, two\.example, three\.example\)/);
assert.match(a.why, /the oldest, 2 hours old to a@one\.example/);
});
test("a deferred message younger than the bound is healthy, and is said", async () => {
const queue = deferred("BBB222", DEFERRED_BOUND_SECONDS - 60, "connect to mx.example.net: Connection timed out");
const a = await mailFlows(container({ queue }).exec, "mailu-smtp", NOW);
assert.equal(a.state, "healthy");
assert.equal(a.healthy, true);
assert.match(a.why, /1 deferred, the oldest 29 minutes old, within 30 minutes/);
});
test("a container that cannot be reached is unhealthy, and says what docker said", async () => {
const gone = failed("Error response from daemon: No such container: mailu-smtp\n");
const a = await mailFlows(container({ status: gone, queue: gone, resolve: gone }).exec, "mailu-smtp", NOW);
assert.equal(a.state, "unhealthy");
assert.equal(a.healthy, false);
assert.match(a.why, /postfix in mailu-smtp does not run: Error response from daemon: No such container: mailu-smtp\./);
assert.match(a.why, /postfix's queue in mailu-smtp could not be read: Error response from daemon: No such container/);
assert.equal(a.queue, undefined);
});
test("a name that does not resolve is unhealthy, though the queue is empty", async () => {
const a = await mailFlows(
container({ queue: "", resolve: failed("gaierror: [Errno -3] Try again\n") }).exec,
"mailu-smtp",
NOW,
);
assert.equal(a.state, "unhealthy");
assert.match(a.why, /mailu-smtp cannot resolve gmail-smtp-in\.l\.google\.com through its resolver: gaierror: \[Errno -3\] Try again\./);
});
test("a container that does not answer in time is said so", async () => {
const hung = Object.assign(new Error("Command failed"), { killed: true, signal: "SIGTERM" });
const a = await mailFlows(container({ queue: hung }).exec, "mailu-smtp", NOW);
assert.equal(a.state, "unhealthy");
assert.match(a.why, /postfix's queue in mailu-smtp could not be read: it did not answer in time\./);
});
test("the health tool is served without the admin API, on the smtp container it is told", async () => {
const { collectTools } = await import("@novox/mesh-sdk/tools");
await import("../dist/tools/index.js");
const mailu = collectTools({ MESH_MAILU_SMTP_CONTAINER: "mailu-smtp" }).find((m) => m.module === "mailu");
const names = (mailu?.tools ?? []).map((t) => t.name);
assert.deepEqual(names, ["mailu_health"]);
assert.match(mailu!.tools[0].description, /inside mailu-smtp/);
});
+4 -27
View File
@@ -4,27 +4,6 @@
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
import { MailuClient } from "../client.js";
import { dockerExec, mailFlows, DEFERRED_BOUND_SECONDS, PUBLIC_NAME, SPREAD_DOMAINS } from "../health.js";
/**
* Whether mail flows (novox/hq ADR 0240, issue 690): the tool the node-engine asks as the smtp container's
* check. It needs no admin API, only the postfix container, so it is served even when the API is not
* configured — a check that vanished with the API would say unhealthy for the wrong reason.
*/
export function getHealthTool(env: NodeJS.ProcessEnv): ToolDefinition {
const container = env.MESH_MAILU_SMTP_CONTAINER || "mailu-smtp";
return {
name: "mailu_health",
description:
`Whether mail flows through mailu, asked inside ${container}: postfix runs; no recipient has waited ` +
`deferred longer than ${DEFERRED_BOUND_SECONDS / 60} minutes on a name-service failure here, and overdue ` +
`deferred mail does not reach ${SPREAD_DOMAINS} or more domains (one or two far domains are said, not ` +
`unhealthy); and ${PUBLIC_NAME} resolves through the resolver postfix uses. Says counts, domains, the ` +
"oldest message's age and its reason (novox/hq ADR 0240, issue 690).",
input: {},
run: async () => mailFlows(dockerExec(), container),
};
}
export function getMailuTools(mailu: MailuClient): ToolDefinition[] {
return [
@@ -164,14 +143,12 @@ export function getMailuTools(mailu: MailuClient): ToolDefinition[] {
];
}
// The admin tools exist only when the admin API is configured; without it, mailu contributes only its
// health tool rather than failing the whole runtime.
// The tools exist only when the admin API is configured; without it, mailu contributes none rather
// than failing the whole runtime.
registerModuleTools("mailu", (env) => {
let tools: ToolDefinition[] = [];
try {
tools = getMailuTools(MailuClient.fromEnv(env));
return getMailuTools(MailuClient.fromEnv(env));
} catch {
tools = [];
return [];
}
return [...tools, getHealthTool(env)];
});
+1 -1
View File
@@ -8,5 +8,5 @@
"skipLibCheck": true,
"noEmit": true
},
"include": ["client.ts", "health.ts", "index.ts", "tools/index.ts"]
"include": ["client.ts", "index.ts", "tools/index.ts"]
}
-96
View File
@@ -1,96 +0,0 @@
// What the catalogue makes of a build outcome it hears (novox/hq issue 712).
//
// Kept apart from index.ts, which subscribes at its top level, so the decision can be tested without a bus.
import type { Made } from "./store.js";
/** What the build seat says when it has built something, and what the control plane replays. */
export interface Built {
module?: string;
commit?: string;
repository?: string;
path?: string;
ref?: string;
manifest?: unknown;
/** What this build published, each pinned as anything else would name it. */
made?: Made[];
/**
* Every artifact this was built on top of, so the edge is derived rather than declared.
*
* References, not module names: the builder sees a pinned image and cannot see which module
* produced it. Turning that into an edge between module-versions is this module's job.
*/
against?: string[];
/**
* This is history, not news — the mesh re-announcing a build this catalogue was not there for.
*
* Registered exactly as any other, and announced as nothing. A module that moved months ago is
* not something anything should act on now: emitting `upgraded` would have the control plane
* decide about a rollout, and `rebuild-needed` would ask for builds of things that are already
* current.
*/
replay?: boolean;
/** Why the build failed; a failed build made no module-version. */
failed?: string;
/** A dry run is looked at, never taken in (novox/hq issue 240). */
"dry-run"?: boolean;
/** A pull request's merge check or a branch tried before one (to-be 45 §9, issue 686): nothing merged. */
"check-outcome"?: unknown;
check?: unknown;
/** The repository's default branch as the build seat read the forge at the build (ADR 0238); empty from a seat older than the rule. */
trunk?: string;
/** Whether the commit is on `trunk`. */
"on-trunk"?: boolean;
/** The branches the commit is on. */
branches?: string[];
}
/** The events a build outcome reaches this module on: the build seat's, as it happens, and the control plane's replay. */
export const BUILD_EVENTS = [
// **The build seat that builds now** (novox/hq ADR 0190). Until issue 712 the catalogue heard only the
// retired seat below, so every build after the handover reached the graph only through the replay this
// module asks for when it starts: a merge built and pushed stayed out of the catalogue until its next start.
"node-build-agent.built",
// The retired build role, while a machine still serves it (ADR 0190's handover). Goes with the seat row.
"mesh-build-machine.built",
// What the mesh already held, when this module asks what it missed (novox/hq ADR 0134).
"mesh-controller.built-before",
] as const;
/**
* Whether a build outcome is a module-version to place in the graph, and why not when it is not.
*
* It mirrors two of the rules the controller keeps before it registers a build (mesh-controller
* cmd/mesh-controller/main.go `builds.Built`, build.go `publishable`): a merge check, a try and a dry run are
* never published, and **only a commit on its repository's trunk is** (ADR 0238) — a branch built through
* the `build` verb is checked, never a module's version. Where the seat names no trunk (one older than the
* rule) it is placed, as the controller lets it through. A failure made nothing and is passed over too.
*
* Not mirrored, because the catalogue cannot see what they read: the branch a module follows when it is not
* the default one (the controller's `follows`; such a module's builds off the default branch are passed over
* here and reach the graph by the replay), ADR 0266's rule that a module registers only from the repository
* it is built from, a superseded request, a build in the scratch namespace, one that failed its gate, and a
* build whose source is unchanged. Each needs the controller's records. This is safe for what the mesh
* sends, which is chosen from the controller's own module table and never from this graph; that the graph
* can still hold a build the controller refused under those rules is a follow-up (hq issue 712).
*/
export function placeable(body: Built):
| { placed: "yes"; module: string; commit: string }
| { placed: "no"; why: string; fault: boolean } {
if (body["check-outcome"] != null || body.check != null) {
return { placed: "no", why: "a merge check or a try: nothing of it merged", fault: false };
}
if (body["dry-run"]) {
return { placed: "no", why: "a dry run is looked at, never taken in", fault: false };
}
if (body.failed) {
return { placed: "no", why: "a failed build made no module-version", fault: false };
}
if (body.trunk && !body["on-trunk"] && !(body.branches ?? []).includes(body.trunk)) {
return { placed: "no", why: `the commit is not on its repository's trunk ${body.trunk}: checked, never published (ADR 0238)`, fault: false };
}
if (!body.module || !body.commit) {
return { placed: "no", why: "it named no module or no commit", fault: true };
}
return { placed: "yes", module: body.module, commit: body.commit };
}
+41 -17
View File
@@ -10,8 +10,7 @@
// diamond need no special handling, and nothing holds a plan.
import { on, emit } from "@novox/mesh-sdk/events";
import { Graph } from "./store.js";
import { BUILD_EVENTS, placeable, type Built } from "./heard.js";
import { Graph, type Made } from "./store.js";
const graph = Graph.fromEnv();
@@ -22,29 +21,54 @@ const graph = Graph.fromEnv();
// would block the very apply that brings the overlay up — stopped being true when a step's failure
// became this module's business and not the machine's (ADR 0136).
/** What the builder says when it has built something. */
interface Built {
module?: string;
commit?: string;
repository?: string;
path?: string;
ref?: string;
manifest?: unknown;
/** What this build published, each pinned as anything else would name it. */
made?: Made[];
/**
* Every artifact this was built on top of, so the edge is derived rather than declared.
*
* References, not module names: the builder sees a pinned image and cannot see which module
* produced it. Turning that into an edge between module-versions is this module's job.
*/
against?: string[];
/**
* This is history, not news — the mesh re-announcing a build this catalogue was not there for.
*
* Registered exactly as any other, and announced as nothing. A module that moved months ago is
* not something anything should act on now: emitting `upgraded` would have the control plane
* decide about a rollout, and `rebuild-needed` would ask for builds of things that are already
* current.
*/
replay?: boolean;
}
/**
* What a build means for the graph, wherever it came from.
*
* Two emitters say the same thing and neither is a mistake: the build machine says it as it happens,
* and the control plane says what it already held when this module asks what it missed
* (novox/hq ADR 0134). A replay is marked as one in its body, so nothing acts on a module that moved
* months ago — see `replay` in heard.ts.
* months ago — see `replay` above.
*/
const placeTheBuild = async (event: { body: unknown }): Promise<void> => {
const body = (event.body ?? {}) as Built;
const placing = placeable(body);
if (placing.placed === "no") {
// A check, a try, a dry run and a failure are the build seat's ordinary outcomes: passed over. One
// that names no module or commit is a fault in the builder, said rather than dropped, so it does
// not look like a missing event.
if (placing.fault) console.error(`mesh-catalog: a build event was not placed: ${placing.why}`, body);
if (!body.module || !body.commit) {
// Said rather than dropped: a build that announced itself without saying what it built is a
// fault in the builder, and a silent discard here would make it look like a missing event.
console.error("mesh-catalog: a build event named no module or no commit; ignored", body);
return;
}
const { module, commit } = placing;
const { upgraded, previous } = await graph.register({
module,
commit,
module: body.module,
commit: body.commit,
repository: body.repository ?? "",
path: body.path ?? "",
ref: body.ref ?? "",
@@ -56,7 +80,7 @@ const placeTheBuild = async (event: { body: unknown }): Promise<void> => {
if (body.replay) return;
await emit("registered", {
module, commit, upgraded,
module: body.module, commit: body.commit, upgraded,
});
// **A rebuild that changed nothing is not an upgrade.** Announcing it would ripple outward
@@ -64,7 +88,7 @@ const placeTheBuild = async (event: { body: unknown }): Promise<void> => {
if (!upgraded) return;
await emit("upgraded", {
module, commit, previous,
module: body.module, commit: body.commit, previous,
});
// What can be built now — stale, and waiting on nothing that is itself stale.
@@ -77,9 +101,9 @@ const placeTheBuild = async (event: { body: unknown }): Promise<void> => {
}
};
// As it happens — from the build seat that builds now, and the retired one while it still answers — and
// what the mesh already held when this module asked what it missed (novox/hq issue 712).
for (const heard of BUILD_EVENTS) await on(heard, placeTheBuild);
// As it happens, and what the mesh already held when this module asked what it missed.
await on("mesh-build-machine.built", placeTheBuild);
await on("mesh-controller.built-before", placeTheBuild);
// **And ask for what was built before this catalogue existed** (novox/hq 04-ISSUES/050).
//
-1
View File
@@ -26,7 +26,6 @@
"postgres-database": "${dir:state}/database.secret"
},
"consumes": [
"node-build-agent.built",
"mesh-build-machine.built",
"mesh-controller.built-before"
],
-72
View File
@@ -1,72 +0,0 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { BUILD_EVENTS, placeable } from "../heard.js";
// A walk's build as the build seat says it (mesh-controller's link.BuildResult), here mailu's of novox/hq issue 712.
const walkBuild = {
id: "build-1791997333800041716",
repository: "http://novox.internal:20000/novox/mesh-catalog.git",
path: "modules/mailu",
ref: "main",
on: "shanks",
trunk: "main",
"on-trunk": true,
branches: ["main"],
module: "mailu",
commit: "e1c8965ddbf1cf9af9cf1eed7a72ae9ffa6d5a87",
manifest: { module: "mailu", unhealthy: { severity: "urgent", why: "mail stops" } },
made: [{ name: "code", kind: "bundle", reference: "artifact-store://mailu/code/blobs/sha256:d041" }],
};
test("the catalogue hears the build seat that builds now, not only the retired one (issue 712)", async () => {
assert.ok(BUILD_EVENTS.includes("node-build-agent.built"));
assert.ok(BUILD_EVENTS.includes("mesh-controller.built-before"));
// Subscribing is not enough: the mesh grants and routes only what the manifest consumes.
const manifest = JSON.parse(await readFile(new URL("../module.json", import.meta.url), "utf8"));
for (const heard of BUILD_EVENTS) assert.ok(manifest.consumes.includes(heard), `${heard} is not consumed`);
});
test("a walk's build is placed, under its module and commit", () => {
assert.deepEqual(placeable(walkBuild), { placed: "yes", module: "mailu", commit: walkBuild.commit });
// A walk that took the check's build of the same key says so, and is placed all the same (ADR 0350 §2).
assert.equal(placeable({ ...walkBuild, reused: "the check's build" } as never).placed, "yes");
});
test("a merge check, a try, a dry run and a failure are passed over without a fault", () => {
for (const [body, why] of [
[{ ...walkBuild, "check-outcome": { verdict: "passed" } }, "merge check"],
[{ ...walkBuild, check: { try: true } }, "merge check or a try"],
[{ ...walkBuild, "dry-run": true }, "dry run"],
[{ ...walkBuild, failed: "exit status 1" }, "failed"],
] as const) {
const placing = placeable(body);
assert.equal(placing.placed, "no");
if (placing.placed === "no") {
assert.match(placing.why, new RegExp(why));
assert.equal(placing.fault, false);
}
}
});
test("an outcome that names no module or commit is said as the builder's fault", () => {
const placing = placeable({ repository: walkBuild.repository, commit: walkBuild.commit });
assert.equal(placing.placed, "no");
if (placing.placed === "no") assert.equal(placing.fault, true);
});
test("a branch built through the build verb is passed over; a commit on the trunk, or from a seat that names none, is placed (ADR 0238)", () => {
const branch = { ...walkBuild, ref: "feature", "on-trunk": false, branches: ["feature"] };
const placing = placeable(branch);
assert.equal(placing.placed, "no");
if (placing.placed === "no") {
assert.match(placing.why, /not on its repository's trunk main/);
assert.equal(placing.fault, false);
}
// On the trunk by its branches although the seat did not set on-trunk.
assert.equal(placeable({ ...branch, branches: ["feature", "main"] }).placed, "yes");
// A seat older than the rule says no trunk: placed, as the controller lets it through.
const { trunk, "on-trunk": _on, branches, ...older } = walkBuild;
assert.equal(placeable(older).placed, "yes");
});
-1
View File
@@ -11,7 +11,6 @@
"include": [
"pg.d.ts",
"store.ts",
"heard.ts",
"index.ts",
"tools/index.ts",
"prepare/index.ts"
@@ -396,9 +396,6 @@ type fakeForge struct {
// open is what OpenPulls answers; openDown a forge that cannot say.
open []OpenPull
openDown bool
// merges, comments and unlabels are what was asked of the forge for ready marks (novox/hq issue 722).
merges, comments, unlabels []string
refuse error
}
func (f *fakeForge) OpenPulls() ([]OpenPull, error) {
@@ -652,25 +649,3 @@ func aWalk(id, repo, commit string, waits bool) Walk {
}
return w
}
// Merge, Comment and Unlabel record what was asked (novox/hq issue 722); Merge answers refuse when set.
func (f *fakeForge) Merge(owner, repo string, number int, head string) error {
f.mu.Lock()
defer f.mu.Unlock()
f.merges = append(f.merges, fmt.Sprintf("%s/%s#%d@%s", owner, repo, number, head))
return f.refuse
}
func (f *fakeForge) Comment(owner, repo string, number int, body string) error {
f.mu.Lock()
defer f.mu.Unlock()
f.comments = append(f.comments, fmt.Sprintf("%s/%s#%d: %s", owner, repo, number, body))
return nil
}
func (f *fakeForge) Unlabel(owner, repo string, number, label int) error {
f.mu.Lock()
defer f.mu.Unlock()
f.unlabels = append(f.unlabels, fmt.Sprintf("%s/%s#%d-%d", owner, repo, number, label))
return nil
}
@@ -69,8 +69,6 @@ type Holder struct {
leaveRefused map[string]string
// tried counts the owed effects Flush tried since this owner started, by how each ended (novox/hq issue 407).
tried owedTries
// marks are the open pull requests read with a ready mark, by owner/repo#number (novox/hq issue 722, ready.go).
marks map[string]*marked
}
// owedTries is how many owed effects were tried since this owner started, and when the last was: three numbers
@@ -122,7 +120,6 @@ func (h *Holder) init() {
h.askTried = map[string]time.Time{}
h.walkAsked = map[string]time.Time{}
h.unchecked = map[string]unchecked{}
h.marks = map[string]*marked{}
h.walks = map[string]seenWalk{}
h.orphans = map[string]time.Time{}
h.asking = map[string]bool{}
@@ -999,12 +996,6 @@ func (h *Holder) Tick() {
for _, ask := range h.leaveStopped() {
ask()
}
h.mu.Lock()
asks := h.readyAsks(h.Now())
h.mu.Unlock()
for _, ask := range asks {
ask()
}
}
// plan works out, under the lock, what to ask; each ask applies its own answer.
@@ -76,9 +76,8 @@ func run(h *Holder, l *listening) {
logf("[mesh-delivery] the controller's walks cannot be read: %v", err)
}
}
// Every open pull request, from the forge itself, every minute (novox/hq issues 347, 722): what says a
// head nobody checks, and the ready marks a merge is asked on.
if now.Sub(looked) >= time.Minute {
// Every open pull request, from the forge itself, every five minutes (novox/hq issue 347).
if now.Sub(looked) >= 5*time.Minute {
looked = now
h.SeeOpenPulls()
}
@@ -65,9 +65,7 @@ func TestItClaimsTheDeliverySeatAndFollowsWhatItHandles(t *testing.T) {
want = append(want, "seat:"+ControllerSeat+"."+v)
}
want = append(want, "gitea.gitea_note_append", "gitea.gitea_delivery_view", "gitea.gitea_commit_status",
"gitea.gitea_commit_statuses", "gitea.gitea_contains", "gitea.gitea_open_pulls",
// The merge on a ready mark (novox/hq issue 722): the merge tool, a comment, a label taken off.
"gitea.gitea_merge_pull_request", "gitea.gitea_add_comment", "gitea.gitea_api")
"gitea.gitea_commit_statuses", "gitea.gitea_contains", "gitea.gitea_open_pulls")
if !reflect.DeepEqual(m.Invokes, want) {
t.Fatalf("invokes %v, wanted %v", m.Invokes, want)
}
@@ -110,9 +110,6 @@ type Delivery struct {
LetGo time.Time `json:"let_go"`
LetGoWhy string `json:"let_go_why,omitempty"`
// Ready is what this owner did with its pull request's ready mark (novox/hq issue 722, ready.go).
Ready *ReadyRun `json:"ready,omitempty"`
Transitions []Transition `json:"transitions,omitempty"`
// Owed are the forge's and the bus's effects of its transitions not yet done: said only after the
// delivery is kept, retried until done.
@@ -91,13 +91,6 @@ type Forge interface {
// OpenPulls is every open pull request, with what its base requires and what its head has (novox/hq issue
// 347). Reads only.
OpenPulls() ([]OpenPull, error)
// Merge asks the forge's merge tool to merge a pull request at the head named (gitea_merge_pull_request, novox/hq
// issue 722): the one path every merge takes. Its refusal is the error, in its words.
Merge(owner, repo string, number int, head string) error
// Comment adds a comment to a pull request.
Comment(owner, repo string, number int, body string) error
// Unlabel takes a label off a pull request, by the label's id.
Unlabel(owner, repo string, number, label int) error
}
// ForgeChecks is what the forge says of a commit's checks (the gitea module's gitea_commit_statuses).
@@ -399,19 +392,6 @@ func (f toolForge) Statuses(owner, repo string, number int, commit, base string)
return &c, nil
}
func (f toolForge) Merge(owner, repo string, number int, head string) error {
return f.tool("gitea_merge_pull_request", map[string]any{"owner": owner, "repo": repo, "number": number, "head": head})
}
func (f toolForge) Comment(owner, repo string, number int, body string) error {
return f.tool("gitea_add_comment", map[string]any{"owner": owner, "repo": repo, "number": number, "body": body})
}
func (f toolForge) Unlabel(owner, repo string, number, label int) error {
return f.tool("gitea_api", map[string]any{"method": "DELETE",
"path": fmt.Sprintf("/repos/%s/%s/issues/%d/labels/%d", owner, repo, number, label)})
}
func (f toolForge) OpenPulls() ([]OpenPull, error) {
raw, err := f.ask("gitea.gitea_open_pulls", map[string]any{})
if err != nil {
@@ -1,323 +0,0 @@
package main
import (
"fmt"
"strings"
"time"
)
// Merge on ready (novox/hq issue 722).
//
// The operator, 2026-10-14: "the reviewer task should merge the request or mark the MR as ready to merge in any case".
// Before, a reviewed pull request with green checks waited until a session called the forge's merge tool, and again
// after every check the tool asked because the trunk had moved. Now the independent reviewer marks the pull request
// ready at the head it approved — the forge label `mesh/ready` and a comment "ready at <full head sha>" (approving
// through the forge is the operator's) — and this owner merges it, through the forge's own merge tool
// (gitea_merge_pull_request), the one path every merge takes:
//
// - only at the head the mark names, and only while that is the pull request's head: a push after the mark clears
// it — the label taken off, and said on the pull request — and the new head needs a mark of its own;
// - only when its delivery is ready here: its check passed against its trunk's head. A verdict out of date is judged
// again by the merge tool, which asks the check against the trunk's head itself (novox/hq ADR 0323 §5); the merge
// is asked again once a minute, and lands on the passing verdict;
// - a group member only at its turn in the group's order, and only once the group's composed check passed for its
// members' heads as they are;
// - a check that failed merges nothing: it is said once on the pull request, and as a stalled line, which the
// controller raises as the condition `delivery.<id>.stalled`. So is a mark the merge tool refused for an hour.
//
// What this owner does with a mark is kept on the delivery (Ready), said in its log, and the merge's transitions name
// it as their author.
// ReadyMark is a pull request's ready mark as the forge reads it (the gitea module's gitea_open_pulls): the head the
// newest "ready at" comment names, who wrote it and when, and the label's id. Head is empty when the label is on and
// no comment names a head.
type ReadyMark struct {
Head string `json:"head"`
By string `json:"by,omitempty"`
At string `json:"at,omitempty"`
LabelID int `json:"label_id"`
}
// ReadyLabel is the label that marks a pull request ready.
const ReadyLabel = "mesh/ready"
// readyTryEvery is how often a marked head's merge is asked while the merge tool refuses it: a check it asked takes
// minutes, and a refusal costs the forge a few reads.
const readyTryEvery = time.Minute
// readyRefusedFor is how long the merge tool may refuse a marked head before it is said as stalled: longer than a
// check takes (the controller's checkTimeout is 45 minutes), so a check on its way is never said.
const readyRefusedFor = time.Hour
// ReadyRun is what this owner did with a delivery's ready mark: kept on the delivery and shown whole by `show`.
type ReadyRun struct {
// Head, By and MarkedAt are the mark: the head it names, who marked it, when.
Head string `json:"head"`
By string `json:"by,omitempty"`
MarkedAt string `json:"marked_at,omitempty"`
// Seen is when this owner first read the mark.
Seen time.Time `json:"seen"`
// Waits is what the merge waits on, when it waits: a verdict, the group's turn or its composed check.
Waits string `json:"waits,omitempty"`
// Tries is how many times the merge was asked; LastTry when, and Answer what the merge tool last answered.
Tries int `json:"tries,omitempty"`
LastTry time.Time `json:"last_try"`
Answer string `json:"answer,omitempty"`
// RefusedSince is when the merge tool began refusing it without a break.
RefusedSince *time.Time `json:"refused_since,omitempty"`
// Merged is when the merge tool merged it.
Merged *time.Time `json:"merged,omitempty"`
// Failed is why it is not merged and will not be on this head: its check failed. Said once on the pull request.
Failed string `json:"failed,omitempty"`
}
// mergedBy is who a merge this owner made on a ready mark is said to be by, in its transitions.
func (r *ReadyRun) mergedBy() string {
if r == nil || r.Merged == nil {
return ""
}
by := byOwner + ", on the ready mark at " + shortOf(r.Head, 12)
if r.By != "" {
by += " by " + r.By
}
return by
}
// marked is a pull request read with a ready mark.
type marked struct {
pull OpenPull
// cleared is when the mark was cleared, the label taken off: not cleared again within readyTryEvery.
cleared time.Time
}
// pullKey is a pull request as a key: owner/repo#number.
func pullKey(p OpenPull) string {
return strings.ToLower(p.Owner+"/"+p.Repo) + fmt.Sprintf("#%d", p.Number)
}
// seeMarks keeps the open pull requests that carry a ready mark; under the lock. A pull request no longer open or
// no longer marked drops out.
func (h *Holder) seeMarks(open []OpenPull) {
seen := map[string]*marked{}
for _, p := range open {
if p.Ready == nil || p.HeadSHA == "" {
continue
}
k := pullKey(p)
m := &marked{pull: p}
if was := h.marks[k]; was != nil && was.pull.Ready != nil && was.pull.Ready.Head == p.Ready.Head &&
was.pull.HeadSHA == p.HeadSHA {
m.cleared = was.cleared
}
seen[k] = m
}
h.marks = seen
}
// sameFull is whether a mark names a head: the whole sha, or — read from an older forge module — at least twelve
// characters of it.
func sameFull(mark, head string) bool {
mark, head = strings.ToLower(mark), strings.ToLower(head)
return len(mark) >= 12 && strings.HasPrefix(head, mark)
}
// readyAsks works out, under the lock, what each ready mark needs: a mark cleared, a merge asked, a failure said.
// The asking happens outside the lock.
func (h *Holder) readyAsks(now time.Time) []func() {
var asks []func()
for k, m := range h.marks {
p, mark := m.pull, *m.pull.Ready
repository := strings.ToLower(p.Owner + "/" + p.Repo)
if !sameFull(mark.Head, p.HeadSHA) {
if !m.cleared.IsZero() && now.Sub(m.cleared) < readyTryEvery {
continue
}
m.cleared = now
asks = append(asks, h.clearMark(k, p, mark))
continue
}
d := h.deliveries[IDOf(repository, p.HeadSHA)]
if d == nil || d.State.Final() || d.MergedAs != "" {
continue // not announced yet, or past its merge
}
if d.Ready == nil || d.Ready.Head != mark.Head {
d.Ready = &ReadyRun{Head: mark.Head, By: mark.By, MarkedAt: mark.At, Seen: now}
h.Logf("[mesh-delivery] %s is marked ready at %s by %s: merged once its check passes against its trunk's head",
d.ID, shortOf(mark.Head, 12), orNone(mark.By))
h.keep(d)
}
r := d.Ready
if r.Merged != nil {
continue
}
if d.State == Rejected {
if r.Failed == "" {
r.Failed = "its check failed"
if d.Check != nil {
r.Failed += ": " + oneLine(d.Check.Summary)
if j := d.Check.Judged(); j != "" {
r.Failed += " (" + j + ")"
}
}
r.Waits = ""
h.Logf("[mesh-delivery] %s is marked ready and not merged: %s", d.ID, r.Failed)
h.keep(d)
asks = append(asks, h.sayOn(d, fmt.Sprintf("mesh-delivery: **not merged.** This pull request is marked "+
"ready at %s, and %s. A new head, marked ready once reviewed, or a recheck that passes, merges it.",
mark.Head, r.Failed)))
}
continue
}
r.Failed = ""
waits := h.readyWaits(d)
if waits != r.Waits {
r.Waits = waits
if waits != "" {
h.Logf("[mesh-delivery] %s is marked ready and waits: %s", d.ID, waits)
}
h.keep(d)
}
if waits != "" || (!r.LastTry.IsZero() && now.Sub(r.LastTry) < readyTryEvery) {
continue
}
r.Tries++
r.LastTry = now
h.keep(d)
asks = append(asks, h.askMerge(d.ID, p, mark.Head))
}
return asks
}
// readyWaits is what a marked delivery's merge waits on; empty when it is asked now. A ready delivery is asked; so is
// a proposed one whose verdict went out of date and whose check nobody asked again — the merge tool asks it.
func (h *Holder) readyWaits(d *Delivery) string {
switch {
case d.State == Ready:
case d.State == Proposed && d.outOfDate() != "" && d.AskedAgain == nil:
case d.State == Proposed && d.AskedAgain != nil:
return "its check against the trunk's head is asked and has not answered"
default:
return "its check has not passed yet (" + string(d.State) + ")"
}
if d.Group == "" {
return ""
}
return h.groupTurnToMerge(d)
}
// groupTurnToMerge is why a group member may not merge yet; empty at its turn: the group's composed check passed for
// its members' heads as they are and is not out of date, and every member before it in the group's order merged.
func (h *Holder) groupTurnToMerge(d *Delivery) string {
g := h.groups[d.Group]
if g == nil {
return ""
}
members := h.membersOf(g)
if state, why := GroupState(g, members); state != "ready" && state != "delivering" {
return fmt.Sprintf("its group %s is %s: %s", g.ID, state, why)
}
if c := g.Check; c == nil || (c.Verdict != "pass" && c.Verdict != "warning") || c.OutOfDate != "" ||
!sameMembers(c.Commits, commitsOf(members)) {
return "its group " + g.ID + "'s composed check has not passed for its members' heads"
}
if !containsString(g.Order, d.ID) {
return "its group " + g.ID + "'s order is not worked out for it yet"
}
for _, id := range g.Order {
if id == d.ID {
return ""
}
if o := h.deliveries[id]; o != nil && o.MergedAs == "" && !o.State.Final() {
return "its group " + g.ID + "'s order merges " + id + " first"
}
}
return ""
}
// askMerge asks the forge's merge tool to merge a marked head, and keeps its answer.
func (h *Holder) askMerge(id string, p OpenPull, head string) func() {
return func() {
err := h.Forge.Merge(p.Owner, p.Repo, p.Number, head)
h.mu.Lock()
defer h.mu.Unlock()
d := h.deliveries[id]
if d == nil || d.Ready == nil {
return
}
r, now := d.Ready, h.Now()
if err == nil {
r.Merged, r.Answer, r.RefusedSince = &now, "merged", nil
h.Logf("[mesh-delivery] %s merged on its ready mark at %s, its check passed against its trunk's head", id,
shortOf(head, 12))
h.keep(d)
return
}
answer := oneLine(err.Error())
if r.RefusedSince == nil {
r.RefusedSince = &now
}
if answer != r.Answer {
h.Logf("[mesh-delivery] %s is marked ready, and the merge tool did not merge it (asked again in %s): %s", id,
readyTryEvery, answer)
}
r.Answer = answer
h.keep(d)
}
}
// clearMark takes a mark off a pull request whose head moved after it, and says so on it.
func (h *Holder) clearMark(k string, p OpenPull, mark ReadyMark) func() {
return func() {
err := h.Forge.Unlabel(p.Owner, p.Repo, p.Number, mark.LabelID)
if err == nil {
err = h.Forge.Comment(p.Owner, p.Repo, p.Number, fmt.Sprintf("mesh-delivery: the ready mark at %s is cleared: "+
"the head is now %s, pushed after it. Mark the new head ready once it is reviewed.", mark.Head, p.HeadSHA))
}
if err != nil {
h.Logf("[mesh-delivery] %s's ready mark at %s names an older head than %s, and could not be cleared yet: %v",
k, shortOf(mark.Head, 12), shortOf(p.HeadSHA, 12), err)
return
}
h.Logf("[mesh-delivery] %s's ready mark at %s cleared: its head is now %s", k, shortOf(mark.Head, 12),
shortOf(p.HeadSHA, 12))
h.mu.Lock()
delete(h.marks, k)
h.mu.Unlock()
}
}
// sayOn comments on a delivery's pull request.
func (h *Holder) sayOn(d *Delivery, body string) func() {
owner, repo, number, id := d.Owner(), d.Repo(), d.Number, d.ID
return func() {
if err := h.Forge.Comment(owner, repo, number, body); err != nil {
h.Logf("[mesh-delivery] %s: could not say on its pull request: %v", id, err)
}
}
}
// readyLines are the marked heads that will not merge: a check that failed at once, a merge refused past its bound;
// as stalled lines, which the controller raises as conditions. Under the lock.
func (h *Holder) readyLines(now time.Time) []StalledLine {
var out []StalledLine
for _, d := range h.deliveries {
r := d.Ready
if r == nil || r.Merged != nil || d.MergedAs != "" || d.State.Final() {
continue
}
switch {
case r.Failed != "" && d.State == Rejected:
out = append(out, StalledLine{ID: d.ID, Number: d.Number, State: d.State, For: now.Sub(r.Seen).Round(time.Second).String(),
Bound: "0s", H2: "none: a new head marked ready, or a recheck that passes — the operator's",
Says: fmt.Sprintf("%s#%d is marked ready at %s and not merged: %s", d.Repository, d.Number,
shortOf(r.Head, 12), r.Failed)})
case r.RefusedSince != nil && now.Sub(*r.RefusedSince) >= readyRefusedFor:
out = append(out, StalledLine{ID: d.ID, Number: d.Number, State: d.State,
For: now.Sub(*r.RefusedSince).Round(time.Second).String(), Bound: readyRefusedFor.String(),
H2: "none: the merge is asked again each minute — the operator's",
Says: fmt.Sprintf("%s#%d is marked ready at %s, and the merge tool has refused it since %s: %s", d.Repository,
d.Number, shortOf(r.Head, 12), r.RefusedSince.UTC().Format(time.RFC3339), r.Answer)})
}
}
return out
}
@@ -1,199 +0,0 @@
package main
import (
"errors"
"strings"
"testing"
"time"
)
// **A pull request marked ready is merged by this owner** (novox/hq issue 722): before, a reviewed pull request with
// green checks waited until a session called the merge tool, and again after every check the tool asked because the
// trunk had moved. Each test below fails on a holder without the ready mark: nothing merged it.
const (
readyHead = "1111111111111111111111111111111111111111"
pushed = "2222222222222222222222222222222222222222"
)
// markedPull is an open pull request as gitea_open_pulls reads it, labelled mesh/ready at the head named.
func markedPull(repo string, number int, head, markedAt string) OpenPull {
owner, name, _ := strings.Cut(repo, "/")
return OpenPull{Owner: owner, Repo: name, Number: number, Base: "main", HeadSHA: head, Labels: []string{ReadyLabel},
Ready: &ReadyMark{Head: markedAt, By: "reviewer", At: "2026-10-14T19:00:00Z", LabelID: 31}}
}
func judgedAt(repo string, number int, sha, gate, trunk string) CheckedEvent {
c := verdict(repo, number, sha, gate)
c.Trunk = trunk
return c
}
func TestAReadyPullRequestMarkedAtItsHeadIsMergedThroughTheMergeTool(t *testing.T) {
w := newWorld(t)
id := IDOf("novox/app", readyHead)
w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go"))
w.h.Checked(judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
w.settleAll()
if len(w.forge.merges) != 0 {
t.Fatalf("an unmarked pull request was merged: %v", w.forge.merges)
}
w.forge.open = []OpenPull{markedPull("novox/app", 7, readyHead, readyHead)}
w.h.SeeOpenPulls()
w.settleAll()
if len(w.forge.merges) != 1 || w.forge.merges[0] != "novox/app#7@"+readyHead {
t.Fatalf("a ready, marked pull request was merged %v; wanted once, at the marked head", w.forge.merges)
}
d := w.delivery(id)
if d.Ready == nil || d.Ready.Merged == nil || d.Ready.By != "reviewer" || d.Ready.Tries != 1 {
t.Fatalf("what was done with the mark is not kept: %+v", d.Ready)
}
// Its merge's transition names who merged it.
w.h.PullMerged(merged("novox/app", 7, readyHead, "m7m7m7m7m7m7"))
w.h.WalkMoved(w.ctl.put(aWalk("plan-7", "novox/app", "m7m7m7m7m7m7", true)))
w.settleAll()
d = w.delivery(id)
var last Transition
for _, tr := range d.Transitions {
if tr.Event == EvMerged {
last = tr
}
}
if !strings.Contains(last.By, "ready mark at "+readyHead[:12]+" by reviewer") {
t.Fatalf("the merge's transition does not name the mark: %+v", last)
}
if len(w.forge.merges) != 1 {
t.Fatalf("merged again: %v", w.forge.merges)
}
}
func TestAPushAfterTheMarkClearsItAndNothingIsMerged(t *testing.T) {
w := newWorld(t)
w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go"))
w.h.PullUpdated(pr("novox/app", 7, pushed, "feat/x", "src/a.go"))
w.h.Checked(judgedAt("novox/app", 7, pushed, "pass", "t1t1t1t1t1t1"))
w.forge.open = []OpenPull{markedPull("novox/app", 7, pushed, readyHead)}
w.h.SeeOpenPulls()
w.settleAll()
if len(w.forge.merges) != 0 {
t.Fatalf("a head pushed after the mark was merged: %v", w.forge.merges)
}
if len(w.forge.unlabels) != 1 || w.forge.unlabels[0] != "novox/app#7-31" {
t.Fatalf("the stale mark's label was not taken off: %v", w.forge.unlabels)
}
if len(w.forge.comments) != 1 || !strings.Contains(w.forge.comments[0], "cleared") ||
!strings.Contains(w.forge.comments[0], pushed) {
t.Fatalf("the cleared mark is not said on the pull request: %v", w.forge.comments)
}
}
func TestAMovedTrunkIsJudgedAgainByTheMergeToolAndMergedOnItsVerdict(t *testing.T) {
w := newWorld(t)
id := IDOf("novox/app", readyHead)
w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go"))
w.h.Checked(judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
// Another merge moves the trunk: its verdict is out of date, and it goes back to proposed.
w.h.PullMerged(merged("novox/app", 9, "9999999999999999", "t2t2t2t2t2t2"))
if w.state(id) != Proposed {
t.Fatalf("out of date, it is %s", w.state(id))
}
// The merge tool asks the check against the trunk's head and refuses: nothing merged.
w.forge.refuse = errors.New("the forge refused gitea_merge_pull_request: The check of head merged into trunk was asked")
w.forge.open = []OpenPull{markedPull("novox/app", 7, readyHead, readyHead)}
w.h.SeeOpenPulls()
w.settleAll()
if len(w.forge.merges) != 1 {
t.Fatalf("the merge tool was not asked once to judge it again: %v", w.forge.merges)
}
// The tool's ask is the head announced again; it waits for that verdict, asking nothing.
w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go"))
w.later(5 * time.Minute)
w.settleAll()
if len(w.forge.merges) != 1 || !strings.Contains(w.delivery(id).Ready.Waits, "asked") {
t.Fatalf("asked again while its check was on its way: %v (%+v)", w.forge.merges, w.delivery(id).Ready)
}
// It passes against the trunk's head: merged, without anyone asking.
w.forge.refuse = nil
w.h.Checked(judgedAt("novox/app", 7, readyHead, "pass", "t2t2t2t2t2t2"))
w.settleAll()
if w.state(id) != Ready || len(w.forge.merges) != 2 || w.delivery(id).Ready.Merged == nil {
t.Fatalf("on its passing verdict it was not merged: %s %v %+v", w.state(id), w.forge.merges, w.delivery(id).Ready)
}
}
func TestAMarkedHeadWhoseCheckFailedIsNotMergedAndIsSaid(t *testing.T) {
w := newWorld(t)
id := IDOf("novox/app", readyHead)
w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go"))
w.h.Checked(judgedAt("novox/app", 7, readyHead, "fail", "t1t1t1t1t1t1"))
w.forge.open = []OpenPull{markedPull("novox/app", 7, readyHead, readyHead)}
w.h.SeeOpenPulls()
w.settleAll()
w.later(10 * time.Minute)
w.h.SeeOpenPulls()
w.settleAll()
if len(w.forge.merges) != 0 {
t.Fatalf("a head whose check failed was merged: %v", w.forge.merges)
}
if len(w.forge.comments) != 1 || !strings.Contains(w.forge.comments[0], "not merged") {
t.Fatalf("the failure is not said once on the pull request: %v", w.forge.comments)
}
lines := w.h.Stalled()
if len(lines) != 1 || lines[0].ID != id || lines[0].State != Rejected || !strings.Contains(lines[0].Says, "marked ready") {
t.Fatalf("the failure is not a stalled line, which the controller raises as a condition: %+v", lines)
}
}
func TestAMarkRefusedForAnHourIsSaid(t *testing.T) {
w := newWorld(t)
w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go"))
w.h.Checked(judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
w.forge.refuse = errors.New("the forge refused gitea_merge_pull_request: mesh/repo-check is pending")
w.forge.open = []OpenPull{markedPull("novox/app", 7, readyHead, readyHead)}
w.h.SeeOpenPulls()
w.settleAll()
w.later(30 * time.Second)
w.settleAll()
if len(w.forge.merges) != 1 {
t.Fatalf("asked again within the minute: %v", w.forge.merges)
}
w.later(readyRefusedFor)
w.settleAll()
if len(w.forge.merges) != 2 {
t.Fatalf("not asked again after a minute: %v", w.forge.merges)
}
if lines := w.h.Stalled(); len(lines) != 1 || !strings.Contains(lines[0].Says, "refused it since") {
t.Fatalf("a mark refused for an hour is not said: %+v", lines)
}
}
func TestAGroupMergesItsMarkedMembersInOrderOnlyOnceComposed(t *testing.T) {
w := newWorld(t)
const ctlHead, catHead = "c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1", "d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2"
ctl, cat := IDOf("novox/mesh-controller", ctlHead), IDOf("novox/mesh-catalog", catHead)
w.ctl.order = func(ms []Member) Order { return Order{Order: []string{ctl, cat}} }
w.h.PullUpdated(pr("novox/mesh-controller", 1, ctlHead, "feat/x", "internal/a.go"))
w.h.PullUpdated(pr("novox/mesh-catalog", 2, catHead, "feat/x", "modules/app/module.json"))
w.h.Checked(judgedAt("novox/mesh-controller", 1, ctlHead, "pass", "t1t1t1t1t1t1"))
w.h.Checked(judgedAt("novox/mesh-catalog", 2, catHead, "pass", "t3t3t3t3t3t3"))
w.forge.open = []OpenPull{markedPull("novox/mesh-controller", 1, ctlHead, ctlHead),
markedPull("novox/mesh-catalog", 2, catHead, catHead)}
w.h.SeeOpenPulls()
w.settleAll()
if len(w.forge.merges) != 0 {
t.Fatalf("a member merged before its group's composed check passed: %v", w.forge.merges)
}
w.h.Checked(CheckedEvent{Group: "feat/x", ID: "check-1", Verdict: "pass", Summary: "both compose"})
w.settleAll()
if len(w.forge.merges) != 1 || w.forge.merges[0] != "novox/mesh-controller#1@"+ctlHead {
t.Fatalf("composed, the first member in the group's order was not merged alone: %v", w.forge.merges)
}
if waits := w.delivery(cat).Ready.Waits; !strings.Contains(waits, "merges "+ctl+" first") {
t.Fatalf("the second member does not say it waits its turn: %q", waits)
}
w.h.PullMerged(merged("novox/mesh-controller", 1, ctlHead, "m1m1m1m1m1m1"))
w.settleAll()
if len(w.forge.merges) != 2 || w.forge.merges[1] != "novox/mesh-catalog#2@"+catHead {
t.Fatalf("after the first member merged, the next was not: %v", w.forge.merges)
}
}
@@ -498,12 +498,7 @@ func Apply(d *Delivery, ev Event, act bool, f Facts) (Transition, error) {
if why == "" {
why = r.Guard
}
by := f.By
if by == "" && (ev == EvMerged || ev == EvDone) && d.MergedAs != "" {
// A merge this owner made on a ready mark names it as its author (novox/hq issue 722).
by = d.Ready.mergedBy()
}
t := Transition{At: f.Now, From: d.State, To: r.To, Event: ev, Why: why, By: by}
t := Transition{At: f.Now, From: d.State, To: r.To, Event: ev, Why: why, By: f.By}
d.State, d.Since = r.To, f.Now
d.Transitions = append(d.Transitions, t)
if len(d.Transitions) > keptTransitions {
@@ -72,9 +72,6 @@ type OpenPull struct {
// than that says none: the head is then read by Statuses alone, as before, and a pending check is not told
// from an answered one.
Checks map[string]HeadCheck `json:"checks,omitempty"`
// Labels are its labels by name, and Ready its ready mark, when it carries one (novox/hq issue 722).
Labels []string `json:"labels,omitempty"`
Ready *ReadyMark `json:"ready,omitempty"`
}
// HeadCheck is one status on a head as the forge keeps it: pending, success, failure, error or warning.
@@ -204,7 +201,6 @@ func (h *Holder) SeeOpenPulls() {
seen[id] = u
}
h.unchecked = seen
h.seeMarks(open)
}
// started is whether the head's check started, rather than was never asked: it has any merge check, or a delivery
@@ -249,7 +249,6 @@ func (h *Holder) Stalled() []StalledLine {
}
out = append(out, h.withChecks(h.uncheckedLines(now))...)
out = append(out, h.orphanLines(now)...)
out = append(out, h.readyLines(now)...)
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
return out
}
+1 -4
View File
@@ -47,10 +47,7 @@
"gitea.gitea_commit_status",
"gitea.gitea_commit_statuses",
"gitea.gitea_contains",
"gitea.gitea_open_pulls",
"gitea.gitea_merge_pull_request",
"gitea.gitea_add_comment",
"gitea.gitea_api"
"gitea.gitea_open_pulls"
],
"state": [
"deliveries",
@@ -121,12 +121,6 @@ func TestItsProcessesAreThePreparationTheActAndTheJudge(t *testing.T) {
!reflect.DeepEqual(m.Tools, []string{HealthTool, HighestTool}) {
t.Fatalf("watch %v, tools %v", watch, m.Tools)
}
// The watch's verdict is whether the database serves, so its check names the provision it exercises: while the
// database's provider is unhealthy, this finding is held under the provider's condition (hq ADR 0240 rule 5,
// issue 145).
if h["needs"] != "postgres-database" {
t.Fatalf("watch's check needs %v; it exercises postgres-database", h["needs"])
}
if watch["env"].(map[string]any)["MESH_KNOWLEDGE_STATUS"] != m.Build.Artifacts[0].Env["MESH_KNOWLEDGE_STATUS"] {
t.Fatal("watch writes another status file than the health tool reads")
}
-1
View File
@@ -156,7 +156,6 @@
"health": {
"kind": "tool",
"tool": "knowledge_health",
"needs": "postgres-database",
"interval": "60s",
"timeout": "10s",
"looks": 2,
+164
View File
@@ -0,0 +1,164 @@
# mesh-vault
Holds the `mesh-vault` seat: the mesh's shared secrets, each kept sealed, handed out only on request and
reply, sealed to whoever asked (novox/hq ADR 0085, ADR 0113, ADR 0349 §7 and §11). Go, one binary
(`cmd/mesh-vault`), launched by its node's runtime and speaking MCP to it through the Go SDK.
## Where the secrets are kept, and why there
In the vault's own store: one file per secret in `${dir:store}` on the vault's own disk, mode 0600 in a
0700 directory. Not a database, as most Go modules keep their data, because:
- the vault is installed before any other module built on the runtime base (ADR 0113 §14), and a database
login is itself a shared secret the vault makes — the vault cannot need one to start;
- every value in the store is ciphertext anyway, so a database would add nothing a file does not;
- no secret is in a bucket, sealed or not (ADR 0201 §13), so the module declares no `state`.
Each record holds the value twice, never in the clear:
- **at rest**, sealed to the vault's own key (the sealed box of ADR 0206, `mesh-sdk/go/seal`). The key is
an X25519 pair the vault makes once in `${dir:state}/vault-key.json`; its private half never leaves it.
The key is made only for an empty store: with the key file missing and secrets in the store, the vault
does not start and says so, rather than make a key that opens none of them.
- **for the operator**, sealed to the operator key (ADR 0113 §22), so nothing exists only inside the
vault. A secret is refused, loudly, while the vault has no operator key.
Every file is written whole, synced, renamed into place and its directory synced.
### What the encryption at rest protects against, and what not
The vault's private key lives in plaintext in `${dir:state}`, on the same disk as the store, readable by the
vault's account alone. So:
- **It protects** against a copy of the store directory alone — a `store` backup, a file sent somewhere,
a listing of `${dir:store}` by a tool that reads files — holding anything usable, and against a value
being found by searching the disk for it.
- **It does not protect** against anyone who can read the vault's state directory too: root on the
vault's node, the vault's own account, or a backup that holds both `state` and `store` (both are
class `valuable`, so the node's backup holds both). Against those, at-rest encryption adds nothing; the
protection is the node's own security. Keeping the key elsewhere (another directory class, a key the
operator unlocks) is for V3's review, before real secrets move in.
## Where the operator key comes from
From the export the mesh writes to the vault's root directory (`${dir:root}/export.json`, the manifest's
`keeps`): its `operator-key` is the public half `operator key set` recorded in the controller. The vault
checks it every minute; a new key makes the vault seal every operator copy again to it — it can, because
it opens its own copy. Each record names the key its copy is sealed to, so a failure partway is finished on
the next minute's pass, and nothing is left sealed to an earlier key for good.
The vault takes an operator key only as SPKI PEM (`SetOperatorKey`). A PEM private key is refused, and so
is a raw 32-byte key, because a raw private key looks the same. The one raw key it reads is the export's,
which the controller writes from what it recorded; the vault wraps it as SPKI before taking it. It keeps
the key raw in base64, the form of the operator's NaCl box. When the secrets move to the vault (design 57's
V3), the operator key moves with them.
### How the operator opens a copy
`mesh-vault.secret_vault_export` answers the vault's copies in the shape `secret export` writes. Saved to a
file, one is opened with the operator's private key by
nox secret recover vault mesh-vault <name> --key <operator key file> --from-export <file> --out <file>
(`--out` is needed: a name holds `/`). `TestTheOperatorRecoversAVaultSecretFromItsExport` reads the file
as that command does and opens it as it does. `secret_export` still answers the controller's export, as
before; until V3 the controller's secrets are there.
## The boxes and the key formats — settled
Two boxes on the mesh today, both over X25519:
| | format | keys | who uses it |
|---|---|---|---|
| NaCl anonymous box | XSalsa20-Poly1305, `box.SealAnonymous` | raw 32 bytes, base64 | the controller sealing into declarations; the node-engine's `sealing.key` (mesh-host `internal/identity/sealing.go`); the operator key and `secret recover --from-export` |
| the sealed box (ADR 0206) | X25519, HKDF-SHA256, AES-256-GCM, `{v, eph, iv, tag, ct}` | PEM: SPKI public, PKCS #8 private | the licence manager and `claude-code`; `mesh-sdk/go/seal` |
What the vault uses:
- **Its answers to `get` are the ADR 0206 box, for every asker, a node included.** The asker sends its
public key as SPKI PEM. A node's provisioner client (V4) needs no second key: its node's sealing key is
X25519 already, so it sends that key's public half wrapped as SPKI and opens the answer with the private
half wrapped as PKCS #8 (`TestANodesSealingKeyOpensTheAnswer` does exactly this). A raw 32-byte key is
refused: a raw private key looks the same, and the vault takes no private key.
- **Its operator copies are the NaCl anonymous box**, to the operator key as it is made today, so the
operator's recovery opens them with the key and the command it already has.
- **The at-rest copy is the ADR 0206 box**, to the vault's own key.
The NaCl box stays on the node-engine only for what the controller seals into declarations, until the last
module has moved (ADR 0349 §17). For the core's own credentials that travel in a declaration sealed by the
vault (ADR 0349 §16), V4 adds opening the ADR 0206 box with the same sealing key to the node-engine.
## The seat's verb
`mesh-vault.get` — `name`, `public_key` (SPKI PEM). It answers `{name, generation, changed-at, sealed}`,
`sealed` being the secret sealed to that key, and only to an asker allowed for that secret, the asker being
the bus user the runtime proved — never an argument.
Every answer and every refusal is recorded in the vault's own store (`handed.jsonl`: who, which secret,
which generation, when, and why a refusal), synced before the answer is given — as ADR 0277 §25 records
asks; a secret whose hand-out cannot be recorded is not handed out. A refusal is recorded only for a caller
of a bus user's shape and a well-formed secret name, so an arbitrary argument fills nothing. The record is
bounded: rolled at 1 MiB into `handed.jsonl.1` … `.3`, the oldest dropped, each roll said loudly in the
journal and counted in `vault_status`. `secret_handed` reads it.
`vault_status` says what needs a person: operator copies still sealed to an earlier key, an operator key in
the export the vault refused, and how often the record was rolled. **It is not yet a condition**: a health
check is declared on a long-running resource (ADR 0240), and the vault is a bundle the tool runner loads, so
nothing asks it on its own; until it runs as a resource of its own (V3's review) these reach a person
through the journal and this tool only.
**Who may ask, today: nobody.** The rule (ADR 0349 §11) is a node's provisioner client under a bus account
of its own, for what its node's declaration names, and a module under its own account (issue 569). Neither
exists yet, so the running vault refuses every asker; the tests set a stand-in list. The real rule goes
behind the `Askers` interface in `askers.go` (design 57's V4 and P3).
## Events
- `secret-changed` — `name`, `by`, `at`, `generation`: which secret, who changed it, when. Never a value
or a sealed copy.
- `provisioned`, `rotated` — the custody ledger's, by fingerprint, as before.
- `provisioner.retirement` (ADR 0230 §35) — `waiting`, `settled`, `approved`, `rejected`, `retired`,
`reenabled`, `deleted`, naming holders, never a value.
## The tools
`secret_export`, `secret_holders`, `secret_verify` answer as the TypeScript vault's did, and its custody
ledger (`${dir:ledger}`, the same JSON files) is kept by a pass over the contributions file every minute.
`secret_handed` and `secret_vault_export` read the vault's own store. None returns a value.
**The ledger follows ADR 0230.** A contributions file that does not say who is asked for (unreadable, not
JSON, no `given` list or a null one, or for another requirement) is not "nobody": the pass records nothing,
retires nobody and starts its count again. A holder no longer asked for is retired — a mark with when and
why in its own ledger file, its history kept — only after the same set has gone unasked in five passes and
ten minutes; more than three at once, or more than half of those held, waits for a person
(`provisioner_retire_approve`, `provisioner_retire_reject`); asked for again it is re-enabled; it is deleted
only by `provisioner_delete`, a person's act with a why. `provisioner_retirement` shows it all. The vault is
mark-only: it holds no access to switch off.
The words are the controller's: the `provisioner.retirement` event is mesh-controller's `link.Retirement`
(`provider-node`, `consumers` as objects) and the `provisioner_retirement` answer its `link.RetirementState`
(`held`, `bound`, `retires_by: mark-only`), so `retire approve`, `retire reject` and `cleanup list` read the
vault and a set waiting for a person raises the controller's urgent condition. The acts take what the SDK's
harness takes: approve and reject repeat the set in `consumers` and refuse any other set; delete repeats the
name in `confirm`; each records the `by` and `via` the controller sends. `testdata/retirement-*.json` are
those types marshalled by the controller (feat/57 at dc36536), and the tests hold the vault's words to them.
What the TypeScript vault did and this one does not: it consumed its own events only to print an audit line,
which the pass now prints itself. Its retirement ran on the SDK's harness, which this one follows: mark-only,
and a holder's ledger file removed only through a person's delete, as here. The difference is where the mark
lives: the harness kept it in its process, so a restart forgot it (ADR 0230 §13); here it is in the ledger
file.
## What comes later
Taking values in (`secret accept`, `secret-ask`, `issued`, `rotate`) is V2; the controller's secrets move
in with V3; nodes ask with V4; pair credentials are minted here with V5. Until V3 the vault's store is
empty and the controller still serves every secret.
## Tests
go test ./...
`vault_test.go` is ADR 0349's vault test list as far as this package goes: no bucket holds a secret; no
private key is accepted; every secret has an operator-sealed copy; `get` answers only an allowed asker,
sealed to its key; `secret-changed` carries no value.
-172
View File
@@ -1,172 +0,0 @@
// mesh-vault's ledger — vault's own code, living in the module (novox/hq ADR 0039). The provisioner and
// the tools both import it, and nothing outside vault does.
//
// **The vault holds no value.** A `secret` is an ordinary pair credential: the controller mints it,
// seals it to the consumer's node and to this one, and the host unseals this node's copy into the
// file the contribution names (ADR 0048). That file is already on this machine, readable by nothing
// but the vault's runtime, and it is the only copy the vault ever sees. Writing a second copy —
// plain, or sealed to a key the vault keeps — would put back exactly the single place that can open
// everything, which is what sealing to the machine was built to remove (ADR 0085's open question is
// how to recover WITHOUT that; the answer is not "keep one anyway").
//
// So what the vault keeps is what makes a secret *owned* rather than merely delivered: who holds
// one, since when, its fingerprint, and every time it changed. Enough to say "this holder's value is
// the one the mesh last delivered" and "it has been rotated twice, last on Tuesday" — and never
// enough to say what it is. The fingerprint is the only thing a tool may take or return, which is
// the rule the source mesh's secret tools were built on: the secret is never an argument.
import { createHash } from "node:crypto";
import { mkdirSync, readdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
import { join } from "node:path";
/** One holder of a secret this vault provides — everything the vault knows, and no value. */
export interface Held {
/** The login the mesh derived for the consumer — `<node>-<module>`, so it names the holder. */
readonly as: string;
/** The consumer's node. */
readonly consumer: string;
/** sha256 of the value the mesh last delivered, `sha256:<hex>`. Compared, never inverted. */
readonly fingerprint: string;
/** Length of the value, so a holder can tell a truncated file from a wrong one. */
readonly length: number;
/** When this holder was first granted a secret. */
readonly since: string;
/** When the value last changed — equal to `since` until the first rotation. */
readonly changed: string;
/** How many times the value has changed since `since`. */
readonly rotations: number;
/** Every earlier fingerprint, oldest first: the audit trail a rotation leaves. */
readonly history: readonly { readonly fingerprint: string; readonly until: string }[];
}
/** What recording a delivery found: a new holder, a changed value, or nothing new. */
export type Outcome = "granted" | "rotated" | "unchanged";
/** sha256 of a value, as `sha256:<hex>`. The one thing about a secret that may be spoken. */
export function fingerprint(value: string): string {
return "sha256:" + createHash("sha256").update(value, "utf8").digest("hex");
}
export class Ledger {
private readonly dir: string;
constructor(dir: string) {
this.dir = dir;
mkdirSync(dir, { recursive: true, mode: 0o700 });
}
/** Build from the module's resolved environment: $MESH_VAULT_LEDGER is where holders are kept. */
static fromEnv(env: NodeJS.ProcessEnv = process.env): Ledger {
const dir = env.MESH_VAULT_LEDGER;
if (!dir) {
throw new Error("MESH_VAULT_LEDGER is not set — the vault has nowhere to keep its ledger");
}
return new Ledger(dir);
}
/**
* Record that the mesh delivered `value` for `as`. Idempotent: the same value again changes
* nothing, a different value is a rotation and is remembered as one. The value is fingerprinted
* here and goes no further.
*/
record(as: string, consumer: string, value: string, now = new Date()): { held: Held; outcome: Outcome } {
const fp = fingerprint(value);
const at = now.toISOString();
const before = this.get(as);
if (!before) {
const held: Held = {
as, consumer, fingerprint: fp, length: value.length,
since: at, changed: at, rotations: 0, history: [],
};
this.write(held);
return { held, outcome: "granted" };
}
if (before.fingerprint === fp && before.length === value.length) {
return { held: before, outcome: "unchanged" };
}
const held: Held = {
...before, consumer, fingerprint: fp, length: value.length, changed: at,
rotations: before.rotations + 1,
history: [...before.history, { fingerprint: before.fingerprint, until: at }],
};
this.write(held);
return { held, outcome: "rotated" };
}
/** Forget a holder the mesh withdrew. Returns whether there was one to forget. */
withdraw(as: string): boolean {
try {
unlinkSync(this.pathOf(as));
return true;
} catch {
return false;
}
}
get(as: string): Held | undefined {
try {
return JSON.parse(readFileSync(this.pathOf(as), "utf8")) as Held;
} catch {
return undefined;
}
}
/** Every holder, by login. */
list(): Held[] {
let names: string[];
try {
names = readdirSync(this.dir);
} catch {
return [];
}
return names
.filter((n) => n.endsWith(".json"))
.map((n) => this.get(n.slice(0, -".json".length)))
.filter((h): h is Held => h !== undefined)
.sort((a, b) => a.as.localeCompare(b.as));
}
private pathOf(as: string): string {
if (!/^[a-z0-9][a-z0-9_.-]*$/.test(as)) {
throw new Error(`a login is a name, not a path: ${JSON.stringify(as)}`);
}
return join(this.dir, `${as}.json`);
}
/** Written whole and renamed into place, so a reader never sees half a record. */
private write(held: Held): void {
const final = this.pathOf(held.as);
const tmp = `${final}.${process.pid}.tmp`;
writeFileSync(tmp, JSON.stringify(held, null, 2) + "\n", { mode: 0o600 });
renameSync(tmp, final);
}
}
/** One entry of the mesh's contributions file, as the vault reads it for its tools. */
export interface Contribution {
readonly from?: string;
readonly node?: string;
readonly as: string;
readonly secret: string;
}
/** The consumers the mesh currently asks this vault to serve — the `receives` file, read plainly. */
export function contributions(receives: string): Contribution[] {
let doc: { given?: Contribution[] };
try {
doc = JSON.parse(readFileSync(receives, "utf8")) as { given?: Contribution[] };
} catch {
return [];
}
return (doc.given ?? []).filter((g) => g.as && g.secret);
}
/** Fingerprint of the value the host currently holds for one contribution, or why it could not. */
export function deliveredFingerprint(c: Contribution): { fingerprint: string; length: number } | { error: string } {
try {
const value = readFileSync(c.secret, "utf8").replace(/\n$/, "");
return { fingerprint: fingerprint(value), length: value.length };
} catch (err) {
return { error: `the delivered secret is not readable: ${err}` };
}
}
@@ -0,0 +1,37 @@
package main
// Who may ask `get` for which secret: the seam the real rule fills (novox/hq ADR 0349 §11).
//
// The rule is: a node's provisioner client, running as a service under a bus account of its own, for a
// secret that node's declaration names; or a module, for a secret its manifest declares, only once its
// code runs under a bus account of its own (issue 569). Neither exists yet — the client and its account are
// design 57's P3, the vault's answer to a node V4 — so the vault that runs allows nobody (NoAsker), and the
// tests set a stand-in list (AllowList). V4 and P3 put the real rule behind this interface.
// Askers decides whether caller (the bus user the runtime proved) may have the secret name, and if not,
// why not.
type Askers interface {
May(caller, name string) (bool, string)
}
// NoAsker allows nobody: the vault's rule until who may ask is decided by a declaration and an account.
type NoAsker struct{}
// May refuses every caller.
func (NoAsker) May(string, string) (bool, string) {
return false, "the vault hands a secret to no asker yet: a node's provisioner client under its own account, " +
"for what its declaration names, is the first it will answer (novox/hq ADR 0349 §11, issue 647)"
}
// AllowList is a stand-in rule: each caller, and the names it may have.
type AllowList map[string][]string
// May allows a caller only the names listed for it.
func (l AllowList) May(caller, name string) (bool, string) {
for _, n := range l[caller] {
if n == name {
return true, ""
}
}
return false, "not on the list of who may ask for it"
}
+168
View File
@@ -0,0 +1,168 @@
package main
// The keys the vault meets, and the one it holds (novox/hq ADR 0113 §2, ADR 0206).
//
// - Its own key: an X25519 pair made once in its state directory, the private half never leaving it. Every
// secret is kept sealed to it at rest.
// - An asker's key: the public half only, as SPKI PEM — the form of the ADR 0206 box (mesh-sdk go/seal).
// A raw 32-byte key is refused, because a raw private key looks the same and the vault takes none.
// - The operator key: the public half, taken as SPKI PEM only and kept raw in base64, the form `operator key
// make` writes and the NaCl box the operator opens uses.
//
// No private key is ever accepted: not as a key to seal to, not as a secret to hold.
import (
"crypto/ecdh"
"crypto/rand"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"git.novox.be/novox/mesh-sdk/go/seal"
"golang.org/x/crypto/nacl/box"
)
// OwnKeyFile is where the vault's own key lives in its state directory.
const OwnKeyFile = "vault-key.json"
// OwnKey is the vault's own key pair, made once in dir. Written whole and synced, then linked into place and
// the directory synced, so a second process reads the first's key and never half of it.
//
// **A key is made only for an empty store.** When the key file is missing and the store holds secrets, the
// key they are sealed to is lost or misplaced, and a new key would open none of them: the vault refuses to
// start, loudly, rather than seal new secrets to a key beside old ones nothing opens. The operator copies
// are what re-raise it (ADR 0113 §21).
func OwnKey(dir string, store *Store) (seal.KeyPair, error) {
if dir == "" {
return seal.KeyPair{}, errors.New("the vault has no state directory: MESH_VAULT_STATE is not set")
}
if err := os.MkdirAll(dir, 0o700); err != nil {
return seal.KeyPair{}, err
}
file := filepath.Join(dir, OwnKeyFile)
if _, err := os.Stat(file); errors.Is(err, os.ErrNotExist) {
if store != nil {
empty, err := store.Empty()
if err != nil {
return seal.KeyPair{}, fmt.Errorf("the vault's key is missing and its store cannot be read: %w", err)
}
if !empty {
return seal.KeyPair{}, fmt.Errorf("THE VAULT'S KEY IS MISSING (%s) WHILE ITS STORE HOLDS SECRETS SEALED TO IT: "+
"the vault does not start and makes no new key, which would open none of them — put the key back, "+
"or re-raise the vault from the operator copies (novox/hq ADR 0113 §21)", file)
}
}
k, err := seal.GenerateKeyPair()
if err != nil {
return seal.KeyPair{}, err
}
raw, _ := json.Marshal(k)
tmp := filepath.Join(dir, fmt.Sprintf(".vault-key.%d.json", os.Getpid()))
if err := writeSynced(tmp, raw); err != nil {
return seal.KeyPair{}, err
}
_ = os.Link(tmp, file) // fails when another made it first, which is right
_ = os.Remove(tmp)
if err := syncDir(dir); err != nil {
return seal.KeyPair{}, err
}
}
raw, err := os.ReadFile(file)
if err != nil {
return seal.KeyPair{}, err
}
var k seal.KeyPair
if err := json.Unmarshal(raw, &k); err != nil || k.PublicKey == "" || k.PrivateKey == "" {
return seal.KeyPair{}, fmt.Errorf("%s is not the vault's key", file)
}
return k, nil
}
var privateKeyPEM = regexp.MustCompile(`-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----`)
// privateKeyIn says whether text carries a private key.
func privateKeyIn(text string) bool {
return privateKeyPEM.MatchString(text) || strings.Contains(text, "PRIVATE KEY")
}
// askerKey is an asker's public key, checked and as PEM: SPKI, X25519, and never a private key.
func askerKey(key string) (string, error) {
if privateKeyIn(key) {
return "", errors.New("that is a private key, and a private key is never sent to the vault (ADR 0113 §2): send the public half")
}
block, rest := pem.Decode([]byte(strings.TrimSpace(key)))
if block == nil || block.Type != "PUBLIC KEY" || strings.TrimSpace(string(rest)) != "" {
return "", errors.New("the key to seal to is one X25519 public key as SPKI PEM (`-----BEGIN PUBLIC KEY-----`), the form of the sealed box (ADR 0206)")
}
k, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
return "", fmt.Errorf("the key to seal to is not a public key: %w", err)
}
pub, ok := k.(*ecdh.PublicKey)
if !ok || pub.Curve() != ecdh.X25519() {
return "", errors.New("the key to seal to is not an X25519 key")
}
return string(pem.EncodeToMemory(block)), nil
}
// checkOperatorKey takes the operator's public key as SPKI PEM — never a private key, and never a raw key,
// which cannot be told from a private one — and gives it in the raw base64 form the operator's NaCl box and
// `secret recover` use.
func checkOperatorKey(key string) (string, error) {
if privateKeyIn(key) {
return "", errors.New("that is a private key: the operator key's private half never enters the mesh (ADR 0113 §2)")
}
block, rest := pem.Decode([]byte(strings.TrimSpace(key)))
if block == nil || block.Type != "PUBLIC KEY" || strings.TrimSpace(string(rest)) != "" {
return "", errors.New("the operator key is one X25519 public key as SPKI PEM (`-----BEGIN PUBLIC KEY-----`); " +
"a raw key is refused, because a raw private key looks the same")
}
k, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
return "", fmt.Errorf("the operator key is not a public key: %w", err)
}
pub, ok := k.(*ecdh.PublicKey)
if !ok || pub.Curve() != ecdh.X25519() {
return "", errors.New("the operator key is not an X25519 key")
}
return base64.StdEncoding.EncodeToString(pub.Bytes()), nil
}
// publicPEM wraps a raw X25519 public key, base64, as SPKI PEM.
func publicPEM(raw string) (string, error) {
b, err := base64.StdEncoding.DecodeString(strings.TrimSpace(raw))
if err != nil || len(b) != 32 {
return "", errors.New("not a raw X25519 key in base64")
}
pub, err := ecdh.X25519().NewPublicKey(b)
if err != nil {
return "", err
}
der, err := x509.MarshalPKIXPublicKey(pub)
if err != nil {
return "", err
}
return string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: der})), nil
}
// operatorSeal seals value to the operator key in the box the operator opens today.
func operatorSeal(key string, value []byte) (string, error) {
raw, err := base64.StdEncoding.DecodeString(key)
if err != nil || len(raw) != 32 {
return "", errors.New("the operator key is not usable")
}
var pub [32]byte
copy(pub[:], raw)
sealed, err := box.SealAnonymous(nil, value, &pub, rand.Reader)
if err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(sealed), nil
}
+251
View File
@@ -0,0 +1,251 @@
package main
// The custody ledger (novox/hq ADR 0085, design 24), carried over from the TypeScript vault: for each pair
// credential the controller delivers to this vault as the `secret` provider, who holds it, since when, its
// fingerprint and every rotation — and never the value. The controller still mints and seals those until
// the secrets move to the vault (design 57's V3, V4); the pass records what it delivered, so
// `secret_holders` and `secret_verify` answer as before. Its files are the same JSON, read as they are.
//
// **A holder the mesh stops asking for is retired, never deleted by a pass** (novox/hq ADR 0230). The vault
// is a mark-only provider: it has no access to switch off, so retiring writes the mark — when and why — into
// the holder's own ledger file, which keeps its rotation history. A holder is retired only once the same set
// has gone unasked in five consecutive passes that read the contributions file and for ten minutes; a set
// over the bound (more than three, or more than half of those held) waits for a person; asked for again, a
// retired holder is re-enabled at once; a holder is deleted only by `provisioner_delete`, a person's act
// with a why. Because the mark is in the ledger file, a restart forgets nothing.
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
)
// isoMillis is JavaScript's toISOString, the form the ledger's times were first written in.
const isoMillis = "2006-01-02T15:04:05.000Z"
// Held is one holder of a secret this vault provides: everything the vault knows of it, and no value.
type Held struct {
As string `json:"as"`
Consumer string `json:"consumer"`
Fingerprint string `json:"fingerprint"`
Length int `json:"length"`
Since string `json:"since"`
Changed string `json:"changed"`
Rotations int `json:"rotations"`
History []Earlier `json:"history"`
// Retired is the mark a retirement writes; nil while the holder is active.
Retired *RetiredMark `json:"retired,omitempty"`
}
// RetiredMark is when and why a holder was retired.
type RetiredMark struct {
At string `json:"at"`
Why string `json:"why"`
}
// Earlier is a fingerprint a rotation replaced, and when.
type Earlier struct {
Fingerprint string `json:"fingerprint"`
Until string `json:"until"`
}
// Outcome is what recording a delivery found.
type Outcome string
const (
Granted Outcome = "granted"
Rotated Outcome = "rotated"
Unchanged Outcome = "unchanged"
)
// Fingerprint is sha256 of a value, as `sha256:<hex>`: the one thing about a secret that may be said.
func Fingerprint(value string) string {
sum := sha256.Sum256([]byte(value))
return "sha256:" + hex.EncodeToString(sum[:])
}
// Ledger is the directory of holders.
type Ledger struct{ dir string }
// NewLedger opens (and makes) the ledger at dir.
func NewLedger(dir string) (*Ledger, error) {
if dir == "" {
return nil, errors.New("MESH_VAULT_LEDGER is not set: the vault has nowhere to keep its ledger")
}
if err := os.MkdirAll(dir, 0o700); err != nil {
return nil, err
}
return &Ledger{dir: dir}, nil
}
var login = regexp.MustCompile(`^[a-z0-9][a-z0-9_.-]*$`)
func (l *Ledger) pathOf(as string) (string, error) {
if !login.MatchString(as) || strings.Contains(as, "..") {
return "", fmt.Errorf("a login is a name, not a path: %q", as)
}
return filepath.Join(l.dir, as+".json"), nil
}
// Record says the mesh delivered value for as: the same value again changes nothing, another is a rotation.
// The value is fingerprinted here and goes no further. A retirement mark is left as it is.
func (l *Ledger) Record(as, consumer, value string, now time.Time) (Held, Outcome, error) {
path, err := l.pathOf(as)
if err != nil {
return Held{}, "", err
}
fp, at := Fingerprint(value), now.UTC().Format(isoMillis)
before, ok := l.Get(as)
if !ok {
h := Held{As: as, Consumer: consumer, Fingerprint: fp, Length: len(value), Since: at, Changed: at, History: []Earlier{}}
return h, Granted, l.write(path, h)
}
if before.Fingerprint == fp && before.Length == len(value) {
return before, Unchanged, nil
}
h := before
h.Consumer, h.Fingerprint, h.Length, h.Changed = consumer, fp, len(value), at
h.Rotations++
h.History = append(append([]Earlier{}, before.History...), Earlier{Fingerprint: before.Fingerprint, Until: at})
return h, Rotated, l.write(path, h)
}
// Mark writes or clears a holder's retirement mark.
func (l *Ledger) Mark(as string, mark *RetiredMark) error {
path, err := l.pathOf(as)
if err != nil {
return err
}
h, ok := l.Get(as)
if !ok {
return fmt.Errorf("the ledger holds nothing for %s", as)
}
h.Retired = mark
return l.write(path, h)
}
// Delete forgets a holder, saying whether there was one. Only a person's `provisioner_delete` reaches it.
func (l *Ledger) Delete(as string) bool {
path, err := l.pathOf(as)
if err != nil {
return false
}
if os.Remove(path) != nil {
return false
}
_ = syncDir(l.dir)
return true
}
// Get is one holder.
func (l *Ledger) Get(as string) (Held, bool) {
path, err := l.pathOf(as)
if err != nil {
return Held{}, false
}
raw, err := os.ReadFile(path)
if err != nil {
return Held{}, false
}
var h Held
if json.Unmarshal(raw, &h) != nil {
return Held{}, false
}
return h, true
}
// List is every holder, by login.
func (l *Ledger) List() []Held {
entries, err := os.ReadDir(l.dir)
if err != nil {
return nil
}
var out []Held
for _, e := range entries {
if strings.HasSuffix(e.Name(), ".json") {
if h, ok := l.Get(strings.TrimSuffix(e.Name(), ".json")); ok {
out = append(out, h)
}
}
}
sort.Slice(out, func(i, j int) bool { return out[i].As < out[j].As })
return out
}
func (l *Ledger) write(path string, h Held) error {
raw, err := json.MarshalIndent(h, "", " ")
if err != nil {
return err
}
return writeWhole(path, append(raw, '\n'))
}
// Contribution is one entry of the contributions file the mesh delivers to the vault.
type Contribution struct {
From string `json:"from,omitempty"`
Node string `json:"node,omitempty"`
As string `json:"as"`
Secret string `json:"secret"`
}
// Contributions is what the mesh asks this vault to serve: the entries with a login and a secret file.
//
// **A file that does not say who is asked for is not "nobody".** One that is unreadable, not JSON, without a
// `given` list (missing or null), or written for another requirement than `secret` is refused, so a pass
// over it retires nobody. An empty `given` list is an answer: nobody is asked for.
func Contributions(receives string) ([]Contribution, error) {
raw, err := os.ReadFile(receives)
if err != nil {
return nil, err
}
var doc struct {
Requirement *string `json:"requirement"`
Given json.RawMessage `json:"given"`
}
if err := json.Unmarshal(raw, &doc); err != nil {
return nil, fmt.Errorf("the contributions file is not JSON: %w", err)
}
if doc.Requirement != nil && *doc.Requirement != "secret" {
return nil, fmt.Errorf("the contributions file is for %q, not for secret", *doc.Requirement)
}
if len(doc.Given) == 0 || string(doc.Given) == "null" {
return nil, errors.New("the contributions file has no `given` list, so it says nothing of who is asked for")
}
var given []Contribution
if err := json.Unmarshal(doc.Given, &given); err != nil {
return nil, fmt.Errorf("the contributions file's `given` is not a list of contributions: %w", err)
}
var out []Contribution
for _, g := range given {
if g.As != "" && g.Secret != "" {
out = append(out, g)
}
}
return out, nil
}
// delivered is the value the mesh delivered for c, without the newline a file may end with.
func delivered(c Contribution) (string, error) {
raw, err := os.ReadFile(c.Secret)
if err != nil {
return "", fmt.Errorf("the delivered secret is not readable: %v", err)
}
return strings.TrimSuffix(string(raw), "\n"), nil
}
// DeliveredFingerprint is the fingerprint and length of what the mesh currently delivers for c.
func DeliveredFingerprint(c Contribution) (string, int, error) {
v, err := delivered(c)
if err != nil {
return "", 0, err
}
return Fingerprint(v), len(v), nil
}
@@ -0,0 +1,129 @@
package main
// The custody ledger the TypeScript vault kept (its client.ts and test/ledger.test.ts), carried over whole:
// who holds a secret the controller delivered for this vault, since when, and its fingerprint — never the
// value. Its files are the same JSON, so the ledger on the vault's disk is read as it is.
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestAFirstDeliveryIsAGrantTheSameAgainNothingANewValueARotation(t *testing.T) {
l, _ := NewLedger(t.TempDir())
t0 := time.Date(2026, 9, 20, 10, 0, 0, 0, time.UTC)
t1 := time.Date(2026, 9, 21, 10, 0, 0, 0, time.UTC)
held, outcome, err := l.Record("anchor-redis", "anchor", "first-value", t0)
if err != nil || outcome != Granted || held.Rotations != 0 || held.Since != t0.Format(isoMillis) ||
held.Fingerprint != Fingerprint("first-value") {
t.Fatalf("%+v %s %v", held, outcome, err)
}
if _, outcome, _ := l.Record("anchor-redis", "anchor", "first-value", t1); outcome != Unchanged {
t.Errorf("the same value again is %s", outcome)
}
held, outcome, _ = l.Record("anchor-redis", "anchor", "second-value", t1)
if outcome != Rotated || held.Rotations != 1 || held.Since != t0.Format(isoMillis) || held.Changed != t1.Format(isoMillis) {
t.Errorf("%+v %s", held, outcome)
}
if len(held.History) != 1 || held.History[0].Fingerprint != Fingerprint("first-value") || held.History[0].Until != t1.Format(isoMillis) {
t.Errorf("history %+v", held.History)
}
}
func TestTheLedgerHoldsFingerprintsNeverTheValue(t *testing.T) {
dir := t.TempDir()
l, _ := NewLedger(dir)
_, _, _ = l.Record("anchor-redis", "anchor", "the-actual-password", time.Now())
_, _, _ = l.Record("anchor-redis", "anchor", "the-rotated-password", time.Now())
entries, _ := os.ReadDir(dir)
for _, e := range entries {
raw, _ := os.ReadFile(filepath.Join(dir, e.Name()))
if strings.Contains(string(raw), "the-actual-password") || strings.Contains(string(raw), "the-rotated-password") {
t.Errorf("%s holds a value", e.Name())
}
info, _ := e.Info()
if info.Mode().Perm() != 0o600 {
t.Errorf("%s is %v", e.Name(), info.Mode())
}
}
}
func TestDeletingForgetsAHolderAndListingIsByLogin(t *testing.T) {
l, _ := NewLedger(t.TempDir())
_, _, _ = l.Record("b-app", "b", "x", time.Now())
_, _, _ = l.Record("a-app", "a", "y", time.Now())
if got := logins(l.List()); got != "a-app,b-app" {
t.Errorf("listed %s", got)
}
if !l.Delete("a-app") || l.Delete("a-app") {
t.Error("deleting found the wrong thing")
}
if got := logins(l.List()); got != "b-app" {
t.Errorf("listed %s", got)
}
}
func TestALoginIsANameNotAPath(t *testing.T) {
l, _ := NewLedger(t.TempDir())
if _, _, err := l.Record("../etc/passwd", "n", "v", time.Now()); err == nil {
t.Error("a path was taken for a login")
}
}
// A ledger the TypeScript vault wrote is read as it is.
func TestALedgerTheTypeScriptVaultWroteIsRead(t *testing.T) {
dir := t.TempDir()
written := `{
"as": "anchor-redis",
"consumer": "anchor",
"fingerprint": "sha256:abc",
"length": 40,
"since": "2026-09-20T10:00:00.000Z",
"changed": "2026-09-21T10:00:00.000Z",
"rotations": 1,
"history": [{"fingerprint": "sha256:old", "until": "2026-09-21T10:00:00.000Z"}]
}
`
_ = os.WriteFile(filepath.Join(dir, "anchor-redis.json"), []byte(written), 0o600)
l, _ := NewLedger(dir)
h, ok := l.Get("anchor-redis")
if !ok || h.Rotations != 1 || h.Length != 40 || h.History[0].Fingerprint != "sha256:old" {
t.Fatalf("%+v %v", h, ok)
}
}
func TestWhatTheMeshDeliversIsFingerprintedNeverReturned(t *testing.T) {
dir := t.TempDir()
secret := filepath.Join(dir, "anchor.redis.secret")
_ = os.WriteFile(secret, []byte("minted-value\n"), 0o600)
receives := filepath.Join(dir, "mesh.json")
doc, _ := json.Marshal(map[string]any{"requirement": "secret", "given": []map[string]string{
{"from": "redis", "node": "anchor", "as": "anchor-redis", "secret": secret},
{"from": "offer-only", "node": "anchor"},
}})
_ = os.WriteFile(receives, doc, 0o600)
asked, err := Contributions(receives)
if err != nil || len(asked) != 1 || asked[0].As != "anchor-redis" {
t.Fatalf("%+v %v", asked, err)
}
fp, length, err := DeliveredFingerprint(asked[0])
if err != nil || fp != Fingerprint("minted-value") || length != len("minted-value") {
t.Errorf("%s %d %v", fp, length, err)
}
if _, _, err := DeliveredFingerprint(Contribution{As: "x", Secret: filepath.Join(dir, "missing")}); err == nil || !strings.Contains(err.Error(), "not readable") {
t.Errorf("a missing file said %v", err)
}
}
func logins(hs []Held) string {
var out []string
for _, h := range hs {
out = append(out, h.As)
}
return strings.Join(out, ",")
}
+94
View File
@@ -0,0 +1,94 @@
// mesh-vault (novox/hq ADR 0085, ADR 0113, ADR 0349 §7 and §11): one binary, launched by its node's runtime
// and speaking MCP to it over stdio through the Go SDK. It holds the `mesh-vault` seat: it keeps every
// secret in its own store, sealed at rest and with a copy sealed to the operator key; it answers the seat's
// verb `get` with a secret sealed to the asker's key; it raises `secret-changed` without a value. Beside
// that it keeps the custody ledger of the pair credentials the controller still delivers to it.
//
// stdout is the MCP channel; everything this module says, it says on stderr.
package main
import (
"fmt"
"os"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
)
func say(format string, args ...any) {
fmt.Fprintf(os.Stderr, "[mesh-vault] "+format+"\n", args...)
}
func emit(event string, body map[string]any) error { return stdio.Emit(event, body) }
// build makes the vault from the module's words: directories, never values.
func build() *Env {
env := &Env{Receives: os.Getenv("MESH_RECEIVES"), Root: os.Getenv("MESH_VAULT_ROOT")}
if l, err := NewLedger(os.Getenv("MESH_VAULT_LEDGER")); err == nil {
env.Ledger = l
} else {
say("%v", err)
}
if env.Ledger != nil && env.Receives != "" {
env.Custody = &Custody{Ledger: env.Ledger, Receives: env.Receives, Resource: "secret", Node: os.Getenv("MESH_NODE"),
Emit: emit, Now: time.Now, Log: say}
}
store, err := OpenStore(os.Getenv("MESH_VAULT_STORE"))
if err != nil {
env.Broken = fmt.Errorf("the vault could not open its store: %w", err)
say("%v", env.Broken)
return env
}
own, err := OwnKey(os.Getenv("MESH_VAULT_STATE"), store)
if err != nil {
env.Broken = fmt.Errorf("the vault could not open its key: %w", err)
say("%v", env.Broken)
return env
}
// Askers is left nil: the vault that runs answers nobody until who may ask arrives (askers.go).
env.Vault = &Vault{Store: store, Own: own, Emit: emit, Now: time.Now, Log: say}
return env
}
// adoptOperatorKey takes the operator key from the export the mesh writes to the vault's root (what
// `operator key set` recorded; the controller keeps only a public half there), and seals again any
// operator copy not yet sealed to the current key. Run every minute, so a failure partway is finished.
func adoptOperatorKey(env *Env) {
if env.Vault == nil {
return
}
if raw := exportedOperatorKey(env.Root); env.Root != "" && raw != "" {
key, err := publicPEM(raw)
if err == nil {
_, err = env.Vault.SetOperatorKey(key, "the mesh's export")
}
env.refuseKey("")
if err != nil {
env.refuseKey(err.Error())
say("the operator key in the mesh's export is not taken, or not every copy is sealed to it yet: %v", err)
}
return
}
if _, err := env.Vault.Reseal(); err != nil {
say("%v", err)
}
}
func main() {
env := build()
go func() {
tick := time.NewTicker(time.Minute)
defer tick.Stop()
for {
adoptOperatorKey(env)
if env.Custody != nil {
env.Custody.Pass()
}
<-tick.C
}
}()
if err := stdio.Serve("", tools(env)); err != nil {
say("%v", err)
os.Exit(1)
}
}
@@ -0,0 +1,466 @@
package main
// Retirement of a holder the mesh stops asking for (novox/hq ADR 0230), in the words the controller reads:
// the `provisioner.retirement` event as mesh-controller's link.Retirement, the `provisioner_retirement`
// answer as link.RetirementState, and the acts as the SDK's harness takes them (mesh-sdk
// src/provisioner/index.ts) — the set repeated in `consumers`, a `why`, and the person's `by` and the
// `via` the controller's verbs say. testdata/retirement-*.json are those types marshalled by the controller
// at the commit its README names, and the tests hold this file's words to them.
//
// The vault is mark-only: it holds no access to switch off, so a retired holder keeps what it has until a
// person deletes it, and the mark — when and why — is in the holder's own ledger file, so a restart forgets
// nothing.
import (
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
"sync"
"time"
)
// EventRetirement is the one event a provider says about retirement (ADR 0230 §35).
const EventRetirement = "provisioner.retirement"
// The rules of ADR 0230 §15 and §18.
const (
StablePasses = 5
StableFor = 10 * time.Minute
RetireAtOnce = 3
RetireFraction = 0.5
SayAgainEvery = time.Hour
)
// RetiredConsumer is one holder in a retirement word or answer (link.RetiredConsumer).
type RetiredConsumer struct {
Consumer string `json:"consumer"`
Node string `json:"node,omitempty"`
RetiredAt string `json:"retired_at,omitempty"`
Why string `json:"why,omitempty"`
SizeBytes *int64 `json:"size_bytes,omitempty"`
Kind string `json:"kind,omitempty"`
Access string `json:"access,omitempty"`
}
// RetirementWord is the event's body (link.Retirement).
type RetirementWord struct {
Provider string `json:"provider"`
ProviderNode string `json:"provider-node"`
Change string `json:"change"`
Consumers []RetiredConsumer `json:"consumers"`
Held int `json:"held"`
Bound string `json:"bound,omitempty"`
Why string `json:"why,omitempty"`
By string `json:"by,omitempty"`
Via string `json:"via,omitempty"`
At time.Time `json:"at"`
Since *time.Time `json:"since,omitempty"`
}
// RetirementWaiting, RetirementRejected and RetirementState are the answer to provisioner_retirement
// (link.RetirementWaiting, link.RetirementRejected, link.RetirementState).
type RetirementWaiting struct {
Consumers []RetiredConsumer `json:"consumers"`
Since string `json:"since"`
Held int `json:"held"`
}
type RetirementRejected struct {
Consumers []RetiredConsumer `json:"consumers"`
By string `json:"by"`
Why string `json:"why"`
At string `json:"at"`
}
type RetirementState struct {
Resource string `json:"resource"`
Node string `json:"node"`
Held []string `json:"held"`
StablePasses int `json:"stable_passes"`
StableForSeconds int `json:"stable_for_seconds,omitempty"`
RetiresBy string `json:"retires_by,omitempty"`
Bound string `json:"bound"`
Waiting *RetirementWaiting `json:"waiting"`
Rejected *RetirementRejected `json:"rejected"`
Retired []RetiredConsumer `json:"retired"`
}
// Act is a person's act on retirement: why, who (the person the controller proved) and through what.
type Act struct {
Why, By, Via string
}
// Custody is the pass over the contributions file that keeps the ledger, and what it remembers between
// passes about the holders the mesh no longer asks for.
type Custody struct {
Ledger *Ledger
Receives string
// Resource is the provision the vault provides; Node the machine it runs on.
Resource string
Node string
Emit func(event string, body map[string]any) error
Now func() time.Time
Log func(format string, args ...any)
mu sync.Mutex
key string
count int
firstSeen time.Time
asked map[string]bool
read bool
waiting *heldSet
rejected *heldSet
}
type heldSet struct {
set []string
since time.Time
saidAt time.Time
held int
by, why string
}
func (c *Custody) resource() string {
if c.Resource == "" {
return "secret"
}
return c.Resource
}
// Pass records every delivered value, re-enables a retired holder asked for again, and counts the holders
// no longer asked for toward their retirement.
func (c *Custody) Pass() {
c.mu.Lock()
defer c.mu.Unlock()
asked, err := Contributions(c.Receives)
if err != nil {
c.Log("THE CONTRIBUTIONS FILE IS NOT USABLE (%s): %v — nothing is recorded or retired on this pass, and "+
"the count toward retiring anyone starts again", c.Receives, err)
c.key, c.count = "", 0
return
}
now := c.Now()
keep := map[string]bool{}
for _, a := range asked {
keep[a.As] = true
value, err := delivered(a)
if err != nil {
c.Log("%s: %v", a.As, err)
continue
}
held, outcome, err := c.Ledger.Record(a.As, a.Node, value, now)
if err != nil {
c.Log("%s: %v", a.As, err)
continue
}
if held.Retired != nil {
if err := c.Ledger.Mark(a.As, nil); err != nil {
c.Log("%s: asked for again, and its retirement mark could not be cleared: %v", a.As, err)
} else {
c.Log("%s: asked for again — re-enabled, its retirement mark cleared", a.As)
c.tell(RetirementWord{Change: "reenabled", Consumers: []RetiredConsumer{{Consumer: a.As, Node: a.Node}}}, now)
}
}
if outcome == Unchanged {
continue
}
event := "provisioned"
if outcome == Rotated {
event = "rotated"
}
c.Log("%s: %s (%s…, rotations %d)", held.As, outcome, held.Fingerprint[:19], held.Rotations)
c.say(event, map[string]any{"consumer": held.Consumer, "as": held.As, "fingerprint": held.Fingerprint, "rotations": held.Rotations})
}
c.asked, c.read = keep, true
c.countUnasked(now)
}
func (c *Custody) active() []Held {
var out []Held
for _, h := range c.Ledger.List() {
if h.Retired == nil {
out = append(out, h)
}
}
return out
}
func (c *Custody) countUnasked(now time.Time) {
active := c.active()
var unasked []string
for _, h := range active {
if !c.asked[h.As] {
unasked = append(unasked, h.As)
}
}
key := strings.Join(unasked, ",")
if c.waiting != nil && strings.Join(c.waiting.set, ",") != key {
c.settle(c.waiting, "the set the mesh no longer asks for changed", now)
c.waiting = nil
}
if c.rejected != nil && strings.Join(c.rejected.set, ",") != key {
c.settle(c.rejected, "the set the mesh no longer asks for changed", now)
c.rejected = nil
}
switch {
case len(unasked) == 0:
c.key, c.count = "", 0
return
case key != c.key:
c.key, c.count, c.firstSeen = key, 1, now
c.Log("no longer asked for: %s; retired once the same set holds for %d passes and %s (novox/hq ADR 0230)",
key, StablePasses, StableFor)
default:
c.count++
}
if c.rejected != nil || c.count < StablePasses || now.Sub(c.firstSeen) < StableFor {
return
}
if overTheBound(len(unasked), len(active)) {
c.wait(unasked, len(active), now)
return
}
c.retire(unasked, len(active), fmt.Sprintf("the mesh stopped asking for it: the same answer in %d passes over %s, from %s",
c.count, now.Sub(c.firstSeen).Round(time.Second), c.firstSeen.UTC().Format(time.RFC3339)), now)
}
func overTheBound(n, held int) bool {
return n > RetireAtOnce || (held > 1 && float64(n) > RetireFraction*float64(held))
}
func bound(held int) string {
return fmt.Sprintf("more than %d, or more than %d%% of the %d held", RetireAtOnce, int(RetireFraction*100), held)
}
func (c *Custody) wait(set []string, held int, now time.Time) {
if c.waiting == nil {
c.waiting = &heldSet{set: set, since: now, held: held, saidAt: now}
c.Log("RETIREMENT WAITS FOR A PERSON: the mesh no longer asks for %d of the %d holder(s) (%s), %s. "+
"Nothing is retired until `retire approve` or `retire reject` (novox/hq ADR 0230)",
len(set), held, strings.Join(set, ", "), bound(held))
} else if now.Sub(c.waiting.saidAt) < SayAgainEvery {
return
} else {
c.waiting.saidAt = now
}
since := c.waiting.since.UTC()
c.tell(RetirementWord{Change: "waiting", Consumers: c.named(set), Held: c.waiting.held, Since: &since}, now)
}
func (c *Custody) settle(s *heldSet, why string, now time.Time) {
c.Log("the set waiting on retirement (%s) is settled, nothing retired: %s", strings.Join(s.set, ", "), why)
c.tell(RetirementWord{Change: "settled", Consumers: c.named(s.set), Why: why}, now)
}
// retire marks each holder in set retired: the ledger keeps it, and its history, until a person deletes it.
func (c *Custody) retire(set []string, held int, why string, now time.Time) []string {
at := now.UTC().Format(isoMillis)
var done []string
for _, as := range set {
if err := c.Ledger.Mark(as, &RetiredMark{At: at, Why: why}); err != nil {
c.Log("%s: could not be marked retired, asked again once its set is stable again: %v", as, err)
continue
}
done = append(done, as)
}
c.key, c.count = "", 0
if len(done) == 0 {
return done
}
c.Log("%s: RETIRED, MARK ONLY — the vault holds no access to switch off, so each keeps what it holds until a "+
"person deletes it with `cleanup delete` (novox/hq ADR 0230): %s", strings.Join(done, ", "), why)
c.tell(RetirementWord{Change: "retired", Consumers: c.retiredOnes(done), Held: held, Why: why}, now)
return done
}
// named is each holder as the controller's words name it.
func (c *Custody) named(set []string) []RetiredConsumer {
out := make([]RetiredConsumer, 0, len(set))
for _, as := range set {
h, _ := c.Ledger.Get(as)
out = append(out, RetiredConsumer{Consumer: as, Node: h.Consumer})
}
return out
}
func (c *Custody) retiredOnes(set []string) []RetiredConsumer {
out := make([]RetiredConsumer, 0, len(set))
for _, as := range set {
h, _ := c.Ledger.Get(as)
out = append(out, retiredOne(h))
}
return out
}
func retiredOne(h Held) RetiredConsumer {
unknown := int64(-1)
r := RetiredConsumer{Consumer: h.As, Node: h.Consumer, SizeBytes: &unknown, Kind: "consumer", Access: "kept"}
if h.Retired != nil {
r.RetiredAt, r.Why = h.Retired.At, h.Retired.Why
}
return r
}
func sameSet(a, b []string) bool {
x, y := append([]string{}, a...), append([]string{}, b...)
sort.Strings(x)
sort.Strings(y)
return strings.Join(x, ",") == strings.Join(y, ",")
}
// Approve retires exactly the set waiting (or rejected) for a person, repeated in consumers; any other set
// is refused (ADR 0230 §20).
func (c *Custody) Approve(consumers []string, act Act) ([]string, error) {
if strings.TrimSpace(act.Why) == "" {
return nil, errors.New("approve: why is required")
}
c.mu.Lock()
defer c.mu.Unlock()
s := c.waiting
if s == nil {
s = c.rejected
}
if s == nil || !sameSet(s.set, consumers) {
return nil, fmt.Errorf("approve: nothing waits with exactly that set (%s); waiting: %s; rejected: %s. Look again",
strings.Join(consumers, ", "), setOf(c.waiting), setOf(c.rejected))
}
now := c.Now()
held := len(c.active())
c.Log("retirement of %s approved by %s: %s", strings.Join(s.set, ", "), someone(act.By), act.Why)
c.tell(RetirementWord{Change: "approved", Consumers: c.named(s.set), Held: held, Why: act.Why, By: act.By, Via: act.Via}, now)
set := s.set
c.waiting, c.rejected = nil, nil
return c.retire(set, held, "approved by "+someone(act.By)+": "+act.Why, now), nil
}
// Reject keeps the waiting set active, repeated in consumers, and asks no more about it while it stays the
// same set.
func (c *Custody) Reject(consumers []string, act Act) ([]string, error) {
if strings.TrimSpace(act.Why) == "" {
return nil, errors.New("reject: why is required")
}
c.mu.Lock()
defer c.mu.Unlock()
if c.waiting == nil || !sameSet(c.waiting.set, consumers) {
return nil, fmt.Errorf("reject: nothing waits with exactly that set (%s); waiting: %s. Look again",
strings.Join(consumers, ", "), setOf(c.waiting))
}
now := c.Now()
c.rejected = &heldSet{set: c.waiting.set, since: now, by: act.By, why: act.Why}
c.waiting = nil
c.Log("retirement of %s rejected by %s: %s; kept active while the mesh says the same",
strings.Join(c.rejected.set, ", "), someone(act.By), act.Why)
c.tell(RetirementWord{Change: "rejected", Consumers: c.named(c.rejected.set), Why: act.Why, By: act.By, Via: act.Via}, now)
return c.rejected.set, nil
}
// Delete forgets one retired holder on a person's word, its name repeated in confirm. Refused: before a
// pass has read the contributions file, a holder that is not retired, one the contributions file asks for
// now, and any act without a why or a matching confirm.
func (c *Custody) Delete(consumer, confirm string, act Act) error {
if strings.TrimSpace(act.Why) == "" {
return errors.New("delete: why is required")
}
if confirm != consumer {
return errors.New("delete: confirm must repeat the consumer's name")
}
c.mu.Lock()
defer c.mu.Unlock()
if !c.read {
return errors.New("delete: no pass has read the contributions file yet, so whether the mesh asks for it is not known; try again in a minute")
}
h, ok := c.Ledger.Get(consumer)
if !ok {
return fmt.Errorf("delete: %s is not held here", consumer)
}
if h.Retired == nil {
return fmt.Errorf("delete: %s is active, not retired — only a retired holder is deleted", consumer)
}
asked, err := Contributions(c.Receives)
if err != nil {
return fmt.Errorf("delete: the contributions file cannot say whether the mesh asks for %s: %v", consumer, err)
}
for _, a := range asked {
if a.As == consumer {
return fmt.Errorf("delete: the mesh asks for %s again; the next pass re-enables it", consumer)
}
}
if !c.Ledger.Delete(consumer) {
return fmt.Errorf("delete: %s could not be deleted", consumer)
}
c.Log("%s: DELETED from the ledger, asked by %s: %s", consumer, someone(act.By), act.Why)
unknown := int64(-1)
c.tell(RetirementWord{Change: "deleted", Consumers: []RetiredConsumer{{Consumer: consumer, Node: h.Consumer, SizeBytes: &unknown}},
Why: act.Why, By: act.By, Via: act.Via}, c.Now())
return nil
}
func someone(by string) string {
if by == "" {
return "someone"
}
return by
}
func setOf(s *heldSet) string {
if s == nil || len(s.set) == 0 {
return "none"
}
return strings.Join(s.set, ", ")
}
// Retirement is the answer to provisioner_retirement.
func (c *Custody) Retirement() RetirementState {
c.mu.Lock()
defer c.mu.Unlock()
st := RetirementState{Resource: c.resource(), Node: c.Node, Held: []string{}, StablePasses: StablePasses,
StableForSeconds: int(StableFor.Seconds()), RetiresBy: "mark-only", Retired: []RetiredConsumer{}}
for _, h := range c.Ledger.List() {
if h.Retired == nil {
st.Held = append(st.Held, h.As)
} else {
st.Retired = append(st.Retired, retiredOne(h))
}
}
st.Bound = bound(len(st.Held))
if c.waiting != nil {
st.Waiting = &RetirementWaiting{Consumers: c.named(c.waiting.set),
Since: c.waiting.since.UTC().Format(time.RFC3339), Held: c.waiting.held}
}
if c.rejected != nil {
st.Rejected = &RetirementRejected{Consumers: c.named(c.rejected.set), By: c.rejected.by, Why: c.rejected.why,
At: c.rejected.since.UTC().Format(time.RFC3339)}
}
return st
}
// tell says one retirement word, with the provider, its machine, the bound and the time filled in.
func (c *Custody) tell(w RetirementWord, now time.Time) {
w.Provider, w.ProviderNode, w.At = c.resource(), c.Node, now.UTC()
if w.Bound == "" {
w.Bound = bound(len(c.active()))
}
if w.Consumers == nil {
w.Consumers = []RetiredConsumer{}
}
c.say(EventRetirement, wordBody(w))
}
// wordBody is a word as the map the SDK's Emit takes, through its JSON, so the bus carries exactly the tags.
func wordBody(w RetirementWord) map[string]any {
raw, _ := json.Marshal(w)
var m map[string]any
_ = json.Unmarshal(raw, &m)
return m
}
func (c *Custody) say(event string, body map[string]any) {
if c.Emit == nil {
return
}
if err := c.Emit(event, body); err != nil {
c.Log("%s could not be said: %v", event, err)
}
}
@@ -0,0 +1,537 @@
package main
// ADR 0230 in the vault's custody ledger, and its words held to the controller's: testdata/retirement-*.json
// are mesh-controller's link.Retirement and link.RetirementState marshalled at dc36536 (feat/57, with #317).
import (
"bytes"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"testing"
"time"
)
// custodyAt is a custody pass over a contributions file in dir, on a clock the test moves.
type custodyAt struct {
*Custody
bus *published
receives string
dir string
clock *time.Time
}
func newCustody(t *testing.T) custodyAt {
dir := t.TempDir()
l, _ := NewLedger(filepath.Join(dir, "ledger"))
bus := &published{}
clock := time.Date(2026, 10, 14, 9, 0, 0, 0, time.UTC)
c := custodyAt{bus: bus, receives: filepath.Join(dir, "mesh.json"), dir: dir, clock: &clock}
c.Custody = &Custody{Ledger: l, Receives: c.receives, Resource: "secret", Node: "novox", Emit: bus.emit,
Now: func() time.Time { return *c.clock }, Log: t.Logf}
return c
}
// ask writes a contributions file asking for these logins, each with its value.
func (c custodyAt) ask(values map[string]string) {
given := []map[string]string{}
for as, v := range values {
f := filepath.Join(c.dir, as+".secret")
_ = os.WriteFile(f, []byte(v), 0o600)
given = append(given, map[string]string{"node": "anchor", "as": as, "secret": f})
}
doc, _ := json.Marshal(map[string]any{"requirement": "secret", "given": given})
_ = os.WriteFile(c.receives, doc, 0o600)
}
// apps is n holders named a-app, b-app, …
func apps(n int) map[string]string {
out := map[string]string{}
for i := 0; i < n; i++ {
out[fmt.Sprintf("%c-app", 'a'+i)] = fmt.Sprint(i)
}
return out
}
func keepOnly(all map[string]string, n int) map[string]string {
names := make([]string, 0, len(all))
for k := range all {
names = append(names, k)
}
sort.Strings(names)
out := map[string]string{}
for _, k := range names[:n] {
out[k] = all[k]
}
return out
}
// passes runs n passes a minute apart.
func (c custodyAt) passes(n int) {
for i := 0; i < n; i++ {
c.Pass()
*c.clock = c.clock.Add(time.Minute)
}
}
// stableFor runs StablePasses passes over an hour: past both of ADR 0230's conditions.
func (c custodyAt) stable() {
for i := 0; i < StablePasses; i++ {
c.Pass()
*c.clock = c.clock.Add(15 * time.Minute)
}
}
func (c custodyAt) changes() []string {
var out []string
for _, e := range c.bus.events {
if e["event"] == EventRetirement {
out = append(out, e["body"].(map[string]any)["change"].(string))
} else {
out = append(out, e["event"].(string))
}
}
return out
}
func (c custodyAt) retired(as string) bool {
h, _ := c.Ledger.Get(as)
return h.Retired != nil
}
func waitingNames(st RetirementState) []string {
if st.Waiting == nil {
return nil
}
var out []string
for _, w := range st.Waiting.Consumers {
out = append(out, w.Consumer)
}
return out
}
func act(why string) Act { return Act{Why: why, By: "person.jochen", Via: "mesh-controller"} }
// ---- the controller's words ----------------------------------------------------------------------------
// paths is every key path in a JSON document, arrays read through their first element.
func paths(t *testing.T, raw []byte) map[string]bool {
t.Helper()
var v any
if err := json.Unmarshal(raw, &v); err != nil {
t.Fatal(err)
}
out := map[string]bool{}
var walk func(prefix string, v any)
walk = func(prefix string, v any) {
switch x := v.(type) {
case map[string]any:
for k, e := range x {
out[prefix+k] = true
walk(prefix+k+".", e)
}
case []any:
if len(x) > 0 {
walk(prefix, x[0])
}
}
}
walk("", v)
return out
}
// sameWords says every key the vault says is a key the controller's type has, and every key the controller
// needs is said.
func sameWords(t *testing.T, what string, ours []byte, golden string, needed ...string) {
t.Helper()
theirs, err := os.ReadFile(filepath.Join("testdata", golden))
if err != nil {
t.Fatal(err)
}
want := paths(t, theirs)
got := paths(t, ours)
for p := range got {
if !want[p] {
t.Errorf("%s says %q, which the controller's type has no field for", what, p)
}
}
for _, p := range needed {
if !got[p] {
t.Errorf("%s does not say %q, which the controller reads", what, p)
}
}
// And decoding strictly into the shape of the controller's golden document's own Go type would succeed:
// the same kinds at the same keys.
var g, o any
_ = json.Unmarshal(theirs, &g)
_ = json.Unmarshal(ours, &o)
sameKinds(t, what, "", g, o)
}
func sameKinds(t *testing.T, what, at string, want, got any) {
switch w := want.(type) {
case map[string]any:
g, ok := got.(map[string]any)
if !ok {
if got != nil {
t.Errorf("%s: %q is %T, the controller's is an object", what, at, got)
}
return
}
for k, v := range g {
if wv, known := w[k]; known {
sameKinds(t, what, at+"."+k, wv, v)
}
}
case []any:
g, ok := got.([]any)
if !ok {
if got != nil {
t.Errorf("%s: %q is %T, the controller's is a list", what, at, got)
}
return
}
if len(w) > 0 {
for _, e := range g {
sameKinds(t, what, at+"[]", w[0], e)
}
}
default:
if got != nil && fmt.Sprintf("%T", want) != fmt.Sprintf("%T", got) {
t.Errorf("%s: %q is %T, the controller's is %T", what, at, got, want)
}
}
}
// Every retirement word the vault says is one the controller's link.ReadRetirement reads: its machine
// named, its change known, its consumers objects.
func TestTheRetirementWordsAreTheControllers(t *testing.T) {
c := newCustody(t)
all := apps(5)
c.ask(all)
c.passes(1)
c.ask(keepOnly(all, 1))
c.stable() // waiting: four of five
st := c.Retirement()
if _, err := c.Reject(waitingNames(st), act("kept for now")); err != nil {
t.Fatal(err)
}
if _, err := c.Approve(waitingNames(st), act("they are gone")); err != nil {
t.Fatal(err)
}
c.passes(1)
if err := c.Delete("b-app", "b-app", act("gone for good")); err != nil {
t.Fatal(err)
}
c.ask(all)
c.passes(1) // re-enabled
seen := map[string]bool{}
for i, e := range c.bus.events {
if e["event"] != EventRetirement {
continue
}
raw := []byte(c.bus.raw[i])
var outer struct {
Body json.RawMessage `json:"body"`
}
_ = json.Unmarshal(raw, &outer)
var w struct {
Change string `json:"change"`
ProviderNode string `json:"provider-node"`
Consumers []struct {
Consumer string `json:"consumer"`
} `json:"consumers"`
}
dec := json.NewDecoder(bytes.NewReader(outer.Body))
if err := dec.Decode(&w); err != nil {
t.Fatalf("a retirement word the controller cannot read: %v: %s", err, outer.Body)
}
if w.ProviderNode != "novox" || len(w.Consumers) == 0 || w.Consumers[0].Consumer == "" {
t.Errorf("a %s word names no machine or no consumer: %s", w.Change, outer.Body)
}
seen[w.Change] = true
sameWords(t, "the "+w.Change+" word", outer.Body, "retirement-word.json",
"provider", "provider-node", "change", "consumers", "consumers.consumer", "at")
}
for _, change := range []string{"waiting", "rejected", "approved", "retired", "deleted", "reenabled"} {
if !seen[change] {
t.Errorf("no %s word was said", change)
}
}
}
// The answer to provisioner_retirement is the controller's RetirementState, so `retire approve`, `retire
// reject` and `cleanup list` read the vault.
func TestTheRetirementAnswerIsTheControllers(t *testing.T) {
c := newCustody(t)
all := apps(5)
c.ask(all)
c.passes(1)
c.ask(keepOnly(all, 1))
c.stable()
raw, _ := json.Marshal(c.Retirement())
sameWords(t, "the waiting answer", raw, "retirement-state.json", "resource", "node", "held", "stable_passes",
"retires_by", "bound", "waiting", "waiting.consumers", "waiting.consumers.consumer", "waiting.since", "retired")
if _, err := c.Approve(waitingNames(c.Retirement()), act("gone")); err != nil {
t.Fatal(err)
}
raw, _ = json.Marshal(c.Retirement())
sameWords(t, "the retired answer", raw, "retirement-state.json", "retired.consumer", "retired.retired_at",
"retired.why", "retired.access")
st := c.Retirement()
if st.RetiresBy != "mark-only" || st.Resource != "secret" || st.Node != "novox" || len(st.Retired) != 4 ||
st.Retired[0].Access != "kept" {
t.Errorf("the answer is %+v", st)
}
}
// ---- the rules, one at a time ---------------------------------------------------------------------------
func TestTheCustodyPassRecordsAndRotates(t *testing.T) {
c := newCustody(t)
c.ask(map[string]string{"anchor-redis": "v1"})
c.passes(1)
c.ask(map[string]string{"anchor-redis": "v2"})
c.passes(1)
if got := strings.Join(c.changes(), ","); got != "provisioned,rotated" {
t.Errorf("announced %s", got)
}
for _, raw := range c.bus.raw {
if strings.Contains(raw, `"v1"`) || strings.Contains(raw, `"v2"`) {
t.Errorf("announced a value: %s", raw)
}
}
}
// A contributions file that does not say who is asked for is no answer: nobody is retired.
func TestAFileThatSaysNothingRetiresNobody(t *testing.T) {
for _, doc := range []string{"{not json", `{"requirement":"secret"}`, `{"requirement":"secret","given":null}`,
`{"requirement":"redis-cache","given":[]}`} {
c := newCustody(t)
c.ask(map[string]string{"anchor-redis": "v1"})
c.passes(1)
_ = os.WriteFile(c.receives, []byte(doc), 0o600)
c.passes(30)
if h, ok := c.Ledger.Get("anchor-redis"); !ok || h.Retired != nil {
t.Errorf("%s: the holder was retired or lost: %+v %v", doc, h, ok)
}
if _, err := Contributions(c.receives); err == nil {
t.Errorf("%s was read as an answer", doc)
}
}
if _, err := Contributions(filepath.Join(t.TempDir(), "missing.json")); err == nil {
t.Error("a missing file was read as an answer")
}
}
// Five passes, not four, however much time has passed.
func TestRetiringTakesFivePasses(t *testing.T) {
c := newCustody(t)
c.ask(apps(3))
c.passes(1)
c.ask(keepOnly(apps(3), 2))
for i := 0; i < StablePasses-1; i++ {
c.Pass()
*c.clock = c.clock.Add(time.Hour)
}
if c.retired("c-app") {
t.Fatal("retired after four passes")
}
c.Pass()
if !c.retired("c-app") {
t.Fatal("not retired after five passes and hours")
}
}
// Ten minutes, not less, however many passes.
func TestRetiringTakesTenMinutes(t *testing.T) {
c := newCustody(t)
c.ask(apps(3))
c.passes(1)
c.ask(keepOnly(apps(3), 2))
for i := 0; i < 20; i++ {
c.Pass()
*c.clock = c.clock.Add(25 * time.Second)
}
if c.retired("c-app") {
t.Fatal("retired before ten minutes")
}
*c.clock = c.clock.Add(5 * time.Minute)
c.Pass()
if !c.retired("c-app") {
t.Fatal("not retired after twenty passes and ten minutes")
}
}
// More than three at once waits, though under half: 4 of 10.
func TestMoreThanThreeWaits(t *testing.T) {
c := newCustody(t)
c.ask(apps(10))
c.passes(1)
c.ask(keepOnly(apps(10), 6))
c.stable()
if got := waitingNames(c.Retirement()); len(got) != 4 {
t.Fatalf("four of ten did not wait: %v", got)
}
if c.retired("j-app") {
t.Fatal("retired while waiting")
}
}
// More than half waits, though three or fewer: 2 of 3.
func TestMoreThanHalfWaits(t *testing.T) {
c := newCustody(t)
c.ask(apps(3))
c.passes(1)
c.ask(keepOnly(apps(3), 1))
c.stable()
if got := waitingNames(c.Retirement()); len(got) != 2 {
t.Fatalf("two of three did not wait: %v", got)
}
}
// Within both bounds it retires without a person: 3 of 7.
func TestWithinTheBoundRetires(t *testing.T) {
c := newCustody(t)
c.ask(apps(7))
c.passes(1)
c.ask(keepOnly(apps(7), 4))
c.stable()
if c.Retirement().Waiting != nil || !c.retired("g-app") {
t.Fatal("three of seven did not retire")
}
}
// A holder is retired, never deleted by a pass, keeps its history, and is re-enabled when asked again.
func TestAHolderNoLongerAskedForIsRetiredNotDeleted(t *testing.T) {
c := newCustody(t)
c.ask(apps(3))
c.passes(1)
c.ask(keepOnly(apps(3), 2))
c.stable()
h, ok := c.Ledger.Get("c-app")
if !ok || h.Retired == nil || h.Retired.Why == "" || h.Fingerprint != Fingerprint("2") {
t.Fatalf("%+v %v", h, ok)
}
c.ask(apps(3))
c.Pass()
if c.retired("c-app") {
t.Error("asked for again, the holder stayed retired")
}
if got := strings.Join(c.changes(), ","); got != "provisioned,provisioned,provisioned,retired,reenabled" {
t.Errorf("announced %s", got)
}
}
// A file that says nothing starts the count again.
func TestTheCountStartsAgain(t *testing.T) {
c := newCustody(t)
c.ask(apps(3))
c.passes(1)
c.ask(keepOnly(apps(3), 2))
c.passes(4)
_ = os.WriteFile(c.receives, []byte(`{"given":null}`), 0o600)
c.passes(1)
c.ask(keepOnly(apps(3), 2))
c.passes(4)
*c.clock = c.clock.Add(time.Hour)
if c.retired("c-app") {
t.Fatal("retired though the count was broken")
}
c.passes(1)
if !c.retired("c-app") {
t.Fatal("not retired after five unbroken passes and an hour")
}
}
// Approve and reject take exactly the set waiting, and refuse any other (ADR 0230 §20).
func TestApproveAndRejectTakeExactlyTheSetWaiting(t *testing.T) {
for _, decide := range []string{"approve", "reject"} {
c := newCustody(t)
c.ask(apps(5))
c.passes(1)
c.ask(keepOnly(apps(5), 1))
c.stable()
set := waitingNames(c.Retirement())
for _, wrong := range [][]string{set[:3], append(append([]string{}, set...), "a-app"), {}} {
if _, err := c.Approve(wrong, act("x")); err == nil {
t.Errorf("approve took the set %v", wrong)
}
if _, err := c.Reject(wrong, act("x")); err == nil {
t.Errorf("reject took the set %v", wrong)
}
}
if _, err := c.Approve(set, Act{}); err == nil {
t.Error("an approval without a why was taken")
}
reversed := append([]string{}, set...)
sort.Sort(sort.Reverse(sort.StringSlice(reversed)))
if decide == "approve" {
done, err := c.Approve(reversed, act("the four apps are gone"))
if err != nil || len(done) != 4 || !c.retired("b-app") || c.retired("a-app") {
t.Fatalf("%v %v", done, err)
}
} else {
if _, err := c.Reject(reversed, act("they come back")); err != nil {
t.Fatal(err)
}
c.passes(10)
if c.retired("b-app") || c.Retirement().Waiting != nil {
t.Error("a rejected set was retired or asked about again")
}
}
}
}
// retiredC is a custody with c-app retired, d-app active.
func retiredC(t *testing.T) custodyAt {
c := newCustody(t)
c.ask(apps(4))
c.passes(1)
c.ask(keepOnly(apps(4), 3))
c.stable()
if !c.retired("d-app") {
t.Fatal("d-app was not retired")
}
return c
}
// Each refusal of a delete, one at a time.
func TestEachDeleteRefusal(t *testing.T) {
fresh := newCustody(t)
fresh.ask(apps(1))
if err := fresh.Delete("a-app", "a-app", act("x")); err == nil || !strings.Contains(err.Error(), "no pass") {
t.Errorf("a delete before any pass read the file: %v", err)
}
c := retiredC(t)
if err := c.Delete("a-app", "a-app", act("x")); err == nil || !strings.Contains(err.Error(), "active") {
t.Errorf("an active holder: %v", err)
}
if err := c.Delete("d-app", "d-app", Act{By: "person.jochen"}); err == nil || !strings.Contains(err.Error(), "why") {
t.Errorf("no why: %v", err)
}
if err := c.Delete("d-app", "c-app", act("x")); err == nil || !strings.Contains(err.Error(), "confirm") {
t.Errorf("a confirm naming another: %v", err)
}
if err := c.Delete("nobody", "nobody", act("x")); err == nil {
t.Error("a holder not held")
}
c.ask(apps(4)) // asked for again, no pass yet
if err := c.Delete("d-app", "d-app", act("x")); err == nil || !strings.Contains(err.Error(), "asks for") {
t.Errorf("a holder the file asks for again: %v", err)
}
c.ask(keepOnly(apps(4), 3))
if err := c.Delete("d-app", "d-app", act("gone for good")); err != nil {
t.Fatal(err)
}
if _, ok := c.Ledger.Get("d-app"); ok {
t.Error("deleted, but still in the ledger")
}
last := c.bus.events[len(c.bus.events)-1]["body"].(map[string]any)
if last["change"] != "deleted" || last["by"] != "person.jochen" || last["via"] != "mesh-controller" {
t.Errorf("the deletion said %v", last)
}
}
+336
View File
@@ -0,0 +1,336 @@
package main
// The vault's own store (novox/hq ADR 0349 §7, ADR 0113 §22): one file per secret on the vault's own disk,
// every value in it sealed — once to the vault's own key, the copy the vault opens to answer `get`, and once
// to the operator key, the copy the operator opens when the vault is lost (ADR 0113 §21). No database and no
// bucket: the vault is installed before any other module (ADR 0113 §14), a database login would be a
// secret only the vault makes, and a bucket holds no secret, sealed or not (ADR 0201 §13).
//
// Each file is written whole and renamed into place, so a reader never sees half a record.
import (
"encoding/json"
"errors"
"fmt"
"net/url"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"sync"
"time"
"git.novox.be/novox/mesh-sdk/go/seal"
)
// OperatorBox names the box an operator copy is sealed in: the NaCl anonymous box the operator key is made
// for today (`operator key make`) and `secret recover --from-export` opens.
const OperatorBox = "nacl-anonymous-box"
// Record is one secret as the store keeps it: never its value.
type Record struct {
Name string `json:"name"`
Generation int `json:"generation"`
// AtRest is the value sealed to the vault's own key (the ADR 0206 box).
AtRest seal.Box `json:"at-rest"`
// Operator is the value sealed to the operator key.
Operator OperatorCopy `json:"operator"`
MadeAt time.Time `json:"made-at"`
ChangedAt time.Time `json:"changed-at"`
ChangedBy string `json:"changed-by"`
}
// OperatorCopy is a value sealed to the operator key named in Key.
type OperatorCopy struct {
Box string `json:"box"`
Key string `json:"key"`
Sealed string `json:"sealed"`
}
// Store is the vault's directory of secrets.
type Store struct {
dir string
// failWrite, when a test sets it, fails the write of a record: how a failure partway is proven.
failWrite func(Record) error
// HandedMax is the size at which the record of hand-outs is rolled (0: HandedMaxDefault).
HandedMax int64
handedMu sync.Mutex
rolled int
}
// The record of hand-outs is bounded: rolled at HandedMaxDefault into handed.jsonl.1 … .HandedKept, the
// oldest dropped, so a flood of asks costs at most (HandedKept+1) × HandedMaxDefault on the vault's disk.
const (
HandedMaxDefault = 1 << 20
HandedKept = 3
)
// OpenStore opens (and makes) the store at dir.
func OpenStore(dir string) (*Store, error) {
if dir == "" {
return nil, errors.New("the vault's store has no directory: MESH_VAULT_STORE is not set")
}
if err := os.MkdirAll(filepath.Join(dir, "secrets"), 0o700); err != nil {
return nil, err
}
return &Store{dir: dir}, nil
}
var secretName = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]*(/[a-z0-9][a-z0-9._-]*)*$`)
// validName says why name is not a secret's name, or nil. A name is words joined by `/`, never a path.
func validName(name string) error {
if len(name) == 0 || len(name) > 200 || !secretName.MatchString(name) {
return fmt.Errorf("%q is not a secret's name: lower-case words joined by /, at most 200 characters", name)
}
return nil
}
func (s *Store) pathOf(name string) string {
return filepath.Join(s.dir, "secrets", url.PathEscape(name)+".json")
}
// Read is the record of one secret, and whether there is one.
func (s *Store) Read(name string) (Record, bool, error) {
if err := validName(name); err != nil {
return Record{}, false, err
}
raw, err := os.ReadFile(s.pathOf(name))
if errors.Is(err, os.ErrNotExist) {
return Record{}, false, nil
}
if err != nil {
return Record{}, false, err
}
var r Record
if err := json.Unmarshal(raw, &r); err != nil {
return Record{}, false, fmt.Errorf("the record of %s is not readable: %w", name, err)
}
return r, true, nil
}
// Write keeps one record, replacing any earlier one.
func (s *Store) Write(r Record) error {
if err := validName(r.Name); err != nil {
return err
}
if s.failWrite != nil {
if err := s.failWrite(r); err != nil {
return err
}
}
raw, err := json.MarshalIndent(r, "", " ")
if err != nil {
return err
}
return writeWhole(s.pathOf(r.Name), append(raw, '\n'))
}
// Empty says whether the store holds no secret at all.
func (s *Store) Empty() (bool, error) {
all, err := s.List()
return len(all) == 0, err
}
// Handing is one answer to `get`, or one refusal, as the store records it (as ADR 0277 §25 records asks):
// who asked, for what, when, and what it got — never the value.
type Handing struct {
At time.Time `json:"at"`
To string `json:"to"`
Name string `json:"name"`
Generation int `json:"generation,omitempty"`
Outcome string `json:"outcome"`
Why string `json:"why,omitempty"`
}
// RecordHanding appends one handing to the store's record, synced before it returns. It says whether the
// record was rolled to make room.
func (s *Store) RecordHanding(h Handing) (rolled bool, err error) {
raw, err := json.Marshal(h)
if err != nil {
return false, err
}
s.handedMu.Lock()
defer s.handedMu.Unlock()
path := filepath.Join(s.dir, "handed.jsonl")
max := s.HandedBound()
if info, err := os.Stat(path); err == nil && info.Mode().IsRegular() && info.Size()+int64(len(raw))+1 > max {
if err := s.roll(path); err != nil {
return false, err
}
rolled = true
}
return rolled, appendSynced(path, append(raw, '\n'))
}
// roll moves handed.jsonl to .1, .1 to .2 and so on, dropping what falls past HandedKept.
func (s *Store) roll(path string) error {
_ = os.Remove(fmt.Sprintf("%s.%d", path, HandedKept))
for i := HandedKept - 1; i >= 1; i-- {
if err := os.Rename(fmt.Sprintf("%s.%d", path, i), fmt.Sprintf("%s.%d", path, i+1)); err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
}
if err := os.Rename(path, path+".1"); err != nil {
return err
}
s.rolled++
return syncDir(s.dir)
}
// Rolled is how many times the record of hand-outs was rolled since the vault started.
func (s *Store) Rolled() int {
s.handedMu.Lock()
defer s.handedMu.Unlock()
return s.rolled
}
func appendSynced(path string, raw []byte) error {
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
if err != nil {
return err
}
if _, err := f.Write(raw); err != nil {
f.Close()
return err
}
if err := f.Sync(); err != nil {
f.Close()
return err
}
return f.Close()
}
// Handed is the latest handings, newest first, at most limit: the current record, then the rolled ones.
func (s *Store) Handed(limit int) ([]Handing, error) {
s.handedMu.Lock()
defer s.handedMu.Unlock()
path := filepath.Join(s.dir, "handed.jsonl")
out := []Handing{}
for i := 0; i <= HandedKept && len(out) < limit; i++ {
file := path
if i > 0 {
file = fmt.Sprintf("%s.%d", path, i)
}
raw, err := os.ReadFile(file)
if errors.Is(err, os.ErrNotExist) {
continue
}
if err != nil {
return nil, err
}
lines := strings.Split(strings.TrimSpace(string(raw)), "\n")
for j := len(lines) - 1; j >= 0 && len(out) < limit; j-- {
var h Handing
if json.Unmarshal([]byte(lines[j]), &h) == nil {
out = append(out, h)
}
}
}
return out, nil
}
// HandedBound is the size at which the record of hand-outs is rolled.
func (s *Store) HandedBound() int64 {
if s.HandedMax > 0 {
return s.HandedMax
}
return HandedMaxDefault
}
// List is every record, by name.
func (s *Store) List() ([]Record, error) {
entries, err := os.ReadDir(filepath.Join(s.dir, "secrets"))
if err != nil {
return nil, err
}
var out []Record
for _, e := range entries {
if !strings.HasSuffix(e.Name(), ".json") {
continue
}
name, err := url.PathUnescape(strings.TrimSuffix(e.Name(), ".json"))
if err != nil {
continue
}
r, ok, err := s.Read(name)
if err != nil {
return nil, err
}
if ok {
out = append(out, r)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out, nil
}
type operatorFile struct {
Key string `json:"key"`
Since time.Time `json:"since"`
By string `json:"by"`
}
// OperatorKey is the operator's public key the store seals to, or "" when it has none.
func (s *Store) OperatorKey() (string, error) {
raw, err := os.ReadFile(filepath.Join(s.dir, "operator.json"))
if errors.Is(err, os.ErrNotExist) {
return "", nil
}
if err != nil {
return "", err
}
var f operatorFile
if err := json.Unmarshal(raw, &f); err != nil {
return "", err
}
return f.Key, nil
}
func (s *Store) setOperatorKey(key, by string, at time.Time) error {
raw, _ := json.MarshalIndent(operatorFile{Key: key, Since: at, By: by}, "", " ")
return writeWhole(filepath.Join(s.dir, "operator.json"), append(raw, '\n'))
}
// writeWhole writes a file readable by the vault alone, synced, then renames it into place and syncs the
// directory, so a crash leaves the old file or the new one, never neither.
func writeWhole(path string, raw []byte) error {
tmp := fmt.Sprintf("%s.%d.tmp", path, os.Getpid())
if err := writeSynced(tmp, raw); err != nil {
return err
}
if err := os.Rename(tmp, path); err != nil {
_ = os.Remove(tmp)
return err
}
return syncDir(filepath.Dir(path))
}
// writeSynced writes a new file at 0600 and syncs it to disk.
func writeSynced(path string, raw []byte) error {
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
if err != nil {
return err
}
if _, err := f.Write(raw); err != nil {
f.Close()
return err
}
if err := f.Sync(); err != nil {
f.Close()
return err
}
return f.Close()
}
// syncDir syncs a directory, so a rename or a link in it is on disk.
func syncDir(dir string) error {
d, err := os.Open(dir)
if err != nil {
return err
}
defer d.Close()
return d.Sync()
}
@@ -0,0 +1,56 @@
{
"resource": "secret",
"node": "novox",
"held": [
"a"
],
"held_sizes": {
"a": 1
},
"stable_passes": 5,
"stable_for_seconds": 600,
"retires_by": "mark-only",
"bound": "b",
"waiting": {
"consumers": [
{
"consumer": "c",
"node": "n",
"retired_at": "t",
"why": "w",
"size_bytes": -1,
"kind": "consumer",
"access": "kept"
}
],
"since": "t",
"held": 1
},
"rejected": {
"consumers": [
{
"consumer": "c",
"node": "n",
"retired_at": "t",
"why": "w",
"size_bytes": -1,
"kind": "consumer",
"access": "kept"
}
],
"by": "b",
"why": "w",
"at": "t"
},
"retired": [
{
"consumer": "c",
"node": "n",
"retired_at": "t",
"why": "w",
"size_bytes": -1,
"kind": "consumer",
"access": "kept"
}
]
}
@@ -0,0 +1,23 @@
{
"provider": "secret",
"provider-node": "novox",
"change": "waiting",
"consumers": [
{
"consumer": "c",
"node": "n",
"retired_at": "t",
"why": "w",
"size_bytes": -1,
"kind": "consumer",
"access": "kept"
}
],
"held": 1,
"bound": "b",
"why": "w",
"by": "person.x",
"via": "mesh-controller",
"at": "2026-10-14T09:00:00Z",
"since": "2026-10-14T09:00:00Z"
}
+399
View File
@@ -0,0 +1,399 @@
package main
// What the vault serves: the seat's verb `get`, and the three tools the TypeScript vault offered —
// secret_export, secret_holders and secret_verify — answering as they did; beside them secret_handed and
// secret_vault_export over the vault's own store, and ADR 0230's four retirement tools over the custody
// ledger, and vault_status. None returns a value: `get` returns one sealed to the asker's key, the others
// fingerprints and ciphertext only.
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
stdio "git.novox.be/novox/mesh-sdk/go"
)
// Env is what the tools work on. A part the module's words did not give is nil, and a tool that needs it
// says so.
type Env struct {
Vault *Vault
Ledger *Ledger
Receives string
Root string
Custody *Custody
// refused is why the operator key in the mesh's export was not taken, or empty.
mu sync.Mutex
refused string
// Broken is why the vault could not be built, said by `get`.
Broken error
}
func (e *Env) keyRefused() string {
e.mu.Lock()
defer e.mu.Unlock()
return e.refused
}
func (e *Env) refuseKey(why string) {
e.mu.Lock()
defer e.mu.Unlock()
e.refused = why
}
// toolDef is a tool with its caller given apart from its arguments, so a test can be the runtime.
type toolDef struct {
Name string
Description string
Input map[string]any
run func(caller string, a map[string]any) (any, error)
}
// Run answers a call the runtime made: the caller is the one it proved.
func (t toolDef) Run(a map[string]any) (any, error) { return t.run(stdio.CallerOf(a), a) }
func (t toolDef) runAs(caller string, a map[string]any) (any, error) { return t.run(caller, a) }
func str(description string) map[string]any {
return map[string]any{"type": "string", "description": description}
}
func definitions(env *Env) []toolDef {
return []toolDef{
{Name: Seat + ".get",
Description: "One secret, sealed to the public key the asker sends (the sealed box of ADR 0206), with its " +
"generation; answered only to an asker the vault allows, as the runtime proved it (ADR 0349 §11). " +
"Never the value in the clear, and never to a private key.",
Input: map[string]any{"name": str("the secret's name"),
"public_key": str("the asker's X25519 public key as SPKI PEM — never a private key")},
run: func(caller string, a map[string]any) (any, error) {
if env.Vault == nil {
if env.Broken != nil {
return nil, env.Broken
}
return nil, errors.New("the vault is not ready")
}
name, _ := a["name"].(string)
key, _ := a["public_key"].(string)
return env.Vault.Get(caller, strings.TrimSpace(name), key)
}},
{Name: "secret_export",
Description: "The mesh's root secrets as this vault keeps them: every secret a module holds for itself, " +
"sealed to the operator's key (novox/hq ADR 0085, amended). Ciphertext — nothing here can open a line " +
"of it; the operator, holding the private key off the mesh, recovers one with " +
"`nox secret recover --from-export` on the control-node. Also lists what is NOT recoverable: secrets " +
"made before the mesh had an operator key.",
Input: map[string]any{"sealed": map[string]any{"type": "boolean",
"description": "include the sealed blobs (default true); false lists holders and the key only"}},
run: func(_ string, a map[string]any) (any, error) {
return exportAnswer(env.Root, a["sealed"] != false), nil
}},
{Name: "secret_holders",
Description: "Who holds a secret from this vault: each consumer's login, node and module, when it was " +
"granted, how many times it has been rotated and when, and the fingerprint of the current value. " +
"Fingerprints only — the value is never returned.",
run: func(string, map[string]any) (any, error) {
if env.Ledger == nil {
return map[string]any{"holders": []any{}, "count": 0, "error": "the vault has no ledger (MESH_VAULT_LEDGER is not set)"}, nil
}
var asked []Contribution
if env.Receives != "" {
asked, _ = Contributions(env.Receives)
}
holders := []map[string]any{}
for _, h := range env.Ledger.List() {
row := map[string]any{}
raw, _ := json.Marshal(h)
_ = json.Unmarshal(raw, &row)
row["module"], row["asked"] = nil, false
for _, c := range asked {
if c.As == h.As {
row["module"], row["asked"] = c.From, true
}
}
holders = append(holders, row)
}
return map[string]any{"holders": holders, "count": len(holders)}, nil
}},
{Name: "secret_verify",
Description: "Check one holder's secret without seeing it: the fingerprint the vault recorded against the " +
"fingerprint of the value the mesh currently delivers here, and optionally against a fingerprint " +
"computed on the holder's own machine (sha256 of the file, as `sha256:<hex>`). Two ends agreeing " +
"proves they agree, not that either works — the login itself is the test.",
Input: map[string]any{"as": str("the holder's login, e.g. anchor-redis"),
"fingerprint": str("optional: sha256:<hex> of the value as the holder reads it, computed there — never the value")},
run: func(_ string, a map[string]any) (any, error) {
as, _ := a["as"].(string)
given, _ := a["fingerprint"].(string)
return verifyAnswer(env, as, given), nil
}},
{Name: "secret_handed",
Description: "Who was handed which secret by `get`, and when, and who was refused, newest first, as the " +
"vault's own store records it. Never a value.",
Input: map[string]any{"limit": map[string]any{"type": "number", "description": "how many (default 50)"}},
run: func(_ string, a map[string]any) (any, error) {
if env.Vault == nil {
return nil, errors.New("the vault is not ready")
}
return env.Vault.Store.Handed(limitOf(a))
}},
{Name: "secret_vault_export",
Description: "Every secret the vault itself holds, as its copy sealed to the operator key, in the shape of " +
"`secret export`: saved to a file, `nox secret recover vault mesh-vault <name> --key <operator key file> " +
"--from-export <file> --out <file>` opens one with the operator's private key. Ciphertext only.",
run: func(string, map[string]any) (any, error) {
if env.Vault == nil {
return nil, errors.New("the vault is not ready")
}
return vaultExport(env.Vault)
}},
{Name: "provisioner_retirement",
Description: "This provider's retirement (novox/hq ADR 0230): the holders it keeps active, a retirement waiting " +
"for a person, one a person rejected, and every holder retired with when and why. Mark-only: a retired " +
"holder keeps what it has until a person deletes it.",
run: func(string, map[string]any) (any, error) {
if env.Custody == nil {
return nil, errors.New("the vault has no custody ledger")
}
return env.Custody.Retirement(), nil
}},
{Name: "provisioner_retire_approve",
Description: "Retire exactly the set waiting for a person (or rejected earlier). Repeat the set in consumers; why is required.",
Input: actInput(true, false),
run: func(caller string, a map[string]any) (any, error) {
if env.Custody == nil {
return nil, errors.New("the vault has no custody ledger")
}
done, err := env.Custody.Approve(stringsOf(a["consumers"]), actOf(caller, a))
if err != nil {
return nil, err
}
return map[string]any{"retired": done}, nil
}},
{Name: "provisioner_retire_reject",
Description: "Keep the set waiting for a person active, though the mesh no longer asks for it. Repeat the set in consumers; why is required.",
Input: actInput(true, false),
run: func(caller string, a map[string]any) (any, error) {
if env.Custody == nil {
return nil, errors.New("the vault has no custody ledger")
}
kept, err := env.Custody.Reject(stringsOf(a["consumers"]), actOf(caller, a))
if err != nil {
return nil, err
}
return map[string]any{"kept": kept}, nil
}},
{Name: "provisioner_delete",
Description: "Delete one RETIRED holder from the custody ledger, its rotation history with it — never an active one. " +
"Repeat its name in confirm; why is required.",
Input: actInput(false, true),
run: func(caller string, a map[string]any) (any, error) {
if env.Custody == nil {
return nil, errors.New("the vault has no custody ledger")
}
consumer, _ := a["consumer"].(string)
confirm, _ := a["confirm"].(string)
if err := env.Custody.Delete(consumer, confirm, actOf(caller, a)); err != nil {
return nil, err
}
return map[string]any{"deleted": consumer, "freed_bytes": -1}, nil
}},
{Name: "vault_status",
Description: "What needs a person's eye in the vault: operator copies still sealed to an earlier key, the operator " +
"key the mesh's export holds if the vault refused it, and how often the record of hand-outs was rolled.",
run: func(string, map[string]any) (any, error) {
if env.Vault == nil {
return map[string]any{"ready": false, "error": fmt.Sprint(env.Broken)}, nil
}
left, err := env.Vault.StillSealedToAnother()
if err != nil {
return nil, err
}
key, _ := env.Vault.Store.OperatorKey()
return map[string]any{"ready": true, "operator_key": key, "copies_on_an_earlier_key": left,
"operator_key_refused": env.keyRefused(), "handed_rolled": env.Vault.Store.Rolled(),
"ok": left == 0 && env.keyRefused() == "" && key != ""}, nil
}},
}
}
// actInput is the arguments of an act on retirement, as the SDK's harness takes them.
func actInput(set, one bool) map[string]any {
text := map[string]any{"type": "string"}
in := map[string]any{"why": text, "by": text, "via": text}
if set {
in["consumers"] = map[string]any{"type": "array", "items": text}
}
if one {
in["consumer"], in["confirm"] = text, text
}
return in
}
// actOf is an act's why, by and via. `by` is the person the controller names; with none, the caller the
// runtime proved.
func actOf(caller string, a map[string]any) Act {
act := Act{}
act.Why, _ = a["why"].(string)
act.By, _ = a["by"].(string)
act.Via, _ = a["via"].(string)
if act.By == "" {
act.By = caller
}
return act
}
func stringsOf(v any) []string {
list, _ := v.([]any)
out := []string{}
for _, x := range list {
if s, ok := x.(string); ok {
out = append(out, s)
}
}
return out
}
func limitOf(a map[string]any) int {
if v, ok := a["limit"].(float64); ok && v > 0 {
return int(v)
}
return 50
}
// keyFingerprint is a key's fingerprint as the controller's secrets.Fingerprint says it: sha256 of the key,
// its first eight bytes in hex.
func keyFingerprint(key string) string {
if key == "" {
return ""
}
sum := sha256.Sum256([]byte(strings.TrimSpace(key)))
return "sha256:" + hex.EncodeToString(sum[:8])
}
// vaultExport is the vault's operator copies in the shape `secret export` writes and `secret recover
// --from-export` reads: each as node "vault", module "mesh-vault" and the secret's name.
func vaultExport(v *Vault) (map[string]any, error) {
key, err := v.Store.OperatorKey()
if err != nil {
return nil, err
}
all, err := v.Store.List()
if err != nil {
return nil, err
}
kept, earlier := []map[string]any{}, []map[string]any{}
for _, rec := range all {
k := map[string]any{"node": "vault", "module": Seat, "name": rec.Name, "kind": "own", "origin": "made",
"sealed": rec.Operator.Sealed, "key": rec.Operator.Key, "made-at": rec.MadeAt}
if rec.Operator.Key == key {
kept = append(kept, k)
} else {
earlier = append(earlier, k)
}
}
return map[string]any{"export": 1, "operator-key": key, "fingerprint": keyFingerprint(key), "kept": kept,
"sealed-to-earlier-key": earlier}, nil
}
func exportAnswer(root string, withBlobs bool) map[string]any {
if root == "" {
return map[string]any{"available": false, "error": "this vault keeps no root secrets (MESH_VAULT_ROOT is not set)"}
}
raw, err := os.ReadFile(filepath.Join(root, "export.json"))
if err != nil {
return map[string]any{"available": false, "error": "the mesh has not written an export here yet: " + err.Error()}
}
var doc struct {
Export int `json:"export"`
OperatorKey string `json:"operator-key"`
Fingerprint string `json:"fingerprint"`
Kept []map[string]any `json:"kept"`
Unrecoverable []map[string]any `json:"unrecoverable"`
}
if err := json.Unmarshal(raw, &doc); err != nil {
return map[string]any{"available": false, "error": "the export is not readable: " + err.Error()}
}
kept := []map[string]any{}
for _, k := range doc.Kept {
if !withBlobs {
k = map[string]any{"node": k["node"], "module": k["module"], "name": k["name"], "origin": k["origin"], "made-at": k["made-at"]}
}
kept = append(kept, k)
}
if doc.Unrecoverable == nil {
doc.Unrecoverable = []map[string]any{}
}
return map[string]any{"available": true, "export": doc.Export, "operator-key": doc.OperatorKey,
"fingerprint": doc.Fingerprint, "count": len(kept), "kept": kept, "unrecoverable": doc.Unrecoverable}
}
// exportedOperatorKey is the operator key in the export the mesh writes to the vault's root, or "".
func exportedOperatorKey(root string) string {
raw, err := os.ReadFile(filepath.Join(root, "export.json"))
if err != nil {
return ""
}
var doc struct {
OperatorKey string `json:"operator-key"`
}
_ = json.Unmarshal(raw, &doc)
return doc.OperatorKey
}
func verifyAnswer(env *Env, as, given string) map[string]any {
if env.Ledger == nil {
return map[string]any{"as": as, "known": false, "error": "the vault has no ledger (MESH_VAULT_LEDGER is not set)"}
}
recorded, ok := env.Ledger.Get(as)
if !ok {
return map[string]any{"as": as, "known": false, "error": "this vault holds nothing for " + as}
}
var delivered, deliveredErr any
var deliveredMatches any
var asked *Contribution
if env.Receives != "" {
all, _ := Contributions(env.Receives)
for i := range all {
if all[i].As == as {
asked = &all[i]
}
}
}
if asked == nil {
deliveredErr = "the mesh does not currently ask this vault to serve that login"
} else if fp, _, err := DeliveredFingerprint(*asked); err != nil {
deliveredErr = err.Error()
} else {
delivered, deliveredMatches = fp, fp == recorded.Fingerprint
}
var givenAny, givenMatches, earlier any
if given != "" {
givenAny, givenMatches = given, given == recorded.Fingerprint
was := false
for _, h := range recorded.History {
was = was || h.Fingerprint == given
}
earlier = was
}
return map[string]any{"as": recorded.As, "known": true, "recorded": recorded.Fingerprint,
"rotations": recorded.Rotations, "changed": recorded.Changed,
"delivered": delivered, "deliveredError": deliveredErr, "deliveredMatchesRecorded": deliveredMatches,
"given": givenAny, "givenMatchesRecorded": givenMatches, "givenIsAnEarlierValue": earlier,
"ok": deliveredMatches != false && givenMatches != false}
}
// tools are the definitions as the SDK serves them.
func tools(env *Env) []stdio.Tool {
var out []stdio.Tool
for _, d := range definitions(env) {
out = append(out, stdio.Tool{Name: d.Name, Description: d.Description, Input: d.Input, Run: d.Run})
}
return out
}
@@ -0,0 +1,152 @@
package main
// The tools the TypeScript vault offered, answering as they did: secret_export, secret_holders and
// secret_verify — none returns a value. And the seat's verb `get`, reached the way the runtime calls it.
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/seal"
)
func toolNamed(t *testing.T, tools []toolDef, name string) toolDef {
t.Helper()
for _, tl := range tools {
if tl.Name == name {
return tl
}
}
t.Fatalf("no tool %s", name)
return toolDef{}
}
func TestTheManifestNamesWhatTheBundleServes(t *testing.T) {
raw, _ := os.ReadFile("../../module.json")
var m struct {
Tools []string `json:"tools"`
Claims []struct {
Name string `json:"name"`
Serves []string `json:"serves"`
} `json:"claims"`
Emits []string `json:"emits"`
}
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
served := map[string]bool{}
for _, tl := range definitions(&Env{}) {
served[tl.Name] = true
}
for _, name := range m.Tools {
if !served[name] {
t.Errorf("the manifest names the tool %s, which the bundle does not serve", name)
}
}
for _, c := range m.Claims {
for _, verb := range c.Serves {
if !served[c.Name+"."+verb] {
t.Errorf("the claim of %s serves %s, which the bundle does not", c.Name, verb)
}
}
}
for name := range served {
if !strings.HasPrefix(name, Seat+".") && !contains(m.Tools, name) {
t.Errorf("the bundle serves %s, which the manifest does not name", name)
}
}
if !contains(m.Emits, ChangedEvent) {
t.Errorf("the manifest does not say the vault emits %s", ChangedEvent)
}
}
func TestSecretExportAnswersTheMeshsExport(t *testing.T) {
root := t.TempDir()
env := &Env{Root: root}
export := toolNamed(t, definitions(env), "secret_export")
got, _ := export.Run(map[string]any{})
if got.(map[string]any)["available"] != false {
t.Errorf("no export said %v", got)
}
doc := `{"export":1,"operator-key":"b3A=","fingerprint":"sha256:f","kept":[{"node":"g14","module":"dunst","name":"token","kind":"own","origin":"made","sealed":"c2VhbGVk","key":"b3A=","made-at":"2026-10-01T00:00:00Z"}],"unrecoverable":[{"node":"ace","module":"x","name":"y"}]}`
_ = os.WriteFile(filepath.Join(root, "export.json"), []byte(doc), 0o600)
got, _ = export.Run(map[string]any{})
a := got.(map[string]any)
if a["available"] != true || a["count"] != 1 || a["operator-key"] != "b3A=" {
t.Errorf("the export said %v", a)
}
got, _ = export.Run(map[string]any{"sealed": false})
kept := got.(map[string]any)["kept"].([]map[string]any)
if _, ok := kept[0]["sealed"]; ok {
t.Error("sealed=false still returned the blobs")
}
}
func TestSecretHoldersAndVerifySayFingerprintsOnly(t *testing.T) {
dir := t.TempDir()
l, _ := NewLedger(filepath.Join(dir, "ledger"))
secret := filepath.Join(dir, "s")
_ = os.WriteFile(secret, []byte("the-value"), 0o600)
receives := filepath.Join(dir, "mesh.json")
doc, _ := json.Marshal(map[string]any{"given": []map[string]string{{"from": "redis", "node": "anchor", "as": "anchor-redis", "secret": secret}}})
_ = os.WriteFile(receives, doc, 0o600)
_, _, _ = l.Record("anchor-redis", "anchor", "the-value", time.Now())
env := &Env{Ledger: l, Receives: receives}
holders, _ := toolNamed(t, definitions(env), "secret_holders").Run(map[string]any{})
verify, _ := toolNamed(t, definitions(env), "secret_verify").Run(map[string]any{"as": "anchor-redis", "fingerprint": Fingerprint("the-value")})
unknown, _ := toolNamed(t, definitions(env), "secret_verify").Run(map[string]any{"as": "nobody"})
for _, answer := range []any{holders, verify, unknown} {
raw, _ := json.Marshal(answer)
if strings.Contains(string(raw), "the-value") {
t.Errorf("a tool returned the value: %s", raw)
}
}
v := verify.(map[string]any)
if v["ok"] != true || v["deliveredMatchesRecorded"] != true || v["givenMatchesRecorded"] != true {
t.Errorf("verify said %v", v)
}
h := holders.(map[string]any)
if h["count"] != 1 {
t.Errorf("holders said %v", h)
}
if unknown.(map[string]any)["known"] != false {
t.Errorf("an unknown login said %v", unknown)
}
}
// The seat's verb, called as the runtime calls it: the caller is the one the runtime proved, never an
// argument.
func TestTheGetVerbTakesItsCallerFromTheRuntime(t *testing.T) {
v := newVault(t, AllowList{"g14.node-provisioner": {"pair/postgres/gitea"}})
if _, err := v.Put("pair/postgres/gitea", "x", "the test"); err != nil {
t.Fatal(err)
}
asker, _ := seal.GenerateKeyPair()
get := toolNamed(t, definitions(&Env{Vault: v.Vault}), Seat+".get")
// No runtime proved a caller here, so the call is unproven whatever the arguments say.
if _, err := get.Run(map[string]any{"name": "pair/postgres/gitea", "public_key": asker.PublicKey, "caller": "g14.node-provisioner"}); err == nil {
t.Error("get answered a caller named in its arguments")
}
answer, err := get.runAs("g14.node-provisioner", map[string]any{"name": "pair/postgres/gitea", "public_key": asker.PublicKey})
if err != nil {
t.Fatal(err)
}
a := answer.(Answer)
if got, err := seal.Open(a.Sealed, asker.PrivateKey); err != nil || got != "x" {
t.Errorf("%q %v", got, err)
}
}
func contains(list []string, s string) bool {
for _, x := range list {
if x == s {
return true
}
}
return false
}
+301
View File
@@ -0,0 +1,301 @@
package main
// The vault's rules (novox/hq ADR 0349 §7 and §11; ADR 0113 §1, §2, §22): it holds every secret sealed at
// rest, with a copy sealed to the operator key; it hands one out only on request and reply, sealed to the
// asker's key, and only to an asker it allows; it says a secret changed — who, when, never a value.
import (
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"regexp"
"sync"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/seal"
)
// Seat is the role this module holds.
const Seat = "mesh-vault"
// ChangedEvent is what the vault raises when a secret it holds changes: who, when, which, its generation.
const ChangedEvent = "secret-changed"
// Vault holds the secrets.
type Vault struct {
Store *Store
// Own is the vault's own key; the private half never leaves its state directory.
Own seal.KeyPair
// Askers decides who may ask `get` for what. Nil allows nobody.
Askers Askers
Emit func(event string, body map[string]any) error
Now func() time.Time
Log func(format string, args ...any)
mu sync.Mutex
}
// Changed is what one change to a secret was. Never its value.
type Changed struct {
Name string `json:"name"`
Generation int `json:"generation"`
At time.Time `json:"at"`
By string `json:"by"`
Unchanged bool `json:"unchanged,omitempty"`
}
// Answer is `get`'s reply: the secret sealed to the asker's key, and which generation it is.
type Answer struct {
Name string `json:"name"`
Generation int `json:"generation"`
ChangedAt time.Time `json:"changed-at"`
Sealed seal.Box `json:"sealed"`
}
func (v *Vault) log(format string, args ...any) {
if v.Log != nil {
v.Log(format, args...)
}
}
// Put keeps value as the secret name: sealed to the vault's key and to the operator key, a new generation
// when it differs from the one held, and `secret-changed` said. The same value again changes nothing.
// Refused: a private key (it stays where it was made), and any value while the vault has no operator key —
// a secret only the vault could open is what ADR 0113 §22 rules out.
func (v *Vault) Put(name, value, by string) (Changed, error) {
if err := validName(name); err != nil {
return Changed{}, err
}
if value == "" {
return Changed{}, fmt.Errorf("%s: a secret is not empty", name)
}
if privateKeyIn(value) {
return Changed{}, fmt.Errorf("%s: that is a private key, and a private key stays where it was made and never reaches the vault (ADR 0113 §2)", name)
}
if by == "" {
by = "the vault"
}
v.mu.Lock()
defer v.mu.Unlock()
operator, err := v.Store.OperatorKey()
if err != nil {
return Changed{}, err
}
if operator == "" {
return Changed{}, fmt.Errorf("%s is not kept: the vault has no operator key to seal its copy to (ADR 0113 §22) — the mesh writes it with the export once `operator key set` has been run", name)
}
before, held, err := v.Store.Read(name)
if err != nil {
return Changed{}, err
}
if held {
if old, err := seal.Open(before.AtRest, v.Own.PrivateKey); err == nil && old == value {
return Changed{Name: name, Generation: before.Generation, At: before.ChangedAt, By: before.ChangedBy, Unchanged: true}, nil
}
}
atRest, err := seal.Seal(value, v.Own.PublicKey)
if err != nil {
return Changed{}, err
}
forOperator, err := operatorSeal(operator, []byte(value))
if err != nil {
return Changed{}, err
}
now := v.Now().UTC()
rec := Record{Name: name, Generation: 1, AtRest: atRest,
Operator: OperatorCopy{Box: OperatorBox, Key: operator, Sealed: forOperator},
MadeAt: now, ChangedAt: now, ChangedBy: by}
if held {
rec.Generation, rec.MadeAt = before.Generation+1, before.MadeAt
}
if err := v.Store.Write(rec); err != nil {
return Changed{}, err
}
c := Changed{Name: name, Generation: rec.Generation, At: now, By: by}
v.announce(c)
return c, nil
}
// Mint makes a fresh value for name and keeps it: 30 random bytes, base64url without padding — 40
// characters, the controller's shape, which fits the tightest backend a minted password reaches.
func (v *Vault) Mint(name, by string) (Changed, error) {
raw := make([]byte, 30)
if _, err := rand.Read(raw); err != nil {
return Changed{}, err
}
return v.Put(name, base64.RawURLEncoding.EncodeToString(raw), by)
}
// announce raises `secret-changed`: who, when, which and its generation — never a value or a sealed copy.
func (v *Vault) announce(c Changed) {
if v.Emit == nil {
return
}
body := map[string]any{"name": c.Name, "by": c.By, "at": c.At.Format(time.RFC3339Nano), "generation": c.Generation}
if err := v.Emit(ChangedEvent, body); err != nil {
v.log("%s is kept at generation %d, but %s could not be said: %v", c.Name, c.Generation, ChangedEvent, err)
}
}
// recordHanding keeps who asked for what, and what they got, in the vault's own store, and says loudly
// when the record had to be rolled to make room.
func (v *Vault) recordHanding(h Handing) error {
h.At = v.Now().UTC()
rolled, err := v.Store.RecordHanding(h)
if err != nil {
v.log("the record of %s asking for %s could not be written: %v", h.To, h.Name, err)
return err
}
if rolled {
v.log("THE RECORD OF HAND-OUTS WAS ROLLED (%d times since the vault started): it reached its bound of %d bytes; "+
"the oldest of %d kept files is dropped. Many asks in little time is worth a look: secret_handed and vault_status",
v.Store.Rolled(), v.Store.HandedBound(), HandedKept)
}
return nil
}
// busUser is the shape of a caller the runtime proves (the SDK's): the one shape a refusal is recorded for.
var busUser = regexp.MustCompile(`^[A-Za-z0-9_-]+(\.[A-Za-z0-9_-]+)*$`)
// Get answers caller with the secret name sealed to publicKey, if caller may ask for it. The caller is the
// bus user the runtime proved, never an argument; an unproven call is refused.
func (v *Vault) Get(caller, name, publicKey string) (Answer, error) {
if caller == "" || caller == stdio.Unproven {
return Answer{}, errors.New("the vault answers only a caller the runtime proved")
}
askers := v.Askers
if askers == nil {
askers = NoAsker{}
}
if !busUser.MatchString(caller) || len(caller) > 200 {
return Answer{}, errors.New("the vault answers only a caller the runtime proved")
}
// A name is judged before anything is recorded, so an arbitrary argument fills no record.
if err := validName(name); err != nil {
return Answer{}, err
}
if ok, why := askers.May(caller, name); !ok {
v.log("refused %s to %s: %s", name, caller, why)
_ = v.recordHanding(Handing{To: caller, Name: name, Outcome: "refused", Why: why})
return Answer{}, fmt.Errorf("%s may not ask for %s: %s", caller, name, why)
}
key, err := askerKey(publicKey)
if err != nil {
return Answer{}, err
}
rec, held, err := v.Store.Read(name)
if err != nil {
return Answer{}, err
}
if !held {
return Answer{}, fmt.Errorf("the vault holds no secret named %s", name)
}
value, err := seal.Open(rec.AtRest, v.Own.PrivateKey)
if err != nil {
return Answer{}, fmt.Errorf("%s does not open with the vault's key: %w", name, err)
}
sealed, err := seal.Seal(value, key)
if err != nil {
return Answer{}, err
}
if err := v.recordHanding(Handing{To: caller, Name: name, Generation: rec.Generation, Outcome: "handed"}); err != nil {
return Answer{}, fmt.Errorf("%s is not handed out: who was handed it could not be recorded: %w", name, err)
}
v.log("handed %s generation %d to %s, sealed to its key", name, rec.Generation, caller)
return Answer{Name: name, Generation: rec.Generation, ChangedAt: rec.ChangedAt, Sealed: sealed}, nil
}
// SetOperatorKey makes key (SPKI PEM) the operator key, then seals again to it every operator copy that is
// not yet — see Reseal. It returns how many copies it sealed again.
//
// The key is recorded first and each record says which key its copy is sealed to, so that record is the
// progress: a failure partway leaves the rest for the next call, and the same key given again (every minute
// from the mesh's export) finishes them. Nothing returns early while a copy is sealed to another key.
func (v *Vault) SetOperatorKey(key, by string) (int, error) {
raw, err := checkOperatorKey(key)
if err != nil {
return 0, err
}
v.mu.Lock()
defer v.mu.Unlock()
current, err := v.Store.OperatorKey()
if err != nil {
return 0, err
}
if current != raw {
if err := v.Store.setOperatorKey(raw, by, v.Now().UTC()); err != nil {
return 0, err
}
v.log("the operator key is now %s (by %s); every operator copy is sealed again to it", raw, by)
}
return v.reseal(raw)
}
// Reseal seals again to the current operator key every copy sealed to another one, and says how many it
// did. Called every minute, so a copy a failure left behind is not left for ever.
func (v *Vault) Reseal() (int, error) {
v.mu.Lock()
defer v.mu.Unlock()
key, err := v.Store.OperatorKey()
if err != nil || key == "" {
return 0, err
}
return v.reseal(key)
}
func (v *Vault) reseal(key string) (int, error) {
all, err := v.Store.List()
if err != nil {
return 0, err
}
n := 0
var failed []error
for _, rec := range all {
if rec.Operator.Key == key && rec.Operator.Box == OperatorBox {
continue
}
value, err := seal.Open(rec.AtRest, v.Own.PrivateKey)
if err != nil {
failed = append(failed, fmt.Errorf("%s does not open with the vault's key: %w", rec.Name, err))
continue
}
sealed, err := operatorSeal(key, []byte(value))
if err == nil {
rec.Operator = OperatorCopy{Box: OperatorBox, Key: key, Sealed: sealed}
err = v.Store.Write(rec)
}
if err != nil {
failed = append(failed, fmt.Errorf("%s: %w", rec.Name, err))
continue
}
n++
}
if len(failed) > 0 {
err := fmt.Errorf("%d operator copies are still sealed to an earlier key, tried again on the next pass: %w",
len(failed), errors.Join(failed...))
v.log("%v", err)
return n, err
}
return n, nil
}
// StillSealedToAnother is how many operator copies are not yet sealed to the current operator key.
func (v *Vault) StillSealedToAnother() (int, error) {
key, err := v.Store.OperatorKey()
if err != nil {
return 0, err
}
all, err := v.Store.List()
if err != nil {
return 0, err
}
n := 0
for _, rec := range all {
if rec.Operator.Key != key {
n++
}
}
return n, nil
}
@@ -0,0 +1,730 @@
package main
// ADR 0349's vault test list, the part this package (V1, issue 674) answers: no bucket holds a secret; no
// private key is ever sent to the vault; every secret has a copy sealed to the operator key; `get` answers
// only an allowed asker, sealed to its key; `secret-changed` carries no value. The rest of that list (a
// consumer binding remakes the provider's credential, an accepted value is not remade, issued and typed
// values) is V2's and V5's.
import (
"crypto/ecdh"
"crypto/rand"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
"testing"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/seal"
"golang.org/x/crypto/nacl/box"
)
// published is everything the vault said on the bus during a test, as it said it.
type published struct {
mu sync.Mutex
events []map[string]any
raw []string
}
func (p *published) emit(event string, body map[string]any) error {
p.mu.Lock()
defer p.mu.Unlock()
b, _ := json.Marshal(map[string]any{"event": event, "body": body})
p.raw = append(p.raw, string(b))
p.events = append(p.events, map[string]any{"event": event, "body": body})
return nil
}
// operatorKey is an operator key the way the controller's `operator key make` makes one: NaCl, raw X25519,
// base64 — the private half kept by the test, as the operator keeps it off the mesh.
func operatorKey(t *testing.T) (public, private string) {
t.Helper()
pub, priv, err := box.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
return base64.StdEncoding.EncodeToString(pub[:]), base64.StdEncoding.EncodeToString(priv[:])
}
// asPEM is a raw operator key as the SPKI PEM the vault takes.
func asPEM(t *testing.T, raw string) string {
t.Helper()
p, err := publicPEM(raw)
if err != nil {
t.Fatal(err)
}
return p
}
// openOperator opens an operator copy the way `secret recover --from-export` does.
func openOperator(t *testing.T, public, private, sealed string) string {
t.Helper()
var pub, priv [32]byte
p, _ := base64.StdEncoding.DecodeString(public)
q, _ := base64.StdEncoding.DecodeString(private)
copy(pub[:], p)
copy(priv[:], q)
blob, err := base64.StdEncoding.DecodeString(sealed)
if err != nil {
t.Fatal(err)
}
out, ok := box.OpenAnonymous(nil, blob, &pub, &priv)
if !ok {
t.Fatal("the operator copy does not open with the operator's key")
}
return string(out)
}
type testVault struct {
*Vault
dir string
bus *published
operatorPublic string
operatorPrivate string
}
func newVault(t *testing.T, askers Askers) testVault {
t.Helper()
dir := t.TempDir()
store, err := OpenStore(filepath.Join(dir, "store"))
if err != nil {
t.Fatal(err)
}
own, err := OwnKey(filepath.Join(dir, "state"), store)
if err != nil {
t.Fatal(err)
}
bus := &published{}
clock := time.Date(2026, 10, 14, 9, 0, 0, 0, time.UTC)
v := &Vault{Store: store, Own: own, Askers: askers, Emit: bus.emit,
Now: func() time.Time { clock = clock.Add(time.Second); return clock }, Log: t.Logf}
pub, priv := operatorKey(t)
if _, err := v.SetOperatorKey(asPEM(t, pub), "the test"); err != nil {
t.Fatal(err)
}
return testVault{Vault: v, dir: dir, bus: bus, operatorPublic: pub, operatorPrivate: priv}
}
// filesUnder is every file's content under dir.
func filesUnder(t *testing.T, dir string) map[string]string {
t.Helper()
out := map[string]string{}
_ = filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err == nil && !info.IsDir() {
raw, _ := os.ReadFile(path)
out[path] = string(raw)
}
return nil
})
return out
}
// ---- no bucket holds a secret ---------------------------------------------------------------------------
// The manifest declares no bucket, and the code opens none: the vault's only place for a secret is its own
// store on its own disk (ADR 0349 §7, ADR 0201 §13).
func TestNoBucketHoldsASecret(t *testing.T) {
raw, err := os.ReadFile("../../module.json")
if err != nil {
t.Fatal(err)
}
var manifest map[string]any
if err := json.Unmarshal(raw, &manifest); err != nil {
t.Fatal(err)
}
if s, ok := manifest["state"]; ok {
t.Errorf("the manifest declares buckets %v: a secret is in no bucket, sealed or not (ADR 0201 §13)", s)
}
sources, _ := filepath.Glob("*.go")
bucket := regexp.MustCompile(`stdio\.State\(|nats-io|jetstream|KeyValue`)
for _, f := range sources {
if strings.HasSuffix(f, "_test.go") {
continue
}
code, _ := os.ReadFile(f)
if m := bucket.Find(code); m != nil {
t.Errorf("%s opens a bucket (%s): the vault keeps every secret in its own store only", f, m)
}
}
// And what the vault publishes while it holds, changes and hands out secrets carries none of them, in
// the clear or sealed.
v := newVault(t, AllowList{"g14.node-provisioner": {"pair/postgres/gitea"}})
value := "the-password-nobody-may-see"
if _, err := v.Put("pair/postgres/gitea", value, "the test"); err != nil {
t.Fatal(err)
}
asker, _ := seal.GenerateKeyPair()
answer, err := v.Get("g14.node-provisioner", "pair/postgres/gitea", asker.PublicKey)
if err != nil {
t.Fatal(err)
}
rec, _, _ := v.Store.Read("pair/postgres/gitea")
for _, said := range v.bus.raw {
for _, secret := range []string{value, answer.Sealed.Ct, rec.AtRest.Ct, rec.Operator.Sealed} {
if strings.Contains(said, secret) {
t.Errorf("the vault published %s, which carries a secret or a sealed copy of one", said)
}
}
}
}
// Every secret is encrypted at rest: no file the vault writes holds a value in the clear, and each opens
// only with the vault's own key.
func TestEverySecretIsEncryptedAtRest(t *testing.T) {
v := newVault(t, nil)
values := map[string]string{"own/g14/dunst/token": "tok-1-plain", "pair/postgres/gitea": "pw-2-plain"}
for name, value := range values {
if _, err := v.Put(name, value, "the test"); err != nil {
t.Fatal(err)
}
}
for path, content := range filesUnder(t, filepath.Join(v.dir, "store")) {
for _, value := range values {
if strings.Contains(content, value) {
t.Errorf("%s holds a value in the clear", path)
}
}
if info, _ := os.Stat(path); info.Mode().Perm()&0o077 != 0 {
t.Errorf("%s is readable by others: %v", path, info.Mode())
}
}
for name, value := range values {
rec, ok, err := v.Store.Read(name)
if err != nil || !ok {
t.Fatalf("%s: %v %v", name, ok, err)
}
got, err := seal.Open(rec.AtRest, v.Own.PrivateKey)
if err != nil || got != value {
t.Errorf("%s at rest opens to %q (%v), not its value", name, got, err)
}
}
}
// ---- no private key is accepted -------------------------------------------------------------------------
func TestNoPrivateKeyIsAccepted(t *testing.T) {
v := newVault(t, AllowList{"g14.node-provisioner": {"pair/postgres/gitea"}})
keys, _ := seal.GenerateKeyPair()
ssh := "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAA\n-----END OPENSSH PRIVATE KEY-----\n"
rsa := "-----BEGIN RSA PRIVATE KEY-----\nMIIBOgIBAAJBAK\n-----END RSA PRIVATE KEY-----\n"
// Not as a secret to hold: a private key stays where it was made (ADR 0113 §2).
for _, value := range []string{keys.PrivateKey, ssh, rsa, "prefix " + keys.PrivateKey} {
if _, err := v.Put("own/g14/sshd/host", value, "the test"); err == nil {
t.Errorf("the vault took a private key as a secret: %.40q", value)
}
}
if _, ok, _ := v.Store.Read("own/g14/sshd/host"); ok {
t.Error("a refused private key was kept all the same")
}
// Not as the key to seal an answer to.
if _, err := v.Put("pair/postgres/gitea", "pw", "the test"); err != nil {
t.Fatal(err)
}
raw32 := base64.StdEncoding.EncodeToString(make([]byte, 32))
for _, key := range []string{keys.PrivateKey, ssh, raw32, "", "not a key"} {
if _, err := v.Get("g14.node-provisioner", "pair/postgres/gitea", key); err == nil {
t.Errorf("get sealed an answer to %.40q, which is not a PEM public key", key)
}
}
// Not as the operator key: neither a PEM private key nor a raw key, which looks the same as a raw
// private one.
rawPub, rawPriv := operatorKey(t)
for _, key := range []string{keys.PrivateKey, rawPriv, rawPub} {
if _, err := v.SetOperatorKey(key, "the test"); err == nil {
t.Errorf("the vault took %.20q as the operator key", key)
}
}
if k, _ := v.Store.OperatorKey(); k != v.operatorPublic {
t.Error("a refused operator key replaced the operator key")
}
// And nothing it publishes or writes names one that it refused.
for _, said := range v.bus.raw {
if strings.Contains(said, "PRIVATE KEY") {
t.Errorf("the vault published a private key: %s", said)
}
}
for path, content := range filesUnder(t, filepath.Join(v.dir, "store")) {
if strings.Contains(content, "PRIVATE KEY") {
t.Errorf("%s holds a private key", path)
}
}
}
// ---- every secret has an operator-sealed copy -----------------------------------------------------------
func TestEverySecretHasAnOperatorSealedCopy(t *testing.T) {
v := newVault(t, nil)
values := map[string]string{"own/g14/dunst/token": "a", "pair/postgres/gitea": "b"}
for name, value := range values {
if _, err := v.Put(name, value, "the test"); err != nil {
t.Fatal(err)
}
}
if _, err := v.Mint("pair/postgres/keycloak", "the test"); err != nil {
t.Fatal(err)
}
all, err := v.Store.List()
if err != nil {
t.Fatal(err)
}
if len(all) != 3 {
t.Fatalf("the store lists %d secrets, not 3", len(all))
}
for _, rec := range all {
if rec.Operator.Key != v.operatorPublic || rec.Operator.Box != OperatorBox {
t.Errorf("%s is sealed to %q in %q, not to the operator key", rec.Name, rec.Operator.Key, rec.Operator.Box)
}
got := openOperator(t, v.operatorPublic, v.operatorPrivate, rec.Operator.Sealed)
want, _ := seal.Open(rec.AtRest, v.Own.PrivateKey)
if got != want || got == "" {
t.Errorf("%s: the operator copy opens to %q, the vault's to %q", rec.Name, got, want)
}
}
// A new operator key: every copy is sealed again to it, and none stays sealed to the old one.
pub, priv := operatorKey(t)
n, err := v.SetOperatorKey(asPEM(t, pub), "the test")
if err != nil || n != 3 {
t.Fatalf("a new operator key sealed %d copies again (%v), not 3", n, err)
}
all, _ = v.Store.List()
for _, rec := range all {
if rec.Operator.Key != pub {
t.Errorf("%s is still sealed to the earlier operator key", rec.Name)
}
openOperator(t, pub, priv, rec.Operator.Sealed)
}
}
// Without an operator key the vault keeps nothing: a secret only it could open is what ADR 0113 §22 rules
// out, so the refusal is loud rather than a copy quietly missing.
func TestNoOperatorKeyKeepsNothing(t *testing.T) {
dir := t.TempDir()
store, _ := OpenStore(filepath.Join(dir, "store"))
own, _ := OwnKey(filepath.Join(dir, "state"), store)
v := &Vault{Store: store, Own: own, Emit: (&published{}).emit, Now: time.Now, Log: t.Logf}
if _, err := v.Put("own/g14/dunst/token", "x", "the test"); err == nil || !strings.Contains(err.Error(), "operator key") {
t.Fatalf("a secret was kept with no operator key to seal its copy to: %v", err)
}
if all, _ := store.List(); len(all) != 0 {
t.Fatalf("the store holds %d secrets", len(all))
}
}
// ---- get answers only an allowed asker, sealed to its key -----------------------------------------------
func TestGetAnswersOnlyAnAllowedAskerSealedToItsKey(t *testing.T) {
v := newVault(t, AllowList{"g14.node-provisioner": {"pair/postgres/gitea"}})
if _, err := v.Put("pair/postgres/gitea", "the-value", "the test"); err != nil {
t.Fatal(err)
}
if _, err := v.Put("own/ace/dunst/token", "another", "the test"); err != nil {
t.Fatal(err)
}
asker, _ := seal.GenerateKeyPair()
answer, err := v.Get("g14.node-provisioner", "pair/postgres/gitea", asker.PublicKey)
if err != nil {
t.Fatal(err)
}
if got, err := seal.Open(answer.Sealed, asker.PrivateKey); err != nil || got != "the-value" {
t.Fatalf("the answer opens to %q (%v)", got, err)
}
other, _ := seal.GenerateKeyPair()
if _, err := seal.Open(answer.Sealed, other.PrivateKey); err == nil {
t.Error("the answer opens with a key it was not sealed to")
}
if _, err := seal.Open(answer.Sealed, v.Own.PrivateKey); err == nil {
t.Error("the answer opens with the vault's own key")
}
if answer.Generation != 1 || answer.Name != "pair/postgres/gitea" {
t.Errorf("the answer says %+v", answer)
}
refusals := []struct{ caller, name, why string }{
{"ace.node-provisioner", "pair/postgres/gitea", "an asker not on the list"},
{"g14.node-provisioner", "own/ace/dunst/token", "a secret the asker is not allowed"},
{stdio.Unproven, "pair/postgres/gitea", "a call whose caller the runtime did not prove"},
{"", "pair/postgres/gitea", "no caller at all"},
{"g14.node-provisioner", "pair/postgres/nothing", "a secret the vault does not hold"},
}
for _, r := range refusals {
if a, err := v.Get(r.caller, r.name, asker.PublicKey); err == nil {
t.Errorf("get answered %s: %+v", r.why, a)
}
}
}
// Until who may ask is decided by a node's declaration and a module's own account (V4, P3), the vault
// that runs answers nobody.
func TestTheRunningVaultAnswersNobodyYet(t *testing.T) {
v := newVault(t, nil) // what main builds: no list
if _, err := v.Put("pair/postgres/gitea", "x", "the test"); err != nil {
t.Fatal(err)
}
asker, _ := seal.GenerateKeyPair()
for _, caller := range []string{"g14.node-provisioner", "novox.mesh-provisioner", "controller", "person.jochen", "novox.node-tools"} {
if _, err := v.Get(caller, "pair/postgres/gitea", asker.PublicKey); err == nil {
t.Errorf("the running vault answered %s", caller)
}
}
}
// The node-engine's sealing key (mesh-host internal/identity/sealing.go: raw X25519, base64, the NaCl
// anonymous box) is the same curve as the ADR 0206 box. A node's client asks with that key's public half as
// SPKI PEM, and opens the answer with its private half as PKCS #8 — no second key on the node, and no NaCl
// in the vault's answers. This is the path V4 builds on.
func TestANodesSealingKeyOpensTheAnswer(t *testing.T) {
v := newVault(t, AllowList{"g14.node-provisioner": {"pair/postgres/gitea"}})
if _, err := v.Put("pair/postgres/gitea", "for-the-node", "the test"); err != nil {
t.Fatal(err)
}
// As mesh-host's GenerateSealingKey makes it.
priv, _ := ecdh.X25519().GenerateKey(rand.Reader)
nodePublic := base64.StdEncoding.EncodeToString(priv.PublicKey().Bytes())
nodePrivate := base64.StdEncoding.EncodeToString(priv.Bytes())
rawPub, _ := base64.StdEncoding.DecodeString(nodePublic)
pubKey, _ := ecdh.X25519().NewPublicKey(rawPub)
spki, _ := x509.MarshalPKIXPublicKey(pubKey)
askWith := string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: spki}))
answer, err := v.Get("g14.node-provisioner", "pair/postgres/gitea", askWith)
if err != nil {
t.Fatal(err)
}
rawPriv, _ := base64.StdEncoding.DecodeString(nodePrivate)
privKey, _ := ecdh.X25519().NewPrivateKey(rawPriv)
pkcs8, _ := x509.MarshalPKCS8PrivateKey(privKey)
openWith := string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: pkcs8}))
if got, err := seal.Open(answer.Sealed, openWith); err != nil || got != "for-the-node" {
t.Fatalf("the node's own sealing key does not open the answer: %q %v", got, err)
}
}
// ---- secret-changed carries no value --------------------------------------------------------------------
func TestSecretChangedCarriesNoValue(t *testing.T) {
v := newVault(t, nil)
if _, err := v.Put("pair/postgres/gitea", "first-value", "g14.mesh-vault-test"); err != nil {
t.Fatal(err)
}
if _, err := v.Put("pair/postgres/gitea", "second-value", "person.jochen"); err != nil {
t.Fatal(err)
}
if _, err := v.Mint("own/g14/dunst/token", "the vault"); err != nil {
t.Fatal(err)
}
var changed []map[string]any
for _, e := range v.bus.events {
if e["event"] == ChangedEvent {
changed = append(changed, e["body"].(map[string]any))
}
}
if len(changed) != 3 {
t.Fatalf("%d secret-changed events, not 3: %v", len(changed), v.bus.events)
}
for i, body := range changed {
keys := make([]string, 0, len(body))
for k := range body {
keys = append(keys, k)
}
for _, k := range keys {
switch k {
case "name", "by", "at", "generation":
default:
t.Errorf("secret-changed carries %q, beyond who, when, which and its generation", k)
}
}
if body["by"] == "" || body["at"] == "" {
t.Errorf("secret-changed %d says not who or when: %v", i, body)
}
}
if changed[1]["by"] != "person.jochen" || changed[1]["generation"] != 2 {
t.Errorf("the second change says %v", changed[1])
}
minted, _, _ := v.Store.Read("own/g14/dunst/token")
mintedValue, _ := seal.Open(minted.AtRest, v.Own.PrivateKey)
for _, said := range v.bus.raw {
for _, value := range []string{"first-value", "second-value", mintedValue} {
if strings.Contains(said, value) {
t.Errorf("an event carries a value: %s", said)
}
}
}
}
// The same value again is no change: no new generation, no event.
func TestTheSameValueAgainIsNoChange(t *testing.T) {
v := newVault(t, nil)
for i := 0; i < 2; i++ {
if _, err := v.Put("pair/postgres/gitea", "same", "the test"); err != nil {
t.Fatal(err)
}
}
rec, _, _ := v.Store.Read("pair/postgres/gitea")
if rec.Generation != 1 {
t.Errorf("the same value twice is generation %d", rec.Generation)
}
n := 0
for _, e := range v.bus.events {
if e["event"] == ChangedEvent {
n++
}
}
if n != 1 {
t.Errorf("%d secret-changed events for one value", n)
}
}
// A secret's name is a name, not a path.
func TestASecretsNameIsNotAPath(t *testing.T) {
v := newVault(t, nil)
for _, name := range []string{"../etc/passwd", "/abs", "a//b", "A/b", "a/../b", "", strings.Repeat("a", 300)} {
if _, err := v.Put(name, "x", "the test"); err == nil {
t.Errorf("the vault kept a secret named %q", name)
}
}
}
// The vault's own key is made once, kept beside it, and the same on the next start.
func TestTheVaultsOwnKeyIsMadeOnce(t *testing.T) {
dir := t.TempDir()
a, err := OwnKey(dir, nil)
if err != nil {
t.Fatal(err)
}
b, err := OwnKey(dir, nil)
if err != nil {
t.Fatal(err)
}
if a != b {
t.Error("the vault's key changed between two starts")
}
info, _ := os.Stat(filepath.Join(dir, OwnKeyFile))
if info.Mode().Perm() != 0o600 {
t.Errorf("the vault's key is %v", info.Mode())
}
}
// The vault's key gone while its store holds secrets: no new key is made, and the vault does not start.
func TestAMissingKeyBesideSecretsIsRefused(t *testing.T) {
v := newVault(t, nil)
if _, err := v.Put("pair/postgres/gitea", "x", "the test"); err != nil {
t.Fatal(err)
}
if err := os.Remove(filepath.Join(v.dir, "state", OwnKeyFile)); err != nil {
t.Fatal(err)
}
if _, err := OwnKey(filepath.Join(v.dir, "state"), v.Store); err == nil || !strings.Contains(err.Error(), "MISSING") {
t.Fatalf("a new key was made beside secrets sealed to the lost one: %v", err)
}
if _, err := os.Stat(filepath.Join(v.dir, "state", OwnKeyFile)); err == nil {
t.Error("a key file was written all the same")
}
}
// A new operator key that fails partway leaves no copy sealed to the earlier key for good: the next call
// with the same key — every minute, from the mesh's export — finishes the rest.
func TestANewOperatorKeyFailingPartwayIsFinished(t *testing.T) {
v := newVault(t, nil)
for _, name := range []string{"a/one", "b/two", "c/three"} {
if _, err := v.Mint(name, "the test"); err != nil {
t.Fatal(err)
}
}
pub, priv := operatorKey(t)
v.Store.failWrite = func(r Record) error {
if r.Name == "b/two" {
return os.ErrPermission
}
return nil
}
n, err := v.SetOperatorKey(asPEM(t, pub), "the test")
if err == nil || n != 2 {
t.Fatalf("a failure partway said %d sealed again, err %v", n, err)
}
if left, _ := v.StillSealedToAnother(); left != 1 {
t.Fatalf("%d copies left on the earlier key, not 1", left)
}
v.Store.failWrite = nil
// The path the minute takes while the export holds the key: the same key again.
if n, err := v.SetOperatorKey(asPEM(t, pub), "the test"); err != nil || n != 1 {
t.Fatalf("the same key again sealed %d again (%v), not the 1 left", n, err)
}
if n, err := v.SetOperatorKey(asPEM(t, pub), "the test"); err != nil || n != 0 {
t.Fatalf("the same key a third time sealed %d (%v)", n, err)
}
all, _ := v.Store.List()
for _, rec := range all {
if rec.Operator.Key != pub {
t.Errorf("%s is still sealed to an earlier key", rec.Name)
}
openOperator(t, pub, priv, rec.Operator.Sealed)
}
}
// Who was handed which secret, and when, and who was refused, is in the vault's own store.
func TestWhoWasHandedWhatIsRecordedInTheStore(t *testing.T) {
v := newVault(t, AllowList{"g14.node-provisioner": {"pair/postgres/gitea"}})
if _, err := v.Put("pair/postgres/gitea", "the-value", "the test"); err != nil {
t.Fatal(err)
}
asker, _ := seal.GenerateKeyPair()
if _, err := v.Get("g14.node-provisioner", "pair/postgres/gitea", asker.PublicKey); err != nil {
t.Fatal(err)
}
_, _ = v.Get("ace.node-provisioner", "pair/postgres/gitea", asker.PublicKey)
handed, err := v.Store.Handed(10)
if err != nil || len(handed) != 2 {
t.Fatalf("%v %v", handed, err)
}
if handed[1].To != "g14.node-provisioner" || handed[1].Outcome != "handed" || handed[1].Generation != 1 || handed[1].At.IsZero() {
t.Errorf("the hand-out is recorded as %+v", handed[1])
}
if handed[0].To != "ace.node-provisioner" || handed[0].Outcome != "refused" || handed[0].Why == "" {
t.Errorf("the refusal is recorded as %+v", handed[0])
}
raw, _ := os.ReadFile(filepath.Join(v.dir, "store", "handed.jsonl"))
if strings.Contains(string(raw), "the-value") {
t.Error("the record of hand-outs holds a value")
}
}
// The vault's operator copies reach the operator's recovery: secret_vault_export is a document
// `secret recover --from-export` reads, found by node "vault", module "mesh-vault" and the secret's name,
// and opened with the operator's private key as that command opens it (mesh-controller
// cmd/mesh-controller/secret.go keptFromExport, internal/secrets Open).
func TestTheOperatorRecoversAVaultSecretFromItsExport(t *testing.T) {
v := newVault(t, nil)
if _, err := v.Put("pair/postgres/gitea", "recover-me", "the test"); err != nil {
t.Fatal(err)
}
answer, err := toolNamed(t, definitions(&Env{Vault: v.Vault}), "secret_vault_export").Run(map[string]any{})
if err != nil {
t.Fatal(err)
}
file := filepath.Join(t.TempDir(), "vault-export.json")
raw, _ := json.Marshal(answer)
_ = os.WriteFile(file, raw, 0o600)
// What `secret recover vault mesh-vault pair/postgres/gitea --from-export <file>` reads.
var e struct {
Kept []struct {
Node, Module, Name, Sealed, Key string
} `json:"kept"`
}
read, _ := os.ReadFile(file)
if err := json.Unmarshal(read, &e); err != nil {
t.Fatal(err)
}
var sealed string
for _, k := range e.Kept {
if k.Node == "vault" && k.Module == "mesh-vault" && k.Name == "pair/postgres/gitea" {
sealed = k.Sealed
if k.Key != v.operatorPublic {
t.Errorf("the export names key %s, not the operator's", k.Key)
}
}
}
if sealed == "" {
t.Fatalf("the export holds no copy of the secret: %s", read)
}
// secrets.Open: the public half derived from the private key, then the anonymous box.
privRaw, _ := base64.StdEncoding.DecodeString(v.operatorPrivate)
key, _ := ecdh.X25519().NewPrivateKey(privRaw)
if got := openOperator(t, base64.StdEncoding.EncodeToString(key.PublicKey().Bytes()), v.operatorPrivate, sealed); got != "recover-me" {
t.Fatalf("recovered %q", got)
}
if strings.Contains(string(read), "recover-me") {
t.Error("the export holds the value in the clear")
}
}
// With no key in the export the minute calls Reseal, which finishes what is pending too.
func TestResealFinishesWhatIsPending(t *testing.T) {
v := newVault(t, nil)
if _, err := v.Mint("a/one", "the test"); err != nil {
t.Fatal(err)
}
pub, _ := operatorKey(t)
v.Store.failWrite = func(Record) error { return os.ErrPermission }
if _, err := v.SetOperatorKey(asPEM(t, pub), "the test"); err == nil {
t.Fatal("a failing write was not said")
}
v.Store.failWrite = nil
if n, err := v.Reseal(); err != nil || n != 1 {
t.Fatalf("%d %v", n, err)
}
}
// A secret is not handed out when who was handed it cannot be recorded.
func TestNoHandOutWithoutItsRecord(t *testing.T) {
v := newVault(t, AllowList{"g14.node-provisioner": {"pair/postgres/gitea"}})
if _, err := v.Put("pair/postgres/gitea", "x", "the test"); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(filepath.Join(v.dir, "store", "handed.jsonl"), 0o700); err != nil {
t.Fatal(err)
}
asker, _ := seal.GenerateKeyPair()
if a, err := v.Get("g14.node-provisioner", "pair/postgres/gitea", asker.PublicKey); err == nil {
t.Fatalf("handed out with no record of it: %+v", a)
}
}
// What an asker cannot shape fills no record: a name that is no secret's name, a caller that is no bus user.
func TestAnArbitraryAskFillsNoRecord(t *testing.T) {
v := newVault(t, nil)
asker, _ := seal.GenerateKeyPair()
for _, ask := range [][2]string{{"g14.x", strings.Repeat("junk ", 1000)}, {"g14.x", "../../etc"},
{"not a bus user!", "pair/a"}, {strings.Repeat("a", 300), "pair/a"}} {
_, _ = v.Get(ask[0], ask[1], asker.PublicKey)
}
if h, _ := v.Store.Handed(10); len(h) != 0 {
t.Errorf("recorded %d arbitrary asks: %+v", len(h), h)
}
}
// The record of hand-outs is bounded: rolled at its size into a fixed number of files, the oldest dropped.
func TestTheRecordOfHandOutsIsBounded(t *testing.T) {
v := newVault(t, nil) // refuses everyone, as the running vault does
v.Store.HandedMax = 2000
asker, _ := seal.GenerateKeyPair()
for i := 0; i < 500; i++ {
_, _ = v.Get("g14.flood", "pair/postgres/gitea", asker.PublicKey)
}
var total int64
files, _ := filepath.Glob(filepath.Join(v.dir, "store", "handed.jsonl*"))
if len(files) != HandedKept+1 {
t.Errorf("%d files of the record, not %d: %v", len(files), HandedKept+1, files)
}
for _, f := range files {
info, _ := os.Stat(f)
if info.Size() > 2000 {
t.Errorf("%s is %d bytes, over its bound", f, info.Size())
}
total += info.Size()
}
if total > int64(HandedKept+1)*2000 {
t.Errorf("the record holds %d bytes", total)
}
if v.Store.Rolled() == 0 {
t.Error("rolling was not counted")
}
if h, _ := v.Store.Handed(5); len(h) != 5 || h[0].To != "g14.flood" {
t.Errorf("the newest are not read back: %+v", h)
}
}
+10
View File
@@ -0,0 +1,10 @@
module mesh-vault
go 1.25.0
require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013235212-c8b07242a009
golang.org/x/crypto v0.50.0
)
require golang.org/x/sys v0.43.0 // indirect
+6
View File
@@ -0,0 +1,6 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013235212-c8b07242a009 h1:I9rm8HhYRJ0+mzQQz+VAH0kgv2YJWkvctEc67Yd0PX8=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013235212-c8b07242a009/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
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/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
-31
View File
@@ -1,31 +0,0 @@
// mesh-vault's events entrypoint, loaded by the per-node tool host (the provisioner runs in the same
// process — ADR 0052). The lifecycle events are EMITTED from the provisioner, where custody
// actually changes (novox/hq ADR 0041/0042):
// mesh-vault.provisioned — a consumer was granted a secret
// mesh-vault.rotated — that consumer's value changed (`rotate secret`)
// mesh-vault.deprovisioned — the consumer went away and its secret was withdrawn
// Here the vault reacts to them, keeping a lightweight audit line of who holds what and when it
// moved — the audit an owner of secrets is best placed to log. Fingerprints, never values.
import { on } from "@novox/mesh-sdk/events";
interface SecretEvent {
as: string;
consumer?: string;
fingerprint?: string;
rotations?: number;
}
await on<SecretEvent>("provisioned", async (e) => {
console.log(`[mesh-vault] secret provisioned for ${e.body?.as} on ${e.body?.consumer} (${e.body?.fingerprint})`);
});
await on<SecretEvent>("rotated", async (e) => {
console.log(`[mesh-vault] secret rotated for ${e.body?.as} — rotation ${e.body?.rotations} (${e.body?.fingerprint})`);
});
await on<SecretEvent>("deprovisioned", async (e) => {
console.log(`[mesh-vault] secret withdrawn from ${e.body?.as}`);
});
console.log("[mesh-vault] auditing secret lifecycle events");
+36 -20
View File
@@ -10,18 +10,10 @@
}
}
],
"capabilities": [
"container-runtime"
],
"emits": [
"provisioned",
"rotated",
"deprovisioned"
],
"consumes": [
"mesh-vault.provisioned",
"mesh-vault.rotated",
"mesh-vault.deprovisioned"
"secret-changed"
],
"receives": {
"secret": "${dir:grants}/mesh.json"
@@ -49,6 +41,12 @@
"path": "${dir:root}",
"class": "valuable",
"why": "the vault's root material"
},
{
"id": "store",
"path": "${dir:store}",
"class": "valuable",
"why": "every secret the vault holds, sealed to its own key and to the operator key"
}
],
"consumers": {
@@ -80,6 +78,11 @@
"id": "root",
"type": "directory",
"mode": "0700"
},
{
"id": "store",
"type": "directory",
"mode": "0700"
}
],
"build": {
@@ -87,29 +90,42 @@
{
"name": "code",
"kind": "bundle",
"language": "typescript",
"entrypoints": [
"index.js",
"tools/index.js",
"provisioner/index.js"
],
"language": "go",
"system": "arch",
"from": "cmd/mesh-vault",
"binary": "mesh-vault",
"loads": [
"index.js",
"tools/index.js",
"provisioner/index.js"
"mesh-vault"
],
"env": {
"MESH_RECEIVES": "${dir:grants}/mesh.json",
"MESH_VAULT_LEDGER": "${dir:ledger}",
"MESH_VAULT_ROOT": "${dir:root}"
"MESH_VAULT_ROOT": "${dir:root}",
"MESH_VAULT_STORE": "${dir:store}",
"MESH_VAULT_STATE": "${dir:state}"
}
}
]
},
"tools": [
"secret_export",
"secret_holders",
"secret_verify",
"secret_handed",
"secret_vault_export",
"provisioner_retirement",
"provisioner_retire_approve",
"provisioner_retire_reject",
"provisioner_delete",
"vault_status"
],
"claims": [
{
"name": "mesh-vault",
"scope": "mesh"
"scope": "mesh",
"serves": [
"get"
]
}
]
}
-17
View File
@@ -1,17 +0,0 @@
{
"name": "@novox/module-mesh-vault",
"version": "0.1.0",
"description": "mesh-vault — provides the mesh `secret` interface: a module's own secret as an ordinary pair credential, held, audited and rotated like any other (novox/hq ADR 0085). Its ledger, provisioner, tools and events live here (ADR 0039).",
"type": "module",
"private": true,
"scripts": {
"test": "node --test --experimental-strip-types 'test/*.test.ts'"
},
"dependencies": {
"@novox/mesh-sdk": "^0.1.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.6.0"
}
}
-48
View File
@@ -1,48 +0,0 @@
// mesh-vault's provisioner — the adapter that makes vault a provider of the mesh `secret` interface. The
// reconcile loop, the contributions file, and reading the mesh's minted value are the sdk harness's;
// this writes only the per-service half (novox/hq ADR 0039/0040/0048) — and for a vault that half is
// taking custody, not creating anything.
//
// The `secret` interface (ADR 0085, design 24): a consumer requires a value for its own use — the
// password of a store it runs privately, an internal token — and reads it from the file the mesh
// writes on its machine. There is no server to create a login on. **The value is the pair
// credential itself**: the controller minted it, sealed it to both nodes, and delivered each its
// copy. What makes it *owned* is this: the vault records who holds it and its fingerprint, notices
// when `rotate secret` delivers a different one, and says so on the mesh. Rotation is not new
// machinery — it is the machinery that already moves a database password, pointed at a secret the
// vault provides (design 13).
import { runProvisioner, type Provision } from "@novox/mesh-sdk/provisioner";
import { emit } from "@novox/mesh-sdk/events";
import { Ledger } from "../client.js";
const ledger = Ledger.fromEnv();
/** Emit a lifecycle event without letting a broker hiccup fail the custody itself. */
async function announce(type: string, body: Record<string, string | number>): Promise<void> {
try {
await emit(type, body);
} catch (err) {
console.error(`[provisioner:secret] emit ${type} failed: ${err}`);
}
}
runProvisioner("secret", {
async create(p: Provision): Promise<void> {
const { held, outcome } = ledger.record(p.as, p.consumer ?? "", p.password);
if (outcome === "unchanged") return; // the harness re-runs create on restart; nothing happened
console.log(`[mesh-vault] ${outcome}: ${held.as} (${held.fingerprint.slice(0, 19)}…, rotations ${held.rotations})`);
await announce(`module.mesh-vault.secret.${outcome === "granted" ? "provisioned" : "rotated"}`, {
consumer: held.consumer,
as: held.as,
fingerprint: held.fingerprint,
rotations: held.rotations,
});
},
async remove(p: { as: string }): Promise<void> {
if (!ledger.withdraw(p.as)) return;
console.log(`[mesh-vault] withdrawn: ${p.as}`);
await announce("deprovisioned", { as: p.as });
},
});
-80
View File
@@ -1,80 +0,0 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Ledger, fingerprint, contributions, deliveredFingerprint } from "../client.ts";
function fresh(): Ledger {
return new Ledger(mkdtempSync(join(tmpdir(), "vault-ledger-")));
}
test("a first delivery is a grant, the same value again is nothing, a new value is a rotation", () => {
const ledger = fresh();
const t0 = new Date("2026-09-20T10:00:00Z");
const t1 = new Date("2026-09-21T10:00:00Z");
const granted = ledger.record("anchor-redis", "anchor", "first-value", t0);
assert.equal(granted.outcome, "granted");
assert.equal(granted.held.rotations, 0);
assert.equal(granted.held.since, t0.toISOString());
assert.equal(granted.held.fingerprint, fingerprint("first-value"));
assert.equal(ledger.record("anchor-redis", "anchor", "first-value", t1).outcome, "unchanged");
assert.equal(ledger.get("anchor-redis")!.rotations, 0, "an unchanged delivery counted as a rotation");
const rotated = ledger.record("anchor-redis", "anchor", "second-value", t1);
assert.equal(rotated.outcome, "rotated");
assert.equal(rotated.held.rotations, 1);
assert.equal(rotated.held.since, t0.toISOString(), "a rotation reset the grant date");
assert.equal(rotated.held.changed, t1.toISOString());
assert.equal(rotated.held.fingerprint, fingerprint("second-value"));
assert.deepEqual(rotated.held.history, [{ fingerprint: fingerprint("first-value"), until: t1.toISOString() }]);
});
test("the ledger holds fingerprints and never the value, in files nobody else can read", () => {
const dir = mkdtempSync(join(tmpdir(), "vault-ledger-"));
const ledger = new Ledger(dir);
ledger.record("anchor-redis", "anchor", "the-actual-password", new Date());
ledger.record("anchor-redis", "anchor", "the-rotated-password", new Date());
for (const name of readdirSync(dir)) {
const raw = readFileSync(join(dir, name), "utf8");
assert.doesNotMatch(raw, /the-actual-password|the-rotated-password/, `${name} holds a value`);
assert.equal(statSync(join(dir, name)).mode & 0o777, 0o600, `${name} is readable by others`);
}
});
test("withdrawing forgets a holder, and listing is by login", () => {
const ledger = fresh();
ledger.record("b-app", "b", "x", new Date());
ledger.record("a-app", "a", "y", new Date());
assert.deepEqual(ledger.list().map((h) => h.as), ["a-app", "b-app"]);
assert.equal(ledger.withdraw("a-app"), true);
assert.equal(ledger.withdraw("a-app"), false, "withdrawing twice said it found something");
assert.deepEqual(ledger.list().map((h) => h.as), ["b-app"]);
});
test("a login is a name, not a path", () => {
const ledger = fresh();
assert.throws(() => ledger.record("../etc/passwd", "n", "v"), /a login is a name/);
});
test("what the mesh delivers is read from the contributions file and fingerprinted, never returned", () => {
const dir = mkdtempSync(join(tmpdir(), "vault-grants-"));
const secret = join(dir, "anchor.redis.secret");
writeFileSync(secret, "minted-value\n"); // the host may leave a trailing newline; the value has none
const receives = join(dir, "mesh.json");
writeFileSync(receives, JSON.stringify({
requirement: "secret",
given: [
{ from: "redis", node: "anchor", as: "anchor-redis", secret },
{ from: "offer-only", node: "anchor" }, // a contribution with no login grants nothing
],
}));
const asked = contributions(receives);
assert.deepEqual(asked.map((c) => c.as), ["anchor-redis"]);
const seen = deliveredFingerprint(asked[0]);
assert.deepEqual(seen, { fingerprint: fingerprint("minted-value"), length: "minted-value".length });
assert.match(JSON.stringify(deliveredFingerprint({ as: "x", secret: join(dir, "missing") })), /not readable/);
});
-131
View File
@@ -1,131 +0,0 @@
// mesh-vault's tools — vault's own code (novox/hq ADR 0039), served through the sdk's tool harness. They
// return structured data about the secrets this vault provides, and **never a value**: a holder is
// identified by its login and a value by its fingerprint. That is the rule the source mesh's
// secret_locate / secret_verify were built on, after a secret printed into a transcript.
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
import { Ledger, contributions, deliveredFingerprint, type Held } from "../client.js";
/** The mesh's export of every operator-sealed secret, as the mesh wrote it into the root dir. */
interface KeptExport {
export: number;
"operator-key": string;
fingerprint: string;
kept: { node: string; module: string; name: string; origin: string; sealed: string; key: string; "made-at": string }[];
unrecoverable?: { node: string; module: string; name: string }[];
}
export function getVaultTools(ledger: Ledger, receives: string | undefined, root: string | undefined): ToolDefinition[] {
return [
{
name: "secret_export",
description:
"The mesh's root secrets as this vault keeps them: every secret a module holds for itself, " +
"sealed to the operator's key (novox/hq ADR 0085, amended). Ciphertext — nothing here can " +
"open a line of it; the operator, holding the private key off the mesh, recovers one with " +
"`nox secret recover --from-export` on the control-node. Also lists what is NOT recoverable: secrets " +
"made before the mesh had an operator key.",
input: {
sealed: {
type: "boolean",
description: "include the sealed blobs (default true); false lists holders and the key only",
},
},
run: async (args) => {
if (!root) return { available: false, error: "this vault keeps no root secrets (MESH_VAULT_ROOT is not set)" };
let doc: KeptExport;
try {
doc = JSON.parse(readFileSync(join(root, "export.json"), "utf8")) as KeptExport;
} catch (err) {
return { available: false, error: `the mesh has not written an export here yet: ${err}` };
}
const withBlobs = args.sealed !== false;
return {
available: true,
export: doc.export,
"operator-key": doc["operator-key"],
fingerprint: doc.fingerprint,
count: doc.kept.length,
kept: doc.kept.map((k) => (withBlobs ? k : { node: k.node, module: k.module, name: k.name, origin: k.origin, "made-at": k["made-at"] })),
unrecoverable: doc.unrecoverable ?? [],
};
},
},
{
name: "secret_holders",
description:
"Who holds a secret from this vault: each consumer's login, node and module, when it was " +
"granted, how many times it has been rotated and when, and the fingerprint of the current " +
"value. Fingerprints only — the value is never returned.",
input: {},
run: async () => {
const asked = receives ? contributions(receives) : [];
const holders = ledger.list().map((h) => ({
...h,
module: asked.find((c) => c.as === h.as)?.from ?? null,
asked: asked.some((c) => c.as === h.as),
}));
return { holders, count: holders.length };
},
},
{
name: "secret_verify",
description:
"Check one holder's secret without seeing it: the fingerprint the vault recorded against " +
"the fingerprint of the value the mesh currently delivers here, and optionally against a " +
"fingerprint computed on the holder's own machine (sha256 of the file, as `sha256:<hex>`). " +
"Two ends agreeing proves they agree, not that either works — the login itself is the test.",
input: {
as: { type: "string", description: "the holder's login, e.g. anchor-redis" },
fingerprint: {
type: "string",
description: "optional: sha256:<hex> of the value as the holder reads it, computed there — never the value",
},
},
run: async (args) => {
const as = String(args.as ?? "");
const recorded = ledger.get(as);
if (!recorded) return { as, known: false, error: `this vault holds nothing for ${as}` };
const asked = receives ? contributions(receives).find((c) => c.as === as) : undefined;
const delivered = asked ? deliveredFingerprint(asked) : { error: "the mesh does not currently ask this vault to serve that login" };
const given = args.fingerprint ? String(args.fingerprint) : undefined;
return verdict(recorded, delivered, given);
},
},
];
}
function verdict(
recorded: Held,
delivered: { fingerprint: string; length: number } | { error: string },
given: string | undefined,
): Record<string, unknown> {
const deliveredMatches = "fingerprint" in delivered ? delivered.fingerprint === recorded.fingerprint : null;
const givenMatches = given === undefined ? null : given === recorded.fingerprint;
return {
as: recorded.as,
known: true,
recorded: recorded.fingerprint,
rotations: recorded.rotations,
changed: recorded.changed,
delivered: "fingerprint" in delivered ? delivered.fingerprint : null,
deliveredError: "error" in delivered ? delivered.error : null,
deliveredMatchesRecorded: deliveredMatches,
given: given ?? null,
givenMatchesRecorded: givenMatches,
givenIsAnEarlierValue: given === undefined ? null : recorded.history.some((h) => h.fingerprint === given),
ok: deliveredMatches !== false && givenMatches !== false,
};
}
// The tools exist only when the ledger can be reached from the environment; without it, vault
// contributes none rather than failing the whole tool runtime.
registerModuleTools("mesh-vault", (env) => {
try {
return getVaultTools(Ledger.fromEnv(env), env.MESH_RECEIVES, env.MESH_VAULT_ROOT);
} catch {
return [];
}
});
-12
View File
@@ -1,12 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["client.ts", "index.ts", "provisioner/index.ts", "tools/index.ts"]
}
+4 -6
View File
@@ -49,9 +49,8 @@ channel's client and no channel's secret.
warrant published to the asker alone. A warrant the bus did not take is published again every minute.
- **What the operator sees when they answer** (issue 372): first, in reply to the answer where it was given,
that it was recorded and whether its asker was told ("You chose Approve: recorded, and claude-code was
told.") — said only once the ask's end is recorded — or, refused, why; then the ask's message edited with no
answers left on it: its question whole (headline, summary, points, link, Details), and below it how it ended
("You chose Approve at 13:30 on Telegram.") and who acts on it (issue 714).
told.") — said only once the ask's end is recorded — or, refused, why; then the ask's message edited to how
it ended, as its title ("You chose Approve at 13:30 on Telegram · <headline>"), with no answers left on it.
What the asker then did is not said: the asks protocol carries no result back to the router, so the
message says only that the asker was told and acts on it itself.
- **An ask never defaults**: at its expiry it ends `expired` and its asker does what it said it would.
@@ -139,9 +138,8 @@ and said in the log). Everything else is the message's note (`asks.Message.Note`
smaller: who asked and when, with what happens if nobody answers; what each answer does; where else it is answered;
and the asker's Details (who proposed it, a fingerprint, how to read it whole) — on a kind that proves who answers
and was given the ask's answers, its Details whole (`asks.Ask.WholeDetails`): every value an approval sets, held to
the secret rule alone, as the whole words are. Answered, the message keeps its headline, body and Details, so the
operator can read back what they answered (issue 714); its note becomes "You chose Approve at 16:06 on Telegram."
with the router's word on the asker.
the secret rule alone, as the whole words are. Answered, the message is one line —
"You chose Approve at 16:06 on Telegram · <headline>" — and the router's word on the asker is its note.
**An ask's values are shown whole where the sender is proven** (hq issue 383). An ask may carry its explanation
twice: as it is held to the whole content rule, and whole (`asks.Ask.Whole`), its paths and addresses as they
@@ -365,12 +365,10 @@ func TestAnApproveFromTheLinkedAccountIsAWarrantForTheAskerAlone(t *testing.T) {
for _, m := range ch.edits {
edited = m
}
// The question stays whole (issue 714); below it, how it ended and what the router knows of the asker
// (issue 372).
if len(edited.Actions) != 0 || edited.Title != "Delivery of hq held for 36 hours" ||
edited.Body != heldAsk(c, "d-1").Explanation ||
edited.Note != "You chose Release at 12:00 on Telegram.\nTold to mesh-delivery, which acts on it itself." {
t.Errorf("%s still offers %q, or does not keep the question and say how it ended (issue 714): %q / %q / %q", ch.name, labelsOf(edited), edited.Title, edited.Body, edited.Note)
// The question's title is how it ended, read at a glance; then what the router knows of the asker (issue 372).
if len(edited.Actions) != 0 || edited.Title != "You chose Release at 12:00 on Telegram · Delivery of hq held for 36 hours" ||
edited.Body != "" || edited.Note != "Told to mesh-delivery, which acts on it itself." {
t.Errorf("%s still offers %q, or does not say how it ended in one line (issue 639): %q / %q / %q", ch.name, labelsOf(edited), edited.Title, edited.Body, edited.Note)
}
}
// The tap is confirmed where it was made, in reply to it, once the warrant stood — and before the question
@@ -626,8 +624,8 @@ func TestAnAskExpiresAndNeverDefaults(t *testing.T) {
for _, m := range tg.edits {
edited = m
}
if len(edited.Actions) != 0 || !strings.HasPrefix(edited.Title, "Delivery of hq") ||
edited.Body == "" || edited.Note != "No longer answerable: nobody answered in time.\nThe delivery stays held." {
if len(edited.Actions) != 0 || !strings.HasPrefix(edited.Title, "No longer answerable: nobody answered in time · Delivery of hq") ||
edited.Body != "" || edited.Note != "The delivery stays held." {
t.Errorf("the copy still offers answers, or does not say how it ended: %+v", edited)
}
}
@@ -51,7 +51,7 @@ func TestAnApprovalIsConfirmedWhereItWasGivenAndThenTheQuestionSaysHowItEnded(t
}
want := []string{
"send You chose Release: recorded, and mesh-delivery was told.",
"edit Delivery of hq held for 36 hours",
"edit You chose Release at 12:00 on Telegram · Delivery of hq held for 36 hours",
}
if strings.Join(log, "\n") != strings.Join(want, "\n") {
t.Fatalf("the operator saw, in order:\n%s\nwant:\n%s", strings.Join(log, "\n"), strings.Join(want, "\n"))
@@ -122,7 +122,7 @@ func TestTheConfirmationNeverClaimsWhatDidNotHappen(t *testing.T) {
for _, m := range tg.edits {
edited = m
}
if !strings.Contains(edited.Note, "\nTold to mesh-delivery as soon as the bus takes it") {
if !strings.HasPrefix(edited.Note, "Told to mesh-delivery as soon as the bus takes it") {
t.Errorf("the question claims its asker was told: %q", edited.Note)
}
})
@@ -9,8 +9,8 @@ import (
)
// novox/hq issue 639: an ask's message reads at a glance on the phone. Its body is the asker's summary and points
// alone; who asked and when, what each answer does, the expiry and the asker's Details are its note. Once answered
// it keeps the question whole, so the operator can read back what they answered (issue 714).
// alone; who asked and when, what each answer does, the expiry and the asker's Details are its note; once answered
// it is one line with the headline.
// decisionAsk is the ask for ADR 0343 as the knowledge base composes it now (its glance form).
func decisionAsk(c *clock) asks.Ask {
@@ -59,8 +59,7 @@ func TestAnAsksMessageIsItsSummaryWithTheRestInItsNote(t *testing.T) {
if n := len(strings.Split(m.Text(), "\n")); n > 9 || len(m.Actions) != 2 {
t.Errorf("the message is %d lines offering %s:\n%s", n, labelsOf(m), m.Text())
}
// Answered, it keeps its headline, summary, points and Details; below them, how it ended and who acts on it;
// its buttons are gone (issue 714: the one line issue 639 made left nothing to read back).
// Answered, it is one line with the headline; the body is not said again.
if out, err := h.Chosen("telegram", choiceOf(ticketFor(m, "Approve"), "42", c.now())); err != nil || out != "chosen" {
t.Fatalf("Approve: %q %v", out, err)
}
@@ -68,9 +67,8 @@ func TestAnAsksMessageIsItsSummaryWithTheRestInItsNote(t *testing.T) {
for _, e := range tg.edits {
edited = e
}
if edited.Title != a.Headline || edited.Body != a.Explanation+"\n"+want[2]+"\n"+want[3] ||
edited.Note != "You chose Approve at "+asked+" on Telegram.\nTold to mesh-knowledge, which acts on it itself." ||
len(edited.Actions) != 0 {
if edited.Title != "You chose Approve at "+asked+" on Telegram · "+a.Headline || edited.Body != "" ||
strings.Contains(edited.Note, "What an agent learns") || len(edited.Actions) != 0 {
t.Errorf("the answered copy: %q\n%q\n%q", edited.Title, edited.Body, edited.Note)
}
}
+10 -43
View File
@@ -856,7 +856,7 @@ func (h *Holder) sayOn(r *Record, what, kind string) Message {
}
}
if r.Answered != "" {
m = h.answeredWords(r, m, kind)
m = h.answeredWords(r, m)
}
if kind == "" {
return m
@@ -910,24 +910,18 @@ func (h *Holder) sayOn(r *Record, what, kind string) Message {
return m
}
// answeredWords is a message that says what came of its question. An ask's own message keeps the question
// whole — its headline as the title, the asker's summary, points and link as the body, its Details after them —
// so the operator can read back what they answered (issue 714, which undid issue 639's one line). How it ended
// ("You chose Approve at 13:30 on Telegram.") and what the router knows of the asker after it are the note,
// below the question, and its answers are gone (issue 372). A channel that must shorten it cuts the body from
// its end, the Details first; never the headline or the note. A condition's message keeps its own title, and
// says how it ended first.
func (h *Holder) answeredWords(r *Record, m Message, kind string) Message {
// answeredWords is a message that says what came of its question. An ask's own message is the question
// itself, so it becomes how it ended and what it was about — "You chose Approve at 13:30 on Telegram ·
// <headline>" — one line, read at a glance on the phone, where the edit rings nothing (issue 372); the body
// is not said again (issue 639), and what the router knows of the asker after it is the note. A condition's
// message keeps its own title, and says it first.
func (h *Holder) answeredWords(r *Record, m Message) Message {
if r.Origin == "ask" {
first, rest, _ := strings.Cut(r.Answered, "\n")
headline, _ := wordsOf(r)
body := []string{}
if b := strings.TrimSpace(m.Body); b != "" {
body = append(body, b)
}
body = append(body, h.answeredDetails(r, kind)...)
out := Message{Title: headline, Body: strings.Join(body, "\n"), Note: strings.TrimSpace(r.Answered),
out := Message{Title: strings.TrimSuffix(first, ".") + " · " + headline, Note: strings.TrimSpace(rest),
Urgent: m.Urgent, Quiet: m.Quiet}
if _, ok := Check(out.Title + "\n" + out.Note); ok {
if _, ok := Check(out.Text()); ok {
return out
}
// Its headline refused (it is the asker's): the outcome is still said, before what may leave.
@@ -936,33 +930,6 @@ func (h *Holder) answeredWords(r *Record, m Message, kind string) Message {
return m
}
// answeredDetails are an answered ask's Details, line by line, as its open message noted them (issue 714): whole
// on a kind that proves who answers, a line that may not leave withheld and said so.
func (h *Holder) answeredDetails(r *Record, kind string) []string {
if r.Ask == "" {
return nil
}
e := h.askOf(r.Ask)
if e == nil {
return nil
}
details, passes := e.rec.Ask.Details, Check
if wd := h.wholeDetailsOf(r, kind); wd != "" {
details, passes = wd, CheckSecret
}
var lines []string
for _, line := range strings.Split(strings.TrimSpace(details), "\n") {
if line = strings.TrimSpace(line); line == "" {
continue
}
if refusal, ok := passes(line); !ok {
line = withheldLine(line, refusal, passes)
}
lines = append(lines, line)
}
return lines
}
// wholeWords is a record's message with the ask's whole words in place of its explanation, when they carry no
// secret's shape once composed; else false, and the message under the whole content rule stands.
func (h *Holder) wholeWords(r *Record, what, whole string) (Message, bool) {
+1 -1
View File
@@ -91,7 +91,7 @@ func (h *Holder) Preview(key, what string) (map[string]any, error) {
asking := h.openAsk(&r) != nil // askOf takes the same lock, so this is asked outside it
m, line, refusal, ownWords := h.allowedWords(&r, what, now)
if r.Answered != "" {
m = h.answeredWords(&r, m, "")
m = h.answeredWords(&r, m)
}
out := map[string]any{
"key": key,
@@ -50,7 +50,7 @@ func TestAReplacedAsksMessageEndsWithIt(t *testing.T) {
m, edited = r.m, true
}
}
if !edited || len(m.Actions) != 0 || !strings.HasPrefix(m.Note, "Replaced by a newer question") {
if !edited || len(m.Actions) != 0 || !strings.Contains(m.Title, "Replaced by a newer question") {
t.Errorf("%s: the replaced ask's message still stands: edited %v, %q %v", ch.name, edited, m.Text(), labelsOf(m))
}
}
@@ -86,7 +86,7 @@ func TestAnAsksMessageLeftOpenAfterItsAskEndedIsEndedOnTheNextTick(t *testing.T)
if got := openAskRecords(h); len(got) != 0 {
t.Fatalf("the message of an ended ask is still open: %v", got)
}
if m, ok := tg.edits["1"]; !ok || len(m.Actions) != 0 || !strings.HasPrefix(m.Note, "Replaced by a newer question") {
if m, ok := tg.edits["1"]; !ok || len(m.Actions) != 0 || !strings.Contains(m.Title, "Replaced by a newer question") {
t.Errorf("the leftover's copy was not ended: %v %q %v", ok, m.Text(), labelsOf(m))
}
}
@@ -61,7 +61,8 @@ func TestAnAsksWholeWordsAreShownWhereTheSenderIsProvenAndMaskedElsewhere(t *tes
if got := w.of("mesh-controller"); len(got) != 0 || store.asks["mesh-controller.s-1"].State != StateOpen {
t.Errorf("the ask was answered: %+v / %s", got, store.asks["mesh-controller.s-1"].State)
}
// Approved, the message keeps its headline and words, and says below them how it ended (issue 714).
// Approved, the message is edited to one line that says so, with the headline; the body is not said again
// (issue 639).
if out, err := h.Chosen("telegram", choiceOf(ticketFor(phone, "Release"), "42", c.now())); err != nil || out != "chosen" {
t.Fatalf("Release: %q %v", out, err)
}
@@ -69,8 +70,8 @@ func TestAnAsksWholeWordsAreShownWhereTheSenderIsProvenAndMaskedElsewhere(t *tes
if !ok {
t.Fatalf("the phone's message was not edited: %+v", tg.edits)
}
if edited.Title != "Set mounts on shanks?" || edited.Body == "" ||
!strings.HasPrefix(edited.Note, "You chose Release at ") || len(edited.Actions) != 0 {
if !strings.HasPrefix(edited.Title, "You chose Release at ") || !strings.HasSuffix(edited.Title, " · Set mounts on shanks?") ||
edited.Body != "" || len(edited.Actions) != 0 {
t.Errorf("the edited message: %q\n%s\nactions %+v", edited.Title, edited.Body, edited.Actions)
}
}
-7
View File
@@ -97,13 +97,6 @@
{
"id": "server",
"type": "container",
"health": {
"kind": "http",
"endpoint": "web",
"path": "/healthz/readiness",
"status": 200,
"needs": "postgres-database"
},
"name": "n8n",
"artifact": "server",
"network": "n8n",
-8
View File
@@ -83,14 +83,6 @@
{
"id": "server",
"type": "container",
"health": {
"kind": "http",
"endpoint": "web",
"path": "/status.php",
"status": 200,
"body": "\"installed\":true",
"needs": "postgres-database"
},
"name": "nextcloud",
"image": "nextcloud@sha256:fb966733647ea03f0446b0c22eac9733c8eb616d37b960caca9d4c3010e14a08",
"env-file": [
+1 -1
View File
@@ -20,7 +20,7 @@ WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -buildvcs=false -ldflags=-buildid= -o /mesh-route-proxy ./examples/route-proxy
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -o /mesh-route-proxy ./examples/route-proxy
# A small runtime with the public CA roots the ACME client needs to reach a real authority, and run
# as root so it can bind :80 and :443 — the two privileged ports a public front door listens on.
+2 -3
View File
@@ -4,11 +4,10 @@ package main
// formatting", "too much text, no formatting, no summary"). The router's words are shown as given —
// `exact-render` — and formatted, never reworded:
//
// - the title in bold: the headline, an answered ask's too (issue 714);
// - the title in bold: the headline, or how an answered ask ended;
// - the body as it is, a line that is a key with its value (as a settings proposal lists them, "+ key: value")
// in monospace;
// - the note — who asked, what each answer does, the expiry, the asker's details; once answered, how it ended
// and who acts on it — apart, after a blank line,
// - the note — who asked, what each answer does, the expiry, the asker's details — apart, after a blank line,
// in a blockquote Telegram shows collapsed to its first lines, expanded on a tap.
//
// Every character Telegram's HTML would read as markup is escaped first, so a message carrying '<' neither
@@ -84,20 +84,3 @@ func TestTheNoteIsEscaped(t *testing.T) {
t.Errorf("%q", got)
}
}
// novox/hq issue 714: an answered ask keeps its question whole, with how it ended below it. Over Telegram's bound,
// the end of the body (the asker's Details) is cut; the headline and the answer are never.
func TestAnAnsweredAskOverTheBoundKeepsItsHeadlineAndItsAnswer(t *testing.T) {
answer := "You chose Approve at 20:03 on Telegram.\nTold to mesh-knowledge, which acts on it itself."
m := asks.Message{Handle: "h-714", To: "42", Title: "Fixes reach every machine at once, unchecked",
Body: "A fix goes to every machine in one step.\n• No machine goes first\n" + strings.Repeat("Details line. ", 400),
Note: answer}
s := partsOf(m)
if n := len([]rune(s.plain())); n > MaxLength {
t.Fatalf("the answered ask is %d characters, over %d", n, MaxLength)
}
if s.title != m.Title || s.note != answer || !strings.HasPrefix(s.body, "A fix goes to every machine in one step.\n• No machine goes first") ||
!strings.HasSuffix(s.body, cutNote) {
t.Errorf("the headline, the summary or the answer was cut:\n%s", s.plain())
}
}
-9
View File
@@ -89,15 +89,6 @@
{
"id": "server",
"type": "container",
"health": {
"kind": "http",
"endpoint": "web",
"path": "/api/share/mesh-health",
"status": 404,
"body": "\"code\":\"not-found\"",
"timeout": "10s",
"needs": "postgres-database"
},
"name": "umami",
"image": "ghcr.io/umami-software/umami@sha256:85909afc45bdcda1917394594a087421fdbb05610fded0fa9f6fb861abb2f367",
"network": "umami",
+13
View File
@@ -0,0 +1,13 @@
{
"agent-managed-settings": {
"type": "object",
"description": "The managed settings every agent session on a node is held to (Claude Code's managed-settings.json), as a JSON object.",
"trusted": true
},
"font-size": {
"type": "number",
"description": "The size a desktop program shows its text in, in points.",
"minimum": 6,
"maximum": 72
}
}