Compare commits

..
Author SHA1 Message Date
jschoubben 37c47f8f95 Mark the delivery gate's pulls readable, as build-agent reads it (issue 1790) 2026-10-22 20:36:21 +02:00
jschoubben d3f9a1855d Mark the delivery gate's asks, merges, owed and source readable, as mesh-delivery's parts read them (issue 1790) 2026-10-22 20:35:29 +02:00
jschoubben a18eeeef5a Say gitea's states with the SDK's emit, as the toolchain's SDK has no keeps yet (issue 1790, issue 1852) 2026-10-22 19:04:21 +02:00
jschoubben 5b6dfc70f4 Wait until the older clipmenud holds the lock before the launcher starts (issue 1814, S1) 2026-10-22 19:03:03 +02:00
jschoubben d45767bb2f Keep another module's path out of i3's fixture (issue 1814, S2) 2026-10-22 18:13:58 +02:00
jschoubben 027ea030fa Qualify a short base image name before buildah reads it (issue 1824) 2026-10-22 18:13:09 +02:00
jschoubben 6eb9eb53de Commit the lock files of mssql, mongodb, model-usage and mesh-catalog, and refuse a Node module without one (issue 1868, ADR 0469 §5) 2026-10-22 18:12:32 +02:00
jschoubben e85b7da63b Let i3's contribution test read its fixture, not the siblings' manifests (issue 1814, S2, ADR 0495 §1) 2026-10-22 18:05:04 +02:00
jschoubben c112df0c1c Announce the states the forge module keeps (issue 1790, ADR 0495 §2 §5) 2026-10-22 18:03:06 +02:00
jschoubben 8e791896f1 Announce the states the delivery gate keeps (issue 1790, ADR 0495 §2 §5) 2026-10-22 18:02:11 +02:00
jschoubben b07a101caf Answer each named path's git object id from gitea_commit_trees (issue 1758, S1-S2, ADR 0499 §3) 2026-10-22 17:59:23 +02:00
jschoubben 72effa1fb1 Copy an upstream artifact into the artifact store by its pinned digest (issue 1587, S11-S13, ADR 0498 §4) 2026-10-22 17:57:05 +02:00
402 changed files with 6038 additions and 14184 deletions
+3
View File
@@ -0,0 +1,3 @@
module git.novox.be/novox/mesh-catalog/checks/locks
go 1.22
+105
View File
@@ -0,0 +1,105 @@
// Command locks refuses a TypeScript module that names packages beyond the SDK in its package.json and commits no
// package-lock.json (novox/hq ADR 0469 §5, issue 1868): the Node build module installs a module's own packages only
// from its committed lock, so such a module is refused by the builder and could not be delivered.
//
// go run . <catalogue root>
//
// Exits 1 on a finding. The packages that count are those the build module counts (mesh-build-node fetch.go
// ownDependencies): the `dependencies` beyond the SDK, typescript and the @types packages.
package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
)
// Finding is one module refused: it names Packages beyond the SDK and has no lock file.
type Finding struct {
Module string
Packages []string
}
// Check reads every module under root/modules that builds a TypeScript artifact.
func Check(root string) ([]Finding, error) {
manifests, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
if err != nil {
return nil, err
}
sort.Strings(manifests)
var out []Finding
for _, path := range manifests {
raw, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var m struct {
Module string `json:"module"`
Build struct {
Artifacts []struct {
Language string `json:"language"`
} `json:"artifacts"`
} `json:"build"`
}
if err := json.Unmarshal(raw, &m); err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
typescript := false
for _, a := range m.Build.Artifacts {
typescript = typescript || a.Language == "typescript"
}
if !typescript {
continue
}
dir := filepath.Dir(path)
pkgRaw, err := os.ReadFile(filepath.Join(dir, "package.json"))
if os.IsNotExist(err) {
continue
}
if err != nil {
return nil, err
}
var pkg struct {
Dependencies map[string]json.RawMessage `json:"dependencies"`
}
if err := json.Unmarshal(pkgRaw, &pkg); err != nil {
return nil, fmt.Errorf("%s: package.json: %w", m.Module, err)
}
var names []string
for n := range pkg.Dependencies {
if n != "@novox/mesh-sdk" && n != "typescript" && !strings.HasPrefix(n, "@types/") {
names = append(names, n)
}
}
if len(names) == 0 {
continue
}
if _, err := os.Stat(filepath.Join(dir, "package-lock.json")); err != nil {
sort.Strings(names)
out = append(out, Finding{Module: m.Module, Packages: names})
}
}
return out, nil
}
func main() {
root := "."
if len(os.Args) > 1 {
root = os.Args[1]
}
found, err := Check(root)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
for _, f := range found {
fmt.Printf("%s names packages beyond the SDK (%s) and has no package-lock.json: commit one made by the build module's toolchain (novox/hq ADR 0469 §5)\n", f.Module, strings.Join(f.Packages, ", "))
}
if len(found) > 0 {
os.Exit(1)
}
fmt.Println("locks: every TypeScript module that names packages beyond the SDK commits its lock")
}
+63
View File
@@ -0,0 +1,63 @@
package main
import (
"os"
"path/filepath"
"reflect"
"testing"
)
// module writes modules/<name> with a TypeScript artifact, the package.json given ("" for none) and, when lock, a lock.
func module(t *testing.T, root, name, language, pkg string, lock bool) {
t.Helper()
dir := filepath.Join(root, "modules", name)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
write := func(file, body string) {
if err := os.WriteFile(filepath.Join(dir, file), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
write("module.json", `{"module":"`+name+`","build":{"artifacts":[{"name":"tools","kind":"bundle","language":"`+language+`","from":"index.ts"}]}}`)
if pkg != "" {
write("package.json", pkg)
}
if lock {
write("package-lock.json", "{}")
}
}
const sdkOnly = `{"dependencies":{"@novox/mesh-sdk":"^0.1.1"},"devDependencies":{"typescript":"^5","@types/node":"^22"}}`
const withPg = `{"dependencies":{"@novox/mesh-sdk":"^0.1.1","pg":"^8","@types/pg":"^8"}}`
func TestAModuleNamingOnlyTheSDKNeedsNoLock(t *testing.T) {
root := t.TempDir()
module(t, root, "plain", "typescript", sdkOnly, false)
module(t, root, "none", "typescript", "", false)
got, err := Check(root)
if err != nil || len(got) != 0 {
t.Fatalf("%v %v", got, err)
}
}
func TestAModuleNamingMorePackagesWithoutALockIsRefusedNamingThem(t *testing.T) {
root := t.TempDir()
module(t, root, "db", "typescript", withPg, false)
module(t, root, "db-locked", "typescript", withPg, true)
got, err := Check(root)
if err != nil {
t.Fatal(err)
}
if want := []Finding{{Module: "db", Packages: []string{"pg"}}}; !reflect.DeepEqual(got, want) {
t.Fatalf("got %v, want %v", got, want)
}
}
func TestAModuleThatIsNotTypeScriptIsNotJudged(t *testing.T) {
root := t.TempDir()
module(t, root, "go", "go", withPg, false)
if got, err := Check(root); err != nil || len(got) != 0 {
t.Fatalf("%v %v", got, err)
}
}
+3
View File
@@ -0,0 +1,3 @@
# The modules the check covers (novox/hq ADR 0507 §9, issue 1838), one a line; issue 1838 widens it.
pacman
kernel
+26 -163
View File
@@ -1,23 +1,12 @@
// Command particulars keeps this mesh's node names out of the modules it covers (novox/hq ADR 0507 §9,
// issue 1838): a module is for any mesh, so no manifest, file, code string, identifier, comment, test name or
// fixture of any module names a node of this one.
// fixture of a covered module names a node of this one.
//
// The names are in the file `particulars` at the catalogue's root, one a line, `#` comments — the one place
// they may be written. Every directory under modules/ is read, with no list of covered modules, so a new module
// is covered from its first commit. A name is matched whole and in any case; a hyphen, an underscore or a
// CamelCase boundary parts one, so a longer word holding a name is another word.
// Some text holds a node name and is not one, so it is blanked before a line is read, each form exact: the
// software's module path (git.novox.be/novox/<repo>), the name of the software's record (novox/hq, followed by
// anything), the hardware model (asus-zephyrus-g14, ROG Zephyrus G14), and the software's own
// name and npm scope (Novox Mesh, novox-mesh, @novox/mesh-sdk with nothing before the @). Any other novox/<x>,
// the forge's host alone and a node after a word's @ (database@novox/postgres) are findings.
//
// A module may declare its hardware model's word in `particulars`, as a line `model <module> <word>`: the word,
// one of the names, is then no finding in that module's directory and still one in every other
// (asus-zephyrus-g14's adopted paths such as /etc/modprobe.d/g14-nvidia-power.conf). A declaration naming no
// module or no listed name makes the list unreadable.
// A hardware's own name holding a name is declared as a line `hardware <word>` (asus-g14): that exact word, in
// any case and not part of a longer hyphened word, is no finding in any module.
// they may be written. The modules covered are in `covered` at checks/particulars/covered, one a line. A name is matched
// whole and in any case; a hyphen, an underscore or a CamelCase boundary parts one, so a longer word holding a name is another word.
// The forge is not a node name: its host (git.novox.be) and the organisation path (novox/<repo>) are blanked
// before a line is read.
//
// go run . <catalogue root>
//
@@ -32,22 +21,13 @@ import (
"os"
"path/filepath"
"regexp"
"slices"
"sort"
"strings"
)
// forge is the software's module path on its forge and the name of its record (novox/hq, in any form: a citation,
// a review, a URL path), blanked before a line is read; any other novox/<x> (a repository in a fixture,
// testdata/novox/x) is not blanked, nor novox/hqx.
var forge = regexp.MustCompile(`(?i:git\.novox\.be/novox/[\w.-]+)|novox/hq(?:$|[^\w-])`)
// ownNames are the hardware model's and the software's own names, blanked before a line is read: the model
// in any separator and case (asus-zephyrus-g14, ASUS_ZEPHYRUS_G14, ROG Zephyrus G14), the software as two
// words or hyphened, whole (Novox Mesh, novox mesh, novox-mesh), so novox-mesh-vault stays a node name joined;
// and the software's npm scope (@novox/mesh-sdk, @novox:registry=), its package path as the Go module path is,
// where no word stands before the @, so a session's who@node (fix-1@novox) is still a node name.
var ownNames = regexp.MustCompile(`(?i)(?:asus[-_ ])?zephyrus[-_ ]g14|(?:^|[^\w-])novox[- ]mesh(?:$|[^\w-])|(?:^|[^\w.-])@novox(?:$|[^\w-])`)
// forge is the forge's host and organisation path, blanked before a line is read; a path segment novox/ elsewhere
// (testdata/novox/x, /srv/novox/data) is not blanked.
var forge = regexp.MustCompile(`(?i)git\.novox\.be(?:/novox/[\w.-]+)?|(?:^|[^/\w.-])novox/[\w.-]+`)
func main() {
root := "."
@@ -59,30 +39,22 @@ func main() {
// check runs the check over a catalogue and returns the exit code: 0 clean, 1 a finding, 2 a list unreadable.
func check(root string) int {
entries, err := readList(filepath.Join(root, "particulars"))
names, models, hardware := splitModels(entries)
names, err := readList(filepath.Join(root, "particulars"))
if err == nil && len(names) == 0 {
err = fmt.Errorf("particulars lists no name — the check would pass on anything")
}
var modules []string
var covered []string
if err == nil {
modules, err = moduleDirs(root)
if err == nil && len(modules) == 0 {
err = fmt.Errorf("modules/ holds no module — the check would pass on anything")
covered, err = readList(filepath.Join(root, "checks/particulars/covered"))
if err == nil && len(covered) == 0 {
err = fmt.Errorf("covered lists no module — the check would pass on anything")
}
}
if err == nil {
err = checkModels(models, names, modules)
}
var blanks *regexp.Regexp
if err == nil {
blanks, err = hardwareWords(hardware)
}
if err != nil {
fmt.Fprintln(os.Stderr, "particulars:", err)
return 2
}
findings, err := run(root, names, modules, models, blanks)
findings, err := run(root, names, covered)
if err != nil {
fmt.Fprintln(os.Stderr, "particulars:", err)
return 2
@@ -94,7 +66,7 @@ func check(root string) int {
fmt.Printf("particulars: %d node name(s) in a module for any mesh (novox/hq ADR 0507 §9) — say what it shows, not where it was seen\n", len(findings))
return 1
}
fmt.Printf("particulars: none of %d node names in the %d modules\n", len(names), len(modules))
fmt.Printf("particulars: none of %d node names in the %d covered modules\n", len(names), len(covered))
return 0
}
@@ -117,104 +89,10 @@ func readList(path string) ([]string, error) {
return out, sc.Err()
}
// splitModels parts the list's entries into the names, the models' words by module (`model <module> <word>`)
// and the hardware's words (`hardware <word>`, kept whole to be checked).
func splitModels(entries []string) (names []string, models map[string][]string, hardware []string) {
models = map[string][]string{}
for _, e := range entries {
f := strings.Fields(e)
if len(f) > 0 && f[0] == "hardware" {
hardware = append(hardware, e)
continue
}
if len(f) > 0 && f[0] == "model" {
if len(f) != 3 {
models[""] = append(models[""], e)
continue
}
models[f[1]] = append(models[f[1]], f[2])
continue
}
names = append(names, e)
}
return names, models, hardware
}
// hardwareWords is the matcher of the declared hardware words, nil when none is declared; a line that is not
// `hardware <word>` makes the list unreadable.
func hardwareWords(lines []string) (*regexp.Regexp, error) {
var words []string
for _, l := range lines {
f := strings.Fields(l)
if len(f) != 2 {
return nil, fmt.Errorf("particulars: %q is not `hardware <word>`", l)
}
words = append(words, regexp.QuoteMeta(f[1]))
}
if len(words) == 0 {
return nil, nil
}
return regexp.MustCompile(`(?i)(?:^|[^A-Za-z0-9-])(?:` + strings.Join(words, "|") + `)(?:$|[^A-Za-z0-9-])`), nil
}
// blankWith blanks what rx matches in s, keeping every other byte where it was.
func blankWith(rx *regexp.Regexp, s string) string {
if rx == nil {
return s
}
return rx.ReplaceAllStringFunc(s, func(m string) string { return strings.Repeat(" ", len(m)) })
}
// checkModels refuses a model declaration that is malformed, names no module or names a word that is no name.
func checkModels(models map[string][]string, names, modules []string) error {
for m, words := range models {
if m == "" {
return fmt.Errorf("particulars: %q is not `model <module> <word>`", words[0])
}
if !slices.Contains(modules, m) {
return fmt.Errorf("particulars: model %s %s names no module under modules/", m, words[0])
}
for _, w := range words {
if !slices.ContainsFunc(names, func(n string) bool { return strings.EqualFold(n, w) }) {
return fmt.Errorf("particulars: model %s %s: %q is not a listed name", m, w, w)
}
}
}
return nil
}
// without returns the names but the words, in any case.
func without(names, words []string) []string {
// run reads every text file of each covered module and returns the findings, sorted.
func run(root string, names, covered []string) ([]string, error) {
var out []string
for _, n := range names {
if !slices.ContainsFunc(words, func(w string) bool { return strings.EqualFold(n, w) }) {
out = append(out, n)
}
}
return out
}
// moduleDirs returns every directory under the catalogue's modules/, sorted.
func moduleDirs(root string) ([]string, error) {
entries, err := os.ReadDir(filepath.Join(root, "modules"))
if err != nil {
return nil, err
}
var out []string
for _, e := range entries {
if e.IsDir() {
out = append(out, e.Name())
}
}
return out, nil
}
// run reads every text file of each module named and returns the findings, sorted; a module's model words are
// not read as names in it.
func run(root string, all, modules []string, models map[string][]string, hardware *regexp.Regexp) ([]string, error) {
var out []string
for _, m := range modules {
names := without(all, models[m])
for _, m := range covered {
dir := filepath.Join(root, "modules", m)
err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
@@ -229,7 +107,7 @@ func run(root string, all, modules []string, models map[string][]string, hardwar
}
rel, _ := filepath.Rel(root, path)
// A path is read too: a testdata directory named after a node is a finding.
for _, n := range hits(blankWith(hardware, filepath.ToSlash(rel)), names) {
for _, n := range hits(filepath.ToSlash(rel), names) {
out = append(out, fmt.Sprintf("%s:0: node name %q in the path", filepath.ToSlash(rel), n))
}
if d.Name() == "go.sum" {
@@ -242,7 +120,7 @@ func run(root string, all, modules []string, models map[string][]string, hardwar
if bytes.IndexByte(b, 0) >= 0 {
return nil
}
out = append(out, scan(filepath.ToSlash(rel), blankWith(hardware, string(b)), names)...)
out = append(out, scan(filepath.ToSlash(rel), string(b), names)...)
return nil
})
if err != nil {
@@ -265,32 +143,17 @@ func scan(file, src string, names []string) []string {
}
// hits returns the node names one line holds: as a word (a hyphen or underscore parts it), as a part of a
// CamelCase identifier (TestAce, novoxTree), the forge's host and organisation path and the hardware's and the
// software's own names blanked first.
// CamelCase identifier (TestAce, novoxTree), the forge's host and organisation path blanked first.
func hits(line string, names []string) []string {
blank := func(s string) string { return strings.Repeat(" ", len(s)) }
line = forge.ReplaceAllStringFunc(line, blank)
line = ownNames.ReplaceAllStringFunc(line, blank)
line = forge.ReplaceAllStringFunc(line, func(s string) string { return strings.Repeat(" ", len(s)) })
var out []string
for _, n := range names {
if matcher(n).MatchString(line) {
q := regexp.QuoteMeta(n)
camel := regexp.QuoteMeta(strings.ToUpper(n[:1]) + n[1:])
rx := regexp.MustCompile(`(?i:(?:^|[^A-Za-z0-9])` + q + `(?:$|[^A-Za-z0-9]))|[a-z0-9]` + camel + `(?:$|[^a-z])|(?:^|[^A-Za-z0-9])` + q + `[A-Z0-9]`)
if rx.MatchString(line) {
out = append(out, strings.ToLower(n))
}
}
return out
}
// matchers holds each name's matcher, compiled once: the check reads every line of every module.
var matchers = map[string]*regexp.Regexp{}
// matcher is the matcher of one name: as a word, or as a part of a CamelCase identifier.
func matcher(n string) *regexp.Regexp {
if rx, ok := matchers[n]; ok {
return rx
}
q := regexp.QuoteMeta(n)
camel := regexp.QuoteMeta(strings.ToUpper(n[:1]) + n[1:])
rx := regexp.MustCompile(`(?i:(?:^|[^A-Za-z0-9])` + q + `(?:$|[^A-Za-z0-9]))|[a-z0-9]` + camel + `(?:$|[^a-z])|(?:^|[^A-Za-z0-9])` + q + `[A-Z0-9]`)
matchers[n] = rx
return rx
}
+5 -201
View File
@@ -35,6 +35,7 @@ func TestTheForgeIsNotANodeName(t *testing.T) {
"see novox/hq issue 5",
"module git.novox.be/novox/mesh-sdk/go v1.2.3",
"https://git.novox.be/novox/mesh-catalog",
"git.novox.be",
"space trace surface grace g14x",
} {
if got := scan("f", s, names); len(got) != 0 {
@@ -67,7 +68,7 @@ func TestANodeNamedPathIsRefusedThroughRun(t *testing.T) {
p := filepath.Join(root, "modules/kernel/testdata/ace/x")
os.MkdirAll(filepath.Dir(p), 0o755)
os.WriteFile(p, []byte("clean\n"), 0o644)
got, err := run(root, names, []string{"kernel"}, nil, nil)
got, err := run(root, names, []string{"kernel"})
if err != nil || len(got) != 1 || !strings.HasPrefix(got[0], "modules/kernel/testdata/ace/x:0: ") {
t.Errorf("got %v, %v", got, err)
}
@@ -81,6 +82,7 @@ func TestCheckReturnsOneOnAFindingAndTwoOnAnUnreadableList(t *testing.T) {
os.WriteFile(p, []byte(s), 0o644)
}
write("particulars", "ace\n")
write("checks/particulars/covered", "kernel\n")
write("modules/kernel/a", "clean\n")
if got := check(root); got != 0 {
t.Errorf("clean = %d, want 0", got)
@@ -95,7 +97,7 @@ func TestCheckReturnsOneOnAFindingAndTwoOnAnUnreadableList(t *testing.T) {
}
}
func TestRunReadsOnlyTheModulesItIsGiven(t *testing.T) {
func TestOnlyCoveredModulesAreRead(t *testing.T) {
root := t.TempDir()
write := func(p, s string) {
p = filepath.Join(root, p)
@@ -104,7 +106,7 @@ func TestRunReadsOnlyTheModulesItIsGiven(t *testing.T) {
}
write("modules/kernel/files/a", "on novox\n")
write("modules/other/files/a", "on novox\n")
got, err := run(root, names, []string{"kernel"}, nil, nil)
got, err := run(root, names, []string{"kernel"})
if err != nil {
t.Fatal(err)
}
@@ -112,201 +114,3 @@ func TestRunReadsOnlyTheModulesItIsGiven(t *testing.T) {
t.Errorf("got %v", got)
}
}
// 1838/AC1: the check reads every directory under modules/ and no list of covered modules, so a module is
// covered from its first commit.
func TestEveryModuleIsReadWithoutAList(t *testing.T) {
root := t.TempDir()
write := func(p, s string) {
p = filepath.Join(root, p)
os.MkdirAll(filepath.Dir(p), 0o755)
os.WriteFile(p, []byte(s), 0o644)
}
write("particulars", "novox\n")
write("modules/kernel/files/a", "clean\n")
if got := check(root); got != 0 {
t.Errorf("clean, no covered list = %d, want 0", got)
}
write("modules/new-module/files/a", "on novox\n")
if got := check(root); got != 1 {
t.Errorf("a finding in a module no list names = %d, want 1", got)
}
}
// 1838/AC2: the hardware model's own name and the software's own name are not node names.
func TestTheHardwareModelAndTheSoftwareAreNotNodeNames(t *testing.T) {
for _, s := range []string{
"modules/asus-zephyrus-g14/README.md",
"the asus-zephyrus-g14 module",
"ASUS_ZEPHYRUS_G14=1",
"product_family: ROG Zephyrus G14",
"# This machine is a node of a Novox mesh",
"Novox Mesh turns a handful of personal machines into one system",
"<!-- novox mesh: this file was changed on two nodes",
`const sealInfo = "novox-mesh sealed box v1"`,
"module git.novox.be/novox/mesh-catalog/modules/kernel",
} {
if got := scan("f", s, names); len(got) != 0 {
t.Errorf("%q refused: %v", s, got)
}
}
}
// 1838/AC2: a node name standing alone is still one, beside the hardware's or the software's name too.
func TestANodeNameBesideTheHardwareOrTheSoftwareIsRefused(t *testing.T) {
cases := map[string]string{
"g14": "the asus-zephyrus-g14 module on g14",
"shanks": "Novox Mesh on shanks",
"novox": "resolves under novox.internal",
"ace": "ROG Zephyrus G14 beside ace",
"novox mesh-ish": "mesh.mod.gitea.call.x.novox-mesh-vault",
}
for want, s := range cases {
got := scan("modules/m/a.go", s, names)
if len(got) != 1 || !strings.HasPrefix(got[0], "modules/m/a.go:1: ") {
t.Errorf("%s: %q gave %v, want one finding naming file:line", want, s, got)
}
}
}
// 1838/AC2: the software's npm scope (@novox/mesh-sdk, @novox:registry=) is its package path, as the Go module
// path is; a session's who@node naming a node is still a node name.
func TestTheSoftwaresNpmScopeIsNotANodeName(t *testing.T) {
for _, s := range []string{
"printf '%s\\n' ${shellQuote(`@novox:registry=${registry}`)}",
"const url = `${asked}@novox%2fmesh-sdk`;",
"# The @novox scope resolves from the mesh's own package registry",
`"dependencies": { "@novox/mesh-sdk": "^0.1.0" }`,
} {
if got := scan("f", s, names); len(got) != 0 {
t.Errorf("%q refused: %v", s, got)
}
}
for _, s := range []string{"by fix-1@novox", "jochen@novox:"} {
if got := scan("f", s, names); len(got) != 1 {
t.Errorf("%q gave %v, want one finding", s, got)
}
}
}
// 1838/AC1: reading every module, the check compiles its matchers once, not once a line: a line read costs a
// few allocations, not a regexp compiled per name (the whole catalogue took 41 s with one per line).
func TestALineIsReadWithoutCompilingAMatcher(t *testing.T) {
line := "a line of code that names nothing at all, with git.novox.be/novox/mesh-sdk in it"
if n := testing.AllocsPerRun(100, func() { hits(line, names) }); n > 20 {
t.Errorf("a line costs %.0f allocations, want at most 20", n)
}
}
// 1838/AC2: a module may declare its hardware model's word in `particulars` (model <module> <word>); the word is
// then no finding in that module, and still one in every other.
func TestAModelsWordIsAllowedInItsModuleOnly(t *testing.T) {
root := t.TempDir()
write := func(p, s string) {
p = filepath.Join(root, p)
os.MkdirAll(filepath.Dir(p), 0o755)
os.WriteFile(p, []byte(s), 0o644)
}
write("particulars", "ace\ng14\nmodel asus-zephyrus-g14 g14\n")
write("modules/asus-zephyrus-g14/README.md", "the adopted /etc/modprobe.d/g14-nvidia-power.conf; a second G14\n")
write("modules/kernel/a", "clean\n")
if got := check(root); got != 0 {
t.Errorf("the model's word in its own module = %d, want 0", got)
}
write("modules/asus-zephyrus-g14/a", "on ace\n")
if got := check(root); got != 1 {
t.Errorf("another node name in the model's module = %d, want 1", got)
}
write("modules/asus-zephyrus-g14/a", "clean\n")
write("modules/kernel/a", "i3's 20-g14.conf\n")
if got := check(root); got != 1 {
t.Errorf("the model's word in another module = %d, want 1", got)
}
write("modules/kernel/a", "clean\n")
write("particulars", "ace\ng14\nmodel no-such-module g14\n")
if got := check(root); got != 2 {
t.Errorf("a model of a module that does not exist = %d, want 2", got)
}
write("particulars", "ace\ng14\nmodel asus-zephyrus-g14 shanks\n")
if got := check(root); got != 2 {
t.Errorf("a model's word that is no listed name = %d, want 2", got)
}
}
// 1838/AC2: only the module path git.novox.be/novox/<repo>, a package name or scope with nothing before its @, and
// the exact record citation novox/hq ADR|issue|design|to-be|research are the forge's or the software's; any
// other novox/<x>, the forge's host alone, and a node after a word's @ are findings.
func TestOnlyTheModulePathThePackageAndTheCitationAreNotNodeNames(t *testing.T) {
for _, s := range []string{
"module git.novox.be/novox/mesh-sdk/go v1.2.3",
"https://git.novox.be/novox/mesh-catalog",
`import { registerModuleTools } from "@novox/mesh-sdk/tools";`,
`"dependencies": { "@novox/ui": "^2.0.0" }`,
"// novox/hq ADR 0507 §9",
"(novox/hq issue 1838)",
"novox/hq design 47 and novox/hq to-be 53, novox/hq research 12",
} {
if got := scan("f", s, names); len(got) != 0 {
t.Errorf("%q refused: %v", s, got)
}
}
for _, s := range []string{
`handedWith("node-b", "gitea", "postgres-database@novox/postgres+pair")`,
"delivery.novox/mesh-controller",
"see novox/mesh-catalog #441",
"git.novox.be",
} {
if got := scan("f", s, names); len(got) != 1 {
t.Errorf("%q gave %v, want one finding", s, got)
}
}
}
// 1838/AC2: novox/hq is the name of the software's own record, no finding in any form; another novox/<repo> is.
func TestTheRecordsNameIsNoFindingInAnyForm(t *testing.T) {
for _, s := range []string{
"novox/hq review round 2",
"novox/hq issues 1665 and 1666",
"repository: novox/hq",
"[the record](https://forge.example.org/novox/hq/src/branch/main/00-META/glossary.md)",
"novox/hq#7 at aaa",
"git.anchor.internal:222/novox/hq.git",
} {
if got := scan("f", s, names); len(got) != 0 {
t.Errorf("%q refused: %v", s, got)
}
}
for _, s := range []string{`repo := "novox/mesh-catalog"`, "novox/hqx", "novox/hq-mirror"} {
if got := scan("f", s, names); len(got) != 1 {
t.Errorf("%q gave %v, want one finding", s, got)
}
}
}
// 1838/AC2: a hardware word declared in particulars (hardware <word>) is no finding anywhere; a name standing
// alone still is.
func TestAHardwareWordIsNoFindingAnywhere(t *testing.T) {
root := t.TempDir()
write := func(p, s string) {
p = filepath.Join(root, p)
os.MkdirAll(filepath.Dir(p), 0o755)
os.WriteFile(p, []byte(s), 0o644)
}
write("particulars", "g14\nhardware asus-g14\n")
write("modules/triggerhappy/a", "/etc/triggerhappy/triggers.d/asus-g14.conf and ASUS-G14\n")
if got := check(root); got != 0 {
t.Errorf("the hardware word = %d, want 0", got)
}
write("modules/triggerhappy/a", "asus-g14.conf on g14\n")
if got := check(root); got != 1 {
t.Errorf("g14 standing alone beside it = %d, want 1", got)
}
write("modules/triggerhappy/a", "my-asus-g14-x\n")
if got := check(root); got != 1 {
t.Errorf("a longer word holding it = %d, want 1", got)
}
write("particulars", "g14\nhardware\n")
if got := check(root); got != 2 {
t.Errorf("a hardware line with no word = %d, want 2", got)
}
}
+4 -2
View File
@@ -55,8 +55,8 @@
set -eu
(cd checks/words && go test -count=1 . && go run . ../..)
# Node names (novox/hq ADR 0507 §9, issue 1838): no node name of this mesh in any module (every directory under
# modules/ is covered, with no list); the names, and a module's hardware model word, are in `particulars`.
# Node names (novox/hq ADR 0507 §9, issue 1838): no node name of this mesh in a covered module; the names are in
# `particulars`, the covered modules in checks/particulars/covered.
(cd checks/particulars && go test -count=1 . && go run . ../..)
# The toolchains (novox/hq ADR 0477 clause 4): the package go only in modules/go, nodejs and npm only in modules/nodejs.
(cd checks/toolchains && go test -count=1 . && go run . ../..)
@@ -64,6 +64,8 @@ set -eu
(cd checks/imagebuild && go test -count=1 . && go run . ../..)
# The image's place (novox/hq ADR 0487 §1): every image builds from a Dockerfile in its own module's directory.
(cd checks/imageplace && go test -count=1 . && go run . ../..)
# The Node lock files (novox/hq ADR 0469 §5, issue 1868): a TypeScript module that names packages beyond the SDK commits its lock.
(cd checks/locks && go test -count=1 . && go run . ../..)
# The agent's home (novox/hq ADR 0266): no archive, and no file written into, under it.
(cd checks/agenthome && go test -count=1 . && go run . ../..)
# The mail-store seat (novox/hq ADR 0378 §3): only mesh-mail may claim it.
+1 -1
View File
@@ -86,7 +86,7 @@ login.
## What changes when it is assigned
| | node-b | node-d |
| | g14 | shanks |
|---|---|---|
| packages | none (all present) | the same |
| GTK settings, `portals.conf` | the found files kept once, then the module's: adds the cursor and Inter, keeps Adwaita dark; `portals.conf` adds the Secret line | the same |
@@ -94,8 +94,7 @@ func TestTheManifestNamesNoMachineAndItsTriggersExist(t *testing.T) {
ids[r["id"].(string)] = true
}
raw, _ := os.ReadFile("../../module.json")
// 1838/AC1: the catalogue-wide check covers a node's name now; this list keeps what it does not.
for _, banned := range []string{"jochen", "/home/", "/run/user/1000"} {
for _, banned := range []string{"jochen", "/home/", "/run/user/1000", "\"g14\"", "shanks"} {
if strings.Contains(string(raw), banned) {
t.Errorf("the manifest says %q", banned)
}
+4 -4
View File
@@ -1,6 +1,6 @@
# betterbird
Betterbird, the mail and calendar client of node-b and node-d (a Thunderbird fork, with the operator's
Betterbird, the mail and calendar client of g14 and shanks (a Thunderbird fork, with the operator's
Exchange calendar through the Owl add-on), as a module (novox/hq issue 345, issue 586, ADR 0208, ADR 0316,
ADR 0317, ADR 0329, to-be 56). It requires `x11-display`, so it is assigned only where a display server is held
on the same machine. It installs Betterbird from a pinned upstream release, ships Owl with it through
@@ -58,7 +58,7 @@ reading the new range there (ADR 0316 §8).
**The policy** turns Betterbird's own updater off (`DisableAppUpdate`, which Betterbird's update check
honours) and installs Owl into every profile from `file:///opt/betterbird/owl/owl.xpi` as
`force_installed`, with `updates_disabled`. A profile that already runs another Owl keeps it: the policy
installs the pinned one where Owl is missing. A newer Owl in the profile (node-b runs 1.5.7) is said as a
installs the pinned one where Owl is missing. A newer Owl in the profile (g14 runs 1.5.7) is said as a
fact by `betterbird_status`; Owl installed by policy cannot be removed in Betterbird, so the remedy is
moving the pin. An older one is a finding of `betterbird_check`, with the same remedy.
@@ -323,10 +323,10 @@ question.
## What changes when it is pushed
The module is assigned to node-d today (look-only, from issue 345's first part); node-b runs Betterbird
The module is assigned to shanks today (look-only, from issue 345's first part); g14 runs Betterbird
outside the mesh. With this version, a push to a node (each waits for the operator: `upgrade: record`):
| | on a node with the community package (node-b, node-d) |
| | on a node with the community package (g14, shanks) |
|---|---|
| the package | `betterbird-bin` removed (quit Betterbird first) |
| `/opt/betterbird` | the mesh's 153.4.0esr-bb10, in the same place, so the same profile opens |
@@ -412,13 +412,13 @@ func TestKeepProposesANodeLayerThroughTheController(t *testing.T) {
f.write(profileDir+"/prefs.js", `user_pref("mail.uifontsize", 16);`+"\n"+`user_pref("mail.spam.manualMark", true);`+"\n")
oldNode, oldAsk := nodeName, asker
t.Cleanup(func() { nodeName, asker = oldNode, oldAsk })
nodeName = func() string { return "node-d" }
nodeName = func() string { return "shanks" }
var asked []map[string]any
asker = func(v string, a any) (json.RawMessage, error) {
args := a.(map[string]any)
asked = append(asked, args)
if args["propose"] == nil {
return json.RawMessage(`{"ok": true, "output": "{\"prefs\": {\"calendar.alarms.playsound\": false}, \"identities\": {\"mail\": {\"account\": \"mail\"}}}\n set at the terminal\nbetterbird on node-d, every value"}`), nil
return json.RawMessage(`{"ok": true, "output": "{\"prefs\": {\"calendar.alarms.playsound\": false}, \"identities\": {\"mail\": {\"account\": \"mail\"}}}\n set at the terminal\nbetterbird on shanks, every value"}`), nil
}
return json.RawMessage(`{"ok": true, "output": "proposed: p-1"}`), nil
}
@@ -427,7 +427,7 @@ func TestKeepProposesANodeLayerThroughTheController(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if len(asked) != 2 || asked[0]["node"] != "node-d" || asked[1]["node"] != "node-d" || asked[1]["propose"] != "true" ||
if len(asked) != 2 || asked[0]["node"] != "shanks" || asked[1]["node"] != "shanks" || asked[1]["propose"] != "true" ||
asked[1]["module"] != "betterbird" {
t.Fatalf("the node's layer read, then proposed: %v", asked)
}
@@ -12,11 +12,11 @@ import (
// ADR 0329 §10, §12: derived from every desktop node's profile, laid out per layer, and proposed one layer at a
// time, carrying the layer as it stands.
// derivingMesh is two desktop nodes, node-b (this one, f) and node-d (f2), and a server that holds no Betterbird, as the
// derivingMesh is two desktop nodes, g14 (this one, f) and shanks (f2), and a server that holds no Betterbird, as the
// controller answers them; layers are the controller's layers, open the proposals that wait.
type derivingMesh struct {
t *testing.T
nodeD *fake
shanks *fake
layers map[string]map[string]any
open bool
proposed []map[string]any
@@ -35,17 +35,17 @@ func (d *derivingMesh) ask(key string, body any) (json.RawMessage, error) {
}
switch {
case key == "seat:mesh-controller.nodes":
return answer(true, `[{"name": "node-b"}, {"name": "node-c"}, {"name": "node-d"}]`)
return answer(true, `[{"name": "g14"}, {"name": "novox"}, {"name": "shanks"}]`)
case key == "seat:mesh-controller.node":
if args["node"] == "node-c" {
return answer(true, "node-c\n assigned: postgres, nats\n")
if args["node"] == "novox" {
return answer(true, "novox\n assigned: postgres, nats\n")
}
return answer(true, fmt.Sprintf("%s\n last heard from here\n assigned: adwaita, betterbird, dunst\n", args["node"]))
case key == "betterbird.betterbird_derive@node-d":
case key == "betterbird.betterbird_derive@shanks":
if d.asleep {
return nil, errors.New("nats: no responders available for request")
}
here, err := d.nodeD.deriveHere(stringsOf(args["kinds"]))
here, err := d.shanks.deriveHere(stringsOf(args["kinds"]))
if err != nil {
d.t.Fatal(err)
}
@@ -53,10 +53,10 @@ func (d *derivingMesh) ask(key string, body any) (json.RawMessage, error) {
return json.Marshal(map[string]any{"content": []any{map[string]any{"type": "text", "text": string(text)}}})
case key == "seat:mesh-controller.settings" && args["proposals"] == "true":
if d.open {
return answer(true, "sab12 set betterbird on the whole mesh (accounts, identities)\n by node-b.node-tools at 2026-10-13 01:00; fingerprint ab12\n waiting for the operator until 2026-10-14 01:00\n"+
"scd34 set mounts on node-d (sources)\n by node-b.node-tools at 2026-10-11 00:05; fingerprint dd0b\n chosen: done\n")
return answer(true, "sab12 set betterbird on the whole mesh (accounts, identities)\n by g14.node-tools at 2026-10-13 01:00; fingerprint ab12\n waiting for the operator until 2026-10-14 01:00\n"+
"scd34 set mounts on shanks (sources)\n by g14.node-tools at 2026-10-11 00:05; fingerprint dd0b\n chosen: done\n")
}
return answer(true, "scd34 set mounts on node-d (sources)\n by node-b.node-tools; fingerprint dd0b\n chosen: done\n")
return answer(true, "scd34 set mounts on shanks (sources)\n by g14.node-tools; fingerprint dd0b\n chosen: done\n")
case key == "seat:mesh-controller.settings" && args["propose"] == "true":
d.proposed = append(d.proposed, args)
return answer(true, "proposal sab12: Set the settings of betterbird?\n fingerprint ab12")
@@ -107,23 +107,23 @@ func TestDeriveLaysEachEntryOutPerLayerAndProposesOneLayerAtATime(t *testing.T)
f2 := newFake(t)
f2.profileWithAccounts()
f2.profileWithConfiguration()
// node-d holds the same accounts and calendars, another signature, another tag colour and a calendar of its own.
// shanks holds the same accounts and calendars, another signature, another tag colour and a calendar of its own.
f2.write(profileDir+"/prefs.js", strings.NewReplacer(`"Regards"`, `"Cheers"`, `"#FF0000"`, `"#CC0000"`).Replace(f2.read(profileDir+"/prefs.js"))+
`user_pref("calendar.registry.s1.type", "caldav");`+"\n"+
`user_pref("calendar.registry.s1.uri", "https://dav.example.org/cal/standup/");`+"\n"+
`user_pref("calendar.registry.s1.name", "Standup");`+"\n")
f2.declareAccounts(adoptable)
mesh := &derivingMesh{t: t, nodeD: f2, layers: map[string]map[string]any{"": {"accounts": map[string]any{"mail": "as approved"}}}}
mesh := &derivingMesh{t: t, shanks: f2, layers: map[string]map[string]any{"": {"accounts": map[string]any{"mail": "as approved"}}}}
oldNode, oldAsk := nodeName, asker
t.Cleanup(func() { nodeName, asker = oldNode, oldAsk })
nodeName = func() string { return "node-b" }
nodeName = func() string { return "g14" }
asker = mesh.ask
a, err := f.Derive(nil, false, false, nil, "")
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(a.From, []string{"node-b", "node-d"}) || len(a.NotHeard) != 0 {
if !reflect.DeepEqual(a.From, []string{"g14", "shanks"}) || len(a.NotHeard) != 0 {
t.Fatalf("derived from the nodes holding Betterbird: %v %v", a.From, a.NotHeard)
}
whole := mustJSON(a)
@@ -134,9 +134,9 @@ func TestDeriveLaysEachEntryOutPerLayerAndProposesOneLayerAtATime(t *testing.T)
}
cals, _ := mw[kindCalendars].(map[string]any)
if cals["home"] == nil || cals["standup"] != nil {
t.Errorf("a calendar both hold is mesh-wide, one only node-d holds is not: %v", cals)
t.Errorf("a calendar both hold is mesh-wide, one only shanks holds is not: %v", cals)
}
if sh := mustJSON(a.Layers["node-d"]); !strings.Contains(sh, "https://dav.example.org/cal/standup/") {
if sh := mustJSON(a.Layers["shanks"]); !strings.Contains(sh, "https://dav.example.org/cal/standup/") {
t.Errorf("an entry on one node only goes into that node's layer: %s", sh)
}
tags, _ := mw[kindTags].(map[string]any)
@@ -147,8 +147,8 @@ func TestDeriveLaysEachEntryOutPerLayerAndProposesOneLayerAtATime(t *testing.T)
for _, d := range a.Differences {
diffs[d.Kind+"/"+d.Entry+"/"+d.Field] = d
}
if d := diffs["identities/mail/signature"]; d.Node != "node-d" || mustJSON(d.There) != `["Cheers"]` {
t.Errorf("the answer lists the field node-d holds differently: %+v", a.Differences)
if d := diffs["identities/mail/signature"]; d.Node != "shanks" || mustJSON(d.There) != `["Cheers"]` {
t.Errorf("the answer lists the field shanks holds differently: %+v", a.Differences)
}
if d := diffs["tags/$label1/color"]; d.There != "#CC0000" || d.MeshWide != "#FF0000" {
t.Errorf("%+v", a.Differences)
@@ -195,22 +195,22 @@ func TestDeriveLaysEachEntryOutPerLayerAndProposesOneLayerAtATime(t *testing.T)
if _, err := f.Derive(nil, false, true, nil, ""); err == nil || !strings.Contains(err.Error(), "sab12 (the whole mesh)") || len(mesh.proposed) != 1 {
t.Fatalf("one open proposal of the module's at a time: %v", err)
}
// Approved: the mesh-wide layer holds it, so the next layer is node-d's own.
// Approved: the mesh-wide layer holds it, so the next layer is shanks's own.
mesh.open = false
mesh.layers[""] = values
a, err = f.Derive(nil, false, true, prefs, "")
if err != nil {
t.Fatal(err)
}
if len(mesh.proposed) != 2 || mesh.proposed[1]["node"] != "node-d" || a.Next != "node-d" ||
if len(mesh.proposed) != 2 || mesh.proposed[1]["node"] != "shanks" || a.Next != "shanks" ||
!strings.Contains(mesh.proposed[1]["values"].(string), "standup") {
t.Fatalf("then the node's own layer: %v", mesh.proposed)
}
var nodeD map[string]any
if err := json.Unmarshal([]byte(mesh.proposed[1]["values"].(string)), &nodeD); err != nil {
var shanks map[string]any
if err := json.Unmarshal([]byte(mesh.proposed[1]["values"].(string)), &shanks); err != nil {
t.Fatal(err)
}
mesh.layers["node-d"] = nodeD
mesh.layers["shanks"] = shanks
if a, err = f.Derive(nil, false, true, prefs, ""); err != nil || len(mesh.proposed) != 2 || !strings.HasPrefix(a.Next, "none") {
t.Fatalf("nothing left to propose: %v %s", err, a.Next)
}
@@ -240,10 +240,10 @@ func TestALayerThatCannotBeReadRefusesTheProposal(t *testing.T) {
f2 := newFake(t)
f2.profileWithAccounts()
f2.declareAccounts(adoptable)
mesh := &derivingMesh{t: t, nodeD: f2, garbled: true}
mesh := &derivingMesh{t: t, shanks: f2, garbled: true}
oldNode, oldAsk := nodeName, asker
t.Cleanup(func() { nodeName, asker = oldNode, oldAsk })
nodeName = func() string { return "node-b" }
nodeName = func() string { return "g14" }
asker = mesh.ask
if _, err := f.Derive(nil, false, true, nil, ""); err == nil || !strings.Contains(err.Error(), "cannot be read as the layer") ||
len(mesh.proposed) != 0 {
@@ -257,7 +257,7 @@ func TestALayerThatCannotBeReadRefusesTheProposal(t *testing.T) {
t.Fatal("an unreadable layer is an error, never an empty layer")
}
mesh.garbled = false
if got, err := readLayer("node-d"); err != nil || len(got) != 0 {
if got, err := readLayer("shanks"); err != nil || len(got) != 0 {
t.Fatalf("no layer, in the controller's words, is an empty layer: %v %v", got, err)
}
}
@@ -107,8 +107,7 @@ func TestItPlacesBetterbirdWhereTheCommunityPackageDidAndOnlyAfterItIsGone(t *te
}
}
s := strings.ToLower(raw)
// 1838/AC1: the catalogue-wide check covers a node's name now; this list keeps what it does not.
for _, never := range []string{"/home/", "jochen", "http://", "mediahuis"} {
for _, never := range []string{"/home/", "jochen", "g14", "shanks", "novox.be", "http://", "mediahuis"} {
if strings.Contains(s, never) {
t.Errorf("module.json names %q", never)
}
+2 -2
View File
@@ -52,7 +52,7 @@ through an injected runner and a fake root in the tests.
## What changes when it is assigned
| | node-b | node-d |
| | g14 | shanks |
|---|---|---|
| package | none: `blueman` 2.4.6 is installed, explicitly, from `extra` | the same |
| start | none: dex starts the applet from the package's entry, in the login session's scope; the tray icon with it | none on disk. **The applet running now came from the predecessor's window-manager line** (a child of i3, since the session of 2026-10-04 16:00). That session began before the `i3` module dropped the line and installed `dex`, so the next login is the first that starts it from the entry |
@@ -62,7 +62,7 @@ The workstations are already in the state this module describes.
## Migration (ADR 0182)
Nothing is required on either machine. On node-d, log out and in once, or run `blueman_restart`, and
Nothing is required on either machine. On shanks, log out and in once, or run `blueman_restart`, and
the applet runs from its one start. `blueman_check` then answers `ok`.
## Leaves as found
@@ -101,8 +101,7 @@ func TestTheToolsAgreeWithTheManifest(t *testing.T) {
t.Errorf("the Go tools bundle: %v", b)
}
s := strings.ToLower(raw)
// 1838/AC1: the catalogue-wide check covers a node's name now; this list keeps what it does not.
for _, never := range []string{"/home/", "jochen", "http", "password", "token"} {
for _, never := range []string{"/home/", "jochen", "g14", "shanks", "novox.be", "http", "password", "token"} {
if strings.Contains(s, never) {
t.Errorf("module.json names %q", never)
}
@@ -20,7 +20,7 @@ func TestTheManifestIsTheStackItsToolsAndTheDaemon(t *testing.T) {
}
}
const show = "Controller 4C:82:A9:97:01:8E (public)\n\tName: node-b\n\tAlias: node-b\n\tPowered: yes\n\tPowerState: on\n\tDiscoverable: no\n\tPairable: yes\n\tUUID: Headset (00001108-0000-1000-8000-00805f9b34fb)\n\tDiscovering: no\n"
const show = "Controller 4C:82:A9:97:01:8E (public)\n\tName: g14\n\tAlias: g14\n\tPowered: yes\n\tPowerState: on\n\tDiscoverable: no\n\tPairable: yes\n\tUUID: Headset (00001108-0000-1000-8000-00805f9b34fb)\n\tDiscovering: no\n"
func headphones(connected bool) string {
c := "no"
@@ -34,7 +34,7 @@ func headphones(connected bool) string {
func TestControllerReadsShow(t *testing.T) {
using(t, func(string, Cmd) Result { return ok("\x1b[0;94m" + show) })
c, err := Controller()
if err != nil || c.Address != "4C:82:A9:97:01:8E" || c.Name != "node-b" || !c.Powered || c.Discoverable || !c.Pairable {
if err != nil || c.Address != "4C:82:A9:97:01:8E" || c.Name != "g14" || !c.Powered || c.Discoverable || !c.Pairable {
t.Fatalf("%+v %v", c, err)
}
using(t, func(string, Cmd) Result { return ok("No default controller available\n") })
@@ -64,7 +64,7 @@ func TestTheAdminProcessAnswersAnAskOverARealBus(t *testing.T) {
Retention: jetstream.WorkQueuePolicy, Discard: jetstream.DiscardNew, MaxAge: 24 * time.Hour, MaxBytes: 8 << 20}); err != nil {
t.Fatal(err)
}
account := process.Account{URL: url, Node: "node-b", Module: "brother", User: "node-b.brother." + DeviceProcessID, Password: "pw"}
account := process.Account{URL: url, Node: "g14", Module: "brother", User: "g14.brother." + DeviceProcessID, Password: "pw"}
if _, err := js.CreateConsumer(ctx, AsksStream, jetstream.ConsumerConfig{Durable: account.QueueConsumerName(),
AckPolicy: jetstream.AckExplicitPolicy, AckWait: 30 * time.Second, MaxDeliver: 3}); err != nil {
t.Fatal(err)
+1 -1
View File
@@ -234,7 +234,7 @@ func TestAFetchedPDFIsStoredUnderItsHashAndPrinted(t *testing.T) {
// A refusal inside the mesh is answered, and nothing is stored nor printed.
fake.body = nil
before := len(r.objects.keys())
if _, err := r.call(t, "printer.print", map[string]any{"url": "http://127.0.0.1:9000/mesh-node-d-brother/x"}); err == nil || !strings.Contains(err.Error(), "loopback") {
if _, err := r.call(t, "printer.print", map[string]any{"url": "http://127.0.0.1:9000/mesh-shanks-brother/x"}); err == nil || !strings.Contains(err.Error(), "loopback") {
t.Errorf("a loopback address: error = %v", err)
}
if len(r.objects.keys()) != before || fake.body != nil {
+1 -1
View File
@@ -160,7 +160,7 @@ func tools(settingsFile string, now func() time.Time) []stdio.Tool {
Input: map[string]any{"type": "object", "properties": map[string]any{
"object": str("a stored document's key: a scan's key, as scan.added names it, or the key node-files.upload answered"),
"path": str("an absolute path of a file on the machine node names"),
"node": str("the machine path is on, such as node-b"),
"node": str("the machine path is on, such as g14"),
"url": str("an http or https address of a document or a page"),
"ask": str("the ask id of an earlier print that was answered as started: alone, it reads that print's outcome"),
"copies": num("1 to 99 (default 1)"),
+14 -14
View File
@@ -56,15 +56,15 @@ func TestPrintTakesExactlyOneSource(t *testing.T) {
for name, args := range map[string]map[string]any{
"none": {},
"only options": {"copies": float64(2)},
"object and path": {"object": key, "path": "/home/ops/a.pdf", "node": "node-b"},
"object and path": {"object": key, "path": "/home/ops/a.pdf", "node": "g14"},
"object and url": {"object": key, "url": "https://example.com/a.pdf"},
"path+node and url": {"path": "/home/ops/a.pdf", "node": "node-b", "url": "https://example.com/a.pdf"},
"all three": {"object": key, "path": "/home/ops/a.pdf", "node": "node-b", "url": "https://example.com/a.pdf"},
"path+node and url": {"path": "/home/ops/a.pdf", "node": "g14", "url": "https://example.com/a.pdf"},
"all three": {"object": key, "path": "/home/ops/a.pdf", "node": "g14", "url": "https://example.com/a.pdf"},
"path without node": {"path": "/home/ops/a.pdf"},
"node without path": {"node": "node-b", "object": key},
"node without path": {"node": "g14", "object": key},
"the retired upload": {"upload": "u1"},
"object and upload": {"object": key, "upload": "u1"},
"path and its object": {"path": "/home/ops/a.pdf", "node": "node-b", "object": key},
"path and its object": {"path": "/home/ops/a.pdf", "node": "g14", "object": key},
} {
_, err := r.call(t, "printer.print", args)
if err == nil {
@@ -82,7 +82,7 @@ func TestPrintTakesExactlyOneSource(t *testing.T) {
}
}
// 1598/AC2: with path and node node-b, printer.print asks node-b's node-files to upload the path into brother's bucket, and
// 1598/AC2: with path and node g14, printer.print asks g14's node-files to upload the path into brother's bucket, and
// queues a print ask that carries the returned key and the options: under 4 KiB, and no path, node or bytes.
func TestPrintOfAFileOfAnotherMachineUploadsThenPrintsTheKey(t *testing.T) {
fake := newIPPFake(t, 31, 5)
@@ -90,12 +90,12 @@ func TestPrintOfAFileOfAnotherMachineUploadsThenPrintsTheKey(t *testing.T) {
key := sha256Hex(doc)
up := withUpload(t, key, nil)
r := newRig(t, `{"printer":"192.0.2.10"}`, "pw")
r.objects.Put(t.Context(), key, "application/pdf", doc) // what node-b's upload stored
got, err := r.call(t, "printer.print", map[string]any{"path": "/home/ops/letter.pdf", "node": "node-b", "copies": float64(2)})
r.objects.Put(t.Context(), key, "application/pdf", doc) // what g14's upload stored
got, err := r.call(t, "printer.print", map[string]any{"path": "/home/ops/letter.pdf", "node": "g14", "copies": float64(2)})
if err != nil {
t.Fatal(err)
}
if len(up.seen()) != 1 || up.seen()[0] != "node-b|/home/ops/letter.pdf|brother-test" {
if len(up.seen()) != 1 || up.seen()[0] != "g14|/home/ops/letter.pdf|brother-test" {
t.Fatalf("uploads asked: %v", up.seen())
}
if len(r.bus.published) != 1 {
@@ -114,7 +114,7 @@ func TestPrintOfAFileOfAnotherMachineUploadsThenPrintsTheKey(t *testing.T) {
}
}
m := got.(map[string]any)
if m["job-id"] != float64(31) || m["node"] != "node-b" || m["path"] != "/home/ops/letter.pdf" {
if m["job-id"] != float64(31) || m["node"] != "g14" || m["path"] != "/home/ops/letter.pdf" {
t.Errorf("answer = %v", m)
}
if _, sent := fake.request(t); string(sent) != string(doc) {
@@ -127,7 +127,7 @@ func TestARefusedUploadIsAnsweredWithItsReasonAndNothingIsPrinted(t *testing.T)
fake := newIPPFake(t, 1, 3)
up := withUpload(t, "", errors.New("/home/ops/.ssh/id_ed25519 is refused by name: it is in .ssh, which holds an account's credentials"))
r := newRig(t, `{"printer":"192.0.2.10"}`, "pw")
_, err := r.call(t, "printer.print", map[string]any{"path": "/home/ops/.ssh/id_ed25519", "node": "node-b"})
_, err := r.call(t, "printer.print", map[string]any{"path": "/home/ops/.ssh/id_ed25519", "node": "g14"})
if err == nil || !strings.Contains(err.Error(), "is refused by name: it is in .ssh") {
t.Fatalf("error = %v", err)
}
@@ -277,7 +277,7 @@ func TestASlowUploadAndPrintAnswersStartedNeverATimeout(t *testing.T) {
t.Cleanup(func() { uploadVia = old })
r := newRig(t, `{"printer":"192.0.2.10"}`, "pw")
r.objects.Put(t.Context(), key, "application/pdf", doc)
got, err := r.call(t, "printer.print", map[string]any{"path": "/home/ops/letter.pdf", "node": "node-b"})
got, err := r.call(t, "printer.print", map[string]any{"path": "/home/ops/letter.pdf", "node": "g14"})
if err != nil {
t.Fatalf("a slow upload failed the call: %v", err)
}
@@ -292,7 +292,7 @@ func TestASlowUploadAndPrintAnswersStartedNeverATimeout(t *testing.T) {
t.Fatal(err)
}
if rm := read.(map[string]any); rm["state"] == nil {
if rm["job-id"] != float64(72) || rm["path"] != "/home/ops/letter.pdf" || rm["node"] != "node-b" {
if rm["job-id"] != float64(72) || rm["path"] != "/home/ops/letter.pdf" || rm["node"] != "g14" {
t.Errorf("the outcome = %v", rm)
}
break
@@ -309,7 +309,7 @@ func TestASlowUploadAndPrintAnswersStartedNeverATimeout(t *testing.T) {
time.Sleep(150 * time.Millisecond)
return "", errors.New("/home/ops/.ssh/id_ed25519 is refused by name")
}
got, _ = r.call(t, "printer.print", map[string]any{"path": "/home/ops/.ssh/id_ed25519", "node": "node-b"})
got, _ = r.call(t, "printer.print", map[string]any{"path": "/home/ops/.ssh/id_ed25519", "node": "g14"})
id2, _ := got.(map[string]any)["ask"].(string)
time.Sleep(300 * time.Millisecond)
if _, err := r.call(t, "printer.print", map[string]any{"ask": id2}); err == nil || !strings.Contains(err.Error(), "refused by name") {
+1 -1
View File
@@ -139,7 +139,7 @@ func newRig(t *testing.T, settings, password string) *printerRig {
return nil
},
Answers: answers,
Node: "node-d",
Node: "shanks",
Now: func() time.Time { return fixedNow },
}
bus := &fakeAskBus{worker: w, answers: answers}
+2 -2
View File
@@ -76,7 +76,7 @@ func TestTheDeviceAnswerCarriesALinkAndNoBytes(t *testing.T) {
}
// The real store: the binding's key signs, for the binding's host and port, in the module's bucket.
s3 := NewS3(StoreFacts{Scheme: "http", At: "node-c.internal", Port: "20001", Bucket: "mesh-node-d-brother", Region: "eu-west",
s3 := NewS3(StoreFacts{Scheme: "http", At: "novox.internal", Port: "20001", Bucket: "mesh-shanks-brother", Region: "eu-west",
Key: "binding-key", Secret: "binding-secret"})
s3.Now = func() time.Time { return fixedNow }
got, err := s3.Link("GET", "abc.pdf", 10*time.Minute)
@@ -84,7 +84,7 @@ func TestTheDeviceAnswerCarriesALinkAndNoBytes(t *testing.T) {
t.Fatal(err)
}
gu, _ := url.Parse(got)
if gu.Host != "node-c.internal:20001" || gu.Path != "/mesh-node-d-brother/abc.pdf" ||
if gu.Host != "novox.internal:20001" || gu.Path != "/mesh-shanks-brother/abc.pdf" ||
!strings.HasPrefix(gu.Query().Get("X-Amz-Credential"), "binding-key/") || gu.Query().Get("X-Amz-Expires") != "600" {
t.Errorf("link %s", got)
}
+1 -1
View File
@@ -111,7 +111,7 @@ func TestScanNowEmitsOneEventAfterTheScanIsKeptUnderTheSha256(t *testing.T) {
if kept, err := r.objects.Get(t.Context(), want+".pdf"); err != nil || !bytes.Equal(kept, pdf) {
t.Errorf("the bucket does not hold the filed PDF under its sha256 (%v)", err)
}
if ev.body["bytes"] != len(pdf) || ev.body["node"] != "node-d" || ev.body["cropped"] != true || ev.body["color-mode"] != "color" {
if ev.body["bytes"] != len(pdf) || ev.body["node"] != "shanks" || ev.body["cropped"] != true || ev.body["color-mode"] != "color" {
t.Errorf("event = %v", ev.body)
}
if want := fixedNow.UTC().Format(time.RFC3339); ev.body["scanned-at"] != want {
@@ -290,11 +290,11 @@ func TestAnAppliedBindingSaysWhyAndWho(t *testing.T) {
p, w := node(t, "laptop")
var asked []string
done, err := OnBinding(p, &BindingState{Licence: "personal", Kind: "subscription", Generation: 7,
Why: "the switch verb", By: "node-b.node-tools"}, seat(t, "personal", "at-7", 7, &asked), writer(w))
Why: "the switch verb", By: "g14.node-tools"}, seat(t, "personal", "at-7", 7, &asked), writer(w))
if err != nil {
t.Fatal(err)
}
for _, want := range []string{`"generation":7`, `"why":"the switch verb"`, `"by":"node-b.node-tools"`} {
for _, want := range []string{`"generation":7`, `"why":"the switch verb"`, `"by":"g14.node-tools"`} {
if !strings.Contains(done, want) {
t.Fatalf("the line %s lacks %s", done, want)
}
@@ -355,8 +355,8 @@ func TestAPathOrAKeyThatIsNotWhatItSaysIsRefused(t *testing.T) {
}
func TestAllIsEveryNodeAndANameThatCannotBeAKeyIsRefused(t *testing.T) {
nodes, err := ExpandNodes([]string{"all"}, func() ([]string, error) { return []string{"node-a", "node-b"}, nil })
if err != nil || strings.Join(nodes, ",") != "node-a,node-b" {
nodes, err := ExpandNodes([]string{"all"}, func() ([]string, error) { return []string{"ace", "g14"}, nil })
if err != nil || strings.Join(nodes, ",") != "ace,g14" {
t.Fatalf("%v %v", nodes, err)
}
p, w := node(t, "laptop")
@@ -251,7 +251,7 @@ func TestTheSessionsGitIsPointedAtTheFileUnderTheOperatorsOwnEnvironment(t *test
func TestForgeCredentialLinesAreReadStrictly(t *testing.T) {
for _, bad := range []string{"", "\n", "token", "https://git.example.test", "https://agent-anchor@git.example.test",
"https://agent-anchor:t@git.example.test/acme/hq.git", "ssh://agent-anchor:t@git.example.test",
"https://agent-anchor:t@git.example.test/novox/hq.git", "ssh://agent-anchor:t@git.example.test",
"https://agent-anchor:t@git.example.test\nnot a line"} {
if _, err := ForgeCredentialLines(bad); err == nil {
t.Errorf("%q was taken", bad)
@@ -131,13 +131,13 @@ func TestWhatIsNotAWorkAroundPasses(t *testing.T) {
g := guard()
for _, command := range []string{
"git push -u origin feat/x",
"git clone ssh://git@git.anchor.internal:222/acme/hq.git",
"git fetch ssh://git@git.anchor.internal:222/acme/hq.git main",
"git clone ssh://git@git.anchor.internal:222/novox/hq.git",
"git fetch ssh://git@git.anchor.internal:222/novox/hq.git main",
`GIT_SSH_COMMAND="ssh -i ~/.ssh/forge -o IdentitiesOnly=yes" git push origin HEAD`,
"git -c core.sshCommand='ssh -p 222' pull",
"ssh -T git@git.anchor.internal -p 222",
"ssh forge",
"scp git@git.anchor.internal:acme/hq.git .",
"scp git@git.anchor.internal:novox/hq.git .",
"ssh github.com",
"ssh -T git@github.com",
"ssh user@203.0.113.7 uptime",
@@ -51,13 +51,13 @@ func suggestedCall(t *testing.T, msg string) map[string]any {
// Issue 1757: the Stop hook's suggested watched call carries `by`, and the tool accepts it as printed.
func TestTheStopHooksSuggestedWatchedCallIsAcceptedByTheTool(t *testing.T) {
p, _ := node(t, "node-b")
p, _ := node(t, "g14")
store := newMemWaits()
waiting = &Waiting{Store: store, Now: func() time.Time { return t0 }}
t.Cleanup(func() { waiting = nil })
s := serve(t, p, &bus{kept: map[string]map[string]any{}}, memConfig{})
text, isErr := s.call(t, "claude_code_wait", map[string]any{"op": "add", "session": "verify-0489-test", "by": "fix-1757@node-b",
text, isErr := s.call(t, "claude_code_wait", map[string]any{"op": "add", "session": "verify-0489-test", "by": "fix-1757@g14",
"what": "the operator's Approve", "read": map[string]any{"address": "mesh-delivery.recheck"}, "bound_minutes": 10})
if isErr {
t.Fatalf("add: %s", text)
@@ -81,14 +81,14 @@ func TestTheStopHooksSuggestedWatchedCallIsAcceptedByTheTool(t *testing.T) {
// Issue 1757: the Stop hook's suggested said call (past its bound) carries `by` too.
func TestTheStopHooksSuggestedSaidCallIsAcceptedByTheTool(t *testing.T) {
p, _ := node(t, "node-b")
p, _ := node(t, "g14")
store := newMemWaits()
clock := t0
waiting = &Waiting{Store: store, Now: func() time.Time { return clock }}
t.Cleanup(func() { waiting = nil })
s := serve(t, p, &bus{kept: map[string]map[string]any{}}, memConfig{})
text, isErr := s.call(t, "claude_code_wait", map[string]any{"op": "add", "session": "verify-0489-test", "by": "fix-1757@node-b",
text, isErr := s.call(t, "claude_code_wait", map[string]any{"op": "add", "session": "verify-0489-test", "by": "fix-1757@g14",
"what": "the operator's Approve", "read": map[string]any{"address": "mesh-delivery.recheck"}, "bound_minutes": 1})
if isErr {
t.Fatalf("add: %s", text)
@@ -114,20 +114,20 @@ func TestTheStopHooksSuggestedSaidCallIsAcceptedByTheTool(t *testing.T) {
// Issue 1757: the SessionStart hook's suggested adopt call already carries `by` — checked, as asked, so a
// regression here is caught the same way as the Stop hook's.
func TestTheSessionStartHooksSuggestedAdoptCallIsAcceptedByTheTool(t *testing.T) {
p, _ := node(t, "node-b")
p, _ := node(t, "g14")
store := newMemWaits()
waiting = &Waiting{Store: store, Now: func() time.Time { return t0 }}
t.Cleanup(func() { waiting = nil })
s := serve(t, p, &bus{kept: map[string]map[string]any{}}, memConfig{})
text, isErr := s.call(t, "claude_code_wait", map[string]any{"op": "add", "session": "sess-old", "by": "fix-1757@node-b",
text, isErr := s.call(t, "claude_code_wait", map[string]any{"op": "add", "session": "sess-old", "by": "fix-1757@g14",
"what": "the operator's Approve", "read": map[string]any{"address": "mesh-delivery.recheck"}, "bound_minutes": 10})
if isErr {
t.Fatalf("add: %s", text)
}
msg := SessionStartMessage("sess-new", WaitsDirPath(p), t0.Add(time.Minute))
if !strings.Contains(msg, "\"by\":\"fix-1757@node-b\"") {
if !strings.Contains(msg, "\"by\":\"fix-1757@g14\"") {
t.Fatalf("the suggested adopt call does not carry by: %s", msg)
}
args := suggestedCall(t, msg)
@@ -813,7 +813,7 @@ func (m *MemoryCarrier) unwritten(key, project, path string, err error) {
}
// OlderBeside is where the older of two versions too large to share one file is kept: beside the file, named for
// the node it was changed on ("MEMORY.md" from node-d: "MEMORY.older-from-node-d.md").
// the node it was changed on ("MEMORY.md" from shanks: "MEMORY.older-from-shanks.md").
func OlderBeside(path, node string) string {
ext := filepath.Ext(path)
return strings.TrimSuffix(path, ext) + ".older-from-" + node + ext
@@ -900,7 +900,7 @@ func (m *MemoryCarrier) Take(key, op string, value json.RawMessage, revision uin
}
// Only noted here, never applied: the runtime hands the watch's values over one at a time and gives the whole
// current set ten seconds, so a watch whose handler reads the state, waits for a look or writes a file never
// starts on a node with a few hundred memory files (issue 367, seen on node-d on 2026-10-13). Drain applies
// starts on a node with a few hundred memory files (issue 367, seen on shanks on 2026-10-13). Drain applies
// what was noted, the newest revision of each key.
m.queueMu.Lock()
if m.queue == nil {
+216 -216
View File
@@ -206,15 +206,15 @@ func holdsText(list []string, s string) bool {
return false
}
const project = "-home-op-projects-acme-hq"
const project = "-home-op-projects-novox-hq"
func twoNodes(t *testing.T) (*memState, *clock, *memNode, *memNode) {
state, c := newMemState(), &clock{now: time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC)}
root := t.TempDir()
return state, c, newMemNode(t, root, "node-b", HomeOperator, state, c), newMemNode(t, root, "node-d", HomeOperator, state, c)
return state, c, newMemNode(t, root, "g14", HomeOperator, state, c), newMemNode(t, root, "shanks", HomeOperator, state, c)
}
// carried writes a file on node-b and carries it to node-d.
// carried writes a file on g14 and carries it to shanks.
func carriedOnBoth(t *testing.T, state *memState, c *clock, a, b *memNode, file, text string) {
t.Helper()
a.write(t, project, file, text, c.now)
@@ -229,39 +229,39 @@ func carriedOnBoth(t *testing.T, state *memState, c *clock, a, b *memNode, file,
// §5, §6: a memory file written on one node appears unchanged on another, at any depth, and is carried once.
func TestAMemoryFileWrittenOnOneNodeAppearsUnchangedOnAnother(t *testing.T) {
state, c, nodeB, nodeD := twoNodes(t)
nodeB.write(t, project, "MEMORY.md", "- [state](state.md) — where things stand\n", c.now)
nodeB.write(t, project, "notes/state.md", "ADR 0343 proposed.\n", c.now)
nodeB.look(t)
state, c, g14, shanks := twoNodes(t)
g14.write(t, project, "MEMORY.md", "- [state](state.md) — where things stand\n", c.now)
g14.write(t, project, "notes/state.md", "ADR 0343 proposed.\n", c.now)
g14.look(t)
state.pump(t)
for file, want := range map[string]string{"MEMORY.md": "- [state](state.md) — where things stand\n", "notes/state.md": "ADR 0343 proposed.\n"} {
if got, ok := nodeD.read(t, project, file); !ok || got != want {
t.Fatalf("%s on node-d: %q, %v", file, got, ok)
if got, ok := shanks.read(t, project, file); !ok || got != want {
t.Fatalf("%s on shanks: %q, %v", file, got, ok)
}
}
before := state.rev
nodeD.look(t)
nodeB.look(t)
shanks.look(t)
g14.look(t)
state.pump(t)
if state.rev != before {
t.Fatalf("a carried file was carried again: %d changes", state.rev-before)
}
// A change on node-d comes back to node-b, over the version it replaced, which node-b keeps.
// A change on shanks comes back to g14, over the version it replaced, which g14 keeps.
c.now = c.now.Add(time.Minute)
nodeD.write(t, project, "notes/state.md", "ADR 0343 accepted.\n", c.now)
nodeD.look(t)
shanks.write(t, project, "notes/state.md", "ADR 0343 accepted.\n", c.now)
shanks.look(t)
state.pump(t)
if got, _ := nodeB.read(t, project, "notes/state.md"); got != "ADR 0343 accepted.\n" {
t.Fatalf("the change did not reach node-b: %q", got)
if got, _ := g14.read(t, project, "notes/state.md"); got != "ADR 0343 accepted.\n" {
t.Fatalf("the change did not reach g14: %q", got)
}
if !holdsText(nodeB.copies(t), "ADR 0343 proposed.\n") {
t.Fatal("the version replaced on node-b was not kept")
if !holdsText(g14.copies(t), "ADR 0343 proposed.\n") {
t.Fatal("the version replaced on g14 was not kept")
}
if len(nodeB.notices)+len(nodeD.notices) != 0 {
t.Fatalf("a plain change warned: %v %v", nodeB.notices, nodeD.notices)
if len(g14.notices)+len(shanks.notices) != 0 {
t.Fatalf("a plain change warned: %v %v", g14.notices, shanks.notices)
}
// No value in the state is anything but a version of a memory file, and no tool answer carries the text.
raw, _ := json.Marshal(nodeB.Status())
raw, _ := json.Marshal(g14.Status())
if strings.Contains(string(raw), "accepted") {
t.Fatalf("the memory tool answered the text: %s", raw)
}
@@ -270,44 +270,44 @@ func TestAMemoryFileWrittenOnOneNodeAppearsUnchangedOnAnother(t *testing.T) {
// §1, §2: only the memory folders are carried; a symbolic link, an oversized file and a secret's shape are not, and are
// listed with why.
func TestOnlyTheMemoryFoldersAreCarried(t *testing.T) {
state, c, nodeB, nodeD := twoNodes(t)
projectDir := filepath.Join(nodeB.Home, ".claude", "projects", project)
nodeB.write(t, project, "MEMORY.md", "index\n", c.now)
state, c, g14, shanks := twoNodes(t)
projectDir := filepath.Join(g14.Home, ".claude", "projects", project)
g14.write(t, project, "MEMORY.md", "index\n", c.now)
for path, text := range map[string]string{
filepath.Join(projectDir, "session.jsonl"): "{}\n",
filepath.Join(nodeB.Home, ".claude", "settings.json"): "{}\n",
filepath.Join(nodeB.Home, ".claude", "history.jsonl"): "{}\n",
filepath.Join(projectDir, "session.jsonl"): "{}\n",
filepath.Join(g14.Home, ".claude", "settings.json"): "{}\n",
filepath.Join(g14.Home, ".claude", "history.jsonl"): "{}\n",
} {
if err := os.WriteFile(path, []byte(text), 0o600); err != nil {
t.Fatal(err)
}
}
nodeB.write(t, project, "key.md", "-----BEGIN OPENSSH PRIVATE KEY-----\nabc\n", c.now)
nodeB.write(t, project, "url.md", "https://op:hunter2@git.example.org/x\n", c.now)
nodeB.write(t, project, "big.md", strings.Repeat("x", MemoryMostBytes+1), c.now)
nodeB.write(t, project, "hash.md", "head 1fe6ea4ee47379007598e163784dbd7ac50b7f79\n", c.now)
g14.write(t, project, "key.md", "-----BEGIN OPENSSH PRIVATE KEY-----\nabc\n", c.now)
g14.write(t, project, "url.md", "https://op:hunter2@git.example.org/x\n", c.now)
g14.write(t, project, "big.md", strings.Repeat("x", MemoryMostBytes+1), c.now)
g14.write(t, project, "hash.md", "head 1fe6ea4ee47379007598e163784dbd7ac50b7f79\n", c.now)
outside := filepath.Join(t.TempDir(), "elsewhere.md")
_ = os.WriteFile(outside, []byte("not memory\n"), 0o600)
if err := os.Symlink(outside, nodeB.path(project, "link.md")); err != nil {
if err := os.Symlink(outside, g14.path(project, "link.md")); err != nil {
t.Fatal(err)
}
nodeB.look(t)
g14.look(t)
state.pump(t)
for _, file := range []string{"key.md", "url.md", "big.md", "link.md"} {
if _, ok := nodeD.read(t, project, file); ok {
if _, ok := shanks.read(t, project, file); ok {
t.Errorf("%s was carried", file)
}
}
for _, file := range []string{"MEMORY.md", "hash.md"} {
if _, ok := nodeD.read(t, project, file); !ok {
if _, ok := shanks.read(t, project, file); !ok {
t.Errorf("%s was not carried", file)
}
}
if _, err := os.Stat(filepath.Join(nodeD.Home, ".claude", "projects", project, "session.jsonl")); err == nil {
if _, err := os.Stat(filepath.Join(shanks.Home, ".claude", "projects", project, "session.jsonl")); err == nil {
t.Error("a session was carried")
}
for _, f := range []string{"settings.json", "history.jsonl"} {
if _, err := os.Stat(filepath.Join(nodeD.Home, ".claude", f)); err == nil {
if _, err := os.Stat(filepath.Join(shanks.Home, ".claude", f)); err == nil {
t.Errorf("%s was carried", f)
}
}
@@ -315,7 +315,7 @@ func TestOnlyTheMemoryFoldersAreCarried(t *testing.T) {
if len(keys) != 2 {
t.Fatalf("the state holds %d keys, not the two memory files: %v", len(keys), keys)
}
not, _ := nodeB.Status()["not carried"].(map[string]string)
not, _ := g14.Status()["not carried"].(map[string]string)
for _, file := range []string{"key.md", "url.md", "big.md", "link.md"} {
if not[project+"/"+file] == "" {
t.Errorf("%s is not listed as not carried: %v", file, not)
@@ -327,8 +327,8 @@ func TestOnlyTheMemoryFoldersAreCarried(t *testing.T) {
func TestHomesOfTheTwoKindsNeverTakeEachOthersMemory(t *testing.T) {
state, c := newMemState(), &clock{now: time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC)}
root := t.TempDir()
laptop := newMemNode(t, root, "node-b", HomeOperator, state, c)
control := newMemNode(t, root, "node-c", HomeAgent, state, c)
laptop := newMemNode(t, root, "g14", HomeOperator, state, c)
control := newMemNode(t, root, "novox", HomeAgent, state, c)
control.write(t, project, "MEMORY.md", "from a confined agent\n", c.now)
control.look(t)
state.pump(t)
@@ -343,7 +343,7 @@ func TestHomesOfTheTwoKindsNeverTakeEachOthersMemory(t *testing.T) {
}
// A value of the other kind put under this kind's key is refused too.
key := MemoryKey(HomeOperator, project, "x.md")
raw, _ := json.Marshal(MemoryVersion{Kind: HomeAgent, Project: project, Path: "x.md", Text: "x", Digest: digestOf([]byte("x")), Node: "node-c"})
raw, _ := json.Marshal(MemoryVersion{Kind: HomeAgent, Project: project, Path: "x.md", Text: "x", Digest: digestOf([]byte("x")), Node: "novox"})
if err := laptop.Take(key, "put", raw, 99); err == nil {
t.Fatal("a value of another kind was taken")
}
@@ -362,31 +362,31 @@ func TestHomesOfTheTwoKindsNeverTakeEachOthersMemory(t *testing.T) {
// §7, §8: a file changed on two nodes since it was carried is kept twice in one file on both, the newer first, with
// one warning; it clears once the file is tidied.
func TestAFileChangedOnTwoNodesIsKeptTwiceTheNewerFirstWithAWarning(t *testing.T) {
state, c, nodeB, nodeD := twoNodes(t)
carriedOnBoth(t, state, c, nodeB, nodeD, "MEMORY.md", "- base\n")
nodeB.write(t, project, "MEMORY.md", "- base\n- learned on node-b\n", c.now.Add(time.Minute))
nodeD.write(t, project, "MEMORY.md", "- base\n- learned on node-d\n", c.now.Add(2*time.Minute))
state, c, g14, shanks := twoNodes(t)
carriedOnBoth(t, state, c, g14, shanks, "MEMORY.md", "- base\n")
g14.write(t, project, "MEMORY.md", "- base\n- learned on g14\n", c.now.Add(time.Minute))
shanks.write(t, project, "MEMORY.md", "- base\n- learned on shanks\n", c.now.Add(2*time.Minute))
c.now = c.now.Add(3 * time.Minute)
nodeB.look(t)
nodeD.look(t)
g14.look(t)
shanks.look(t)
state.pump(t)
nodeB.look(t)
nodeD.look(t)
g14.look(t)
shanks.look(t)
state.pump(t)
a, _ := nodeB.read(t, project, "MEMORY.md")
b, _ := nodeD.read(t, project, "MEMORY.md")
a, _ := g14.read(t, project, "MEMORY.md")
b, _ := shanks.read(t, project, "MEMORY.md")
if a != b {
t.Fatalf("the nodes did not end on one text:\n%s\n----\n%s", a, b)
}
newer, older := strings.Index(a, "learned on node-d"), strings.Index(a, "learned on node-b")
newer, older := strings.Index(a, "learned on shanks"), strings.Index(a, "learned on g14")
marker := strings.Index(a, "<!-- novox mesh: this file was changed on two nodes")
if newer < 0 || older < 0 || marker < 0 || !(newer < marker && marker < older) {
t.Fatalf("not both versions, the newer first, with the line between:\n%s", a)
}
if !strings.Contains(a[marker:], "changed on node-d") || !strings.Contains(a[marker:], "changed on node-b") {
if !strings.Contains(a[marker:], "changed on shanks") || !strings.Contains(a[marker:], "changed on g14") {
t.Fatalf("the line does not name both nodes:\n%s", a)
}
notices := append(append([]map[string]any{}, nodeB.notices...), nodeD.notices...)
notices := append(append([]map[string]any{}, g14.notices...), shanks.notices...)
if len(notices) != 1 || notices[0]["severity"] != "warning" || notices[0]["clear"] != nil {
t.Fatalf("not one warning: %v", notices)
}
@@ -406,22 +406,22 @@ func TestAFileChangedOnTwoNodesIsKeptTwiceTheNewerFirstWithAWarning(t *testing.T
if h, _ := notices[0]["headline"].(string); len([]rune(h)) > 60 {
t.Errorf("the headline is longer than sixty characters: %q", h)
}
all := append(nodeB.copies(t), nodeD.copies(t)...)
for _, v := range []string{"- base\n- learned on node-b\n", "- base\n- learned on node-d\n"} {
all := append(g14.copies(t), shanks.copies(t)...)
for _, v := range []string{"- base\n- learned on g14\n", "- base\n- learned on shanks\n"} {
if !holdsText(all, v) {
t.Errorf("the version %q was not kept", v)
}
}
if st := nodeB.Status(); len(st["conflicts"].([]conflict))+len(nodeD.Status()["conflicts"].([]conflict)) != 1 {
if st := g14.Status(); len(st["conflicts"].([]conflict))+len(shanks.Status()["conflicts"].([]conflict)) != 1 {
t.Fatalf("the conflict is not listed: %v", st)
}
// Tidied on either node, the warning clears.
c.now = c.now.Add(time.Minute)
nodeB.write(t, project, "MEMORY.md", "- base\n- learned on node-b\n- learned on node-d\n", c.now)
nodeB.look(t)
g14.write(t, project, "MEMORY.md", "- base\n- learned on g14\n- learned on shanks\n", c.now)
g14.look(t)
state.pump(t)
cleared := false
for _, n := range append(nodeB.notices, nodeD.notices...) {
for _, n := range append(g14.notices, shanks.notices...) {
if n["clear"] == true {
cleared = true
}
@@ -429,39 +429,39 @@ func TestAFileChangedOnTwoNodesIsKeptTwiceTheNewerFirstWithAWarning(t *testing.T
if !cleared {
t.Fatal("the warning did not clear once the file was tidied")
}
if got, _ := nodeD.read(t, project, "MEMORY.md"); got != "- base\n- learned on node-b\n- learned on node-d\n" {
t.Fatalf("the tidied file did not reach node-d: %q", got)
if got, _ := shanks.read(t, project, "MEMORY.md"); got != "- base\n- learned on g14\n- learned on shanks\n" {
t.Fatalf("the tidied file did not reach shanks: %q", got)
}
}
// The order of the two versions: the later change first, equal times by node name.
func TestMergeVersionsPutsTheNewerFirst(t *testing.T) {
at := time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC)
m := string(MergeVersions([]byte("old\n"), "node-b", at, []byte("new\n"), "node-d", at.Add(time.Second)))
m := string(MergeVersions([]byte("old\n"), "g14", at, []byte("new\n"), "shanks", at.Add(time.Second)))
if strings.Index(m, "new") > strings.Index(m, "old") {
t.Fatalf("the newer is not first: %s", m)
}
m = string(MergeVersions([]byte("from node-d\n"), "node-d", at, []byte("from node-a\n"), "node-a", at))
if strings.Index(m, "from node-a") > strings.Index(m, "from node-d") {
m = string(MergeVersions([]byte("from shanks\n"), "shanks", at, []byte("from ace\n"), "ace", at))
if strings.Index(m, "from ace") > strings.Index(m, "from shanks") {
t.Fatalf("equal times are not ordered by node name: %s", m)
}
}
// §9: a removal moves the other node's unchanged file into the copies.
func TestARemovalMovesTheUnchangedFileIntoTheCopies(t *testing.T) {
state, c, nodeB, nodeD := twoNodes(t)
carriedOnBoth(t, state, c, nodeB, nodeD, "old.md", "an old note\n")
carriedOnBoth(t, state, c, nodeB, nodeD, "MEMORY.md", "index\n")
if err := os.Remove(nodeB.path(project, "old.md")); err != nil {
state, c, g14, shanks := twoNodes(t)
carriedOnBoth(t, state, c, g14, shanks, "old.md", "an old note\n")
carriedOnBoth(t, state, c, g14, shanks, "MEMORY.md", "index\n")
if err := os.Remove(g14.path(project, "old.md")); err != nil {
t.Fatal(err)
}
nodeB.look(t)
g14.look(t)
state.pump(t)
if _, ok := nodeD.read(t, project, "old.md"); ok {
t.Fatal("the removal did not reach node-d")
if _, ok := shanks.read(t, project, "old.md"); ok {
t.Fatal("the removal did not reach shanks")
}
if !holdsText(nodeD.copies(t), "an old note\n") {
t.Fatal("the removed version was not kept on node-d")
if !holdsText(shanks.copies(t), "an old note\n") {
t.Fatal("the removed version was not kept on shanks")
}
raw, _, ok, _ := state.Get(MemoryKey(HomeOperator, project, "old.md"))
var v MemoryVersion
@@ -469,7 +469,7 @@ func TestARemovalMovesTheUnchangedFileIntoTheCopies(t *testing.T) {
if !ok || !v.Removed || v.Text != "" {
t.Fatalf("the removal is not a marker in the state: %s", raw)
}
if got, _ := nodeD.read(t, project, "MEMORY.md"); got != "index\n" {
if got, _ := shanks.read(t, project, "MEMORY.md"); got != "index\n" {
t.Fatal("another file was touched")
}
}
@@ -477,23 +477,23 @@ func TestARemovalMovesTheUnchangedFileIntoTheCopies(t *testing.T) {
// §9: a change outlives a removal, whichever reaches the state first.
func TestAChangeOutlivesARemoval(t *testing.T) {
for _, removalFirst := range []bool{true, false} {
state, c, nodeB, nodeD := twoNodes(t)
carriedOnBoth(t, state, c, nodeB, nodeD, "note.md", "v1\n")
_ = os.Remove(nodeB.path(project, "note.md"))
nodeD.write(t, project, "note.md", "v2\n", c.now.Add(time.Minute))
state, c, g14, shanks := twoNodes(t)
carriedOnBoth(t, state, c, g14, shanks, "note.md", "v1\n")
_ = os.Remove(g14.path(project, "note.md"))
shanks.write(t, project, "note.md", "v2\n", c.now.Add(time.Minute))
if removalFirst {
nodeB.look(t)
g14.look(t)
state.pump(t)
nodeD.look(t)
shanks.look(t)
} else {
nodeD.look(t)
nodeB.look(t)
shanks.look(t)
g14.look(t)
}
state.pump(t)
nodeB.look(t)
nodeD.look(t)
g14.look(t)
shanks.look(t)
state.pump(t)
for _, n := range []*memNode{nodeB, nodeD} {
for _, n := range []*memNode{g14, shanks} {
if got, _ := n.read(t, project, "note.md"); got != "v2\n" {
t.Fatalf("removal first %v: the change did not outlive the removal on %s: %q", removalFirst, n.Node, got)
}
@@ -503,51 +503,51 @@ func TestAChangeOutlivesARemoval(t *testing.T) {
// §9: a missing folder, a state that lost its keys and a deleted key remove nothing.
func TestAMissingFolderOrAnAbsentKeyRemovesNothing(t *testing.T) {
state, c, nodeB, nodeD := twoNodes(t)
carriedOnBoth(t, state, c, nodeB, nodeD, "MEMORY.md", "index\n")
state, c, g14, shanks := twoNodes(t)
carriedOnBoth(t, state, c, g14, shanks, "MEMORY.md", "index\n")
key := MemoryKey(HomeOperator, project, "MEMORY.md")
// The folder gone on node-b.
if err := os.RemoveAll(filepath.Join(nodeB.Home, ".claude", "projects", project, "memory")); err != nil {
// The folder gone on g14.
if err := os.RemoveAll(filepath.Join(g14.Home, ".claude", "projects", project, "memory")); err != nil {
t.Fatal(err)
}
nodeB.look(t)
g14.look(t)
state.pump(t)
if got, _ := nodeD.read(t, project, "MEMORY.md"); got != "index\n" {
if got, _ := shanks.read(t, project, "MEMORY.md"); got != "index\n" {
t.Fatal("a missing folder removed the file elsewhere")
}
// A deleted key.
_ = state.Delete(key)
state.pump(t)
if got, _ := nodeD.read(t, project, "MEMORY.md"); got != "index\n" {
if got, _ := shanks.read(t, project, "MEMORY.md"); got != "index\n" {
t.Fatal("a deleted key removed the file")
}
// The state lost: the file is carried again, not removed.
nodeD.look(t)
shanks.look(t)
state.pump(t)
if _, _, ok, _ := state.Get(key); !ok {
t.Fatal("a file whose key the state lost was not carried again")
}
if got, _ := nodeD.read(t, project, "MEMORY.md"); got != "index\n" {
t.Fatalf("the file was touched on node-d: %q", got)
if got, _ := shanks.read(t, project, "MEMORY.md"); got != "index\n" {
t.Fatalf("the file was touched on shanks: %q", got)
}
}
// §10: on a first carrying, two nodes' different versions are both kept, and each node's own files reach the other.
func TestTheFirstCarryingKeepsEveryVersion(t *testing.T) {
state, c, nodeB, nodeD := twoNodes(t)
nodeB.write(t, project, "MEMORY.md", "- laptop's index\n", c.now)
nodeB.write(t, project, "laptop.md", "only on the laptop\n", c.now)
nodeD.write(t, project, "MEMORY.md", "- workstation's index\n", c.now.Add(time.Hour))
nodeD.write(t, project, "workstation.md", "only on the workstation\n", c.now)
nodeD.write(t, project, "same.md", "same\n", c.now)
nodeB.write(t, project, "same.md", "same\n", c.now)
nodeB.look(t)
state, c, g14, shanks := twoNodes(t)
g14.write(t, project, "MEMORY.md", "- laptop's index\n", c.now)
g14.write(t, project, "laptop.md", "only on the laptop\n", c.now)
shanks.write(t, project, "MEMORY.md", "- workstation's index\n", c.now.Add(time.Hour))
shanks.write(t, project, "workstation.md", "only on the workstation\n", c.now)
shanks.write(t, project, "same.md", "same\n", c.now)
g14.write(t, project, "same.md", "same\n", c.now)
g14.look(t)
state.pump(t)
nodeD.look(t)
shanks.look(t)
state.pump(t)
nodeB.look(t)
g14.look(t)
state.pump(t)
for _, n := range []*memNode{nodeB, nodeD} {
for _, n := range []*memNode{g14, shanks} {
index, _ := n.read(t, project, "MEMORY.md")
if !strings.Contains(index, "laptop's index") || !strings.Contains(index, "workstation's index") ||
strings.Index(index, "workstation's index") > strings.Index(index, "laptop's index") {
@@ -559,23 +559,23 @@ func TestTheFirstCarryingKeepsEveryVersion(t *testing.T) {
}
}
}
if n := len(nodeB.notices) + len(nodeD.notices); n != 1 {
if n := len(g14.notices) + len(shanks.notices); n != 1 {
t.Fatalf("%d warnings for one conflict", n)
}
}
// Nothing is written through a symbolic link: a memory folder that is one is neither read nor written.
func TestNothingIsWrittenThroughASymbolicLink(t *testing.T) {
state, c, nodeB, nodeD := twoNodes(t)
state, c, g14, shanks := twoNodes(t)
elsewhere := t.TempDir()
if err := os.MkdirAll(filepath.Join(nodeD.Home, ".claude", "projects", project), 0o700); err != nil {
if err := os.MkdirAll(filepath.Join(shanks.Home, ".claude", "projects", project), 0o700); err != nil {
t.Fatal(err)
}
if err := os.Symlink(elsewhere, filepath.Join(nodeD.Home, ".claude", "projects", project, "memory")); err != nil {
if err := os.Symlink(elsewhere, filepath.Join(shanks.Home, ".claude", "projects", project, "memory")); err != nil {
t.Fatal(err)
}
nodeB.write(t, project, "MEMORY.md", "index\n", c.now)
nodeB.look(t)
g14.write(t, project, "MEMORY.md", "index\n", c.now)
g14.look(t)
state.pump(t)
if entries, _ := os.ReadDir(elsewhere); len(entries) != 0 {
t.Fatalf("written through a symbolic link: %v", entries)
@@ -619,24 +619,24 @@ func (s *memState) replay(t *testing.T, n *memNode) {
// §6: a node that was away while a file changed twice takes the newest version, whose base it never saw, because
// its own file holds no change it did not carry; the version replaced is kept.
func TestANodeThatWasAwayTakesTheNewestVersion(t *testing.T) {
state, c, nodeB, nodeD := twoNodes(t)
carriedOnBoth(t, state, c, nodeB, nodeD, "MEMORY.md", "v1\n")
state.watchers = []*MemoryCarrier{nodeB.MemoryCarrier}
state, c, g14, shanks := twoNodes(t)
carriedOnBoth(t, state, c, g14, shanks, "MEMORY.md", "v1\n")
state.watchers = []*MemoryCarrier{g14.MemoryCarrier}
for _, v := range []string{"v2\n", "v3\n"} {
c.now = c.now.Add(time.Minute)
nodeB.write(t, project, "MEMORY.md", v, c.now)
nodeB.look(t)
g14.write(t, project, "MEMORY.md", v, c.now)
g14.look(t)
state.pump(t)
}
state.watchers = append(state.watchers, nodeD.MemoryCarrier)
state.replay(t, nodeD)
if got, _ := nodeD.read(t, project, "MEMORY.md"); got != "v3\n" {
state.watchers = append(state.watchers, shanks.MemoryCarrier)
state.replay(t, shanks)
if got, _ := shanks.read(t, project, "MEMORY.md"); got != "v3\n" {
t.Fatalf("the node that was away holds %q", got)
}
if !holdsText(nodeD.copies(t), "v1\n") {
if !holdsText(shanks.copies(t), "v1\n") {
t.Fatal("the version replaced was not kept")
}
if len(nodeB.notices)+len(nodeD.notices) != 0 {
if len(g14.notices)+len(shanks.notices) != 0 {
t.Fatal("taking a newer version warned")
}
}
@@ -644,24 +644,24 @@ func TestANodeThatWasAwayTakesTheNewestVersion(t *testing.T) {
// §2, §7: two versions too large to share one file are kept in two: the newer in the file, the older beside it,
// both carried; and a text that would not fit in one value of the state is not carried.
func TestTwoVersionsTooLargeForOneFileAreKeptInTwo(t *testing.T) {
state, c, nodeB, nodeD := twoNodes(t)
carriedOnBoth(t, state, c, nodeB, nodeD, "big.md", "base\n")
older := strings.Repeat("from node-b ", MemoryMostBytes/12-100) + "\n"
newer := strings.Repeat("from node-d ", MemoryMostBytes/12-100) + "\n"
nodeB.write(t, project, "big.md", older, c.now.Add(time.Minute))
nodeD.write(t, project, "big.md", newer, c.now.Add(2*time.Minute))
state, c, g14, shanks := twoNodes(t)
carriedOnBoth(t, state, c, g14, shanks, "big.md", "base\n")
older := strings.Repeat("from g14 ", MemoryMostBytes/9-100) + "\n"
newer := strings.Repeat("from shanks ", MemoryMostBytes/12-100) + "\n"
g14.write(t, project, "big.md", older, c.now.Add(time.Minute))
shanks.write(t, project, "big.md", newer, c.now.Add(2*time.Minute))
c.now = c.now.Add(3 * time.Minute)
nodeB.look(t)
nodeD.look(t)
g14.look(t)
shanks.look(t)
state.pump(t)
nodeB.look(t)
nodeD.look(t)
g14.look(t)
shanks.look(t)
state.pump(t)
beside := OlderBeside("big.md", "node-b")
if beside != "big.older-from-node-b.md" {
beside := OlderBeside("big.md", "g14")
if beside != "big.older-from-g14.md" {
t.Fatalf("the older is named %q", beside)
}
for _, n := range []*memNode{nodeB, nodeD} {
for _, n := range []*memNode{g14, shanks} {
if got, _ := n.read(t, project, "big.md"); got != newer {
t.Fatalf("%s's file does not hold the newer version", n.Node)
}
@@ -669,18 +669,18 @@ func TestTwoVersionsTooLargeForOneFileAreKeptInTwo(t *testing.T) {
t.Fatalf("%s does not hold the older version beside it", n.Node)
}
}
notices := append(append([]map[string]any{}, nodeB.notices...), nodeD.notices...)
notices := append(append([]map[string]any{}, g14.notices...), shanks.notices...)
if len(notices) != 1 || !strings.Contains(notices[0]["explanation"].(string), "second file beside it") {
t.Fatalf("not one warning saying the older is beside it: %v", notices)
}
// Under the size limit, but too large once encoded: not carried, and said.
nodeB.write(t, project, "angles.md", strings.Repeat("<", MemoryMostBytes-10), c.now)
nodeB.look(t)
g14.write(t, project, "angles.md", strings.Repeat("<", MemoryMostBytes-10), c.now)
g14.look(t)
state.pump(t)
if _, ok := nodeD.read(t, project, "angles.md"); ok {
if _, ok := shanks.read(t, project, "angles.md"); ok {
t.Fatal("a text too large for one value was carried")
}
not, _ := nodeB.Status()["not carried"].(map[string]string)
not, _ := g14.Status()["not carried"].(map[string]string)
if not[project+"/angles.md"] == "" {
t.Fatalf("it is not listed as not carried: %v", not)
}
@@ -713,8 +713,8 @@ func (s *memState) putRaw(t *testing.T, v MemoryVersion) string {
// Review of #276, 1: a value whose project or path leads out of a memory folder is never taken, and nothing is
// written for it.
func TestAValueLeadingOutOfAMemoryFolderIsNeverTaken(t *testing.T) {
state, c, nodeB, nodeD := twoNodes(t)
root := filepath.Dir(filepath.Dir(nodeD.Home))
state, c, g14, shanks := twoNodes(t)
root := filepath.Dir(filepath.Dir(shanks.Home))
for _, bad := range []struct{ project, path string }{
{"../../../escaped", "x.md"}, {"..", "x.md"}, {"a/b", "x.md"}, {"", "x.md"},
{project, "../x.md"}, {project, "/abs.md"}, {project, "a//x.md"}, {project, "./x.md"}, {project, "a/../../x.md"},
@@ -727,8 +727,8 @@ func TestAValueLeadingOutOfAMemoryFolderIsNeverTaken(t *testing.T) {
}
}
state.pump(t)
nodeB.look(t)
nodeD.look(t)
g14.look(t)
shanks.look(t)
_ = filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error {
if err == nil && !d.IsDir() {
if raw, _ := os.ReadFile(p); string(raw) == "planted\n" {
@@ -748,21 +748,21 @@ func TestWhatCannotBeReadIsNeverTakenForGone(t *testing.T) {
if os.Geteuid() == 0 {
t.Skip("root reads a file whatever its mode")
}
state, c, nodeB, nodeD := twoNodes(t)
carriedOnBoth(t, state, c, nodeB, nodeD, "MEMORY.md", "index\n")
carriedOnBoth(t, state, c, nodeB, nodeD, "notes/deep.md", "deep\n")
file, folder := nodeB.path(project, "MEMORY.md"), filepath.Dir(nodeB.path(project, "notes/deep.md"))
state, c, g14, shanks := twoNodes(t)
carriedOnBoth(t, state, c, g14, shanks, "MEMORY.md", "index\n")
carriedOnBoth(t, state, c, g14, shanks, "notes/deep.md", "deep\n")
file, folder := g14.path(project, "MEMORY.md"), filepath.Dir(g14.path(project, "notes/deep.md"))
_ = os.Chmod(file, 0)
_ = os.Chmod(folder, 0)
t.Cleanup(func() { _ = os.Chmod(file, 0o644); _ = os.Chmod(folder, 0o755) })
nodeB.look(t)
g14.look(t)
state.pump(t)
for _, f := range []string{"MEMORY.md", "notes/deep.md"} {
if _, ok := nodeD.read(t, project, f); !ok {
t.Errorf("%s, unreadable on node-b, was removed on node-d", f)
if _, ok := shanks.read(t, project, f); !ok {
t.Errorf("%s, unreadable on g14, was removed on shanks", f)
}
}
not, _ := nodeB.Status()["not carried"].(map[string]string)
not, _ := g14.Status()["not carried"].(map[string]string)
if len(not) == 0 {
t.Fatalf("nothing is said as not carried: %v", not)
}
@@ -782,33 +782,33 @@ func TestNoUnscreenedTextReachesTheState(t *testing.T) {
token := "ghp_" + strings.Repeat("A1b2C3d4", 5) + "\n"
big := strings.Repeat("x", MemoryMostBytes+10) + "\n"
for _, text := range []string{token, big} {
// The removal path: removed on node-b, changed on node-d to what is never carried.
state, c, nodeB, nodeD := twoNodes(t)
carriedOnBoth(t, state, c, nodeB, nodeD, "note.md", "v1\n")
_ = os.Remove(nodeB.path(project, "note.md"))
nodeD.write(t, project, "note.md", text, c.now.Add(time.Minute))
nodeB.look(t)
// The removal path: removed on g14, changed on shanks to what is never carried.
state, c, g14, shanks := twoNodes(t)
carriedOnBoth(t, state, c, g14, shanks, "note.md", "v1\n")
_ = os.Remove(g14.path(project, "note.md"))
shanks.write(t, project, "note.md", text, c.now.Add(time.Minute))
g14.look(t)
state.pump(t)
if state.holds(text[:20]) {
t.Errorf("the removal path put text that is never carried into the state")
}
if got, _ := nodeD.read(t, project, "note.md"); got != text {
t.Errorf("the change on node-d was not kept")
if got, _ := shanks.read(t, project, "note.md"); got != text {
t.Errorf("the change on shanks was not kept")
}
// The conflict path: changed on node-b, and on node-d to what is never carried.
state, c, nodeB, nodeD = twoNodes(t)
carriedOnBoth(t, state, c, nodeB, nodeD, "note.md", "v1\n")
nodeB.write(t, project, "note.md", "v2\n", c.now.Add(time.Minute))
nodeD.write(t, project, "note.md", text, c.now.Add(2*time.Minute))
nodeB.look(t)
// The conflict path: changed on g14, and on shanks to what is never carried.
state, c, g14, shanks = twoNodes(t)
carriedOnBoth(t, state, c, g14, shanks, "note.md", "v1\n")
g14.write(t, project, "note.md", "v2\n", c.now.Add(time.Minute))
shanks.write(t, project, "note.md", text, c.now.Add(2*time.Minute))
g14.look(t)
state.pump(t)
nodeD.look(t)
shanks.look(t)
state.pump(t)
if state.holds(text[:20]) {
t.Errorf("the conflict path put text that is never carried into the state")
}
if got, _ := nodeD.read(t, project, "note.md"); got != text {
t.Errorf("node-d's own version was not kept as it was")
if got, _ := shanks.read(t, project, "note.md"); got != text {
t.Errorf("shanks's own version was not kept as it was")
}
}
}
@@ -819,24 +819,24 @@ func TestAConflictThatCannotBeWrittenIsNotMergedAgainAndAgain(t *testing.T) {
if os.Geteuid() == 0 {
t.Skip("root writes a folder whatever its mode")
}
state, c, nodeB, nodeD := twoNodes(t)
carriedOnBoth(t, state, c, nodeB, nodeD, "MEMORY.md", "base\n")
nodeB.write(t, project, "MEMORY.md", "base\nfrom node-b\n", c.now.Add(time.Minute))
nodeD.write(t, project, "MEMORY.md", "base\nfrom node-d\n", c.now.Add(2*time.Minute))
folder := filepath.Dir(nodeD.path(project, "MEMORY.md"))
state, c, g14, shanks := twoNodes(t)
carriedOnBoth(t, state, c, g14, shanks, "MEMORY.md", "base\n")
g14.write(t, project, "MEMORY.md", "base\nfrom g14\n", c.now.Add(time.Minute))
shanks.write(t, project, "MEMORY.md", "base\nfrom shanks\n", c.now.Add(2*time.Minute))
folder := filepath.Dir(shanks.path(project, "MEMORY.md"))
_ = os.Chmod(folder, 0o500)
t.Cleanup(func() { _ = os.Chmod(folder, 0o700) })
nodeB.look(t)
g14.look(t)
state.pump(t)
for i := 0; i < 5; i++ {
_ = nodeD.Look()
_ = shanks.Look()
state.pump(t)
}
if state.holds("<!-- novox mesh") {
t.Fatal("a merge that could not be written here reached the state")
}
unwritten := 0
for _, n := range nodeD.notices {
for _, n := range shanks.notices {
if strings.HasSuffix(n["key"].(string), ".unwritten") {
unwritten++
}
@@ -845,13 +845,13 @@ func TestAConflictThatCannotBeWrittenIsNotMergedAgainAndAgain(t *testing.T) {
t.Fatalf("%d warnings that the file could not be written", unwritten)
}
_ = os.Chmod(folder, 0o700)
nodeD.look(t)
shanks.look(t)
state.pump(t)
nodeB.look(t)
g14.look(t)
state.pump(t)
for _, n := range []*memNode{nodeB, nodeD} {
for _, n := range []*memNode{g14, shanks} {
got, _ := n.read(t, project, "MEMORY.md")
if strings.Count(got, "<!-- novox mesh") != 1 || !strings.Contains(got, "from node-b") || !strings.Contains(got, "from node-d") {
if strings.Count(got, "<!-- novox mesh") != 1 || !strings.Contains(got, "from g14") || !strings.Contains(got, "from shanks") {
t.Fatalf("%s does not hold both versions once:\n%s", n.Node, got)
}
}
@@ -860,24 +860,24 @@ func TestAConflictThatCannotBeWrittenIsNotMergedAgainAndAgain(t *testing.T) {
// Review of #276, minor: a file that changed between its reading and its writing is not written over; text that is
// not UTF-8 is not carried.
func TestAFileChangedBeforeItsWritingIsNotWrittenOver(t *testing.T) {
state, c, nodeB, nodeD := twoNodes(t)
state, c, g14, shanks := twoNodes(t)
_ = state
nodeB.write(t, project, "a.md", "read\n", c.now)
nodeB.write(t, project, "a.md", "changed meanwhile\n", c.now)
err := nodeB.writeLocal(project, "a.md", []byte("incoming\n"), "replaced", digestOf([]byte("read\n")))
g14.write(t, project, "a.md", "read\n", c.now)
g14.write(t, project, "a.md", "changed meanwhile\n", c.now)
err := g14.writeLocal(project, "a.md", []byte("incoming\n"), "replaced", digestOf([]byte("read\n")))
if !errors.Is(err, errChangedHere) {
t.Fatalf("written over a change: %v", err)
}
if got, _ := nodeB.read(t, project, "a.md"); got != "changed meanwhile\n" {
if got, _ := g14.read(t, project, "a.md"); got != "changed meanwhile\n" {
t.Fatalf("the change was replaced: %q", got)
}
nodeB.write(t, project, "latin1.md", "caf\xe9\n", c.now)
nodeB.look(t)
g14.write(t, project, "latin1.md", "caf\xe9\n", c.now)
g14.look(t)
state.pump(t)
if _, ok := nodeD.read(t, project, "latin1.md"); ok {
if _, ok := shanks.read(t, project, "latin1.md"); ok {
t.Fatal("text that is not UTF-8 was carried")
}
not, _ := nodeB.Status()["not carried"].(map[string]string)
not, _ := g14.Status()["not carried"].(map[string]string)
if !strings.Contains(not[project+"/latin1.md"], "UTF-8") {
t.Fatalf("it is not said why: %v", not)
}
@@ -886,28 +886,28 @@ func TestAFileChangedBeforeItsWritingIsNotWrittenOver(t *testing.T) {
// §9: a node that was away takes a removal of a version newer than the one it holds, when it holds no change of
// its own: the removed file does not come back.
func TestARemovalReachesANodeThatWasAway(t *testing.T) {
state, c, nodeB, nodeD := twoNodes(t)
carriedOnBoth(t, state, c, nodeB, nodeD, "old.md", "v1\n")
state.watchers = []*MemoryCarrier{nodeB.MemoryCarrier}
state, c, g14, shanks := twoNodes(t)
carriedOnBoth(t, state, c, g14, shanks, "old.md", "v1\n")
state.watchers = []*MemoryCarrier{g14.MemoryCarrier}
c.now = c.now.Add(time.Minute)
nodeB.write(t, project, "old.md", "v2\n", c.now)
nodeB.look(t)
g14.write(t, project, "old.md", "v2\n", c.now)
g14.look(t)
state.pump(t)
_ = os.Remove(nodeB.path(project, "old.md"))
nodeB.look(t)
_ = os.Remove(g14.path(project, "old.md"))
g14.look(t)
state.pump(t)
state.watchers = append(state.watchers, nodeD.MemoryCarrier)
state.replay(t, nodeD)
nodeD.look(t)
state.watchers = append(state.watchers, shanks.MemoryCarrier)
state.replay(t, shanks)
shanks.look(t)
state.pump(t)
nodeB.look(t)
if _, ok := nodeD.read(t, project, "old.md"); ok {
g14.look(t)
if _, ok := shanks.read(t, project, "old.md"); ok {
t.Fatal("the removal did not reach the node that was away")
}
if _, ok := nodeB.read(t, project, "old.md"); ok {
if _, ok := g14.read(t, project, "old.md"); ok {
t.Fatal("the removed file came back")
}
if !holdsText(nodeD.copies(t), "v1\n") {
if !holdsText(shanks.copies(t), "v1\n") {
t.Fatal("the version removed on the node that was away was not kept")
}
}
@@ -924,25 +924,25 @@ func (s slowState) Get(key string) (json.RawMessage, uint64, bool, error) {
}
// Issue 367, seen live: the runtime hands a watch its current values one at a time and gives them all ten seconds;
// node-d's handler read the state and waited for its look for each of a few hundred values, so the watch never
// started and nothing written on node-b reached it. The handler now only notes a change: it returns at once while a
// shanks's handler read the state and waited for its look for each of a few hundred values, so the watch never
// started and nothing written on g14 reached it. The handler now only notes a change: it returns at once while a
// look holds the carrier and while the state is slow to answer, and Drain applies what it noted.
func TestTheWatchHandlerReturnsAtOnceAndDrainAppliesWhatItNoted(t *testing.T) {
state, c, nodeB, nodeD := twoNodes(t)
nodeB.write(t, project, "MEMORY.md", "index\n", c.now)
nodeB.look(t)
state, c, g14, shanks := twoNodes(t)
g14.write(t, project, "MEMORY.md", "index\n", c.now)
g14.look(t)
state.mu.Lock()
state.pending = nil
state.mu.Unlock()
slow := slowState{memState: state, gate: make(chan struct{})}
nodeD.Store = slow
shanks.Store = slow
key := MemoryKey(HomeOperator, project, "MEMORY.md")
raw, rev, _, _ := state.Get(key)
nodeD.mu.Lock() // a look running
shanks.mu.Lock() // a look running
done := make(chan error, 1)
start := time.Now()
for i := 0; i < 400; i++ {
go func() { done <- nodeD.Take(key, "put", raw, rev) }()
go func() { done <- shanks.Take(key, "put", raw, rev) }()
}
for i := 0; i < 400; i++ {
select {
@@ -957,12 +957,12 @@ func TestTheWatchHandlerReturnsAtOnceAndDrainAppliesWhatItNoted(t *testing.T) {
if took := time.Since(start); took > time.Second {
t.Fatalf("400 values took %s to hand over", took)
}
nodeD.mu.Unlock()
shanks.mu.Unlock()
close(slow.gate)
if err := nodeD.Drain(); err != nil {
if err := shanks.Drain(); err != nil {
t.Fatal(err)
}
if got, _ := nodeD.read(t, project, "MEMORY.md"); got != "index\n" {
if got, _ := shanks.read(t, project, "MEMORY.md"); got != "index\n" {
t.Fatalf("what the watch noted was not applied: %q", got)
}
}
@@ -117,7 +117,7 @@ func proposalTools(p Paths) []stdio.Tool {
Input: map[string]any{
"scope": str("mesh (every node) or node (the nodes given, or this one)"),
"nodes": str("for scope node: a comma-separated list of nodes; absent is this node"),
"name": str("the section's name: lower-case letters, digits and -, e.g. handoff-from-node-b"),
"name": str("the section's name: lower-case letters, digits and -, e.g. handoff-from-g14"),
"text": str("the section's text, markdown, as every session will read it"),
"remove": map[string]any{"type": "boolean", "description": "propose removing the approved section of this name and scope instead"},
},
@@ -173,7 +173,7 @@ func questionTools(p Paths, notLaunched error) []stdio.Tool {
}, "required": []string{"label", "means"}}},
"why": str("why the recommended option is the one you recommend, at most 120 bytes"),
"on_expiry": str("what you do if nobody answers before it expires, e.g. 'I leave the pull request open'"),
"by": str("required: your session's name, as who@node or who, e.g. fix-589@node-b"),
"by": str("required: your session's name, as who@node or who, e.g. fix-589@g14"),
"expires_minutes": map[string]any{"type": "integer",
"description": "minutes until it expires, 5 to 1430; 60 when absent"},
},
@@ -55,7 +55,7 @@ type router struct {
func newRouter() *router {
return &router{published: map[string]asks.Ask{}, records: map[string]json.RawMessage{},
kinds: []stdio.KindHeld{{Seat: "channel", Kind: "telegram", Module: "telegram", Node: "node-c",
kinds: []stdio.KindHeld{{Seat: "channel", Kind: "telegram", Module: "telegram", Node: "novox",
Capabilities: []string{"choice", "exact-render", "verified-sender"}}}}
}
@@ -106,7 +106,7 @@ const handover = "Hand-over from the laptop.\n\nThe controller's merge of the as
func proposeHandover(t *testing.T, p Paths, store *memProposals, r *router) string {
t.Helper()
out, err := Propose(p, ProposeInput{Scope: ScopeMesh, Name: "handoff-from-node-b", Text: handover}, store, r, t0)
out, err := Propose(p, ProposeInput{Scope: ScopeMesh, Name: "handoff-from-g14", Text: handover}, store, r, t0)
if err != nil {
t.Fatal(err)
}
@@ -136,7 +136,7 @@ func renderedInstructions(t *testing.T, p Paths) string {
}
func TestAProposalAsksTheOperatorAtTheLevelApproveAndChangesNothing(t *testing.T) {
p, _ := node(t, "node-b")
p, _ := node(t, "g14")
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
id := proposeHandover(t, p, store, r)
@@ -153,10 +153,10 @@ func TestAProposalAsksTheOperatorAtTheLevelApproveAndChangesNothing(t *testing.T
asks.Glance(a.Explanation) != nil {
t.Errorf("the phone does not say what is asked at a glance: %q", a.Explanation)
}
if a.Headline != "Add Claude instructions 'handoff-from-node-b'?" {
if a.Headline != "Add Claude instructions 'handoff-from-g14'?" {
t.Errorf("headline: %q", a.Headline)
}
if !strings.HasPrefix(a.Details, "The text:\n"+strings.TrimRight(handover, "\n")+"\nProposed through the claude-code module on node-b at ") {
if !strings.HasPrefix(a.Details, "The text:\n"+strings.TrimRight(handover, "\n")+"\nProposed through the claude-code module on g14 at ") {
t.Errorf("a short text is not shown whole: %q", a.Details)
}
if len(a.Options) != 2 {
@@ -180,26 +180,26 @@ func TestAProposalAsksTheOperatorAtTheLevelApproveAndChangesNothing(t *testing.T
}
func TestApprovedTextReachesEveryNodeOnlyOnAWarrantEachNodeProves(t *testing.T) {
nodeB, _ := node(t, "node-b")
nodeD, _ := node(t, "node-d")
g14, _ := node(t, "g14")
shanks, _ := node(t, "shanks")
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
id := proposeHandover(t, nodeB, store, r)
id := proposeHandover(t, g14, store, r)
r.close(r.chose(id, OptionApprove, t0.Add(time.Hour)))
rec := &recorded{}
changed, said, err := Reconcile(nodeD, store, r, rec.record, t0.Add(time.Hour+time.Minute))
changed, said, err := Reconcile(shanks, store, r, rec.record, t0.Add(time.Hour+time.Minute))
if err != nil || !changed {
t.Fatalf("node-d did not take the approved text: %v %v", err, said)
t.Fatalf("shanks did not take the approved text: %v %v", err, said)
}
got := renderedInstructions(t, nodeD)
if !strings.Contains(got, "## Instructions the operator approved") || !strings.Contains(got, "### handoff-from-node-b") ||
got := renderedInstructions(t, shanks)
if !strings.Contains(got, "## Instructions the operator approved") || !strings.Contains(got, "### handoff-from-g14") ||
!strings.Contains(got, "Do not merge it before the gate is green.") {
t.Fatalf("a new session on node-d does not read the text:\n%s", got)
t.Fatalf("a new session on shanks does not read the text:\n%s", got)
}
if len(rec.asks) != 1 || rec.asks[0] != id {
t.Errorf("the warrant was not handed to the hand-act log: %v", rec.asks)
}
// Heard again, or replayed: decided once, recorded once.
if changed, _, _ := Reconcile(nodeD, store, r, rec.record, t0.Add(2*time.Hour)); changed || len(rec.asks) != 1 {
if changed, _, _ := Reconcile(shanks, store, r, rec.record, t0.Add(2*time.Hour)); changed || len(rec.asks) != 1 {
t.Errorf("a warrant heard twice acted twice (changed %v, recorded %v)", changed, rec.asks)
}
}
@@ -259,7 +259,7 @@ func TestNothingBecomesActiveWithoutAValidWarrant(t *testing.T) {
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
p, _ := node(t, "node-d")
p, _ := node(t, "shanks")
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
id := proposeHandover(t, p, store, r)
c.tamper(r, store, id)
@@ -278,7 +278,7 @@ func TestNothingBecomesActiveWithoutAValidWarrant(t *testing.T) {
}
func TestARemovalNeedsTheSameApprovalAndARecordDeletedRemovesNothing(t *testing.T) {
p, _ := node(t, "node-d")
p, _ := node(t, "shanks")
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
id := proposeHandover(t, p, store, r)
r.close(r.chose(id, OptionApprove, t0.Add(time.Hour)))
@@ -289,31 +289,31 @@ func TestARemovalNeedsTheSameApprovalAndARecordDeletedRemovesNothing(t *testing.
if _, _, err := Reconcile(p, store, r, nil, t0.Add(2*time.Hour)); err != nil {
t.Fatal(err)
}
if !strings.Contains(renderedInstructions(t, p), "### handoff-from-node-b") {
if !strings.Contains(renderedInstructions(t, p), "### handoff-from-g14") {
t.Fatal("deleting the bus record removed an approved section")
}
out, err := Propose(p, ProposeInput{Scope: ScopeMesh, Name: "handoff-from-node-b", Remove: true}, store, r, t0.Add(3*time.Hour))
out, err := Propose(p, ProposeInput{Scope: ScopeMesh, Name: "handoff-from-g14", Remove: true}, store, r, t0.Add(3*time.Hour))
if err != nil {
t.Fatal(err)
}
rid := out["proposed"].(string)
if r.published[rid].Explanation != "Every Claude session on the mesh stops reading the instructions 'handoff-from-node-b' from its next start." {
if r.published[rid].Explanation != "Every Claude session on the mesh stops reading the instructions 'handoff-from-g14' from its next start." {
t.Errorf("removal ask: %q", r.published[rid].Explanation)
}
if !strings.Contains(renderedInstructions(t, p), "### handoff-from-node-b") {
if !strings.Contains(renderedInstructions(t, p), "### handoff-from-g14") {
t.Fatal("proposing a removal removed it")
}
r.close(r.chose(rid, OptionApprove, t0.Add(4*time.Hour)))
if changed, _, _ := Reconcile(p, store, r, nil, t0.Add(4*time.Hour)); !changed {
t.Fatal("the approved removal changed nothing")
}
if strings.Contains(renderedInstructions(t, p), "handoff-from-node-b") {
if strings.Contains(renderedInstructions(t, p), "handoff-from-g14") {
t.Fatal("the approved removal left the section")
}
// The first approval, replayed into the bus again, brings nothing back: it was decided here.
r2 := r.records[id]
_ = store.Put(id, Proposal{ID: id, Action: ActionAdd, Scope: ScopeMesh, Name: "handoff-from-node-b", Text: handover,
Digest: textDigest(handover), From: "node-b", At: t0})
_ = store.Put(id, Proposal{ID: id, Action: ActionAdd, Scope: ScopeMesh, Name: "handoff-from-g14", Text: handover,
Digest: textDigest(handover), From: "g14", At: t0})
r.records[id] = r2
if changed, _, _ := Reconcile(p, store, r, nil, t0.Add(5*time.Hour)); changed {
t.Fatal("a replayed approval brought a removed section back")
@@ -321,22 +321,22 @@ func TestARemovalNeedsTheSameApprovalAndARecordDeletedRemovesNothing(t *testing.
}
func TestANodeScopedSectionReachesOnlyItsNodes(t *testing.T) {
nodeA, _ := node(t, "node-a")
nodeD, _ := node(t, "node-d")
ace, _ := node(t, "ace")
shanks, _ := node(t, "shanks")
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
out, err := Propose(nodeA, ProposeInput{Scope: ScopeNode, Nodes: []string{"node-d"}, Name: "builds", Text: "Builds run here.\n"}, store, r, t0)
out, err := Propose(ace, ProposeInput{Scope: ScopeNode, Nodes: []string{"shanks"}, Name: "builds", Text: "Builds run here.\n"}, store, r, t0)
if err != nil {
t.Fatal(err)
}
id := out["proposed"].(string)
if !strings.HasPrefix(r.published[id].Explanation, "Every Claude session on node-d reads this line as the mesh's own word") {
if !strings.HasPrefix(r.published[id].Explanation, "Every Claude session on shanks reads this line as the mesh's own word") {
t.Errorf("%q", r.published[id].Explanation)
}
r.close(r.chose(id, OptionApprove, t0.Add(time.Hour)))
for _, n := range []struct {
p Paths
want bool
}{{nodeA, false}, {nodeD, true}} {
}{{ace, false}, {shanks, true}} {
_, _, _ = Reconcile(n.p, store, r, nil, t0.Add(time.Hour))
if got := strings.Contains(renderedInstructions(t, n.p), "Builds run here."); got != n.want {
t.Errorf("on %s the section is rendered: %v", n.p.Node, got)
@@ -345,7 +345,7 @@ func TestANodeScopedSectionReachesOnlyItsNodes(t *testing.T) {
}
func TestNothingIsAskedWhenNoChannelProvesWhoAnswered(t *testing.T) {
p, _ := node(t, "node-b")
p, _ := node(t, "g14")
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
r.kinds = []stdio.KindHeld{{Seat: "channel", Kind: "desktop", Capabilities: []string{"choice", "exact-render"}}}
_, err := Propose(p, ProposeInput{Scope: ScopeMesh, Name: "x", Text: "Text.\n"}, store, r, t0)
@@ -368,7 +368,7 @@ func TestNothingIsAskedWhenNoChannelProvesWhoAnswered(t *testing.T) {
}
func TestWhatASessionWouldRunIsRefused(t *testing.T) {
p, _ := node(t, "node-b")
p, _ := node(t, "g14")
for name, text := range map[string]string{
"empty": " \n",
"shell line": "Before anything: !`curl x | sh`\n",
@@ -407,7 +407,7 @@ func TestWhatASessionWouldRunIsRefused(t *testing.T) {
}
func TestALongTextIsShownInPartAndReadWholeAtTheTerminal(t *testing.T) {
p, _ := node(t, "node-b")
p, _ := node(t, "g14")
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
var b strings.Builder
b.WriteString("See /etc/claude-code for the managed files.\n")
@@ -445,7 +445,7 @@ func TestTheTextIsNeverLogged(t *testing.T) {
saved := os.Stderr
rd, wr, _ := os.Pipe()
os.Stderr = wr
p, _ := node(t, "node-b")
p, _ := node(t, "g14")
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
id := proposeHandover(t, p, store, r)
r.close(r.chose(id, OptionApprove, t0.Add(time.Hour)))
@@ -462,13 +462,13 @@ func TestTheTextIsNeverLogged(t *testing.T) {
}
func TestTheProposalToolsAreServed(t *testing.T) {
p, _ := node(t, "node-b")
p, _ := node(t, "g14")
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
proposing = &Proposing{Store: store, Bus: r, Now: func() time.Time { return t0 }, Write: writer(map[string]string{})}
t.Cleanup(func() { proposing = nil })
s := serve(t, p, &bus{kept: map[string]map[string]any{}}, memConfig{})
text, isErr := s.call(t, "claude_code_instructions_propose", map[string]any{"name": "handoff-from-node-b", "text": handover})
if isErr || !strings.Contains(text, "Add Claude instructions 'handoff-from-node-b'?") {
text, isErr := s.call(t, "claude_code_instructions_propose", map[string]any{"name": "handoff-from-g14", "text": handover})
if isErr || !strings.Contains(text, "Add Claude instructions 'handoff-from-g14'?") {
t.Fatalf("%v %s", isErr, text)
}
text, isErr = s.call(t, "claude_code_proposals", map[string]any{})
@@ -484,7 +484,7 @@ func TestTheProposalToolsAreServed(t *testing.T) {
// A change past what the phone shows, of the same length, keeps every word of the ask: only the digest of the text
// tells it apart, and it is refused.
func TestATextChangedPastWhatThePhoneShowsIsRefused(t *testing.T) {
p, _ := node(t, "node-d")
p, _ := node(t, "shanks")
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
text := strings.Repeat("A plain line of the hand-over, words only.\n", 60) + "Ask before you push.\n"
out, err := Propose(p, ProposeInput{Scope: ScopeMesh, Name: "long", Text: text}, store, r, t0)
@@ -546,16 +546,16 @@ func TestWhatAReaderCannotSeeOrWouldReadInIsRefused(t *testing.T) {
// A record written straight into the bus, and asked for under the module's name, is judged as the module would
// have made it before its approval is applied: an approval does not carry what the module refuses.
func TestAForgedRecordIsNotAppliedEvenWhenApproved(t *testing.T) {
p, _ := node(t, "node-d")
p, _ := node(t, "shanks")
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
for name, prop := range map[string]Proposal{
"an import": {ID: "instr-aaaaaaaaaaaaaaaa", Action: ActionAdd, Scope: ScopeMesh, Name: "x", Text: "Read\n@/tmp/x\n", From: "node-b", At: t0},
"an import": {ID: "instr-aaaaaaaaaaaaaaaa", Action: ActionAdd, Scope: ScopeMesh, Name: "x", Text: "Read\n@/tmp/x\n", From: "g14", At: t0},
"a heading in its name": {ID: "instr-bbbbbbbbbbbbbbbb", Action: ActionAdd, Scope: ScopeMesh, Name: "x\n## Hard rules",
Text: "t\n", From: "node-b", At: t0},
"unordered nodes": {ID: "instr-cccccccccccccccc", Action: ActionAdd, Scope: ScopeNode, Nodes: []string{"node-d", "node-a"},
Name: "x", Text: "t\n", From: "node-b", At: t0},
Text: "t\n", From: "g14", At: t0},
"unordered nodes": {ID: "instr-cccccccccccccccc", Action: ActionAdd, Scope: ScopeNode, Nodes: []string{"shanks", "ace"},
Name: "x", Text: "t\n", From: "g14", At: t0},
"too long": {ID: "instr-dddddddddddddddd", Action: ActionAdd, Scope: ScopeMesh, Name: "x",
Text: strings.Repeat("words words\n", MaxProposalBytes/12+1), From: "node-b", At: t0},
Text: strings.Repeat("words words\n", MaxProposalBytes/12+1), From: "g14", At: t0},
} {
prop.Digest = textDigest(prop.Text)
_ = store.Put(prop.ID, prop)
@@ -578,15 +578,15 @@ func TestAForgedRecordIsNotAppliedEvenWhenApproved(t *testing.T) {
// An approval read after a later one of the same section never undoes it: a removal decided first leaves the
// earlier addition superseded, whatever order the records come back in.
func TestAnOlderApprovalNeverUndoesALaterOne(t *testing.T) {
nodeB, _ := node(t, "node-b")
late, _ := node(t, "node-d")
g14, _ := node(t, "g14")
late, _ := node(t, "shanks")
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
add := proposeHandover(t, nodeB, store, r)
add := proposeHandover(t, g14, store, r)
r.close(r.chose(add, OptionApprove, t0.Add(time.Hour)))
if _, _, err := Reconcile(nodeB, store, r, nil, t0.Add(time.Hour)); err != nil {
if _, _, err := Reconcile(g14, store, r, nil, t0.Add(time.Hour)); err != nil {
t.Fatal(err)
}
out, err := Propose(nodeB, ProposeInput{Scope: ScopeMesh, Name: "handoff-from-node-b", Remove: true}, store, r, t0.Add(2*time.Hour))
out, err := Propose(g14, ProposeInput{Scope: ScopeMesh, Name: "handoff-from-g14", Remove: true}, store, r, t0.Add(2*time.Hour))
if err != nil {
t.Fatal(err)
}
@@ -602,7 +602,7 @@ func TestAnOlderApprovalNeverUndoesALaterOne(t *testing.T) {
if changed, _, _ := Reconcile(late, store, r, nil, t0.Add(5*time.Hour)); changed {
t.Fatal("the earlier addition, read after the removal, was applied")
}
if strings.Contains(renderedInstructions(t, late), "handoff-from-node-b") {
if strings.Contains(renderedInstructions(t, late), "handoff-from-g14") {
t.Fatal("a removed section came back")
}
if got := outcomeHere(t, late, add); !strings.Contains(got, "superseded") {
@@ -611,7 +611,7 @@ func TestAnOlderApprovalNeverUndoesALaterOne(t *testing.T) {
}
func TestAnUnreadableRecordIsNeverTakenForAnEmptyOne(t *testing.T) {
p, _ := node(t, "node-d")
p, _ := node(t, "shanks")
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
id := proposeHandover(t, p, store, r)
r.close(r.chose(id, OptionApprove, t0.Add(time.Hour)))
@@ -630,27 +630,27 @@ func TestAnUnreadableRecordIsNeverTakenForAnEmptyOne(t *testing.T) {
// A section for some nodes is its own: a proposal of the same name for other nodes adds another, and replaces
// or removes nothing of the first.
func TestANodeSectionIsNotReplacedFromOtherNodes(t *testing.T) {
nodeA, _ := node(t, "node-a")
ace, _ := node(t, "ace")
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
out, _ := Propose(nodeA, ProposeInput{Scope: ScopeNode, Nodes: []string{"node-a"}, Name: "builds", Text: "On node-a.\n"}, store, r, t0)
out, _ := Propose(ace, ProposeInput{Scope: ScopeNode, Nodes: []string{"ace"}, Name: "builds", Text: "On ace.\n"}, store, r, t0)
r.close(r.chose(out["proposed"].(string), OptionApprove, t0.Add(time.Hour)))
_, _, _ = Reconcile(nodeA, store, r, nil, t0.Add(time.Hour))
out, err := Propose(nodeA, ProposeInput{Scope: ScopeNode, Nodes: []string{"node-d"}, Name: "builds", Text: "On node-d.\n"}, store, r, t0.Add(2*time.Hour))
_, _, _ = Reconcile(ace, store, r, nil, t0.Add(time.Hour))
out, err := Propose(ace, ProposeInput{Scope: ScopeNode, Nodes: []string{"shanks"}, Name: "builds", Text: "On shanks.\n"}, store, r, t0.Add(2*time.Hour))
if err != nil {
t.Fatal(err)
}
if out["action"] != ActionAdd {
t.Errorf("a section for other nodes was proposed as %v", out["action"])
}
if _, err := Propose(nodeA, ProposeInput{Scope: ScopeNode, Nodes: []string{"node-d"}, Name: "builds", Remove: true}, store, r, t0.Add(2*time.Hour)); err == nil {
t.Error("a removal for other nodes was taken for the section on node-a")
if _, err := Propose(ace, ProposeInput{Scope: ScopeNode, Nodes: []string{"shanks"}, Name: "builds", Remove: true}, store, r, t0.Add(2*time.Hour)); err == nil {
t.Error("a removal for other nodes was taken for the section on ace")
}
}
// A module that only asks is issued no channel kinds (they come with a kinded bench a module uses or watches): an
// empty list is no refusal, and the router, which judges, is asked (the live failure of 2026-10-10).
func TestAnAskerIssuedNoKindsLeavesTheJudgingToTheRouter(t *testing.T) {
p, _ := node(t, "node-c")
p, _ := node(t, "novox")
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
r.kinds = nil
out, err := Propose(p, ProposeInput{Scope: ScopeMesh, Name: "x", Text: "Text.\n"}, store, r, t0)
@@ -664,7 +664,7 @@ func TestAnAskerIssuedNoKindsLeavesTheJudgingToTheRouter(t *testing.T) {
// The router's refusal reaches the proposer at once, and discards the proposal on every node that hears it.
func TestTheRoutersRefusalIsSaidAndDiscards(t *testing.T) {
p, _ := node(t, "node-c")
p, _ := node(t, "novox")
store := &memProposals{m: map[string]json.RawMessage{}}
r := newRouter()
r.kinds = nil
@@ -687,7 +687,7 @@ func TestTheRoutersRefusalIsSaidAndDiscards(t *testing.T) {
// A proposal whose ask ended and that the router keeps no record of is discarded, never left waiting.
func TestAnAskTheRouterKeepsNoRecordOfIsDiscardedAfterItsTime(t *testing.T) {
p, _ := node(t, "node-c")
p, _ := node(t, "novox")
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
id := proposeHandover(t, p, store, r)
delete(r.records, id)
@@ -726,7 +726,7 @@ func (r *refusingRouter) Publish(subject string, body any, id string) error {
// A refusal heard, and then the same ask taken after all and approved: the approval is taken, not lost in silence.
func TestAnAskTakenAfterItsRefusalIsStillAnswered(t *testing.T) {
p, _ := node(t, "node-c")
p, _ := node(t, "novox")
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
id := proposeHandover(t, p, store, r)
if _, err := HeardEnd(p, store, asks.Warrant{Ask: id, Asker: Module, Outcome: asks.OutcomeRefused, Words: "no channel", At: t0}, t0); err != nil {
@@ -747,9 +747,9 @@ func TestAnAskTakenAfterItsRefusalIsStillAnswered(t *testing.T) {
// A proposer's summary is the explanation, held to a glance; a proposal made before issue 639 is composed as it was
// asked, so the warrant for it still holds after the change.
func TestAProposalIsAskedAtAGlanceAndAnOlderOneAsItWasAsked(t *testing.T) {
p, _ := node(t, "node-b")
p, _ := node(t, "g14")
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
out, err := Propose(p, ProposeInput{Scope: ScopeMesh, Name: "handoff-from-node-b", Text: handover,
out, err := Propose(p, ProposeInput{Scope: ScopeMesh, Name: "handoff-from-g14", Text: handover,
Summary: "Every session learns how hand-overs are written, so the next one reads them the same way."}, store, r, t0)
if err != nil {
t.Fatal(err)
@@ -351,7 +351,7 @@ func AskQuestion(p Paths, in QuestionInput, store QuestionStore, bus AskBus, now
by = who
}
if by == "" {
return refuse("it does not say who asks: give by, your session's name as who@node, such as fix-589@node-b")
return refuse("it does not say who asks: give by, your session's name as who@node, such as fix-589@g14")
}
life := in.Expires
if life == 0 {
@@ -26,7 +26,7 @@ func mergeTonight() QuestionInput {
},
Why: "it is reviewed and the checks are green",
OnExpiry: "I leave the pull request open",
By: "fix-589@node-b",
By: "fix-589@g14",
}
}
@@ -40,7 +40,7 @@ func askMergeTonight(t *testing.T, p Paths, store *memQuestions, r *router, at t
}
func TestAQuestionIsAskedUnderTheModulesNameAtTheLevelApprove(t *testing.T) {
p, _ := node(t, "node-b")
p, _ := node(t, "g14")
store, r := newMemQuestions(), newRouter()
out, err := AskQuestion(p, mergeTonight(), store, r, t0)
if err != nil {
@@ -72,7 +72,7 @@ func TestAQuestionIsAskedUnderTheModulesNameAtTheLevelApprove(t *testing.T) {
if !strings.HasSuffix(a.Explanation, "\n• Recommended: Merge now, because it is reviewed and the checks are green.") || asks.Glance(a.Explanation) != nil {
t.Errorf("the explanation does not read at a glance:\n%s", a.Explanation)
}
for _, want := range []string{"the agent session fix-589 on node-b", "performs nothing by itself"} {
for _, want := range []string{"the agent session fix-589 on g14", "performs nothing by itself"} {
if !strings.Contains(a.Details, want) || strings.Contains(a.Explanation, want) {
t.Errorf("the details do not say %q:\n%s", want, a.Details)
}
@@ -83,7 +83,7 @@ func TestAQuestionIsAskedUnderTheModulesNameAtTheLevelApprove(t *testing.T) {
}
func TestTheChosenAnswerIsReadBackByItsKey(t *testing.T) {
p, _ := node(t, "node-b")
p, _ := node(t, "g14")
store, r := newMemQuestions(), newRouter()
key := askMergeTonight(t, p, store, r, t0)
out, err := ReadAnswer(store, r, key, t0.Add(time.Minute))
@@ -105,7 +105,7 @@ func TestTheChosenAnswerIsReadBackByItsKey(t *testing.T) {
}
func TestAnAnswerThatDoesNotHoldToTheQuestionIsNotProven(t *testing.T) {
p, _ := node(t, "node-b")
p, _ := node(t, "g14")
store, r := newMemQuestions(), newRouter()
key := askMergeTonight(t, p, store, r, t0)
w := r.chose(key, "o1", t0.Add(time.Minute))
@@ -140,7 +140,7 @@ func TestAnAnswerThatDoesNotHoldToTheQuestionIsNotProven(t *testing.T) {
}
func TestAQuestionIsRefusedOutsideItsBounds(t *testing.T) {
p, _ := node(t, "node-b")
p, _ := node(t, "g14")
for name, change := range map[string]func(*QuestionInput){
"one option": func(in *QuestionInput) { in.Options = in.Options[:1] },
"five options": func(in *QuestionInput) { in.Options = append(in.Options, in.Options[1], in.Options[1], in.Options[1]) },
@@ -154,7 +154,7 @@ func TestAQuestionIsRefusedOutsideItsBounds(t *testing.T) {
"an option unsaid": func(in *QuestionInput) { in.Options[1].Means = "" },
"too short": func(in *QuestionInput) { in.Expires = time.Minute },
"too long": func(in *QuestionInput) { in.Expires = 2 * 24 * time.Hour },
"another node": func(in *QuestionInput) { in.By = "fix-589@node-d" },
"another node": func(in *QuestionInput) { in.By = "fix-589@shanks" },
"an invisible char": func(in *QuestionInput) { in.Context += "\u200b" },
"invisible label": func(in *QuestionInput) { in.Options[0].Label = "Merge\u200bnow" },
"invisible means": func(in *QuestionInput) { in.Options[1].Means += "\u202e" },
@@ -182,7 +182,7 @@ func TestAQuestionIsRefusedOutsideItsBounds(t *testing.T) {
}
func TestAtMostTwoQuestionsWaitSoAProposalFindsRoom(t *testing.T) {
p, _ := node(t, "node-b")
p, _ := node(t, "g14")
store, r := newMemQuestions(), newRouter()
first := askMergeTonight(t, p, store, r, t0)
askMergeTonight(t, p, store, r, t0)
@@ -219,7 +219,7 @@ func (r *questionRefusingRouter) Publish(subject string, body any, id string) er
}
func TestTheRoutersRefusalIsSaidAndKept(t *testing.T) {
p, _ := node(t, "node-b")
p, _ := node(t, "g14")
store := newMemQuestions()
refusing := &questionRefusingRouter{router: newRouter(), store: store}
within, every := RouterAnswersWithin, RouterAnswersEvery
@@ -244,7 +244,7 @@ func TestTheRoutersRefusalIsSaidAndKept(t *testing.T) {
}
func TestAnExpiredQuestionSaysWhatTheSessionDoes(t *testing.T) {
p, _ := node(t, "node-b")
p, _ := node(t, "g14")
store, r := newMemQuestions(), newRouter()
key := askMergeTonight(t, p, store, r, t0)
r.close(asks.Warrant{Ask: key, Outcome: asks.OutcomeExpired, At: t0.Add(QuestionDefault)})
@@ -255,7 +255,7 @@ func TestAnExpiredQuestionSaysWhatTheSessionDoes(t *testing.T) {
}
func TestTheQuestionToolsAreServed(t *testing.T) {
p, _ := node(t, "node-b")
p, _ := node(t, "g14")
r := newRouter()
questions := newMemQuestions()
proposing = &Proposing{Store: &memProposals{m: map[string]json.RawMessage{}}, Bus: r, Now: func() time.Time { return t0 },
@@ -268,7 +268,7 @@ func TestTheQuestionToolsAreServed(t *testing.T) {
map[string]any{"label": "Merge now", "means": "I merge it", "recommended": true},
map[string]any{"label": "Wait", "means": "I leave it open"},
},
"why": "it is reviewed", "on_expiry": "I leave it open", "by": "fix-589@node-b", "expires_minutes": 30,
"why": "it is reviewed", "on_expiry": "I leave it open", "by": "fix-589@g14", "expires_minutes": 30,
})
if isErr || !strings.Contains(text, `"key"`) {
t.Fatalf("%v %s", isErr, text)
@@ -292,7 +292,7 @@ func TestAReadWaitsNoLongerThanItsBound(t *testing.T) {
if AnswerWaitsAtMost != 60*time.Second {
t.Fatalf("a read may wait %s; ADR 0332 says 60 s", AnswerWaitsAtMost)
}
p, _ := node(t, "node-b")
p, _ := node(t, "g14")
store, r := newMemQuestions(), newRouter()
key := askMergeTonight(t, p, store, r, t0)
most, every := AnswerWaitsAtMost, AnswerLooksEvery
@@ -369,7 +369,7 @@ func (s *memQuestions) Put(key string, value any) error {
// The edges of ADR 0332 §2 that are allowed: a 60-character headline and 1430 minutes.
func TestTheEdgesOfAQuestionAreAsked(t *testing.T) {
p, _ := node(t, "node-b")
p, _ := node(t, "g14")
in := mergeTonight()
in.Headline = strings.Repeat("x", 59) + "?"
in.Expires = 1430 * time.Minute
@@ -380,7 +380,7 @@ func TestTheEdgesOfAQuestionAreAsked(t *testing.T) {
// A warrant naming another asker or another ask, or an option the question did not offer, is no answer to it.
func TestAWarrantForAnotherAskOrOptionIsNotProven(t *testing.T) {
p, _ := node(t, "node-b")
p, _ := node(t, "g14")
for name, change := range map[string]func(*asks.Warrant){
"another asker": func(w *asks.Warrant) { w.Asker = "mesh-controller" },
"another ask": func(w *asks.Warrant) { w.Ask = "q-0000000000000000" },
@@ -400,7 +400,7 @@ func TestAWarrantForAnotherAskOrOptionIsNotProven(t *testing.T) {
// Two nodes asking at the same moment take two slots at most: of three sessions racing, one is refused.
func TestTheCapHoldsAcrossNodesAskingAtOnce(t *testing.T) {
store, r := newMemQuestions(), &lockedRouter{router: newRouter()}
nodes := []string{"node-b", "node-d", "node-a"}
nodes := []string{"g14", "shanks", "ace"}
var wg sync.WaitGroup
errs := make([]error, len(nodes))
for i, name := range nodes {
@@ -430,7 +430,7 @@ func TestTheCapHoldsAcrossNodesAskingAtOnce(t *testing.T) {
// A slot that cannot be read refuses the question: the cap fails closed.
func TestTheCapFailsClosed(t *testing.T) {
p, _ := node(t, "node-b")
p, _ := node(t, "g14")
store, r := newMemQuestions(), newRouter()
store.failGet = "slot-"
if _, err := AskQuestion(p, mergeTonight(), store, r, t0); err == nil || !strings.Contains(err.Error(), "could not be counted") {
@@ -444,7 +444,7 @@ func TestTheCapFailsClosed(t *testing.T) {
// A question whose refusal was not heard here, and of which the router holds no record, keeps its slot and reads as
// waiting until it expires — the router may still take it from its queue — and only then frees its slot and ends.
func TestAQuestionTheRouterHasNotRecordedHoldsItsSlotUntilItExpires(t *testing.T) {
p, _ := node(t, "node-b")
p, _ := node(t, "g14")
store, r := newMemQuestions(), newRouter()
first := askMergeTonight(t, p, store, r, t0)
askMergeTonight(t, p, store, r, t0)
@@ -470,13 +470,13 @@ func TestAQuestionTheRouterHasNotRecordedHoldsItsSlotUntilItExpires(t *testing.T
// A slot is taken only at the revision read: when another node takes both slots between this node's read and its
// write, this node's writes are refused and the question is refused — a plain write would take a slot it lost.
func TestASlotIsTakenOnlyAtTheRevisionRead(t *testing.T) {
p, _ := node(t, "node-b")
p, _ := node(t, "g14")
store, r := newMemQuestions(), newRouter()
// Two questions that ended: their slots are free to take again.
for _, id := range []string{askMergeTonight(t, p, store, r, t0), askMergeTonight(t, p, store, r, t0)} {
r.close(asks.Warrant{Ask: id, Outcome: asks.OutcomeExpired, At: t0.Add(time.Minute)})
}
nodeD, _ := node(t, "node-d")
shanks, _ := node(t, "shanks")
other := newRouter()
store.afterGet = func(key string) {
if !strings.HasPrefix(key, "slot-") {
@@ -484,7 +484,7 @@ func TestASlotIsTakenOnlyAtTheRevisionRead(t *testing.T) {
}
// Another node asks between this node's read of the slot and its write: its question waits.
q := Question{ID: newQuestionID(), Headline: "Another", Context: "c", Why: "w", OnExpiry: "e", By: "other",
From: nodeD.Node, At: t0, Expires: t0.Add(time.Hour),
From: shanks.Node, At: t0, Expires: t0.Add(time.Hour),
Options: []QuestionOption{{ID: "o1", Label: "A", Means: "a", Recommended: true}, {ID: "o2", Label: "B", Means: "b"}}}
_ = store.Put(q.ID, q)
_ = other.Publish(asks.AskSubject(Module), q.Ask(), q.ID)
@@ -122,7 +122,7 @@ func TestSessionStartRejectsAnUnsafeSessionID(t *testing.T) {
func TestSessionStartListsEveryNameOnTheNodeWithOpenWaits(t *testing.T) {
waitsDir := t.TempDir()
lastCall := time.Date(2026, 10, 21, 9, 55, 0, 0, time.UTC)
idx := NodeIndex{Node: "node-b", At: lastCall, Names: []NodeIndexEntry{
idx := NodeIndex{Node: "g14", At: lastCall, Names: []NodeIndexEntry{
{Name: "fix-1629", Open: 2, CurrentSession: "sess-old", LastCall: lastCall},
}}
raw, err := indented(idx)
@@ -135,7 +135,7 @@ func TestSessionStartListsEveryNameOnTheNodeWithOpenWaits(t *testing.T) {
if !strings.Contains(msg, "fix-1629") || !strings.Contains(msg, "2 open wait") {
t.Fatalf("the name and its count are not listed:\n%s", msg)
}
want := `claude_code_wait {"op":"adopt","by":"fix-1629@node-b","session":"sess-brand-new"}`
want := `claude_code_wait {"op":"adopt","by":"fix-1629@g14","session":"sess-brand-new"}`
if !strings.Contains(msg, want) {
t.Fatalf("the exact adopt call is not given:\n%s\nwant it to contain:\n%s", msg, want)
}
@@ -77,13 +77,13 @@ func addApproveWait(t *testing.T, p Paths, store WaitStore, name, session string
// 1629/AC1: add answers an id; list answers the open waits with their age and whether each is past its bound.
func TestAddAnswersAnIDAndListShowsItsAgeAndPastBound(t *testing.T) {
p, _ := node(t, "node-b")
p, _ := node(t, "g14")
store := newMemWaits()
id := addApproveWait(t, p, store, "fix-1629", "sess-1", t0)
if !strings.HasPrefix(id, WaitPrefix) {
t.Fatalf("id %q does not start with %q", id, WaitPrefix)
}
out, err := ListWaits(p, "sess-1", "fix-1629@node-b", store, t0.Add(time.Minute))
out, err := ListWaits(p, "sess-1", "fix-1629@g14", store, t0.Add(time.Minute))
if err != nil {
t.Fatal(err)
}
@@ -91,7 +91,7 @@ func TestAddAnswersAnIDAndListShowsItsAgeAndPastBound(t *testing.T) {
if len(open) != 1 || open[0]["id"] != id || open[0]["past_bound"] != false || open[0]["age_seconds"] != 60 {
t.Fatalf("%+v", open)
}
out, err = ListWaits(p, "sess-1", "fix-1629@node-b", store, t0.Add(11*time.Minute))
out, err = ListWaits(p, "sess-1", "fix-1629@g14", store, t0.Add(11*time.Minute))
if err != nil {
t.Fatal(err)
}
@@ -104,9 +104,9 @@ func TestAddAnswersAnIDAndListShowsItsAgeAndPastBound(t *testing.T) {
// 1629/AC2: watched, done and said change the wait; said is refused for a wait not past its bound; done removes
// it from the open list.
func TestWatchedDoneAndSaidChangeTheWait(t *testing.T) {
p, _ := node(t, "node-b")
p, _ := node(t, "g14")
store := newMemWaits()
by := "fix-1629@node-b"
by := "fix-1629@g14"
id := addApproveWait(t, p, store, "fix-1629", "sess-2", t0)
if _, err := SaidWait(p, "sess-2", by, id, store, t0.Add(time.Minute)); err == nil || !strings.Contains(err.Error(), "not past its bound") {
@@ -143,9 +143,9 @@ func TestWatchedDoneAndSaidChangeTheWait(t *testing.T) {
// 1629/AC3: every change rewrites the session's waits file whole, atomically; it holds exactly the open waits
// and the write time.
func TestEveryChangeRewritesTheWaitsFileWhole(t *testing.T) {
p, _ := node(t, "node-b")
p, _ := node(t, "g14")
store := newMemWaits()
by := "fix-1629@node-b"
by := "fix-1629@g14"
id1 := addApproveWait(t, p, store, "fix-1629", "sess-3", t0)
f, err := readWaitsFile(waitsFilePath(p, "sess-3"))
if err != nil {
@@ -179,9 +179,9 @@ func TestEveryChangeRewritesTheWaitsFileWhole(t *testing.T) {
// 1629/AC4: the waits are read again after the module "restarts" (the state is kept) and for the same name
// after a /clear that keeps calling with the same session id; a different name lists none of them.
func TestWaitsSurviveARestartAndAreNeverSeenByAnotherName(t *testing.T) {
p, _ := node(t, "node-b")
p, _ := node(t, "g14")
store := newMemWaits()
by := "fix-1629@node-b"
by := "fix-1629@g14"
addApproveWait(t, p, store, "fix-1629", "sess-4", t0)
// A restart: a fresh Waiting over the same store reads the same waits.
@@ -199,7 +199,7 @@ func TestWaitsSurviveARestartAndAreNeverSeenByAnotherName(t *testing.T) {
}
// A different name, same node: none of them.
out, err = ListWaits(p, "sess-other", "fix-other@node-b", store, t0.Add(2*time.Minute))
out, err = ListWaits(p, "sess-other", "fix-other@g14", store, t0.Add(2*time.Minute))
if err != nil || len(out["open"].([]map[string]any)) != 0 {
t.Fatalf("another name saw them: %v %v", out, err)
}
@@ -208,7 +208,7 @@ func TestWaitsSurviveARestartAndAreNeverSeenByAnotherName(t *testing.T) {
// 1629/AC5: add refuses an empty what, a bound under 1 or over 1440 minutes, a read address that is not an
// address, and more than 50 open waits per name, naming the argument.
func TestAddRefusesOutsideItsBounds(t *testing.T) {
p, _ := node(t, "node-b")
p, _ := node(t, "g14")
base := func() WaitInput {
return WaitInput{Session: "sess-5", By: "fix-1629@" + p.Node, What: "something", Read: WaitRead{Address: "mesh-delivery.recheck"}, Bound: 10}
}
@@ -247,8 +247,8 @@ func TestAddRefusesOutsideItsBounds(t *testing.T) {
// Review round 1 item (b): list, watched, done and said refuse a session id that is not a plain one (the
// same waitSessionID add already holds to), naming "session" — not silently treating it as just-another-key.
func TestListWatchedDoneAndSaidRefuseABadSessionID(t *testing.T) {
p, _ := node(t, "node-b")
by := "fix-1629@node-b"
p, _ := node(t, "g14")
by := "fix-1629@g14"
for _, bad := range []string{"", "a/b", "../x"} {
store := newMemWaits()
if _, err := ListWaits(p, bad, by, store, t0); err == nil || !strings.Contains(err.Error(), "session") {
@@ -268,13 +268,13 @@ func TestListWatchedDoneAndSaidRefuseABadSessionID(t *testing.T) {
// 1629/AC6: the tool is served through the module's own dispatch.
func TestTheWaitToolIsServedThroughTheModulesDispatch(t *testing.T) {
p, _ := node(t, "node-b")
p, _ := node(t, "g14")
store := newMemWaits()
waiting = &Waiting{Store: store, Now: func() time.Time { return t0 }}
t.Cleanup(func() { waiting = nil })
s := serve(t, p, &bus{kept: map[string]map[string]any{}}, memConfig{})
text, isErr := s.call(t, "claude_code_wait", map[string]any{"op": "add", "session": "sess-6", "by": "fix-1629@node-b",
text, isErr := s.call(t, "claude_code_wait", map[string]any{"op": "add", "session": "sess-6", "by": "fix-1629@g14",
"what": "the operator's Approve", "read": map[string]any{"address": "mesh-delivery.recheck"}, "bound_minutes": 10})
if isErr {
t.Fatalf("add: %s", text)
@@ -288,12 +288,12 @@ func TestTheWaitToolIsServedThroughTheModulesDispatch(t *testing.T) {
t.Fatalf("no id answered: %s", text)
}
text, isErr = s.call(t, "claude_code_wait", map[string]any{"op": "list", "session": "sess-6", "by": "fix-1629@node-b"})
text, isErr = s.call(t, "claude_code_wait", map[string]any{"op": "list", "session": "sess-6", "by": "fix-1629@g14"})
if isErr || !strings.Contains(text, id) {
t.Fatalf("list: %v %s", isErr, text)
}
text, isErr = s.call(t, "claude_code_wait", map[string]any{"op": "done", "session": "sess-6", "by": "fix-1629@node-b",
text, isErr = s.call(t, "claude_code_wait", map[string]any{"op": "done", "session": "sess-6", "by": "fix-1629@g14",
"id": id, "outcome": "approved"})
if isErr {
t.Fatalf("done: %s", text)
@@ -305,10 +305,10 @@ func TestTheWaitToolIsServedThroughTheModulesDispatch(t *testing.T) {
// ADR 0490 §1: a name nothing has bound yet is claimed by the first session that calls for it (add), without
// a separate adopt first.
func TestAFirstAddBindsAnUnboundNameAtOnce(t *testing.T) {
p, _ := node(t, "node-b")
p, _ := node(t, "g14")
store := newMemWaits()
id := addApproveWait(t, p, store, "fix-1629", "sess-a", t0)
out, err := ListWaits(p, "sess-a", "fix-1629@node-b", store, t0)
out, err := ListWaits(p, "sess-a", "fix-1629@g14", store, t0)
if err != nil || len(out["open"].([]map[string]any)) != 1 || out["open"].([]map[string]any)[0]["id"] != id {
t.Fatalf("%v %v", out, err)
}
@@ -317,9 +317,9 @@ func TestAFirstAddBindsAnUnboundNameAtOnce(t *testing.T) {
// ADR 0490 §1/§3: once a name is bound to a session, a different, non-adopted session calling for the same
// name (any op) is refused, naming the session that holds it and when it last called.
func TestADifferentSessionIsRefusedWithoutAdopting(t *testing.T) {
p, _ := node(t, "node-b")
p, _ := node(t, "g14")
store := newMemWaits()
by := "fix-1629@node-b"
by := "fix-1629@g14"
addApproveWait(t, p, store, "fix-1629", "sess-old", t0)
if _, err := AddWait(p, WaitInput{Session: "sess-new", By: by, What: "another", Read: WaitRead{Address: "mesh-delivery.recheck"},
@@ -341,9 +341,9 @@ func TestADifferentSessionIsRefusedWithoutAdopting(t *testing.T) {
// ADR 0490 §2/§3: adopt right after a simulated /clear binds the name to the new session id at once; the
// mirrored file of the old session id is emptied and the new one holds the waits, with no delay.
func TestAdoptRightAfterAClearBindsAtOnce(t *testing.T) {
p, _ := node(t, "node-b")
p, _ := node(t, "g14")
store := newMemWaits()
by := "fix-1629@node-b"
by := "fix-1629@g14"
id := addApproveWait(t, p, store, "fix-1629", "sess-old", t0)
out, err := AdoptName(p, by, "sess-new", store, t0.Add(time.Minute))
@@ -376,9 +376,9 @@ func TestAdoptRightAfterAClearBindsAtOnce(t *testing.T) {
// ADR 0490 §3: adopt of a name bound to another id always takes it over, however recently that id called —
// no bound protects it.
func TestAdoptAlwaysTakesOverEvenJustCalled(t *testing.T) {
p, _ := node(t, "node-b")
p, _ := node(t, "g14")
store := newMemWaits()
by := "fix-1629@node-b"
by := "fix-1629@g14"
addApproveWait(t, p, store, "fix-1629", "sess-fresh", t0)
// sess-fresh calls again right before being adopted away from.
if _, err := ListWaits(p, "sess-fresh", by, store, t0.Add(10*time.Second)); err != nil {
@@ -396,9 +396,9 @@ func TestAdoptAlwaysTakesOverEvenJustCalled(t *testing.T) {
// ADR 0490 §3: the earlier id's next claude_code_wait call (any op) answers that its name now answers to
// another session, naming it.
func TestTheEarlierIDIsToldOnItsNextCall(t *testing.T) {
p, _ := node(t, "node-b")
p, _ := node(t, "g14")
store := newMemWaits()
by := "fix-1629@node-b"
by := "fix-1629@g14"
addApproveWait(t, p, store, "fix-1629", "sess-old", t0)
if _, err := AdoptName(p, by, "sess-new", store, t0.Add(time.Minute)); err != nil {
t.Fatal(err)
@@ -413,11 +413,11 @@ func TestTheEarlierIDIsToldOnItsNextCall(t *testing.T) {
// ADR 0490 §2: the node-wide index lists every name on this node with an open wait, for the SessionStart
// hook to read directly.
func TestTheNodeWideIndexListsEveryNameWithOpenWaits(t *testing.T) {
p, _ := node(t, "node-b")
p, _ := node(t, "g14")
store := newMemWaits()
addApproveWait(t, p, store, "fix-1629", "sess-x", t0)
addApproveWait(t, p, store, "fix-1744", "sess-y", t0)
if _, err := DoneWait(p, "sess-x", "fix-1629@node-b", wantsOnlyWait(t, store, p, "fix-1629", "sess-x"), "done",
if _, err := DoneWait(p, "sess-x", "fix-1629@g14", wantsOnlyWait(t, store, p, "fix-1629", "sess-x"), "done",
store, t0.Add(time.Minute)); err != nil {
t.Fatal(err)
}
@@ -426,7 +426,7 @@ func TestTheNodeWideIndexListsEveryNameWithOpenWaits(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if idx.Node != "node-b" || len(idx.Names) != 1 || idx.Names[0].Name != "fix-1744" || idx.Names[0].Open != 1 ||
if idx.Node != "g14" || len(idx.Names) != 1 || idx.Names[0].Name != "fix-1744" || idx.Names[0].Open != 1 ||
idx.Names[0].CurrentSession != "sess-y" {
t.Fatalf("%+v", idx)
}
@@ -448,18 +448,18 @@ func wantsOnlyWait(t *testing.T, store WaitStore, p Paths, name, session string)
// claude_code_wait op=adopt, served through the module's own dispatch (1629/AC6, ADR 0490).
func TestAdoptIsServedThroughTheModulesDispatch(t *testing.T) {
p, _ := node(t, "node-b")
p, _ := node(t, "g14")
store := newMemWaits()
waiting = &Waiting{Store: store, Now: func() time.Time { return t0 }}
t.Cleanup(func() { waiting = nil })
s := serve(t, p, &bus{kept: map[string]map[string]any{}}, memConfig{})
text, isErr := s.call(t, "claude_code_wait", map[string]any{"op": "add", "session": "sess-old", "by": "fix-1629@node-b",
text, isErr := s.call(t, "claude_code_wait", map[string]any{"op": "add", "session": "sess-old", "by": "fix-1629@g14",
"what": "the operator's Approve", "read": map[string]any{"address": "mesh-delivery.recheck"}, "bound_minutes": 10})
if isErr {
t.Fatalf("add: %s", text)
}
text, isErr = s.call(t, "claude_code_wait", map[string]any{"op": "adopt", "session": "sess-new", "by": "fix-1629@node-b"})
text, isErr = s.call(t, "claude_code_wait", map[string]any{"op": "adopt", "session": "sess-new", "by": "fix-1629@g14"})
if isErr || !strings.Contains(text, "sess-old") {
t.Fatalf("adopt: %v %s", isErr, text)
}
@@ -470,7 +470,7 @@ func TestAdoptIsServedThroughTheModulesDispatch(t *testing.T) {
// Review round 2, item 2 (ADR 0490 §1): one session id with two different names bound to it holds both
// names' waits in its one mirrored file, not only the name an op last acted on.
func TestASessionsFileHoldsEveryNameBoundToIt(t *testing.T) {
p, _ := node(t, "node-b")
p, _ := node(t, "g14")
store := newMemWaits()
idX := addApproveWait(t, p, store, "fix-x", "sess-shared", t0)
idY := addApproveWait(t, p, store, "fix-y", "sess-shared", t0.Add(time.Minute))
@@ -491,12 +491,12 @@ func TestASessionsFileHoldsEveryNameBoundToIt(t *testing.T) {
// Review round 2, item 2: adopting one of two names bound to the same session away keeps that session's
// file holding the other name's waits — it is never emptied wholesale.
func TestAdoptingOneNameAwayKeepsTheSessionsOtherNamesWaits(t *testing.T) {
p, _ := node(t, "node-b")
p, _ := node(t, "g14")
store := newMemWaits()
idX := addApproveWait(t, p, store, "fix-x", "sess-shared", t0)
idY := addApproveWait(t, p, store, "fix-y", "sess-shared", t0.Add(time.Minute))
if _, err := AdoptName(p, "fix-y@node-b", "sess-y-new", store, t0.Add(2*time.Minute)); err != nil {
if _, err := AdoptName(p, "fix-y@g14", "sess-y-new", store, t0.Add(2*time.Minute)); err != nil {
t.Fatal(err)
}
@@ -513,9 +513,9 @@ func TestAdoptingOneNameAwayKeepsTheSessionsOtherNamesWaits(t *testing.T) {
// Review round 2, item b: adopt clears the refusal counter of both the earlier id and the new one, so a
// fresh three-strikes count starts under the new binding.
func TestAdoptClearsTheRefusalCounterOfBothIDs(t *testing.T) {
p, _ := node(t, "node-b")
p, _ := node(t, "g14")
store := newMemWaits()
by := "fix-1629@node-b"
by := "fix-1629@g14"
addApproveWait(t, p, store, "fix-1629", "sess-old", t0)
waitsDir := WaitsDirPath(p)
if err := writeRefusalCounters(waitsDir, "sess-old", map[string]int{"w-1": 2}); err != nil {
@@ -538,9 +538,9 @@ func TestAdoptClearsTheRefusalCounterOfBothIDs(t *testing.T) {
// Review round 2, item c: list is read-only — it never binds an unbound name, and never refuses a
// mismatched session; it answers the name's waits and who currently holds it.
func TestListNeverBindsAndAnswersWhoHoldsTheName(t *testing.T) {
p, _ := node(t, "node-b")
p, _ := node(t, "g14")
store := newMemWaits()
by := "fix-1629@node-b"
by := "fix-1629@g14"
// An unbound name: list answers no waits, and binds nothing (a later add from a different session still
// succeeds, as the first caller for an unbound name).
@@ -347,7 +347,7 @@ func TestAReportIsReadAsTheAgentModuleWritesIt(t *testing.T) {
func TestANodeReportingAnAdoptedAccountLaterIsBoundToIt(t *testing.T) {
mm := newMesh(t)
ctx := context.Background()
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("node-c", "rt-new", true, t0)})
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("novox", "rt-new", true, t0)})
late := mm.login("laptop", "rt-older", true, t0.Add(-24*time.Hour))
_, _ = mm.m.Consider(ctx, []Holdings{late})
if mm.state["laptop"].Licence != licence1 {
@@ -477,16 +477,16 @@ func TestEveryMoveSaysWhatWhyAndWho(t *testing.T) {
t.Fatal(err)
}
asked := WithCause(ctx, Cause{Why: "the switch verb", By: "node-b.node-tools"})
asked := WithCause(ctx, Cause{Why: "the switch verb", By: "g14.node-tools"})
if _, err := mm.m.Bind(asked, "laptop", "api"); err != nil {
t.Fatal(err)
}
if s := mm.state["laptop"]; s.Why != "the switch verb" || s.By != "node-b.node-tools" {
if s := mm.state["laptop"]; s.Why != "the switch verb" || s.By != "g14.node-tools" {
t.Fatalf("the node is told %+v", s)
}
said := strings.Join(lines, "\n")
if !strings.Contains(said, "switched laptop from "+licence1+" to api (generation ") ||
!strings.Contains(said, "the switch verb; asked by node-b.node-tools") {
!strings.Contains(said, "the switch verb; asked by g14.node-tools") {
t.Fatalf("the journal said:\n%s", said)
}
var switched string
@@ -495,18 +495,18 @@ func TestEveryMoveSaysWhatWhyAndWho(t *testing.T) {
switched = e
}
}
for _, want := range []string{`"by":"node-b.node-tools"`, `"from":"` + licence1 + `"`, `"licence":"api"`, `"consumer":"laptop"`, `"generation":`} {
for _, want := range []string{`"by":"g14.node-tools"`, `"from":"` + licence1 + `"`, `"licence":"api"`, `"consumer":"laptop"`, `"generation":`} {
if !strings.Contains(switched, want) {
t.Fatalf("the event %q lacks %s; events %v", switched, want, mm.events)
}
}
last := mm.store.Audits[len(mm.store.Audits)-1]
if raw, _ := json.Marshal(last); !strings.Contains(string(raw), `"by":"node-b.node-tools"`) || !strings.Contains(string(raw), "switched") {
if raw, _ := json.Marshal(last); !strings.Contains(string(raw), `"by":"g14.node-tools"`) || !strings.Contains(string(raw), "switched") {
t.Fatalf("the audit kept %s", raw)
}
_, _ = mm.m.Rotate(WithCause(ctx, Cause{Why: "the refresh verb", By: "node-c.node-tools"}), licence1, true)
if !strings.Contains(strings.Join(mm.events, "|"), `licence.rotated {"by":"node-c.node-tools"`) {
_, _ = mm.m.Rotate(WithCause(ctx, Cause{Why: "the refresh verb", By: "novox.node-tools"}), licence1, true)
if !strings.Contains(strings.Join(mm.events, "|"), `licence.rotated {"by":"novox.node-tools"`) {
t.Fatalf("a rotation asked for said no caller: %v", mm.events)
}
@@ -1,6 +1,7 @@
package main
import (
"bufio"
"context"
"net"
"os"
@@ -170,20 +171,33 @@ func TestTheLauncherWaitsQuietlyWhileAnotherClipmenudHoldsTheLock(t *testing.T)
store := dir + "/cm/clipmenu.6.op"
os.MkdirAll(store, 0o700)
lock := store + "/session_lock"
// the older clipmenud: it holds the lock the way clipmenud does, until it is killed
holder := exec.Command("flock", "-x", "-n", lock, "sleep", "60")
// the older clipmenud: it holds the lock the way clipmenud does, until it is killed. It says "held" once it has the
// lock, so the test goes on only when the lock is held for certain: a holder that is slow to start (the checker
// runs thousands of tests at once) must not let the launcher find the lock free (issue 1814).
holder := exec.Command("flock", "-x", "-n", lock, "sh", "-c", "echo held; exec sleep 60")
holder.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
held, err := holder.StdoutPipe()
if err != nil {
t.Fatal(err)
}
if err := holder.Start(); err != nil {
t.Fatal(err)
}
// flock's child shares the lock, so the whole group goes
release := func() { syscall.Kill(-holder.Process.Pid, syscall.SIGKILL); holder.Wait() }
defer release()
for i := 0; i < 50; i++ {
if probe := exec.Command("flock", "-n", lock, "true"); probe.Run() != nil {
break
said := make(chan string, 1)
go func() {
line, _ := bufio.NewReader(held).ReadString('\n')
said <- strings.TrimSpace(line)
}()
select {
case line := <-said:
if line != "held" {
t.Fatalf("the older clipmenud did not take the lock: %q", line)
}
time.Sleep(50 * time.Millisecond)
case <-time.After(30 * time.Second):
t.Fatal("the older clipmenud did not take the lock within 30 s")
}
var out syncBuffer
cmd := exec.Command("sh", file)
+1 -1
View File
@@ -22,7 +22,7 @@ Research 027 asked for "`cups` with the printer's driver". Measured on 2026-10-0
| workstation | queue | device | prints through | driver package installed |
|---|---|---|---|---|
| laptop | `Brother` (MFC-L8690CDW) | `ipp://` on the LAN | **IPP Everywhere, driverless** | `brother-mfc-l8690cdw` (AUR), unused |
| desktop | `Brother_MFC_NodeC` (default) | `ipp://` on the LAN | **IPP Everywhere, driverless** | `brother-mfc-l8390cdw` and its `-debug` (AUR), unused |
| desktop | `Brother_MFC_Novox` (default) | `ipp://` on the LAN | **IPP Everywhere, driverless** | `brother-mfc-l8390cdw` and its `-debug` (AUR), unused |
| desktop | `Kanjuro` (Canon PIXMA MG4200) | `cnijnet:` | the vendor's PPD and filter | `cnijfilter-mg4200` 3.80 (AUR) |
**Both Brother queues already print without a vendor driver.** CUPS's own IPP Everywhere support,
+7 -7
View File
@@ -36,13 +36,13 @@ func theDesktop(t *testing.T, more func(line string, c Cmd) (Result, bool)) *fak
case "lpstat -r":
return ok("scheduler is running\n")
case "lpstat -d":
return ok("system default destination: Brother_MFC_NodeC\n")
return ok("system default destination: Brother_MFC_Novox\n")
case "lpstat -p":
return ok("printer Brother_MFC_NodeC is idle. enabled since Mon Jun 29 21:34:30 2026\n" +
return ok("printer Brother_MFC_Novox is idle. enabled since Mon Jun 29 21:34:30 2026\n" +
"printer Kanjuro disabled since Sun Jun 9 11:16:03 2024 -\n\tPaused\n")
case "lpstat -a":
return ok("Brother_MFC_NodeC accepting requests since Mon Jun 29 21:34:30 2026\nKanjuro not accepting requests since Sun Jun 9 11:16:03 2024 -\n\tRejecting Jobs\n")
case "lpoptions -p Brother_MFC_NodeC":
return ok("Brother_MFC_Novox accepting requests since Mon Jun 29 21:34:30 2026\nKanjuro not accepting requests since Sun Jun 9 11:16:03 2024 -\n\tRejecting Jobs\n")
case "lpoptions -p Brother_MFC_Novox":
return ok(`copies=1 device-uri=ipp://192.0.2.171/ipp/port1 finishings=3 marker-levels=70,100,8,100 marker-low-levels=10,10,10,10 marker-names='Black\ Toner\ Cartridge,Cyan\ Toner\ Cartridge,Magenta\ Toner\ Cartridge,Yellow\ Toner\ Cartridge' marker-types=toner,toner,toner,toner printer-is-shared=false printer-make-and-model='Printer - IPP Everywhere' printer-state-reasons=none`)
case "lpoptions -p Kanjuro":
return ok(`device-uri=cnijnet:/18-0C-AC-B0-62-83 printer-is-shared=false printer-make-and-model='Canon MG4200 series Ver.3.80' printer-state-reasons=paused`)
@@ -54,7 +54,7 @@ func theDesktop(t *testing.T, more func(line string, c Cmd) (Result, bool)) *fak
func TestPrintersReadsStateDeviceModelAndSupplies(t *testing.T) {
theDesktop(t, nil)
got, err := Printers()
if err != nil || len(got.Printers) != 2 || got.Default != "Brother_MFC_NodeC" || got.Scheduler != "scheduler is running" {
if err != nil || len(got.Printers) != 2 || got.Default != "Brother_MFC_Novox" || got.Scheduler != "scheduler is running" {
t.Fatalf("%+v %v", got, err)
}
b, k := got.Printers[0], got.Printers[1]
@@ -186,11 +186,11 @@ func TestDefaultReadsAndSetsThroughSudo(t *testing.T) {
return Result{}, false
})
got, err := Default("")
if err != nil || got["default"] != "Brother_MFC_NodeC" {
if err != nil || got["default"] != "Brother_MFC_Novox" {
t.Fatalf("%v %v", got, err)
}
got, err = Default("Kanjuro")
if err != nil || got["default"] != "Kanjuro" || got["was"] != "Brother_MFC_NodeC" {
if err != nil || got["default"] != "Kanjuro" || got["was"] != "Brother_MFC_Novox" {
t.Fatalf("%v %v", got, err)
}
if l := f.lines(); l[len(l)-1] != "sudo -n lpadmin -d Kanjuro" {
@@ -133,7 +133,7 @@ func TestANotifierThatStartedAgainIsShownNothingFromMemory(t *testing.T) {
d, shown, said, store := newDesk(t, "laptop")
d.Paused("laptop", false)
d.SayStanding()
take(t, d, asks.Show, asks.Message{Handle: "open", Title: "2 failed services on node-d",
take(t, d, asks.Show, asks.Message{Handle: "open", Title: "2 failed services on shanks",
Buttons: []asks.Button{{Label: "Silence for a week", Ticket: "t-open"}}})
take(t, d, asks.Show, asks.Message{Handle: "silenced", Title: "Agent-home shrank",
Buttons: []asks.Button{{Label: "Silence for a week", Ticket: "t-silenced"}}})
@@ -162,7 +162,7 @@ func TestANotifierThatStartedAgainIsShownNothingFromMemory(t *testing.T) {
t.Fatalf("the standing after the start: %d said, last %s", len(got)-standings, got[len(got)-1])
}
// The router says the open one again under its handle: in place of its notification, with its answers.
take(t, d, asks.Show, asks.Message{Handle: "open", Title: "2 failed services on node-d",
take(t, d, asks.Show, asks.Message{Handle: "open", Title: "2 failed services on shanks",
Buttons: []asks.Button{{Label: "Silence for a week", Ticket: "t-open"}}})
last := (*shown)[len(*shown)-1]
if len(*shown) != before+1 || last.args["replace_id"] != openID || last.args["actions"] == nil {
@@ -329,7 +329,7 @@ func TestItRunsAsItsOwnAccount(t *testing.T) {
// was not shown; a message under a new handle replaces nothing.
func TestAMessageSaidAgainUnderItsHandleReplacesItsNotification(t *testing.T) {
d, shown, _, store := newDesk(t, "laptop", "desktop")
take(t, d, asks.Send, asks.Message{Handle: "m1", Title: "2 failed services on node-d"})
take(t, d, asks.Send, asks.Message{Handle: "m1", Title: "2 failed services on shanks"})
take(t, d, asks.Send, asks.Message{Handle: "m2", Title: "another problem"})
if len(*shown) != 4 {
t.Fatalf("shown %d", len(*shown))
@@ -342,7 +342,7 @@ func TestAMessageSaidAgainUnderItsHandleReplacesItsNotification(t *testing.T) {
first := store["m1"].On
// The desk now names a third machine as well.
d.Machines = func() []string { return []string{"laptop", "desktop", "third"} }
take(t, d, asks.Show, asks.Message{Handle: "m1", Title: "Still open: 2 failed services on node-d", Urgent: true,
take(t, d, asks.Show, asks.Message{Handle: "m1", Title: "Still open: 2 failed services on shanks", Urgent: true,
Buttons: []asks.Button{{Label: "Silence for a week", Ticket: "t1"}}})
again := (*shown)[4:]
if len(again) != 3 {
@@ -367,7 +367,7 @@ func TestAMessageSaidAgainUnderItsHandleReplacesItsNotification(t *testing.T) {
// later edit or clearing still reaches it; failing everywhere, it is said to the router as failed.
func TestAFailedReplaceKeepsTheNotificationItWasToReplace(t *testing.T) {
d, shown, said, store := newDesk(t, "laptop", "desktop")
take(t, d, asks.Send, asks.Message{Handle: "m1", Title: "2 failed services on node-d"})
take(t, d, asks.Send, asks.Message{Handle: "m1", Title: "2 failed services on shanks"})
first := store["m1"].On
notify := d.Notify
d.Notify = func(machine string, args map[string]any) (json.RawMessage, error) {
@@ -376,12 +376,12 @@ func TestAFailedReplaceKeepsTheNotificationItWasToReplace(t *testing.T) {
}
return notify(machine, args)
}
take(t, d, asks.Send, asks.Message{Handle: "m1", Title: "Still open: 2 failed services on node-d"})
take(t, d, asks.Send, asks.Message{Handle: "m1", Title: "Still open: 2 failed services on shanks"})
if got := store["m1"].On["desktop"]; got != first["desktop"] {
t.Fatalf("the desktop's notification %d was forgotten: %v", first["desktop"], store["m1"].On)
}
d.Notify = func(string, map[string]any) (json.RawMessage, error) { return nil, errors.New("away") }
take(t, d, asks.Send, asks.Message{Handle: "m1", Title: "Now urgent: 2 failed services on node-d"})
take(t, d, asks.Send, asks.Message{Handle: "m1", Title: "Now urgent: 2 failed services on shanks"})
if len(said[asks.IntakeSubject(asks.FailedWhat, Kind)]) != 1 {
t.Fatalf("shown nowhere, and not said as failed")
}
@@ -49,8 +49,7 @@ func TestTheManifestNamesExactlyTheToolsTheBundleServesAndNoMachine(t *testing.T
if len(m.Resources) != 0 {
t.Errorf("the module only reads, and declares %d resources", len(m.Resources))
}
// 1838/AC1: the catalogue-wide check covers a node's name now; this list keeps what it does not.
for _, banned := range []string{"jochen", "/home/"} {
for _, banned := range []string{"g14", "novox", "ace", "shanks", "jochen", "/home/"} {
if strings.Contains(string(raw), banned) {
t.Errorf("the manifest says %q", banned)
}
@@ -134,8 +134,8 @@ func aResolverMachine(t *testing.T, port string, now func() time.Time) *Machine
}
}
write(ConfigFile, "bind-interfaces\nlisten-address=127.0.0.2\nlisten-address=127.0.0.1\nno-resolv\n")
write(NodesFile, "local=/internal/\naddress=/node-c.internal/127.0.0.2\nhost-record=node-c.internal,127.0.0.2\n"+
"address=/node-a.internal/10.77.0.2\nhost-record=node-a.internal,10.77.0.2\n")
write(NodesFile, "local=/internal/\naddress=/novox.internal/127.0.0.2\nhost-record=novox.internal,127.0.0.2\n"+
"address=/ace.internal/10.77.0.2\nhost-record=ace.internal,10.77.0.2\n")
if err := os.MkdirAll(filepath.Join(root, filepath.Dir(StateFile)), 0o755); err != nil {
t.Fatal(err)
}
@@ -147,13 +147,13 @@ func TestTheConfigurationAndTheRosterSayWhereAndWhatToAsk(t *testing.T) {
if strings.Join(listen, " ") != "10.77.0.1 127.0.0.1" {
t.Fatalf("listen addresses %v", listen)
}
names := AnsweredNames("local=/internal/\naddress=/node-a.internal/10.77.0.2\nhost-record=node-a.internal,10.77.0.2\n" +
"host-record=node-c.internal,10.77.0.1\n")
names := AnsweredNames("local=/internal/\naddress=/ace.internal/10.77.0.2\nhost-record=ace.internal,10.77.0.2\n" +
"host-record=novox.internal,10.77.0.1\n")
q, ok := Question(listen, names)
if !ok || q.Name != "node-c.internal" || q.Address != "10.77.0.1" {
if !ok || q.Name != "novox.internal" || q.Address != "10.77.0.1" {
t.Fatalf("asks %+v", q)
}
if q, _ := Question([]string{"127.0.0.1"}, names); q.Name != "node-a.internal" {
if q, _ := Question([]string{"127.0.0.1"}, names); q.Name != "ace.internal" {
t.Fatalf("with no own name in the roster, asks %+v", q)
}
}
@@ -165,7 +165,7 @@ func TestALookFindsAResolverThatStoppedAnsweringOnOneAddress(t *testing.T) {
m := aResolverMachine(t, port, time.Now)
private := startStandIn(t, "127.0.0.2", port, "127.0.0.2")
startStandIn(t, "127.0.0.1", port, "127.0.0.2")
if l := m.Look(t.Context()); !l.Healthy || len(l.Answers) != 4 || l.Asked.Name != "node-c.internal" {
if l := m.Look(t.Context()); !l.Healthy || len(l.Answers) != 4 || l.Asked.Name != "novox.internal" {
t.Fatalf("a resolver answering everywhere: %+v", l)
}
private.stop()
@@ -173,14 +173,14 @@ func TestALookFindsAResolverThatStoppedAnsweringOnOneAddress(t *testing.T) {
if l.Healthy || len(l.Wrong) != 2 {
t.Fatalf("a resolver that stopped answering on its private address: %+v", l)
}
if why := l.Why(); !strings.Contains(why, "127.0.0.2:"+port+" over udp did not answer node-c.internal: refused") ||
if why := l.Why(); !strings.Contains(why, "127.0.0.2:"+port+" over udp did not answer novox.internal: refused") ||
!strings.Contains(why, "over tcp") || strings.Contains(why, "127.0.0.1:") {
t.Fatalf("said as %q", why)
}
}
func TestAWrongAnswerIsNotAnAnswer(t *testing.T) {
q := Named{Name: "node-c.internal", Address: "10.77.0.1"}
q := Named{Name: "novox.internal", Address: "10.77.0.1"}
for _, c := range []struct {
a Answer
want string
@@ -198,7 +198,7 @@ func TestAWrongAnswerIsNotAnAnswer(t *testing.T) {
func failing(at time.Time) Look {
return Look{At: at, Answers: []Answer{{Server: "10.77.0.1:53", Over: "udp", Failed: "refused: nothing listens there"}},
Wrong: []string{"10.77.0.1:53 over udp did not answer node-c.internal: refused: nothing listens there"}}
Wrong: []string{"10.77.0.1:53 over udp did not answer novox.internal: refused: nothing listens there"}}
}
func healthy(at time.Time) Look { return Look{At: at, Healthy: true} }
@@ -323,7 +323,7 @@ func TestTheWatchRestartsAResolverThatStoppedAnsweringAndHealthSaysWhetherItHelp
look()
}
h := m.Health(t.Context())
if h.Healthy || !strings.Contains(h.Why, "did not answer node-c.internal: refused") ||
if h.Healthy || !strings.Contains(h.Why, "did not answer novox.internal: refused") ||
!strings.Contains(h.Why, "which did not help") {
t.Fatalf("a restart that did not help: %q", h.Why)
}
@@ -482,10 +482,10 @@ func TestResolveAsksOnlyAListenAddress(t *testing.T) {
m := aResolverMachine(t, port, time.Now)
startStandIn(t, "127.0.0.2", port, "127.0.0.2")
startStandIn(t, "127.0.0.1", port, "127.0.0.2")
if _, err := m.Resolve(t.Context(), "node-c.internal", "8.8.8.8", ""); err == nil || !strings.Contains(err.Error(), "not one the resolver listens on") {
if _, err := m.Resolve(t.Context(), "novox.internal", "8.8.8.8", ""); err == nil || !strings.Contains(err.Error(), "not one the resolver listens on") {
t.Fatalf("another address was asked: %v", err)
}
got, err := m.Resolve(t.Context(), "node-c.internal", "127.0.0.2", "tcp")
got, err := m.Resolve(t.Context(), "novox.internal", "127.0.0.2", "tcp")
if err != nil {
t.Fatal(err)
}
@@ -493,10 +493,10 @@ func TestResolveAsksOnlyAListenAddress(t *testing.T) {
if len(answers) != 1 || answers[0].Over != "tcp" || answers[0].Code != "NOERROR" || answers[0].Addresses[0] != "127.0.0.2" {
t.Fatalf("%+v", answers)
}
if got, _ := m.Resolve(t.Context(), "node-c.internal", "", ""); len(got.(map[string]any)["answers"].([]Answer)) != 4 {
if got, _ := m.Resolve(t.Context(), "novox.internal", "", ""); len(got.(map[string]any)["answers"].([]Answer)) != 4 {
t.Fatalf("every listen address over both: %+v", got)
}
if _, err := m.Resolve(t.Context(), "node-c.internal", "", "quic"); err == nil {
if _, err := m.Resolve(t.Context(), "novox.internal", "", "quic"); err == nil {
t.Fatal("over quic was accepted")
}
if _, err := m.Resolve(t.Context(), "", "", ""); err == nil {
@@ -540,12 +540,12 @@ func TestTheAnnouncerSaysWhatChangedAndRemembersOnlyWhatWasSaid(t *testing.T) {
}
// The roster changes: one machine added, one gone. An unreadable roster says nothing.
nodes := filepath.Join(m.Root, NodesFile)
if err := os.WriteFile(nodes, []byte("host-record=node-c.internal,127.0.0.2\nhost-record=node-d.internal,10.77.0.4\n"), 0o644); err != nil {
if err := os.WriteFile(nodes, []byte("host-record=novox.internal,127.0.0.2\nhost-record=shanks.internal,10.77.0.4\n"), 0o644); err != nil {
t.Fatal(err)
}
Tell(m, &a, emit)
if got := strings.Join(said, "|"); !strings.Contains(got, "name.added node-d.internal") ||
!strings.Contains(got, "name.removed node-a.internal") || len(said) != 2 {
if got := strings.Join(said, "|"); !strings.Contains(got, "name.added shanks.internal") ||
!strings.Contains(got, "name.removed ace.internal") || len(said) != 2 {
t.Fatalf("the roster's change: %v", said)
}
said = nil
@@ -32,7 +32,7 @@ func archiveOf(t *testing.T, h tar.Header, body string) string {
// Issue 348: route-proxy asked 10.10.0.1 alone while the machine listed both mesh resolvers; its copy
// of the file could not be read by anything on the mesh.
func TestAContainersResolverFileIsReadThroughTheArchiveAndComparedWithTheMachines(t *testing.T) {
inside := "# Generated by Docker Engine.\nnameserver 10.10.0.1\nsearch node-c.internal\noptions timeout:1 attempts:2\n"
inside := "# Generated by Docker Engine.\nnameserver 10.10.0.1\nsearch novox.internal\noptions timeout:1 attempts:2\n"
f := (&fake{}).on("docker cp route-proxy:/etc/resolv.conf -",
Ran{Stdout: archiveOf(t, tar.Header{Name: "resolv.conf", Typeflag: tar.TypeReg, Mode: 0o644}, inside)})
c := client(f, 1000)
@@ -47,7 +47,7 @@ func TestAContainersResolverFileIsReadThroughTheArchiveAndComparedWithTheMachine
t.Fatal(err)
}
rc := got["resolv_conf"].(ResolvConf)
if strings.Join(rc.Nameservers, ",") != "10.10.0.1" || strings.Join(rc.Search, ",") != "node-c.internal" ||
if strings.Join(rc.Nameservers, ",") != "10.10.0.1" || strings.Join(rc.Search, ",") != "novox.internal" ||
strings.Join(rc.Options, ",") != "timeout:1,attempts:2" || rc.Raw != inside {
t.Fatalf("parsed: %+v", rc)
}
@@ -17,7 +17,7 @@ func TestANotificationsChosenAnswerIsSaidAsAnEvent(t *testing.T) {
withBus(t)
bin := fakeBinaries(t, map[string]string{"notify-send": `for a in "$@"; do printf '[%s]' "$a"; done > "$LOG"; echo 42; sleep 0.2; echo "$CHOSEN"`})
t.Setenv("LOG", filepath.Join(bin, "log"))
t.Setenv("MESH_NODE", "node-b")
t.Setenv("MESH_NODE", "g14")
var mu sync.Mutex
var said []map[string]any
before := emit
@@ -58,7 +58,7 @@ func TestANotificationsChosenAnswerIsSaidAsAnEvent(t *testing.T) {
time.Sleep(20 * time.Millisecond)
}
mu.Lock()
if len(said) != 1 || said[0]["token"] != "c3d4" || said[0]["id"] != 42 || said[0]["machine"] != "node-b" {
if len(said) != 1 || said[0]["token"] != "c3d4" || said[0]["id"] != 42 || said[0]["machine"] != "g14" {
t.Fatalf("said %v", said)
}
said = nil
@@ -96,7 +96,7 @@ func TestActionsAreRefusedForWhatTheyCannotBe(t *testing.T) {
// **A bundle that started says so**, so the operator channel shows again the answers lost with the one
// before.
func TestABundleThatStartedSaysSo(t *testing.T) {
t.Setenv("MESH_NODE", "node-b")
t.Setenv("MESH_NODE", "g14")
var said []string
before := emit
emit = func(event string, body any) error {
@@ -105,7 +105,7 @@ func TestABundleThatStartedSaysSo(t *testing.T) {
}
t.Cleanup(func() { emit = before })
sayStarted()
if len(said) != 1 || said[0] != "started map[machine:node-b]" {
if len(said) != 1 || said[0] != "started map[machine:g14]" {
t.Fatalf("%v", said)
}
}
@@ -227,8 +227,7 @@ func checkNoSecretsOrInstallationNames(t *testing.T) {
t.Fatal(err)
}
s := strings.ToLower(string(raw))
// 1838/AC1: the catalogue-wide check covers a node's name now; this list keeps what it does not.
for _, never := range []string{"/home/", "jochen", "api_key", ".hal/", "greenclip daemon"} {
for _, never := range []string{"/home/", "jochen", "g14", "shanks", "novox.be", "api_key", ".hal/", "greenclip daemon"} {
if strings.Contains(s, never) {
t.Errorf("module.json names %q", never)
}
+1 -1
View File
@@ -48,7 +48,7 @@ const (
// the operator's own addresses. fail2ban keeps a file entry in its own form, not as written: it reads
// `file:/var/lib/…` and prints it back as `file://var/lib/…` (server/ipdns.py, FileIPAddrSet.__repr__, in 1.1),
// in `get <jail> ignoreip` and in its `Ignore … by …` lines alike. So the path is compared, with fail2ban's own
// reading of the slashes after `file:`, never the text (seen on node-a on 2026-10-13: every jail held the list
// reading of the slashes after `file:`, never the text (seen on ace on 2026-10-13: every jail held the list
// and the watch said none did).
func isOurList(word string) bool {
path, ok := strings.CutPrefix(strings.TrimSpace(word), "file:")
@@ -16,7 +16,7 @@ import (
)
// asPrinted is the list's entry as fail2ban 1.1 prints it back, in `get <jail> ignoreip` and in its log: it reads
// `file:/var/lib/…` and keeps it as `file://var/lib/…` (FileIPAddrSet.__repr__), as node-a's daemon printed it on
// `file:/var/lib/…` and keeps it as `file://var/lib/…` (FileIPAddrSet.__repr__), as ace's daemon printed it on
// 2026-10-13.
const asPrinted = "file://var/lib/mesh-fail2ban/own-addresses"
@@ -259,8 +259,7 @@ func checkNoSecretsOrInstallationNames(t *testing.T) {
t.Fatal(err)
}
s := strings.ToLower(string(raw))
// 1838/AC1: the catalogue-wide check covers a node's name now; this list keeps what it does not.
for _, never := range []string{"/home/", "jochen", "api_key", ".hal/", "greenclip daemon"} {
for _, never := range []string{"/home/", "jochen", "g14", "shanks", "novox.be", "api_key", ".hal/", "greenclip daemon"} {
if strings.Contains(s, never) {
t.Errorf("module.json names %q", never)
}
@@ -82,8 +82,7 @@ func TestTheToolsAgreeWithTheManifest(t *testing.T) {
t.Errorf("the Go tools bundle: %v", b)
}
s := strings.ToLower(raw)
// 1838/AC1: the catalogue-wide check covers a node's name now; this list keeps what it does not.
for _, never := range []string{"/home/", "jochen", "http", "password", "token"} {
for _, never := range []string{"/home/", "jochen", "g14", "shanks", "novox.be", "http", "password", "token"} {
if strings.Contains(s, never) {
t.Errorf("module.json names %q", never)
}
+17 -2
View File
@@ -533,7 +533,7 @@ export class GiteaClient {
* **A listing the forge cut is no listing** (it says `truncated` on a page with nothing more to read, or the pages
* run past a bound): the call fails naming the repository and the commit, never answering the directories it did
* see, since a directory left out would be taken for one without a module. Reads only. */
async commitTrees(owner: string, repo: string, sha: string): Promise<{ root: string; trees: Record<string, string> }> {
async commitTrees(owner: string, repo: string, sha: string, paths?: Record<string, string[]>): Promise<{ root: string; trees: Record<string, string>; paths?: Record<string, Record<string, string>> }> {
const base = `${repoPath(owner, repo)}/git/trees/${commitSegment(sha)}?recursive=true`;
const cut = (why: string) => new Error(`the forge's tree listing of ${owner}/${repo} at ${sha} was cut (${why}); no trees are answered from a part`);
const entries: any[] = [];
@@ -560,7 +560,22 @@ export class GiteaClient {
if (!tree) throw new Error(`the forge's tree listing of ${owner}/${repo} at ${sha} holds ${e.path} but not the tree of ${dir}`);
trees[dir] = tree;
}
return { root, trees };
if (paths === undefined) return { root, trees };
// **Named paths** (novox/hq ADR 0499 §3, issue 1758): each asked path's git object id at the commit, a file's or a
// directory's, from the same listing, by directory and by path relative to it. A path the listing lacks fails
// naming both, and nothing is answered in part.
const objectOf = new Map<string, string>();
for (const e of entries) if (typeof e?.path === "string" && (e.type === "tree" || e.type === "blob")) objectOf.set(e.path, String(e.sha));
const named: Record<string, Record<string, string>> = {};
for (const [dir, list] of Object.entries(paths)) {
named[dir] = {};
for (const p of list) {
const id = objectOf.get(dir === "" ? p : `${dir}/${p}`);
if (!id) throw new Error(`the path ${p} of ${dir === "" ? "the repository root" : dir} is not in ${owner}/${repo} at ${sha}`);
named[dir][p] = id;
}
}
return { root, trees, paths: named };
}
/** Whether a repository holds a file at a commit: true, false for a 404, thrown when the forge cannot say. */
+5
View File
@@ -38,6 +38,7 @@
import { emit, on } from "@novox/mesh-sdk/events";
import { registerModuleTools } from "@novox/mesh-sdk/tools";
import { statesAnnouncement } from "./kept.js";
import { commitStatusTool } from "./delivery.js";
import { announce, keepWatermark, readWatermark } from "./announce.js";
import { mergeTool } from "./merge.js";
@@ -473,6 +474,10 @@ const gates = new GateWriter({
const pace = new Pace();
if (!gitea) pace.unheard("the forge is not watched: no way to a token", false);
// The states this module keeps and which others may read, said on the bus before it serves (novox/hq ADR 0495 clause 2).
// Said with `emit`, not the SDK's `keeps`, which the toolchain's SDK does not have yet: switch to `keeps` once that SDK is 0.1.15 or later (issue 1852).
await emit("states", statesAnnouncement(process.env.MESH_MODULE || "gitea", process.env.MESH_NODE));
// gitea_events is served whether or not the forge is watched: it says so (novox/hq issue 729).
registerModuleTools("gitea", () => [eventsTool(pace), ...(gitea ? [commitStatusTool({
setCommitStatus: (owner, repo, sha, status) => (gitea as GiteaClient).setCommitStatus(owner, repo, sha, status),
+20
View File
@@ -0,0 +1,20 @@
// The states this module keeps on the bus and whether other modules may read them (novox/hq ADR 0495 clause 2; issue
// 1790): the module says it itself, from its code, as it starts (index.ts calls keeps with this list), and a reader is
// granted what it said (clause 3). A state is readable when another module's manifest reads it; none reads gitea's.
// test/kept.test.ts holds the list to the manifest's `state`.
export const KEPT: readonly { readonly name: string; readonly readable?: boolean }[] = [
{ name: "announced" },
{ name: "bound" },
];
/** The announcement of these states, in the layout mesh-sdk's go/announce reads (novox/hq ADR 0495 clause 2): version 1,
* the module, the machine when known, and the states sorted by name, `readable` only where true. It is said as the event
* `states`, which is the subject mesh.mod.gitea.event.states. gitea says it with the SDK's `emit` because the
* toolchain's SDK, which judges R4e's group, predates the SDK's `keeps` (0.1.15); switch to `keeps` once the toolchain's
* SDK is 0.1.15 or later (issue 1852). */
export function statesAnnouncement(module: string, node: string | undefined) {
const states = [...KEPT]
.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
.map((k) => (k.readable ? { name: k.name, readable: true } : { name: k.name }));
return { v: 1, module, ...(node ? { node } : {}), states };
}
+50
View File
@@ -171,3 +171,53 @@ test("the root tree is git's hash of the root's own entries, never the listing's
server.close();
}
});
test("named paths: each asked path's git object id at the commit, a file's or a directory's, by directory (1758 S1, ADR 0499 §3)", async () => {
const { client, server } = await serve([{ sha: COMMIT, url: "u", tree: LISTING, truncated: false, page: 1, total_count: LISTING.length }]);
try {
const got = await client.commitTrees("novox", "hq", COMMIT, { "modules/a": ["main.go", "module.json"], "modules/b": ["c"], "": ["README.md", "modules/d"] });
assert.deepEqual(got.paths, {
"modules/a": { "main.go": h("6"), "module.json": h("5") },
"modules/b": { c: h("8") },
"": { "README.md": h("1"), "modules/d": h("a") },
});
assert.deepEqual(got.trees, { "": ROOT, "modules/a": h("4"), "modules/b/c": h("8") });
assert.equal((await client.commitTrees("novox", "hq", COMMIT)).paths, undefined, "no paths asked, none answered");
} finally {
server.close();
}
});
test("a named path the listing lacks fails naming the directory and the path, and answers nothing", async () => {
const { client, server } = await serve([{ sha: COMMIT, url: "u", tree: LISTING, truncated: false, page: 1, total_count: LISTING.length }]);
try {
await assert.rejects(client.commitTrees("novox", "hq", COMMIT, { "modules/a": ["main.go", "gone.go"] }), (err: Error) => {
assert.match(err.message, /modules\/a/);
assert.match(err.message, /gone\.go/);
return true;
});
for (const bad of [[""], ["../x"], ["/abs"], ["a//b"]]) {
await assert.rejects(client.commitTrees("novox", "hq", COMMIT, { "modules/a": bad }), /./, JSON.stringify(bad));
}
} finally {
server.close();
}
});
test("gitea_commit_trees takes paths as {directory: [paths]} and refuses another shape before the forge is asked", async () => {
const { getGiteaTools } = await import("../tools/index.ts");
const { client, server, asked } = await serve([{ sha: COMMIT, url: "u", tree: LISTING, truncated: false, page: 1, total_count: LISTING.length }]);
try {
const tool = getGiteaTools(client).find((t) => t.name === "gitea_commit_trees")!;
assert.ok("paths" in tool.input, "the tool names the argument");
const got: any = await tool.run({ owner: "novox", repo: "hq", sha: COMMIT, paths: { "modules/a": ["main.go"] } } as any);
assert.deepEqual(got.paths, { "modules/a": { "main.go": h("6") } });
const before = asked.length;
for (const paths of ["modules/a", ["main.go"], { "modules/a": "main.go" }, { "modules/a": [1] }]) {
await assert.rejects(tool.run({ owner: "novox", repo: "hq", sha: COMMIT, paths } as any), /paths/, JSON.stringify(paths));
}
assert.equal(asked.length, before, "the forge was asked for a refused call");
} finally {
server.close();
}
});
+25
View File
@@ -0,0 +1,25 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { readFileSync } from "node:fs";
// 1790: the states the module announces are the states its manifest declares, no more, no fewer; none is readable
// because no other module's manifest reads one of them.
test("the states the module announces are the states its manifest declares", async () => {
const { KEPT } = await import("../kept.ts");
const manifest = JSON.parse(readFileSync(new URL("../module.json", import.meta.url), "utf8"));
const declared = (manifest.state as (string | { name: string })[]).map((s) => (typeof s === "string" ? s : s.name)).sort();
assert.deepEqual(KEPT.map((k) => k.name).sort(), declared);
assert.ok(KEPT.every((k) => !k.readable), "no other module's manifest reads a state of gitea");
});
test("the announcement has the layout mesh-sdk's go/announce reads: version 1, the module, the node, the states sorted by name", async () => {
const { statesAnnouncement } = await import("../kept.ts");
assert.deepEqual(statesAnnouncement("gitea", "ace"), { v: 1, module: "gitea", node: "ace", states: [{ name: "announced" }, { name: "bound" }] });
assert.deepEqual(statesAnnouncement("gitea", undefined), { v: 1, module: "gitea", states: [{ name: "announced" }, { name: "bound" }] });
});
test("index.ts says the announcement as the event `states` before the tools are served", () => {
const source = readFileSync(new URL("../index.ts", import.meta.url), "utf8");
assert.match(source, /await emit\("states", statesAnnouncement\(/);
assert.ok(source.indexOf('await emit("states"') < source.indexOf('registerModuleTools("gitea"'), "announced before the tools are served");
});
+19 -2
View File
@@ -60,6 +60,22 @@ function namesChecked(tools: ToolDefinition[]): ToolDefinition[] {
}));
}
/** The `paths` argument of gitea_commit_trees: {directory: [path relative to it]}, each path plain (no empty, `.` or `..`
* segment, not absolute); anything else is refused before the forge is asked. Absent is absent. */
function namedPaths(raw: unknown): Record<string, string[]> | undefined {
if (raw === undefined || raw === null) return undefined;
if (typeof raw !== "object" || Array.isArray(raw)) throw new Error("paths is {directory: [path, ...]}");
const out: Record<string, string[]> = {};
for (const [dir, list] of Object.entries(raw as Record<string, unknown>)) {
if (!Array.isArray(list) || list.some((p) => typeof p !== "string")) throw new Error(`paths of ${dir || "the root"} is a list of paths`);
for (const p of list as string[]) {
if (p === "" || p.split("/").some((seg) => seg === "" || seg === "." || seg === "..")) throw new Error(`paths: ${JSON.stringify(p)} of ${dir || "the root"} is not a plain relative path`);
}
out[dir] = list as string[];
}
return out;
}
export function getGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefinition[] {
return namesChecked(allGiteaTools(gitea, admin));
}
@@ -525,18 +541,19 @@ function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefini
},
{
name: "gitea_commit_trees",
description: "A commit's trees from the forge's one recursive listing: the root tree, and the tree of every directory that holds a module.json (the root as an empty name), each by its git hash. What the forge bridge puts in a merge's message (novox/hq ADR 0437 §5). The commit is a full 40-hex sha. A listing the forge cut is refused, never answered in part. Reads only.",
description: "A commit's trees from the forge's one recursive listing: the root tree, and the tree of every directory that holds a module.json (the root as an empty name), each by its git hash. What the forge bridge puts in a merge's message (novox/hq ADR 0437 §5). With paths, {directory: [path relative to it]}, the answer also carries paths, {directory: {path: git object id}} of each named file or directory at the commit (novox/hq ADR 0499 §3); a path the commit lacks fails naming the directory and the path. The commit is a full 40-hex sha. A listing the forge cut is refused, never answered in part. Reads only.",
input: {
owner: { type: "string", description: "the repository owner" },
repo: { type: "string", description: "the repository name" },
sha: { type: "string", description: "the commit, its full 40-hex sha" },
paths: { type: "object", description: "optional, {directory: [path relative to it]}: the git object id of each named file or directory is answered under paths; the root directory is the empty name" },
},
run: async (args) => {
const owner = String(args.owner ?? "").trim(), repo = String(args.repo ?? "").trim();
const sha = String(args.sha ?? "").trim();
if (!owner || !repo) throw new Error("name the repository: owner and repo");
if (!/^[0-9a-f]{40}$/i.test(sha)) throw new Error("sha is a commit's full 40-hex sha");
return gitea.commitTrees(owner, repo, sha);
return gitea.commitTrees(owner, repo, sha, namedPaths(args.paths));
},
},
{
@@ -257,8 +257,7 @@ func checkNoSecretsOrInstallationNames(t *testing.T) {
t.Fatal(err)
}
s := strings.ToLower(string(raw))
// 1838/AC1: the catalogue-wide check covers a node's name now; this list keeps what it does not.
for _, never := range []string{"/home/", "jochen", "api_key", ".hal/", "greenclip daemon"} {
for _, never := range []string{"/home/", "jochen", "g14", "shanks", "novox.be", "api_key", ".hal/", "greenclip daemon"} {
if strings.Contains(s, never) {
t.Errorf("module.json names %q", never)
}
@@ -105,7 +105,7 @@ export function hasField(schema: readonly SchemaField[] | null | undefined, name
// **Nothing loses its connection without someone seeing it.** Before Home Assistant is touched the
// broker itself is asked whether it takes the delivered login (the provisioner creates it within
// seconds of the grant): if not, nothing is written and the step fails saying why, and Home
// Assistant keeps the login it has — the carried `luffy` on node-a, which mosquitto keeps. If Home
// Assistant keeps the login it has — the carried `luffy` on ace, which mosquitto keeps. If Home
// Assistant's own connection test refuses the new settings, the flow saves nothing, and the step
// fails with Home Assistant's reason. A login that may not subscribe to the discovery topics is said
// as a warning: discovery would find nothing.
@@ -299,8 +299,8 @@ export function describe(r: { type: string; reason?: string; errors?: Record<str
// is the bound one and which is loaded: already as the mesh says. The key needs no digest here:
// a Servarr app has one key, so an entry loaded against the app holds the key the app took.
// 5. A working entry at a different URL that reaches **the same app** — the same process, by the
// app's own status (start time, data folder, version) — is left as it is and said: node-a's entries
// say `127.0.0.1:<port>` and the binding says `node-a.internal:<port>`, one Sonarr either way.
// app's own status (start time, data folder, version) — is left as it is and said: ace's entries
// say `127.0.0.1:<port>` and the binding says `ace.internal:<port>`, one Sonarr either way.
// 6. Anything else is refused, loudly, with what the operator can do; nothing is removed.
+26 -26
View File
@@ -6,7 +6,7 @@
// minted value before the operator accepts the app's) is never written.
//
// Home Assistant and the apps are fakes answering as the real ones do (flow shapes checked against
// ghcr.io/home-assistant/home-assistant 2026.9.3, the build node-a runs).
// ghcr.io/home-assistant/home-assistant 2026.9.3, the build ace runs).
import { test } from "node:test";
import assert from "node:assert/strict";
@@ -21,7 +21,7 @@ const PWD_NOT_CHANGED = "__**password_not_changed**__";
const MINTED = "mesh-minted-password";
function mqttBinding(): Binding {
return { provision: "mqtt-topic", from: "node-a", at: "node-a.internal", as: "mesh_node-a_hass", serves: { scheme: "mqtt", port: 1883 } };
return { provision: "mqtt-topic", from: "ace", at: "ace.internal", as: "mesh_ace_hass", serves: { scheme: "mqtt", port: 1883 } };
}
/** The MQTT reconfigure form as Home Assistant serializes it, pre-filled from an entry. */
@@ -138,29 +138,29 @@ function memoryMarks(): Marks & { store: Map<string, string> } {
return { store, get: async (k) => store.get(k), set: async (k, v) => void store.set(k, v) };
}
const takes = (suback = 0): Probe => async (_h, _p, user, pass) => ({ connack: user === "mesh_node-a_hass" && pass === MINTED ? 0 : 5, suback });
const takes = (suback = 0): Probe => async (_h, _p, user, pass) => ({ connack: user === "mesh_ace_hass" && pass === MINTED ? 0 : 5, suback });
const nodeAMqttEntry = () => ({
const aceMqttEntry = () => ({
entry_id: "7d1e", domain: "mqtt", state: "loaded",
data: { broker: "127.0.0.1", port: 1883, protocol: "5", username: "luffy", password: "luffys-password" },
});
test("mqtt: the broker is asked first; a login it does not take is never written", async () => {
const f = fakeHass({ entries: { mqtt: [nodeAMqttEntry()] } });
const f = fakeHass({ entries: { mqtt: [aceMqttEntry()] } });
const out = await reconcileMqtt({ hass: f.hass, probe: async () => ({ connack: 5 }), marks: memoryMarks() }, mqttBinding(), MINTED);
assert.equal(out.result, "refused");
assert.match((out as { problem: string }).problem, /does not \(yet\) take the login mesh_node-a_hass/);
assert.match((out as { problem: string }).problem, /does not \(yet\) take the login mesh_ace_hass/);
assert.deepEqual(f.calls, []); // Home Assistant not even asked
assert.equal(f.entries.mqtt[0].data.username, "luffy");
});
test("mqtt: node-a's entry (127.0.0.1, luffy) is moved to the bound broker and login, every other setting kept", async () => {
const f = fakeHass({ entries: { mqtt: [nodeAMqttEntry()] } });
test("mqtt: ace's entry (127.0.0.1, luffy) is moved to the bound broker and login, every other setting kept", async () => {
const f = fakeHass({ entries: { mqtt: [aceMqttEntry()] } });
const marks = memoryMarks();
const out = await reconcileMqtt({ hass: f.hass, probe: takes(), marks }, mqttBinding(), `${MINTED}\n`);
assert.deepEqual(out, { what: "mqtt", result: "written", fields: ["broker", "username", "password"] });
assert.deepEqual(f.entries.mqtt[0].data, {
broker: "node-a.internal", port: 1883, protocol: "5", username: "mesh_node-a_hass", password: MINTED,
broker: "ace.internal", port: 1883, protocol: "5", username: "mesh_ace_hass", password: MINTED,
other_settings: { keepalive: 60, transport: "tcp", set_ca_cert: "off", set_client_cert: false },
});
assert.ok(marks.store.get("mqtt"));
@@ -175,7 +175,7 @@ test("mqtt: node-a's entry (127.0.0.1, luffy) is moved to the bound broker and l
});
test("mqtt: a new password alone is written (the digest tells)", async () => {
const f = fakeHass({ entries: { mqtt: [nodeAMqttEntry()] } });
const f = fakeHass({ entries: { mqtt: [aceMqttEntry()] } });
const marks = memoryMarks();
await reconcileMqtt({ hass: f.hass, probe: takes(), marks }, mqttBinding(), MINTED);
const rotated: Probe = async () => ({ connack: 0, suback: 0 });
@@ -185,7 +185,7 @@ test("mqtt: a new password alone is written (the digest tells)", async () => {
});
test("mqtt: Home Assistant's own connection test refusing saves nothing and fails loudly", async () => {
const f = fakeHass({ entries: { mqtt: [nodeAMqttEntry()] }, accepts: () => false });
const f = fakeHass({ entries: { mqtt: [aceMqttEntry()] }, accepts: () => false });
const marks = memoryMarks();
const out = await reconcileMqtt({ hass: f.hass, probe: takes(), marks }, mqttBinding(), MINTED);
assert.equal(out.result, "refused");
@@ -199,11 +199,11 @@ test("mqtt: a fresh Home Assistant gets an entry; a grant without the discovery
const out = await reconcileMqtt({ hass: f.hass, probe: takes(0x80), marks: memoryMarks() }, mqttBinding(), MINTED);
assert.equal(out.result, "written");
assert.match((out as { note?: string }).note ?? "", /may not subscribe to homeassistant\/#/);
assert.equal(f.entries.mqtt[0].data.broker, "node-a.internal");
assert.equal(f.entries.mqtt[0].data.broker, "ace.internal");
});
test("mqtt: two entries, or a binding without a port, are refused rather than guessed", async () => {
const f = fakeHass({ entries: { mqtt: [nodeAMqttEntry(), { ...nodeAMqttEntry(), entry_id: "other" }] } });
const f = fakeHass({ entries: { mqtt: [aceMqttEntry(), { ...aceMqttEntry(), entry_id: "other" }] } });
assert.equal((await reconcileMqtt({ hass: f.hass, probe: takes(), marks: memoryMarks() }, mqttBinding(), MINTED)).result, "refused");
const noPort = { ...mqttBinding(), serves: {} };
assert.match(((await reconcileMqtt({ hass: f.hass, probe: takes(), marks: memoryMarks() }, noPort, MINTED)) as { problem: string }).problem, /no usable port/);
@@ -214,9 +214,9 @@ test("mqtt: two entries, or a binding without a port, are refused rather than gu
const SONARR = APPS.find((a) => a.domain === "sonarr") as ServarrApp;
const RADARR = APPS.find((a) => a.domain === "radarr") as ServarrApp;
const KEY = "the-apps-own-key";
const servarrBinding = (port: number, at = "node-a.internal"): Binding => ({ provision: "sonarr-api", from: "node-a", at, as: "mesh_node-a_hass", serves: { scheme: "http", port, "url-base": "" } });
const servarrBinding = (port: number, at = "ace.internal"): Binding => ({ provision: "sonarr-api", from: "ace", at, as: "mesh_ace_hass", serves: { scheme: "http", port, "url-base": "" } });
/** One running Sonarr, answering on several addresses (127.0.0.1 and node-a.internal are one host). */
/** One running Sonarr, answering on several addresses (127.0.0.1 and ace.internal are one host). */
function apps(instances: Record<string, { startTime: string }>): Http & { asked: string[] } {
const asked: string[] = [];
return {
@@ -231,18 +231,18 @@ function apps(instances: Record<string, { startTime: string }>): Http & { asked:
},
};
}
const oneSonarr = () => apps({ "node-a.internal:8989": { startTime: "t1" }, "127.0.0.1:8989": { startTime: "t1" } });
const oneSonarr = () => apps({ "ace.internal:8989": { startTime: "t1" }, "127.0.0.1:8989": { startTime: "t1" } });
const sonarrEntry = (state = "loaded") => ({ entry_id: "5a1d", domain: "sonarr", state, data: { url: "http://127.0.0.1:8989", api_key: KEY } });
test("servarr: the mesh's minted key is never written; the remedy names the accept", async () => {
const f = fakeHass({ entries: { sonarr: [sonarrEntry()] } });
const out = await reconcileApp({ hass: f.hass, http: oneSonarr() }, SONARR, servarrBinding(8989), "minted-by-the-mesh");
assert.equal(out.result, "refused");
assert.match((out as { problem: string }).problem, /secret accept <this node> home-assistant sonarr-api --provider node-a/);
assert.match((out as { problem: string }).problem, /secret accept <this node> home-assistant sonarr-api --provider ace/);
assert.deepEqual(f.calls, []);
});
test("servarr: node-a's entry at 127.0.0.1 reaches the same Sonarr the mesh bound at node-a.internal — left, and said", async () => {
test("servarr: ace's entry at 127.0.0.1 reaches the same Sonarr the mesh bound at ace.internal — left, and said", async () => {
const f = fakeHass({ entries: { sonarr: [sonarrEntry()] }, devices: [{ id: "d", config_entries: ["5a1d"], configuration_url: "http://127.0.0.1:8989" }] });
const out = await reconcileApp({ hass: f.hass, http: oneSonarr() }, SONARR, servarrBinding(8989), KEY);
assert.equal(out.result, "equivalent");
@@ -250,13 +250,13 @@ test("servarr: node-a's entry at 127.0.0.1 reaches the same Sonarr the mesh boun
});
test("servarr: an entry already at the bound URL is unchanged", async () => {
const f = fakeHass({ entries: { sonarr: [sonarrEntry()] }, devices: [{ id: "d", config_entries: ["5a1d"], configuration_url: "http://node-a.internal:8989" }] });
const f = fakeHass({ entries: { sonarr: [sonarrEntry()] }, devices: [{ id: "d", config_entries: ["5a1d"], configuration_url: "http://ace.internal:8989" }] });
assert.deepEqual(await reconcileApp({ hass: f.hass, http: oneSonarr() }, SONARR, servarrBinding(8989), KEY), { what: "sonarr", result: "unchanged" });
});
test("servarr: a working entry that reaches a different app is refused, and nothing is removed", async () => {
const f = fakeHass({ entries: { sonarr: [sonarrEntry()] }, devices: [{ id: "d", config_entries: ["5a1d"], configuration_url: "http://127.0.0.1:8989" }] });
const two = apps({ "node-a.internal:8989": { startTime: "t1" }, "127.0.0.1:8989": { startTime: "another" } });
const two = apps({ "ace.internal:8989": { startTime: "t1" }, "127.0.0.1:8989": { startTime: "another" } });
const out = await reconcileApp({ hass: f.hass, http: two }, SONARR, servarrBinding(8989), KEY);
assert.equal(out.result, "refused");
assert.match((out as { problem: string }).problem, /never does/);
@@ -267,7 +267,7 @@ test("servarr: no entry — one is made at the bound URL through the user flow",
const f = fakeHass({ entries: {} });
const out = await reconcileApp({ hass: f.hass, http: oneSonarr() }, SONARR, servarrBinding(8989), KEY);
assert.deepEqual(out, { what: "sonarr", result: "written", fields: ["entry"] });
assert.deepEqual(f.submitted[0], { url: "http://node-a.internal:8989", api_key: KEY, more_options: { verify_ssl: false } });
assert.deepEqual(f.submitted[0], { url: "http://ace.internal:8989", api_key: KEY, more_options: { verify_ssl: false } });
});
test("servarr: a reauth Home Assistant started is finished with the bound URL and key", async () => {
@@ -276,10 +276,10 @@ test("servarr: a reauth Home Assistant started is finished with the bound URL an
entries: { radarr: [entry] },
reauth: [{ flow_id: "r1", handler: "radarr", step_id: "reauth_confirm", context: { source: "reauth", entry_id: "1955" } }],
});
const radarr = apps({ "node-a.internal:7878": { startTime: "t" } });
const radarr = apps({ "ace.internal:7878": { startTime: "t" } });
const out = await reconcileApp({ hass: f.hass, http: radarr }, RADARR, { ...servarrBinding(7878), provision: "radarr-api" }, KEY);
assert.deepEqual(out, { what: "radarr", result: "written", fields: ["api_key", "url"] });
assert.deepEqual(f.submitted[0], { url: "http://node-a.internal:7878", api_key: KEY, verify_ssl: false });
assert.deepEqual(f.submitted[0], { url: "http://ace.internal:7878", api_key: KEY, verify_ssl: false });
});
test("form values: suggested first, then default, sections nested", () => {
@@ -287,7 +287,7 @@ test("form values: suggested first, then default, sections nested", () => {
broker: "b", port: 1, protocol: "5", username: "u", password: PWD_NOT_CHANGED,
other_settings: { keepalive: 60, transport: "tcp", set_ca_cert: "off", set_client_cert: false },
});
assert.ok(sameUrl("http://node-a.internal:8989/", "http://node-a.internal:8989"));
assert.ok(sameUrl("http://NODE-A.internal", "http://node-a.internal:80"));
assert.ok(!sameUrl("http://127.0.0.1:8989", "http://node-a.internal:8989"));
assert.ok(sameUrl("http://ace.internal:8989/", "http://ace.internal:8989"));
assert.ok(sameUrl("http://ACE.internal", "http://ace.internal:80"));
assert.ok(!sameUrl("http://127.0.0.1:8989", "http://ace.internal:8989"));
});
+23 -28
View File
@@ -10,7 +10,6 @@ import (
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"testing"
)
@@ -204,41 +203,37 @@ func TestTheLoginManagersEntryRunsTheSessionsStart(t *testing.T) {
}
}
// Every module of the catalogue that contributes window-manager lines (novox/hq ADR 0212) is placed in
// the main file as the controller places them — in module order, each module's under a line naming it
// — and loaded as i3 would load them on a machine with all of them assigned: no two bind one key, and
// every line parses.
// The window-manager lines the catalogue's modules contribute to the main file (novox/hq ADR 0212) are kept in
// testdata/window-manager-lines.json, one entry per contributing module in module order, as the controller places
// them. The test reads that file, never the other modules' manifests: a module may not read a manifest (ADR 0495
// §1), and a test that reads its siblings' fails wherever they are not beside it (issue 1814). The main file and
// those lines are placed together — each module's under a line naming it — and loaded as i3 would load them on a
// machine with all of them assigned: no two bind one key, and every line parses.
func TestTheMainFileAndEveryModulesContributionLoadTogether(t *testing.T) {
i3, err := exec.LookPath("i3")
if err != nil {
t.Skip("no i3 here to check with")
}
dir := t.TempDir()
manifests, _ := filepath.Glob(filepath.Join("..", "..", "..", "*", "module.json"))
sort.Strings(manifests)
raw, err := os.ReadFile(filepath.Join("testdata", "window-manager-lines.json"))
if err != nil {
t.Fatal(err)
}
var contributions []struct {
Module string `json:"module"`
Content []string `json:"content"`
}
if err := json.Unmarshal(raw, &contributions); err != nil {
t.Fatal(err)
}
var placed strings.Builder
var found []string
for _, p := range manifests {
raw, _ := os.ReadFile(p)
var m struct {
Module string `json:"module"`
Contributions []struct {
Seat, Kind, Content string
} `json:"contributions"`
}
json.Unmarshal(raw, &m)
named := false
for _, c := range m.Contributions {
if c.Seat != "node-display-session" || c.Kind != "config" {
continue
}
if !named {
placed.WriteString("# " + m.Module + "\n")
named = true
found = append(found, m.Module)
}
placed.WriteString(c.Content)
if !strings.HasSuffix(c.Content, "\n") {
for _, c := range contributions {
placed.WriteString("# " + c.Module + "\n")
found = append(found, c.Module)
for _, content := range c.Content {
placed.WriteString(content)
if !strings.HasSuffix(content, "\n") {
placed.WriteString("\n")
}
}
@@ -0,0 +1,50 @@
[
{
"module": "asus-zephyrus-g14",
"content": [
"# The laptop's own lines in i3 (module asus-zephyrus-g14, novox/hq ADR 0208, ADR 0210). Owned by the\n# mesh: replaced at every push. Once the controller places contributions to node-display-session\n# (ADR 0210), these become the module's contribution instead of a file in i3's directory.\n#\n# The keys the firmware turns into ordinary key presses. The vendor keys that reach no X client are\n# triggerhappy's (/etc/triggerhappy/triggers.d/asus-g14.conf): M4 and Fn+F4/F5 for media, Fn+F7/F8 for\n# the panel, Fn+F10 for the touchpad. The keyboard backlight (Fn+F2/F3) is the firmware's and asusd's.\n\n# Fn+F6, the screenshot key: the firmware sends Super+Shift+S. Released before it runs, because the\n# screenshot grabs the pointer to select a region, which fails while the key is still held.\nbindsym --release $mod+Shift+s exec --no-startup-id $XDG_CONFIG_HOME/i3/scripts/screenshot.sh\n\n# Fn+F9, the display key: the firmware sends Super+P. Odd workspaces to the panel, even ones to the\n# external output.\nbindsym $mod+p exec --no-startup-id /usr/local/lib/example-laptop/bin/zephyrus-display order\n\n# The keyboard backlight's level, shown when it changes.\nexec --no-startup-id /usr/local/lib/example-laptop/bin/zephyrus-kbd-notify\n# The laptop's own lines in i3 (module asus-zephyrus-g14, novox/hq ADR 0208, ADR 0210). Owned by the\n# mesh: replaced at every push. Once the controller places contributions to node-display-session\n# (ADR 0210), these become the module's contribution instead of a file in i3's directory.\n#\n# The model's panel and touchpad.\n\n# The internal panel is the primary output, where the bars' tray goes. Which monitors are on and where\n# is the display server's (autorandr, run at every session start).\nexec --no-startup-id /usr/local/lib/example-laptop/bin/zephyrus-display primary\n\n# The touchpad's settings again, by hand. X applies them itself whenever the device appears.\nbindsym $mod+Shift+x exec --no-startup-id /usr/local/lib/example-laptop/bin/zephyrus-touchpad reset\n"
]
},
{
"module": "clipmenu",
"content": [
"# The clipboard's history key (module clipmenu, novox/hq ADR 0208). Owned by the mesh: replaced at\n# every push. clipmenu shows the history through `dmenu`, the node's dmenu-compatible command, which\n# the holder of node-launcher answers (rofi on the workstations); the chosen entry is put back on the\n# clipboard.\nbindsym $mod+period exec --no-startup-id clipmenu -p Clipboard\n"
]
},
{
"module": "dunst",
"content": [
"# The notifications' key (module dunst, novox/hq ADR 0208). Owned by the mesh: replaced at every push.\n# Super+N opens the menu (rofi) of the actions and links of every notification on screen, so one is\n# chosen from the keyboard as a tap chooses it on the notification.\nbindsym $mod+n exec --no-startup-id dunstctl context\n"
]
},
{
"module": "feh",
"content": [
"# The wallpaper's key (module feh, novox/hq ADR 0208). Owned by the mesh: replaced at every push.\n# It puts the declared wallpaper back, after a monitor change or a wallpaper set for the session.\nbindsym $mod+Shift+b exec --no-startup-id ~/.fehbg\n"
]
},
{
"module": "i3status-rust",
"content": [
"# The bars (module i3status-rust, novox/hq ADR 0208). Owned by the mesh: replaced at every push.\n# i3 reads this file through its configuration's `include ~/.config/i3/config.d/*.conf`. The bottom\n# bar shows the machine; the top bar the focused window and the tray, on the primary output. The\n# face is the monospace one every desktop module names.\nbar {\n font pango:JetBrainsMono Nerd Font 11\n position bottom\n status_command i3status-rs ~/.config/i3status-rust/bottom-bar.toml\n tray_output none\n colors {\n separator #ffffff\n background #000000\n statusline #ffffff\n # The text on an accent-coloured button is dark: light text on the accent was barely\n # legible.\n focused_workspace #de5200 #de5200 #000000\n active_workspace #de5200 #de5200 #000000\n inactive_workspace #000000 #000000 #ffffff\n urgent_workspace #2f343a #900000 #ffffff\n }\n}\n\nbar {\n font pango:JetBrainsMono Nerd Font 11\n position top\n status_command i3status-rs ~/.config/i3status-rust/top-bar.toml\n workspace_buttons no\n tray_output primary\n colors {\n separator #ffffff\n background #000000\n statusline #ffffff\n }\n}\n"
]
},
{
"module": "polychromatic",
"content": [
"# The Razer peripherals' tray (module polychromatic, novox/hq ADR 0208, ADR 0212). Owned by the mesh:\n# replaced at every push. This line is the tray's one start, at the session's start (exec, not\n# exec_always, so a reload starts nothing). The package's own login helper starts it too while the\n# application's setting \"Start the tray applet when I log on\" is ticked; polychromatic_check names\n# that as a second start.\nexec --no-startup-id polychromatic-tray-applet\n"
]
},
{
"module": "rofi",
"content": [
"# The launcher's key bindings (module rofi, novox/hq ADR 0208). Owned by the mesh: replaced at every\n# push. i3 reads this file through its configuration's `include ~/.config/i3/config.d/*.conf`, after\n# the variables it sets, so $mod is i3's.\nbindsym $mod+d exec --no-startup-id ~/.local/bin/rofi-launch drun\nbindsym $mod+t exec --no-startup-id ~/.local/bin/rofi-launch run\nbindsym $mod+Shift+t exec --no-startup-id ~/.local/bin/rofi-launch sudo\nbindsym $mod+Shift+w exec --no-startup-id ~/.local/bin/rofi-launch window\nbindsym $mod+Escape exec --no-startup-id ~/.local/bin/rofi-powermenu\n"
]
},
{
"module": "slack",
"content": [
"# Slack (module slack, novox/hq ADR 0208, ADR 0212). Owned by the mesh: replaced at every push.\n# Its one start: at login, to the tray (-s), with the arguments of the package's own desktop entry.\n# No XDG autostart entry starts it as well; slack_check names one if it appears.\nexec --no-startup-id /usr/bin/slack --gtk-version=3 -s\n# The chat window. Slack owns four X windows, and the only one i3 manages has the class \"slack\" in\n# lower case; the three of class \"Slack\" (the packaged entry's StartupWMClass) are its unmanaged\n# helpers and its tray icon, so a rule on class=\"Slack\" matches nothing. window_role keeps a call or\n# screen-share window out of the rules. $ws3 is i3's, in scope here.\nfor_window [class=\"(?i)^slack$\" window_role=\"browser-window\"] move to workspace $ws3\nfor_window [class=\"(?i)^slack$\" window_role=\"browser-window\"] floating disable\nfor_window [class=\"(?i)^slack$\" window_role=\"browser-window\"] border pixel 2\n"
]
}
]
@@ -261,8 +261,7 @@ func checkNoSecretsOrInstallationNames(t *testing.T) {
t.Fatal(err)
}
s := strings.ToLower(string(raw))
// 1838/AC1: the catalogue-wide check covers a node's name now; this list keeps what it does not.
for _, never := range []string{"/home/", "jochen", "api_key", ".hal/", "greenclip daemon"} {
for _, never := range []string{"/home/", "jochen", "g14", "shanks", "novox.be", "api_key", ".hal/", "greenclip daemon"} {
if strings.Contains(s, never) {
t.Errorf("module.json names %q", never)
}
+17 -17
View File
@@ -108,13 +108,13 @@ const grants = new ApiGrants(new InfluxDBClient(`http://127.0.0.1:${port}`, ADMI
const PW = "mesh-minted-password-of-forty-characters";
/** Grafana on node-a, as the mesh hands it to the provisioner. */
/** Grafana on ace, as the mesh hands it to the provisioner. */
function grafana(password = PW, values: Record<string, unknown> = { access: "read" }) {
return { as: "mesh_node-a_grafana", password, consumer: "node-a", values };
return { as: "mesh_ace_grafana", password, consumer: "ace", values };
}
/** Node-RED on node-a: writes one bucket. */
/** Node-RED on ace: writes one bucket. */
function nodered(password = PW, values: Record<string, unknown> = { access: "write", buckets: ["zurag"] }) {
return { as: "mesh_node-a_nodered", password, consumer: "node-a", values };
return { as: "mesh_ace_nodered", password, consumer: "ace", values };
}
function only(token: string): Rec {
@@ -144,7 +144,7 @@ test("what a contribution may ask for, and what is refused", () => {
test("a reader is given one authorization, reading every bucket of the org, under the mesh's password", async () => {
assert.equal(await grants.ensure(grafana()), "created");
const a = only("mesh_node-a_grafana");
const a = only("mesh_ace_grafana");
assert.equal(a.orgID, "org1");
assert.equal(a.status, "active");
assert.ok(a.description.startsWith(MARK));
@@ -158,8 +158,8 @@ test("a writer is allowed its own buckets only, and a missing one is made — ne
const made = buckets.find((b) => b.name === "printer");
assert.ok(made, "the missing bucket was created");
assert.deepEqual(made!.retentionRules, [], "kept for ever: retention is the operator's choice");
assert.deepEqual(perms(only("mesh_node-a_nodered")), [`write:buckets:${made!.id}`, "write:buckets:zb"]);
assert.equal(await grants.remove("mesh_node-a_nodered"), "removed");
assert.deepEqual(perms(only("mesh_ace_nodered")), [`write:buckets:${made!.id}`, "write:buckets:zb"]);
assert.equal(await grants.remove("mesh_ace_nodered"), "removed");
assert.equal(buckets.length, 2, "withdrawing the consumer leaves every bucket and its data");
});
@@ -168,30 +168,30 @@ test("applying the same grant again writes nothing", async () => {
calls = [];
assert.equal(await grants.ensure(grafana()), "unchanged");
assert.ok(calls.every((c) => c.startsWith("GET")), `only reads: ${calls.join(", ")}`);
only("mesh_node-a_grafana");
only("mesh_ace_grafana");
});
test("a rotated password is set in place; a changed access remakes only the mesh's own", async () => {
await grants.ensure(nodered());
const id = only("mesh_node-a_nodered").id;
const id = only("mesh_ace_nodered").id;
assert.equal(await grants.holds(nodered("rotated-password-0123456789")), false);
assert.equal(await grants.ensure(nodered("rotated-password-0123456789")), "updated");
assert.equal(only("mesh_node-a_nodered").id, id, "updated, not replaced");
assert.equal(only("mesh_ace_nodered").id, id, "updated, not replaced");
assert.equal(await grants.holds(nodered("rotated-password-0123456789")), true);
await grants.ensure(nodered(PW, { access: "read-write", buckets: ["zurag"] }));
assert.deepEqual(perms(only("mesh_node-a_nodered")), ["read:buckets:zb", "write:buckets:zb"]);
assert.deepEqual(perms(only("mesh_ace_nodered")), ["read:buckets:zb", "write:buckets:zb"]);
assert.equal(await grants.holds(nodered(PW, { access: "read-write", buckets: ["zurag"] })), true);
});
test("an authorization disabled, re-passworded or deleted behind the mesh's back is not held, and is made whole", async () => {
await grants.ensure(grafana());
only("mesh_node-a_grafana").status = "inactive";
only("mesh_ace_grafana").status = "inactive";
assert.equal(await grants.holds(grafana()), false);
assert.equal(await grants.ensure(grafana()), "updated");
assert.equal(await grants.holds(grafana()), true);
only("mesh_node-a_grafana").password = "somebody-else-set-this";
only("mesh_ace_grafana").password = "somebody-else-set-this";
assert.equal(await grants.holds(grafana()), false);
await grants.ensure(grafana());
assert.equal(await grants.holds(grafana()), true);
@@ -211,14 +211,14 @@ test("holds only reads, and a bucket gone missing is not held rather than made",
});
test("an authorization of the same name the mesh did not make is refused, and left exactly as it was", async () => {
auths = [{ id: "theirs", token: "mesh_node-a_grafana", orgID: "org1", status: "active", description: "hand-made",
auths = [{ id: "theirs", token: "mesh_ace_grafana", orgID: "org1", status: "active", description: "hand-made",
permissions: [{ action: "write", resource: { type: "buckets", orgID: "org1" } }], password: "their-password" }];
const before = JSON.stringify(auths);
await assert.rejects(grants.ensure(grafana()), /did not make/);
assert.equal(JSON.stringify(auths), before);
assert.ok(calls.every((c) => c.startsWith("GET")), `only reads: ${calls.join(", ")}`);
assert.equal(await grants.holds(grafana()), false);
assert.equal(await grants.remove("mesh_node-a_grafana"), "not ours");
assert.equal(await grants.remove("mesh_ace_grafana"), "not ours");
assert.equal(auths.length, 1, "never deleted");
});
@@ -240,7 +240,7 @@ test("an org the instance does not have, or a non-mesh name, makes nothing", asy
test("a withdrawn consumer's authorization is removed, and an absent one is not an error", async () => {
await grants.ensure(grafana());
assert.equal(await grants.remove("mesh_node-a_grafana"), "removed");
assert.equal(await grants.remove("mesh_ace_grafana"), "removed");
assert.equal(auths.length, 0);
assert.equal(await grants.remove("mesh_node-a_grafana"), "absent");
assert.equal(await grants.remove("mesh_ace_grafana"), "absent");
});
+4 -23
View File
@@ -1,9 +1,8 @@
# kernel
The kernel's run-time settings on a machine (novox/hq to-be 42 Phase 1, row 11). It holds two things:
**transparent huge pages**, set per machine, and **the fallback kernel** (`linux-lts` with its headers) and
its place on the boot menu (novox/hq ADR 0508 §9 and §10). The default kernel's package, and microcode, are
not built yet.
The kernel's run-time settings on a machine (novox/hq to-be 42 Phase 1, row 11). Today it holds one:
**transparent huge pages**, set per machine. The rest of the row (the kernel package, microcode, boot
entries) is not built yet.
## Why (novox/hq issue 746)
@@ -36,34 +35,16 @@ under the new mode. A failed rotation is said in the unit's journal and does not
rotation is made while the machine is still starting (`systemctl is-system-running` says `initializing`
or `starting`): nothing is in huge pages yet, and the persistent journal opens after this unit.
## The fallback kernel (novox/hq ADR 0508, issue 1841)
Every machine keeps a second, older kernel to start from, so a kernel upgrade that does not boot is not the
end of the machine. The module declares the packages `linux-lts` and `linux-lts-headers` (so every DKMS module is
built for the fallback kernel too) after its boot-entries service, so the entry is in place before the kernel is.
| | what | notes |
|---|---|---|
| file | `${dir:state}/boot-entries` | the script. It reads from the machine which loader booted it: the EFI variable `LoaderEntrySelected` present means systemd-boot, a `/boot/grub/grub.cfg` without it means GRUB, neither fails the unit loudly |
| | systemd-boot | writes `/boot/loader/entries/mesh-linux-lts.conf` from `/proc/cmdline` without `initrd=` and `BOOT_IMAGE=`, with the microcode images present in `/boot` and the fallback initramfs as initrd lines; `loader.conf` and the default entry are never touched |
| | GRUB | runs `grub-mkconfig -o /boot/grub/grub.cfg` only when the set of `/boot/vmlinuz-*` differs from the kernels the menu names, with `GRUB_TOP_LEVEL` naming the kernel the machine booted (`BOOT_IMAGE`; else the non-fallback kernel), since GRUB lists kernels in reverse version order and would otherwise make the fallback kernel the default; `/etc/default/grub` still overrides it when it sets one |
| | room | while `/boot/vmlinuz-linux-lts` is not there yet, less than 96 MiB free on `/boot` fails the unit and says so (the fallback kernel writes about 72 MB, issue 1836) |
| | a kernel-copying hook | a hook in `/etc/pacman.d/hooks` that the mesh did not write, triggered by kernel images and copying to one fixed file name in `/boot`, would put the fallback kernel in the default kernel's place: it is moved into `${dir:state}/taken-over/` and named in the unit's journal. Each kernel is then placed under its own package's name by the distribution's hooks |
| file | `/etc/systemd/system/mesh-kernel-boot-entries.service` | oneshot, sandboxed: the file system read-only but for `/boot`, `/etc/pacman.d/hooks` and the module's state; no home, no network |
| file | `/etc/pacman.d/hooks/90-mesh-kernel-boot-entries.hook` | runs the script after a kernel image is installed, upgraded or removed |
| packages | `linux-lts`, `linux-lts-headers` | declared after the service and the hook |
## Tools
| tool | | answers |
|---|---|---|
| `kernel_boot` | r | the loader (`systemd-boot`, `grub` or `none`), the menu's entries, the kernels in `/boot`, the entry booted (systemd-boot) or the kernel image booted (GRUB), whether the fallback kernel is on the menu with its kernel in `/boot`, and the room free on `/boot` in KiB |
| `kernel_huge_pages` | r | the mode, defrag and shared-memory mode; each per-size control and what `inherit` resolves to; huge pages by kind from `/proc/meminfo` (MiB); the units holding the most file pages in huge folios (`memory.stat` `file_thp`) with their dirty and writeback bytes, khugepaged's collapses and the bytes their cgroup wrote (`io.stat`, the most any one device saw: a mirror and its members each count the same write) |
## Tests
`go test ./...`: the tool over a stand-in tree holding the control node's own files of 2026-10-14; the
manifest's shape (the default, the order, the unit and its sandbox, reload, restart and health); the boot-entries script run with `sh` over stand-in roots (a systemd-boot entries directory, a GRUB menu, a hook that copies kernels to one fixed name): the entry from the booted command line, a menu generated again only when the kernels changed, no loader, no room, the taken-over hook; `kernel_boot` over the same roots; and the huge-pages script as the mesh
manifest's shape (the default, the order, the unit and its sandbox, reload, restart and health); and the script as the mesh
writes it, run with `sh` against a stand-in directory: it sets and reads back, keeps what it found once,
rotates the journal once and not for `always` nor while the machine starts, goes straight to a new value on a
reload without rewriting what it found, gives back what it found and then keeps nothing, refuses a value the kernel
-89
View File
@@ -1,89 +0,0 @@
package main
// kernel_boot: which loader booted the machine, its entries, the kernels in /boot, the entry it booted and
// whether the fallback kernel is on the menu (novox/hq ADR 0508 §9, issue 1841). Reads only.
import (
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"syscall"
"unicode/utf16"
)
const loaderSelected = "sys/firmware/efi/efivars/LoaderEntrySelected-4a67b082-0a4c-41cf-b6c7-440b29bb8c4f"
// BootState is what kernel_boot answers.
type BootState struct {
Loader string `json:"loader"`
Entries []string `json:"entries"`
Kernels []string `json:"kernels"`
Booted string `json:"booted"`
FallbackPresent bool `json:"fallback_present"`
BootFreeKiB int64 `json:"boot_free_kib"`
}
var grubKernel = regexp.MustCompile(`(?m)^\s*linux\s+(?:/boot)?/(vmlinuz-\S+)`)
var grubTitle = regexp.MustCompile(`(?m)^\s*menuentry\s+'([^']*)'`)
var bootImage = regexp.MustCompile(`BOOT_IMAGE=(?:/boot)?/?(\S+)`)
// Boot reads the machine, the loader the way the boot-entries script does: the loader's EFI variable means
// systemd-boot, a grub.cfg without it GRUB.
func (m *Machine) Boot() (*BootState, error) {
b := &BootState{Loader: "none", Entries: []string{}, Kernels: []string{}}
files, _ := filepath.Glob(m.path("boot/vmlinuz-*"))
for _, f := range files {
b.Kernels = append(b.Kernels, filepath.Base(f))
}
sort.Strings(b.Kernels)
hasKernel := func(name string) bool {
for _, k := range b.Kernels {
if k == name {
return true
}
}
return false
}
var s syscall.Statfs_t
if err := syscall.Statfs(m.path("boot"), &s); err == nil {
b.BootFreeKiB = int64(s.Bavail) * int64(s.Bsize) / 1024
}
if raw, err := os.ReadFile(m.path(loaderSelected)); err == nil {
b.Loader = "systemd-boot"
if len(raw) > 4 {
var u []uint16
for i := 4; i+1 < len(raw); i += 2 {
u = append(u, uint16(raw[i])|uint16(raw[i+1])<<8)
}
b.Booted = strings.TrimRight(string(utf16.Decode(u)), "\x00")
}
files, _ := filepath.Glob(m.path("boot/loader/entries/*.conf"))
for _, f := range files {
b.Entries = append(b.Entries, filepath.Base(f))
}
sort.Strings(b.Entries)
if raw, err := os.ReadFile(m.path("boot/loader/entries/mesh-linux-lts.conf")); err == nil {
b.FallbackPresent = strings.Contains(string(raw), "/vmlinuz-linux-lts") && hasKernel("vmlinuz-linux-lts")
}
return b, nil
}
if raw, err := os.ReadFile(m.path("boot/grub/grub.cfg")); err == nil {
b.Loader = "grub"
for _, t := range grubTitle.FindAllStringSubmatch(string(raw), -1) {
b.Entries = append(b.Entries, t[1])
}
for _, k := range grubKernel.FindAllStringSubmatch(string(raw), -1) {
if k[1] == "vmlinuz-linux-lts" && hasKernel("vmlinuz-linux-lts") {
b.FallbackPresent = true
}
}
if cmd, err := m.read("proc/cmdline"); err == nil {
if i := bootImage.FindStringSubmatch(cmd); i != nil {
b.Booted = i[1]
}
}
}
return b, nil
}
@@ -1,90 +0,0 @@
package main
// kernel_boot over stand-in roots: a systemd-boot machine with and without the fallback entry, a GRUB machine
// with and without the fallback kernel in its menu, and a machine with neither (novox/hq ADR 0508 §9, issue 1841).
import (
"os"
"path/filepath"
"testing"
)
const selectedVar = "sys/firmware/efi/efivars/LoaderEntrySelected-4a67b082-0a4c-41cf-b6c7-440b29bb8c4f"
func put(t *testing.T, root, p, content string) {
t.Helper()
full := filepath.Join(root, p)
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
t.Fatal(err)
}
write(t, full, content, 0o644)
}
func bootOf(t *testing.T, root string) *BootState {
t.Helper()
got, err := (&Machine{Root: root}).Boot()
if err != nil {
t.Fatal(err)
}
return got
}
func TestKernelBootReadsASystemdBootMachineWithItsFallbackEntry(t *testing.T) {
root := t.TempDir()
put(t, root, selectedVar, "\x07\x00\x00\x00a\x00r\x00c\x00h\x00.\x00c\x00o\x00n\x00f\x00\x00\x00")
put(t, root, "boot/loader/entries/arch.conf", defaultEntry)
put(t, root, "boot/loader/entries/mesh-linux-lts.conf", "title Arch Linux (linux-lts, fallback)\nlinux /vmlinuz-linux-lts\n")
put(t, root, "boot/vmlinuz-linux", "k")
put(t, root, "boot/vmlinuz-linux-lts", "k")
got := bootOf(t, root)
if got.Loader != "systemd-boot" || got.Booted != "arch.conf" || !got.FallbackPresent {
t.Fatalf("%+v", got)
}
if len(got.Entries) != 2 || got.Entries[0] != "arch.conf" || got.Entries[1] != "mesh-linux-lts.conf" {
t.Fatalf("entries: %v", got.Entries)
}
if len(got.Kernels) != 2 || got.Kernels[0] != "vmlinuz-linux" || got.Kernels[1] != "vmlinuz-linux-lts" {
t.Fatalf("kernels: %v", got.Kernels)
}
if got.BootFreeKiB <= 0 {
t.Fatalf("free: %d", got.BootFreeKiB)
}
}
func TestKernelBootSaysASystemdBootMachineHasNoFallbackYet(t *testing.T) {
root := t.TempDir()
put(t, root, selectedVar, "\x07\x00\x00\x00a\x00r\x00c\x00h\x00.\x00c\x00o\x00n\x00f\x00\x00\x00")
put(t, root, "boot/loader/entries/arch.conf", defaultEntry)
put(t, root, "boot/vmlinuz-linux", "k")
// The entry file alone is not the fallback: the kernel it names must be in /boot.
put(t, root, "boot/loader/entries/mesh-linux-lts.conf", "linux /vmlinuz-linux-lts\n")
if got := bootOf(t, root); got.FallbackPresent {
t.Fatalf("a fallback entry without its kernel: %+v", got)
}
}
func TestKernelBootReadsAGrubMachine(t *testing.T) {
root := t.TempDir()
put(t, root, "boot/grub/grub.cfg", grubMenu)
put(t, root, "boot/vmlinuz-linux", "k")
put(t, root, "proc/cmdline", bootedCmdline)
got := bootOf(t, root)
if got.Loader != "grub" || got.FallbackPresent || got.Booted != "vmlinuz-linux" {
t.Fatalf("%+v", got)
}
if len(got.Entries) != 2 || got.Entries[0] != "Arch Linux" {
t.Fatalf("entries: %v", got.Entries)
}
put(t, root, "boot/vmlinuz-linux-lts", "k")
put(t, root, "boot/grub/grub.cfg", grubMenu+"menuentry 'Arch Linux LTS' {\n\tlinux\t/boot/vmlinuz-linux-lts root=x rw\n}\n")
if got := bootOf(t, root); !got.FallbackPresent {
t.Fatalf("%+v", got)
}
}
func TestKernelBootSaysWhenThereIsNoLoader(t *testing.T) {
got := bootOf(t, t.TempDir())
if got.Loader != "none" || got.FallbackPresent || len(got.Entries) != 0 {
t.Fatalf("%+v", got)
}
}
@@ -1,284 +0,0 @@
package main
// The boot-entries script as the mesh writes it, run with sh over a stand-in root (novox/hq ADR 0508 §9 and
// §10, issue 1841). The fixtures are the shapes read from machines: a systemd-boot entries directory, a GRUB
// menu, a hook that copies every kernel image to the default kernel's name, and the command line a machine
// booted with.
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
const bootedCmdline = "initrd=\\initramfs-linux.img BOOT_IMAGE=/vmlinuz-linux root=UUID=2f6c1e0a-5c0e-4d52-9a57-1d8a2a7a3c11 rw quiet loglevel=3 initrd=\\intel-ucode.img\n"
const defaultEntry = "title Arch Linux\nlinux /vmlinuz-linux\ninitrd /intel-ucode.img\ninitrd /initramfs-linux.img\noptions root=UUID=2f6c1e0a-5c0e-4d52-9a57-1d8a2a7a3c11 rw quiet\n"
const loaderConf = "default arch.conf\ntimeout 3\nconsole-mode max\n"
// grubMenu is the part of a generated grub.cfg that names kernels, for a machine holding one kernel.
const grubMenu = `menuentry 'Arch Linux' --class arch {
set root='hd0,gpt2'
linux /boot/vmlinuz-linux root=UUID=2f6c1e0a-5c0e-4d52-9a57-1d8a2a7a3c11 rw quiet
initrd /boot/initramfs-linux.img
}
menuentry 'Arch Linux, with fallback initramfs' --class arch {
linux /boot/vmlinuz-linux root=UUID=2f6c1e0a-5c0e-4d52-9a57-1d8a2a7a3c11 rw quiet
initrd /boot/initramfs-linux-fallback.img
}
`
// kernelCopyingHook copies every installed kernel image to the default kernel's name on the boot partition.
const kernelCopyingHook = `[Trigger]
Operation = Install
Operation = Upgrade
Type = Path
Target = usr/lib/modules/*/vmlinuz
[Action]
Description = Copying the kernel to the default name
When = PostTransaction
Exec = /usr/bin/sh -c '/usr/bin/cp /usr/lib/modules/$(uname -r)/vmlinuz /boot/vmlinuz-linux'
`
// otherHook is a hook of the distribution's shape that has nothing to do with kernel images.
const otherHook = `[Trigger]
Operation = Upgrade
Type = Package
Target = pacman-mirrorlist
[Action]
Description = Updating the mirror list
When = PostTransaction
Exec = /usr/bin/cp /etc/pacman.d/mirrorlist.pacnew /etc/pacman.d/mirrorlist
`
type bootBed struct {
script, root, state, bin, mkconfig string
need string
}
func newBootBed(t *testing.T) bootBed {
t.Helper()
dir := t.TempDir()
b := bootBed{script: filepath.Join(dir, "boot-entries"), root: filepath.Join(dir, "root"), state: filepath.Join(dir, "state"),
bin: filepath.Join(dir, "bin"), mkconfig: filepath.Join(dir, "mkconfig")}
content := manifest(t).resource(t, "boot-entries").text(t)
content = strings.ReplaceAll(content, "${dir:state}", b.state)
if strings.Contains(content, "${") {
t.Fatalf("a substitution the test does not make is left in the script")
}
for _, d := range []string{b.root + "/boot", b.root + "/proc", b.root + "/etc/pacman.d/hooks", b.state, b.bin} {
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
}
write(t, b.script, content, 0o755)
write(t, b.root+"/proc/cmdline", bootedCmdline, 0o644)
// the stand-in grub-mkconfig records the kernel it was told to put first, then its arguments.
write(t, filepath.Join(b.bin, "grub-mkconfig"), "#!/bin/sh\necho \"top=$GRUB_TOP_LEVEL $@\" >>"+b.mkconfig+"\n", 0o755)
return b
}
func (b bootBed) put(t *testing.T, p, content string) {
t.Helper()
full := filepath.Join(b.root, p)
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
t.Fatal(err)
}
write(t, full, content, 0o644)
}
func (b bootBed) systemdBoot(t *testing.T) {
b.put(t, "sys/firmware/efi/efivars/LoaderEntrySelected-4a67b082-0a4c-41cf-b6c7-440b29bb8c4f", "\x07\x00\x00\x00a\x00r\x00c\x00h\x00.\x00c\x00o\x00n\x00f\x00\x00\x00")
b.put(t, "boot/loader/loader.conf", loaderConf)
b.put(t, "boot/loader/entries/arch.conf", defaultEntry)
b.put(t, "boot/vmlinuz-linux", "k")
}
func (b bootBed) run(t *testing.T) (string, error) {
t.Helper()
args := []string{b.script, "run", b.root, b.state}
if b.need != "" {
args = append(args, b.need)
}
cmd := exec.Command("sh", args...)
cmd.Env = append(os.Environ(), "PATH="+b.bin+string(os.PathListSeparator)+os.Getenv("PATH"))
out, err := cmd.CombinedOutput()
return string(out), err
}
func (b bootBed) read(t *testing.T, p string) string {
t.Helper()
raw, _ := os.ReadFile(filepath.Join(b.root, p))
return string(raw)
}
func TestASystemdBootMachineGetsTheFallbackEntryFromItsBootedCommandLine(t *testing.T) {
b := newBootBed(t)
b.systemdBoot(t)
b.put(t, "boot/intel-ucode.img", "u")
out, err := b.run(t)
if err != nil || !strings.Contains(out, "wrote") {
t.Fatalf("%v %s", err, out)
}
want := "title Arch Linux (linux-lts, fallback)\nlinux /vmlinuz-linux-lts\ninitrd /intel-ucode.img\ninitrd /initramfs-linux-lts.img\n" +
"options root=UUID=2f6c1e0a-5c0e-4d52-9a57-1d8a2a7a3c11 rw quiet loglevel=3\n"
if got := b.read(t, "boot/loader/entries/mesh-linux-lts.conf"); got != want {
t.Fatalf("entry:\n%s\nwant:\n%s", got, want)
}
// The default entry and the loader's own file are as they were.
if b.read(t, "boot/loader/entries/arch.conf") != defaultEntry || b.read(t, "boot/loader/loader.conf") != loaderConf {
t.Fatalf("the default entry or loader.conf was touched")
}
if out, err := b.run(t); err != nil || !strings.Contains(out, "as it was") {
t.Fatalf("again: %v %s", err, out)
}
}
func TestWithoutMicrocodeThereIsNoMicrocodeLine(t *testing.T) {
b := newBootBed(t)
b.systemdBoot(t)
if out, err := b.run(t); err != nil {
t.Fatalf("%v %s", err, out)
}
if got := b.read(t, "boot/loader/entries/mesh-linux-lts.conf"); strings.Contains(got, "ucode") || !strings.Contains(got, "initrd /initramfs-linux-lts.img") {
t.Fatalf("entry: %s", got)
}
}
func TestAGrubMachineIsGeneratedAgainOnlyWhenItsKernelsChanged(t *testing.T) {
b := newBootBed(t)
b.put(t, "boot/grub/grub.cfg", grubMenu)
b.put(t, "boot/vmlinuz-linux", "k")
if out, err := b.run(t); err != nil || !strings.Contains(out, "left as it is") {
t.Fatalf("same: %v %s", err, out)
}
if _, err := os.Stat(b.mkconfig); err == nil {
t.Fatalf("the menu was generated again with the same kernels")
}
b.put(t, "boot/vmlinuz-linux-lts", "k")
if out, err := b.run(t); err != nil || !strings.Contains(out, "generated again") {
t.Fatalf("changed: %v %s", err, out)
}
raw, _ := os.ReadFile(b.mkconfig)
if string(raw) != "top=/boot/vmlinuz-linux -o "+filepath.Join(b.root, "boot/grub/grub.cfg")+"\n" {
t.Fatalf("grub-mkconfig was asked %q", raw)
}
}
// ADR 0508 §9: GRUB's menu lists kernels in reverse version order, so linux-lts would come first and become the
// default entry (GRUB_DEFAULT=0); the kernel the machine booted is put first, and the fallback kernel never.
func TestGrubKeepsTheBootedKernelFirstAndNeverTheFallback(t *testing.T) {
for name, c := range map[string]struct{ cmdline, want string }{
"booted from the default kernel": {"BOOT_IMAGE=/vmlinuz-linux root=UUID=x rw\n", "top=/boot/vmlinuz-linux"},
"booted from a grub device path": {"BOOT_IMAGE=(hd0,msdos1)/vmlinuz-linux-zen root=UUID=x rw\n", "top=/boot/vmlinuz-linux-zen"},
"booted from the fallback kernel": {"BOOT_IMAGE=/vmlinuz-linux-lts root=UUID=x rw\n", "top=/boot/vmlinuz-linux"},
"no image on the command line": {"root=UUID=x rw\n", "top=/boot/vmlinuz-linux"},
"an image no longer in /boot": {"BOOT_IMAGE=/vmlinuz-linux-gone root=UUID=x rw\n", "top=/boot/vmlinuz-linux"},
} {
b := newBootBed(t)
b.put(t, "boot/grub/grub.cfg", grubMenu)
b.put(t, "proc/cmdline", c.cmdline)
for _, k := range []string{"vmlinuz-linux", "vmlinuz-linux-lts", "vmlinuz-linux-zen"} {
b.put(t, "boot/"+k, "k")
}
if out, err := b.run(t); err != nil {
t.Fatalf("%s: %v %s", name, err, out)
}
raw, _ := os.ReadFile(b.mkconfig)
if !strings.HasPrefix(string(raw), c.want+" -o ") {
t.Errorf("%s: grub-mkconfig was asked %q, want %s first", name, raw, c.want)
}
}
}
func TestAMachineWithNoLoaderFailsLoudly(t *testing.T) {
b := newBootBed(t)
out, err := b.run(t)
if err == nil || !strings.Contains(out, "no boot loader found") {
t.Fatalf("%v %s", err, out)
}
}
func TestABootPartitionWithoutRoomFailsBeforeAnythingIsWritten(t *testing.T) {
b := newBootBed(t)
b.systemdBoot(t)
b.need = "999999999999"
out, err := b.run(t)
if err == nil || !strings.Contains(out, "free room there before the kernel is installed") {
t.Fatalf("%v %s", err, out)
}
if b.read(t, "boot/loader/entries/mesh-linux-lts.conf") != "" {
t.Fatalf("an entry was written")
}
// Once the fallback kernel is in place the room is not asked for again.
b.put(t, "boot/vmlinuz-linux-lts", "k")
if out, err := b.run(t); err != nil {
t.Fatalf("%v %s", err, out)
}
}
func TestAHookThatCopiesKernelsToOneFixedNameIsMovedIntoTheStateAndNamed(t *testing.T) {
b := newBootBed(t)
b.systemdBoot(t)
b.put(t, "etc/pacman.d/hooks/60-copy-kernel.hook", kernelCopyingHook)
b.put(t, "etc/pacman.d/hooks/70-mirrorlist.hook", otherHook)
b.put(t, "etc/pacman.d/hooks/90-mesh-kernel-boot-entries.hook", "[Trigger]\nTarget = usr/lib/modules/*/vmlinuz\n[Action]\nExec = /bin/cp x /boot/y\n")
out, err := b.run(t)
if err != nil || !strings.Contains(out, "taken over: the pacman hook 60-copy-kernel.hook") {
t.Fatalf("%v %s", err, out)
}
if b.read(t, "etc/pacman.d/hooks/60-copy-kernel.hook") != "" {
t.Fatalf("the copying hook is still in the hook directory")
}
if raw, _ := os.ReadFile(filepath.Join(b.state, "taken-over", "60-copy-kernel.hook")); string(raw) != kernelCopyingHook {
t.Fatalf("moved hook: %q", raw)
}
if b.read(t, "etc/pacman.d/hooks/70-mirrorlist.hook") != otherHook || b.read(t, "etc/pacman.d/hooks/90-mesh-kernel-boot-entries.hook") == "" {
t.Fatalf("a hook that copies no kernel was moved")
}
}
func TestTheBootEntriesUnitAndHookAreTheModules(t *testing.T) {
m := manifest(t)
unit := m.resource(t, "boot-unit").text(t)
for _, line := range []string{"Type=oneshot", "RemainAfterExit=yes", "ExecStart=${dir:state}/boot-entries run", "ProtectSystem=strict",
"ReadWritePaths=/boot ${dir:state} /etc/pacman.d/hooks", "NoNewPrivileges=yes", "WantedBy=multi-user.target"} {
if !strings.Contains(unit, line+"\n") {
t.Errorf("the unit lacks %q", line)
}
}
hook := m.resource(t, "boot-hook")
text := hook.text(t)
if hook["path"] != "/etc/pacman.d/hooks/90-mesh-kernel-boot-entries.hook" || !strings.Contains(text, "Target = usr/lib/modules/*/vmlinuz\n") ||
!strings.Contains(text, "When = PostTransaction\n") || !strings.Contains(text, "Exec = ${dir:state}/boot-entries run\n") {
t.Fatalf("the hook: %v\n%s", hook["path"], text)
}
apply := m.resource(t, "boot-apply")
if apply["unit"] != "mesh-kernel-boot-entries.service" || apply["state"] != "running" || apply["boot"] != "enabled" {
t.Fatalf("apply: %v", apply)
}
}
// The fallback kernel and its headers are declared after the boot-entries service, so the entry is placed
// before the kernel is installed and every DKMS module is built for it (ADR 0508 §9).
func TestTheFallbackKernelAndItsHeadersAreDeclaredAfterTheBootEntriesUnit(t *testing.T) {
m := manifest(t)
at := map[string]int{}
for i, r := range m.Resources {
at[r["id"].(string)] = i
}
for id, pkg := range map[string]string{"linux-lts": "linux-lts", "linux-lts-headers": "linux-lts-headers"} {
r := m.resource(t, id)
if r["type"] != "package" || r["package"] != pkg || r["absent"] == true {
t.Errorf("%s: %v", id, r)
}
if at[id] < at["boot-apply"] || at[id] < at["boot-hook"] {
t.Errorf("%s is declared before the boot-entries unit and hook", id)
}
}
}
-10
View File
@@ -46,15 +46,5 @@ func tools(m *Machine) []stdio.Tool {
return m.HugePages(n)
},
},
{
Name: "kernel_boot",
Description: "How this machine boots and whether the fallback kernel is on its menu (novox/hq ADR 0508 §9): the loader that booted it " +
"(systemd-boot, read from the EFI variable it sets, grub, or none), the menu's entries, the kernels in /boot, the entry " +
"the machine booted (systemd-boot) or the kernel image it booted (grub), whether the fallback kernel linux-lts is on " +
"the menu with its kernel in /boot, and the room free on /boot in KiB (the fallback kernel needs about 72 MB). " +
"Replaces ls /boot, bootctl list, reading grub.cfg and df /boot. (r)",
Input: schema(map[string]any{}),
Run: func(args map[string]any) (any, error) { return m.Boot() },
},
}
}
@@ -24,7 +24,7 @@ func TestTheDefaultIsMadviseAndTheUnitFollowsTheSetting(t *testing.T) {
for _, r := range m.Resources {
ids = append(ids, r["id"].(string))
}
if strings.Join(ids, " ") != "state huge-pages unit apply boot-entries boot-unit boot-hook boot-apply linux-lts linux-lts-headers" {
if strings.Join(ids, " ") != "state huge-pages unit apply" {
t.Fatalf("order: %v", ids)
}
script := m.resource(t, "huge-pages")
@@ -1,13 +0,0 @@
# Module kernel, novox/hq ADR 0508 §9, issue 1841. After a kernel image is installed, upgraded or removed, the
# fallback kernel's boot entry (or the GRUB menu) is placed again. Written by the mesh; change the module.
[Trigger]
Operation = Install
Operation = Upgrade
Operation = Remove
Type = Path
Target = usr/lib/modules/*/vmlinuz
[Action]
Description = Placing the fallback kernel on the boot menu
When = PostTransaction
Exec = ${dir:state}/boot-entries run
-133
View File
@@ -1,133 +0,0 @@
#!/bin/sh
# The fallback kernel's place on this machine's boot menu (module kernel, novox/hq ADR 0508 §9 and §10,
# issue 1841). Written by the mesh and run as root by mesh-kernel-boot-entries.service at boot and when the
# module is applied, and by the pacman hook 90-mesh-kernel-boot-entries.hook after a kernel is installed,
# upgraded or removed. Replaced on every push; change the module, never this file.
#
# boot-entries run [<root> [<this module's state> [<room needed in /boot, KiB>]]]
#
# The three are arguments only so the module's tests can run this against a stand-in tree.
#
# It reads from the machine which loader booted it, never from a setting:
# systemd-boot the EFI variable LoaderEntrySelected is present: the entry mesh-linux-lts.conf is written
# from the command line the machine booted with.
# GRUB /boot/grub/grub.cfg exists and that variable does not: the menu is generated again, only
# when the set of kernels in /boot differs from the menu's.
# neither it fails loudly. The default entry is never touched.
# And a pacman hook of someone else's that copies kernel images to one fixed name in /boot is moved into this
# module's state first (ADR 0508 §10): it would put the fallback kernel in the default kernel's place.
set -eu
root=/
state='${dir:state}'
[ $# -ge 2 ] && root=$(printf '%s' "$2" | sed 's|/$||')
[ $# -ge 3 ] && state=$3
boot=$root/boot
hooks=$root/etc/pacman.d/hooks
efivar=$root/sys/firmware/efi/efivars/LoaderEntrySelected-4a67b082-0a4c-41cf-b6c7-440b29bb8c4f
ours=90-mesh-kernel-boot-entries.hook
# The fallback kernel and its initramfs are about 72 MB in /boot (issue 1836): less room fails the unit
# before a package writes half of them.
need_kib=98304
[ $# -ge 4 ] && need_kib=$4
[ $# -ge 1 ] && [ "$1" = run ] || {
echo "usage: boot-entries run [<root> [<state directory> [<KiB needed>]]]" >&2
exit 2
}
# A hook that is not ours, triggered by kernel images, whose action copies to one fixed name in /boot.
copies_kernels() {
grep -Eqi '^[[:space:]]*Target[[:space:]]*=.*(vmlinuz|usr/lib/modules)' "$1" &&
grep -Eq '^[[:space:]]*Exec[[:space:]]*=.*(cp|install|ln|rsync)[[:space:]].*/boot/[A-Za-z0-9._+-]+([^A-Za-z0-9._+/%$*-]|$)' "$1"
}
if [ -d "$hooks" ]; then
for hook in "$hooks"/*.hook; do
[ -f "$hook" ] || continue
[ "$(basename "$hook")" = "$ours" ] && continue
copies_kernels "$hook" || continue
mkdir -p "$state/taken-over"
mv -f "$hook" "$state/taken-over/$(basename "$hook")"
echo "taken over: the pacman hook $(basename "$hook") copied kernel images to one fixed name in /boot; moved to $state/taken-over/ so each kernel keeps its own package's name"
done
fi
if [ -e "$efivar" ]; then
loader=systemd-boot
elif [ -f "$boot/grub/grub.cfg" ]; then
loader=grub
else
echo "no boot loader found: the machine was not booted by systemd-boot (no LoaderEntrySelected variable) and there is no $boot/grub/grub.cfg; the fallback kernel has no menu to be placed on" >&2
exit 1
fi
# Room first, only while the fallback kernel is not in /boot yet.
if [ ! -e "$boot/vmlinuz-linux-lts" ]; then
free=$(df -Pk "$boot" | awk 'NR==2 {print $4}')
[ -n "$free" ] || free=0
if [ "$free" -lt "$need_kib" ]; then
echo "$boot has $free KiB free and the fallback kernel needs $need_kib KiB: free room there before the kernel is installed" >&2
exit 1
fi
fi
case "$loader" in
systemd-boot)
entries=$boot/loader/entries
cmdline=$(sed -e 's/BOOT_IMAGE=[^ ]*//g' -e 's/initrd=[^ ]*//g' "$root/proc/cmdline" | tr -s ' \n' ' ' | sed -e 's/^ //' -e 's/ $//')
[ -n "$cmdline" ] || {
echo "$root/proc/cmdline holds no command line to boot the fallback kernel with" >&2
exit 1
}
want=$(
echo "title Arch Linux (linux-lts, fallback)"
echo "linux /vmlinuz-linux-lts"
for ucode in intel-ucode.img amd-ucode.img; do
[ -e "$boot/$ucode" ] && echo "initrd /$ucode"
done
echo "initrd /initramfs-linux-lts.img"
echo "options $cmdline"
)
mkdir -p "$entries"
if [ "$(cat "$entries/mesh-linux-lts.conf" 2>/dev/null || true)" = "$want" ]; then
echo "systemd-boot: mesh-linux-lts.conf, as it was"
else
printf '%s\n' "$want" >"$entries/mesh-linux-lts.conf"
echo "systemd-boot: wrote $entries/mesh-linux-lts.conf from the command line the machine booted with; the default entry is as it was"
fi
;;
grub)
cfg=$boot/grub/grub.cfg
menu=$(sed -n 's|^[[:space:]]*linux[[:space:]]*\(/boot\)\{0,1\}/\(vmlinuz-[^[:space:]]*\).*|\2|p' "$cfg" | sort -u)
have=$(cd "$boot" && ls -d vmlinuz-* 2>/dev/null | sort -u || true)
if [ "$menu" = "$have" ]; then
echo "grub: the menu lists the kernels in /boot, left as it is"
else
# GRUB lists kernels in reverse version order, so the fallback kernel would come first and become the
# default entry: the kernel the machine booted is put first (GRUB_TOP_LEVEL; /etc/default/grub still
# overrides it when it sets one), the fallback kernel never.
top=$(tr ' ' '\n' <"$root/proc/cmdline" | sed -n 's|^BOOT_IMAGE=\(.*\)$|\1|p' | head -n 1)
top=$(printf '%s' "$top" | sed 's|.*/||')
case "$top" in
vmlinuz-linux-lts | "") top= ;;
esac
[ -n "$top" ] && [ -e "$boot/$top" ] || top=
if [ -z "$top" ]; then
if [ -e "$boot/vmlinuz-linux" ]; then
top=vmlinuz-linux
else
top=$(printf '%s\n' $have | grep -v '^vmlinuz-linux-lts$' | head -n 1 || true)
fi
fi
if [ -n "$top" ]; then
GRUB_TOP_LEVEL=/boot/$top grub-mkconfig -o "$cfg"
else
grub-mkconfig -o "$cfg"
fi
first=$top
[ -n "$first" ] || first="the menu's own order"
echo "grub: the kernels in /boot changed ($(echo $have | tr '\n' ' ')); the menu is generated again with $first first; the default entry is as it was"
fi
;;
esac
@@ -1,22 +0,0 @@
[Unit]
Description=The fallback kernel's place on this machine's boot menu
# Module kernel, novox/hq ADR 0508 §9, issue 1841. Starting this unit reads which loader booted the machine and
# places the fallback kernel's boot entry (systemd-boot) or generates the menu again (GRUB); it fails, and says
# why, when there is no loader or the boot partition has no room. The kernel packages are declared after this
# unit, so the entry is in place before they are installed.
After=local-fs.target
Wants=local-fs.target
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=${dir:state}/boot-entries run
# It writes the boot partition, the pacman hook directory and its own state, and nothing else.
ProtectSystem=strict
ReadWritePaths=/boot ${dir:state} /etc/pacman.d/hooks
ProtectHome=yes
PrivateNetwork=yes
NoNewPrivileges=yes
[Install]
WantedBy=multi-user.target
+1 -48
View File
@@ -13,8 +13,7 @@
}
},
"tools": [
"kernel_huge_pages",
"kernel_boot"
"kernel_huge_pages"
],
"resources": [
{
@@ -54,52 +53,6 @@
"health": {
"kind": "unit"
}
},
{
"id": "boot-entries",
"type": "file",
"path": "${dir:state}/boot-entries",
"mode": "0755",
"trusted": true,
"template": "files/boot-entries.sh"
},
{
"id": "boot-unit",
"type": "file",
"path": "/etc/systemd/system/mesh-kernel-boot-entries.service",
"mode": "0644",
"template": "files/mesh-kernel-boot-entries.service"
},
{
"id": "boot-hook",
"type": "file",
"path": "/etc/pacman.d/hooks/90-mesh-kernel-boot-entries.hook",
"mode": "0644",
"template": "files/90-mesh-kernel-boot-entries.hook"
},
{
"id": "boot-apply",
"type": "service",
"unit": "mesh-kernel-boot-entries.service",
"state": "running",
"boot": "enabled",
"restart-on": [
"boot-unit",
"boot-entries"
],
"health": {
"kind": "unit"
}
},
{
"id": "linux-lts",
"type": "package",
"package": "linux-lts"
},
{
"id": "linux-lts-headers",
"type": "package",
"package": "linux-lts-headers"
}
],
"build": {
@@ -52,7 +52,7 @@ type guardWorld struct {
func newGuardWorld(t *testing.T) *guardWorld {
w := &guardWorld{now: time.Date(2026, 10, 5, 23, 55, 0, 0, time.UTC)}
w.f = newFakeKeycloak(t, "NodeC", "old-password-from-2022")
w.f = newFakeKeycloak(t, "Novox", "old-password-from-2022")
w.x = &fakeExec{f: w.f}
w.g = &Guard{KC: w.f.client("the-mesh-minted-this"), Exec: w.x,
Now: func() time.Time { return w.now },
@@ -195,7 +195,7 @@ func TestWhileTheAdminIsRefusedTheProvisionerDoesNotAskKeycloak(t *testing.T) {
w := newGuardWorld(t)
w.x.fails = true
w.g.Ensure(ctx, true, false)
a := provisioner{clients: OidcClients{KC: w.g.KC, Realm: "NodeC"}, guard: w.g,
a := provisioner{clients: OidcClients{KC: w.g.KC, Realm: "Novox"}, guard: w.g,
announce: func(string, map[string]any) {}, log: func(string, ...any) {}}
logins := w.f.logins
err := a.Create(ctx, grafana("s", nil))
@@ -91,13 +91,13 @@ func movedWorld(t *testing.T, h *harness.Harness) (f *fakeKeycloak, start func()
}
}
// 1332/AC1: a consumer only the grant file gives (oidc-client for node-c.mesh-mail-web, not moved) is wanted and
// 1332/AC1: a consumer only the grant file gives (oidc-client for novox.mesh-mail-web, not moved) is wanted and
// Adapter.Create is asked for its client, though keycloak runs on the moved path.
func TestAGrantFileConsumerNotMovedIsMadeOnTheMovedPath(t *testing.T) {
mailWeb := "mesh_node_c_mail_web"
receives := grantGives(t, map[string]any{"as": mailWeb, "node": "node-c", "values": map[string]any{
mailWeb := "mesh_novox_mail_web"
receives := grantGives(t, map[string]any{"as": mailWeb, "node": "novox", "values": map[string]any{
"label": "mail-web", "endpoint": "web", "callback": "/login/generic_oauth", "name": "mail.example.org"}})
h := &harness.Harness{Resource: ProvisionName, Receives: receives, Node: "node-c"}
h := &harness.Harness{Resource: ProvisionName, Receives: receives, Node: "novox"}
f, start := movedWorld(t, h)
start()
waitUntil(t, time.Second, func() bool {
@@ -113,13 +113,13 @@ func TestAGrantFileConsumerNotMovedIsMadeOnTheMovedPath(t *testing.T) {
})
}
// 1332/AC2: a grant-file consumer whose client already exists in the backend (mesh_node_a_grafana) counts as asked and
// 1332/AC2: a grant-file consumer whose client already exists in the backend (mesh_ace_grafana) counts as asked and
// is not held for retirement on the moved path.
func TestAGrantFileConsumerAlreadyInKeycloakIsNotRetiredOnTheMovedPath(t *testing.T) {
grafana := "mesh_node_a_grafana"
receives := grantGives(t, map[string]any{"as": grafana, "node": "node-a", "values": map[string]any{
grafana := "mesh_ace_grafana"
receives := grantGives(t, map[string]any{"as": grafana, "node": "ace", "values": map[string]any{
"label": "grafana", "endpoint": "web", "callback": "/login/generic_oauth", "name": "grafana.example.org"}})
h := &harness.Harness{Resource: ProvisionName, Receives: receives, Node: "node-a",
h := &harness.Harness{Resource: ProvisionName, Receives: receives, Node: "ace",
StablePasses: 1, StableFor: time.Millisecond}
f, start := movedWorld(t, h)
// Seed the backend with the client the mesh made earlier, exactly as grafana's already is — before anything
@@ -8,7 +8,7 @@ import "testing"
// 1332/AC3: the moved-path harness, built from the environment, reads MESH_RECEIVES the way the grant path does
// (main.go's other branch, line 67), as postgres's moved harness does (postgres-provider/main.go:43).
func TestTheMovedPathHarnessReadsMESH_RECEIVESAsTheGrantPathDoes(t *testing.T) {
env := map[string]string{"MESH_RECEIVES": "/grants/mesh.json", "MESH_NODE": "node-c"}
env := map[string]string{"MESH_RECEIVES": "/grants/mesh.json", "MESH_NODE": "novox"}
getenv := func(k string) string { return env[k] }
h := movedHarness(getenv, provisioner{})
if h.Receives != env["MESH_RECEIVES"] {
@@ -14,7 +14,7 @@ import (
"git.novox.be/novox/mesh-sdk/go/provider/harness"
)
const testIssuer = "https://id.example.org/realms/NodeC"
const testIssuer = "https://id.example.org/realms/Novox"
// keycloak's own part of the moved path (issue 849): the shared driving of it is proven in the SDK's harness package.
@@ -143,12 +143,12 @@ func TestBothPathsLeaveTheSameClientInKeycloak(t *testing.T) {
})
viaFile := made(func(p provisioner) {
list, _ := json.Marshal([]provider.ConsumerCredential{{Consumer: "home", Module: "grafana",
Credential: provider.PairName("node-c", ProvisionName, "home", "grafana", ""), Value: "pw-" + grafana}})
Credential: provider.PairName("novox", ProvisionName, "home", "grafana", ""), Value: "pw-" + grafana}})
file := filepath.Join(t.TempDir(), "consumers.json")
if err := os.WriteFile(file, list, 0o600); err != nil {
t.Fatal(err)
}
h := &harness.Harness{Resource: ProvisionName, Adapter: p, Node: "node-c",
h := &harness.Harness{Resource: ProvisionName, Adapter: p, Node: "novox",
Now: func() time.Time { return now }, Log: func(string, ...any) {}}
run, _, _, err := moved(file, h, testIssuer)
if err != nil {
@@ -156,10 +156,10 @@ func TestBothPathsLeaveTheSameClientInKeycloak(t *testing.T) {
}
bus := &bucketBus{buckets: map[string]map[string][]byte{}}
plan, _ := json.Marshal(map[string]any{"node": "home", "module": "grafana", "provisioning": map[string]any{
"provisions": []any{map[string]any{"provision": ProvisionName, "provider": "node-c",
"provisions": []any{map[string]any{"provision": ProvisionName, "provider": "novox",
"provider-module": "keycloak", "contributes": grafanaContributes, "names": grafanaNames}}}})
_ = bus.Put(ctx, provider.AssignmentsBucket, "home.grafana", plan)
pr := provider.New(provider.Options{Node: "node-c", Module: "keycloak", Bus: bus,
pr := provider.New(provider.Options{Node: "novox", Module: "keycloak", Bus: bus,
Vault: provider.ConsumersFile{Paths: []string{file}},
Offers: map[string]provider.Offer{ProvisionName: {Credential: provider.PerConsumer,
Serves: servedValues(testIssuer)}},
@@ -64,7 +64,7 @@ func RolesMapper() Rep {
var realmPath = regexp.MustCompile(`/realms/([^/]+)/?$`)
// RealmOf is the realm named by an issuer URL — `https://id.example/realms/NodeC` is realm `NodeC`.
// RealmOf is the realm named by an issuer URL — `https://id.example/realms/Novox` is realm `Novox`.
// The issuer is the one value an assignment sets, so the realm is read out of it rather than set a
// second time where the two could disagree.
func RealmOf(issuer string) (string, error) {
@@ -15,8 +15,8 @@ import (
)
func oidcWorld(t *testing.T) (*fakeKeycloak, OidcClients) {
f := newFakeKeycloak(t, "NodeC", "pw")
return f, OidcClients{KC: f.client("pw"), Realm: "NodeC"}
f := newFakeKeycloak(t, "Novox", "pw")
return f, OidcClients{KC: f.client("pw"), Realm: "Novox"}
}
// grafana is a dashboard on the home server, as the mesh hands it to the provisioner.
@@ -53,8 +53,8 @@ func mustHold(t *testing.T, o OidcClients, p harness.Provision, want bool) {
func TestTheRealmIsReadOutOfTheIssuer(t *testing.T) {
for issuer, want := range map[string]string{
"https://id.example.org/realms/NodeC": "NodeC",
"https://id.example.org/realms/NodeC/": "NodeC",
"https://id.example.org/realms/Novox": "Novox",
"https://id.example.org/realms/Novox/": "Novox",
"http://127.0.0.1:18500/realms/master": "master",
} {
if got, err := RealmOf(issuer); err != nil || got != want {
-1
View File
@@ -1,4 +1,3 @@
MESH_LAB_FORGE=${config:forge}
MESH_LAB_FORGE_OWNER=${config:forge-owner}
MESH_LAB_FORGE_BINDING=${dir:state}/package-registry.json
MESH_LAB_FORGE_SECRET=${dir:state}/package-registry.secret
-6
View File
@@ -37,12 +37,6 @@
"description": "The forge the lab's machines take their packages from, as its address: the operator's, with no default (novox/hq ADR 0112).",
"trusted": true,
"required": true
},
"forge-owner": {
"type": "string",
"description": "The owner on the forge the mesh's repositories and npm packages are under, such as the organisation that holds them: the operator's, with no default (novox/hq ADR 0507 §9).",
"trusted": true,
"required": true
}
},
"resources": [
+2 -2
View File
@@ -60,8 +60,8 @@ test("no run starts while the base image is rebuilt", () => {
});
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", "mesh-org", "/work/.base-x.git-credentials");
assert.match(s, /clone --quiet --depth 1 'https:\/\/forge.invalid\/mesh-org\/mesh-lab.git'/);
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");
});
-26
View File
@@ -1,26 +0,0 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
// The forge's owner differs between meshes (novox/hq ADR 0507 §9, issue 1838): the lab takes it as a config value
// the operator gives, with no default, and no file of the lab names it.
// 1838/AC4
const here = join(import.meta.dirname, "..");
test("the forge's owner is a required config value with no default, and reaches the lab's env-file", () => {
const manifest = JSON.parse(readFileSync(join(here, "module.json"), "utf8")) as { config: Record<string, Record<string, unknown>> };
const owner = manifest.config["forge-owner"];
assert.ok(owner, "the manifest declares no forge-owner");
assert.equal(owner.required, true);
assert.ok(!("default" in owner), `forge-owner has the default ${JSON.stringify(owner.default)}`);
assert.match(readFileSync(join(here, "files", "lab.env"), "utf8"), /^MESH_LAB_FORGE_OWNER=\$\{config:forge-owner\}$/m);
});
test("no file of the lab's code names an owner on the forge", () => {
for (const f of readdirSync(join(here, "tools")).filter((f) => f.endsWith(".ts"))) {
const src = readFileSync(join(here, "tools", f), "utf8");
assert.doesNotMatch(src, /\}\/[a-z][\w.-]*\/\$\{repo\}|\}\/[a-z][\w.-]*\/mesh-lab\.git|\/api\/packages\/[a-z][\w.-]*\/npm\//, f);
}
});
+66 -66
View File
@@ -92,8 +92,8 @@ test("a run fails at its setup, naming the checkout, when nothing of it can be i
// checkouts are.
test("a run checks out mesh-tools-go and says where its checkouts are", () => {
assert.ok(REPOSITORIES.includes("mesh-tools-go"));
const s = script("/work", "run-x", "http://forge", "mesh-org", ["test/integration/genesis-keeps-its-bus.test.ts"], {});
assert.match(s, /mesh-org\/mesh-tools-go\.git' '\/work\/run-x\/mesh-tools-go'/);
const s = script("/work", "run-x", "http://forge", ["test/integration/genesis-keeps-its-bus.test.ts"], {});
assert.match(s, /novox\/mesh-tools-go\.git' '\/work\/run-x\/mesh-tools-go'/);
assert.match(s, /export MESH_LAB_REPOS='\/work\/run-x'/);
assert.doesNotMatch(s, /cd '\/work\/run-x'\/\$repo && npm ci/);
for (const repo of NPM_REPOSITORIES) assert.ok(s.includes(`'/work/run-x/${repo}'/package-lock.json`), repo);
@@ -113,22 +113,22 @@ function aBinding(as: string, secret: string): { binding: string; secret: string
}
test("the forge credential is the bound account and its sealed secret, for the forge's own origin", () => {
const b = aBinding("lab-node-d", "s3cret-of-forty-characters");
assert.equal(forgeCredential("http://node-c.internal:20000", b.binding, b.secret),
"http://lab-node-d:s3cret-of-forty-characters@node-c.internal:20000");
assert.equal(forgeCredential("http://node-c.internal:20000/", b.binding, ""), undefined);
const b = aBinding("lab-shanks", "s3cret-of-forty-characters");
assert.equal(forgeCredential("http://novox.internal:20000", b.binding, b.secret),
"http://lab-shanks:s3cret-of-forty-characters@novox.internal:20000");
assert.equal(forgeCredential("http://novox.internal:20000/", b.binding, ""), undefined);
assert.equal(forgeCredential("", b.binding, b.secret), undefined);
assert.equal(forgeCredential("http://node-c.internal:20000", "/nowhere", b.secret), undefined);
assert.equal(forgeCredential("http://novox.internal:20000", "/nowhere", b.secret), undefined);
});
test("a run clones through git's credential store, and its script carries no secret", () => {
const b = aBinding("lab-node-d", "s3cret-of-forty-characters");
const credential = forgeCredential("http://node-c.internal:20000", b.binding, b.secret);
const b = aBinding("lab-shanks", "s3cret-of-forty-characters");
const credential = forgeCredential("http://novox.internal:20000", b.binding, b.secret);
const path = credentialPath("/work", "run-x");
const s = script("/work", "run-x", "http://node-c.internal:20000", "mesh-org", ["t.test.ts"], {}, path);
const s = script("/work", "run-x", "http://novox.internal:20000", ["t.test.ts"], {}, path);
assert.ok(credential);
assert.doesNotMatch(s, /s3cret/);
assert.match(s, /git -c credential\.helper= -c 'credential\.helper=store --file=\/work\/\.run-x\.git-credentials' clone --quiet --depth 50 --branch 'main' 'http:\/\/node-c\.internal:20000\/mesh-org\/mesh-tools-go\.git'/);
assert.match(s, /git -c credential\.helper= -c 'credential\.helper=store --file=\/work\/\.run-x\.git-credentials' clone --quiet --depth 50 --branch 'main' 'http:\/\/novox\.internal:20000\/novox\/mesh-tools-go\.git'/);
assert.match(s, /export GIT_TERMINAL_PROMPT=0/);
assert.equal(path.startsWith("/work/run-x/"), false, "the credential is kept out of the run's own directory");
assert.equal(credentialArgs(undefined), "");
@@ -139,10 +139,10 @@ test("a run clones through git's credential store, and its script carries no sec
test("git presents the stored credential to the forge, and only when one is given", () => {
const dir = mkdtempSync(join(tmpdir(), "lab-git-"));
const store = join(dir, "git-credentials");
writeFileSync(store, "http://lab-node-d:pw@forge.invalid:20000\n", { mode: 0o600 });
writeFileSync(store, "http://lab-shanks:pw@forge.invalid:20000\n", { mode: 0o600 });
const fill = spawnSync("sh", ["-c", `printf 'protocol=http\\nhost=forge.invalid:20000\\n\\n' | git ${credentialArgs(store)}credential fill`],
{ encoding: "utf8", env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } });
assert.match(fill.stdout, /username=lab-node-d/);
assert.match(fill.stdout, /username=lab-shanks/);
assert.match(fill.stdout, /password=pw/);
const other = spawnSync("sh", ["-c", `printf 'protocol=http\\nhost=elsewhere.invalid\\n\\n' | git ${credentialArgs(store)}credential fill`],
{ encoding: "utf8", env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } });
@@ -168,7 +168,7 @@ test("a stopped script removes the credential, whatever ends it", () => {
});
test("the script removes the credential as soon as its clones are done", () => {
const s = script("/work", "run-x", "http://forge", "mesh-org", ["t.test.ts"], {}, "/work/.run-x.git-credentials");
const s = script("/work", "run-x", "http://forge", ["t.test.ts"], {}, "/work/.run-x.git-credentials");
const lastClone = s.lastIndexOf("clone --quiet");
const removed = s.indexOf("rm -f '/work/.run-x.git-credentials'", lastClone);
const building = s.indexOf("npm ci");
@@ -252,10 +252,10 @@ test("the lab's tools bundle names its bound credential and binding in its env",
assert.equal(env.MESH_LAB_FORGE_BINDING, m.binds["npm-package-registry"]);
});
// The next setup failure of novox/hq issue 624 (run-2026-10-13T13-15-26-634Z on node-d): npm 12 refused
// The next setup failure of novox/hq issue 624 (run-2026-10-13T13-15-26-634Z on shanks): npm 12 refused
// mesh-tools' locked @novox/mesh-sdk with EALLOWREMOTE. The lab named the registry by its forge setting's
// address (http://node-c.internal:20000/…), the lock file holds the address the forge writes every tarball
// the forge's URL, and npm 12 installs a locked tarball only from its configured registry.
// address (http://novox.internal:20000/…), the lock file holds the address the forge writes every tarball
// URL at (https://git.novox.be/…), and npm 12 installs a locked tarball only from its configured registry.
// A run names the registry by the address the registry names itself by.
import { npmRegistry } from "../tools/runs.ts";
@@ -270,54 +270,54 @@ function aRegistry(answer: () => Response | Promise<Response>): { get: typeof fe
const packument = (tarball: string) => new Response(JSON.stringify({
name: "@novox/mesh-sdk", "dist-tags": { latest: "0.1.14" },
versions: { "0.1.1": { dist: { tarball: "http://old.example/api/packages/mesh-org/npm/x.tgz" } }, "0.1.14": { dist: { tarball } } },
versions: { "0.1.1": { dist: { tarball: "http://old.example/api/packages/novox/npm/x.tgz" } }, "0.1.14": { dist: { tarball } } },
}), { status: 200, headers: { "content-type": "application/json" } });
test("a run names the package registry by the address the registry gives its tarballs", async () => {
const r = aRegistry(() => packument("https://forge.example.org/api/packages/mesh-org/npm/%40novox%2Fmesh-sdk/-/0.1.14/mesh-sdk-0.1.14.tgz"));
const got = await npmRegistry("http://forge.example.org:3000/", "mesh-org", r.get, "https://forge.example.org/api/packages/mesh-org/npm/");
assert.deepEqual(got, { registry: "https://forge.example.org/api/packages/mesh-org/npm/" });
assert.deepEqual(r.asked, ["http://forge.example.org:3000/api/packages/mesh-org/npm/@novox%2fmesh-sdk"]);
const r = aRegistry(() => packument("https://forge.example.org/api/packages/novox/npm/%40novox%2Fmesh-sdk/-/0.1.14/mesh-sdk-0.1.14.tgz"));
const got = await npmRegistry("http://forge.example.org:3000/", r.get, "https://forge.example.org/api/packages/novox/npm/");
assert.deepEqual(got, { registry: "https://forge.example.org/api/packages/novox/npm/" });
assert.deepEqual(r.asked, ["http://forge.example.org:3000/api/packages/novox/npm/@novox%2fmesh-sdk"]);
// The forge's own scheme is taken too, with its port.
const plain = await npmRegistry("http://forge.example.org:3000", "mesh-org",
aRegistry(() => packument("http://forge.example.org:3000/api/packages/mesh-org/npm/%40novox%2Fmesh-sdk/-/0.1.14/mesh-sdk-0.1.14.tgz")).get);
assert.deepEqual(plain, { registry: "http://forge.example.org:3000/api/packages/mesh-org/npm/" });
const plain = await npmRegistry("http://forge.example.org:3000",
aRegistry(() => packument("http://forge.example.org:3000/api/packages/novox/npm/%40novox%2Fmesh-sdk/-/0.1.14/mesh-sdk-0.1.14.tgz")).get);
assert.deepEqual(plain, { registry: "http://forge.example.org:3000/api/packages/novox/npm/" });
});
// What the registry answers may come over plain HTTP, so it never decides where packages come from: only
// the forge's public root or its own address, scheme, host and port, under the npm path, with nothing after
// it (review of mesh-catalog #275, ADR 0346).
test("a registry that does not give an address the lab takes leaves the forge's, and the reason is said", async () => {
const sdk = "/api/packages/mesh-org/npm/%40novox%2Fmesh-sdk/-/0.1.14/mesh-sdk-0.1.14.tgz";
const sdk = "/api/packages/novox/npm/%40novox%2Fmesh-sdk/-/0.1.14/mesh-sdk-0.1.14.tgz";
for (const [answer, why] of [
[() => new Response("not found", { status: 404 }), /it answered 404/],
[() => new Response("{}", { status: 200 }), /no tarball/],
[() => { throw new Error("connect ECONNREFUSED"); }, /ECONNREFUSED/],
[() => packument(`https://forge.example.org/elsewhere/mesh-sdk-0.1.14.tgz`), /not under \/api\/packages\/mesh-org\/npm\//],
[() => packument(`https://forge.example.org/elsewhere/mesh-sdk-0.1.14.tgz`), /not under \/api\/packages\/novox\/npm\//],
[() => packument(`https://attacker.example.net${sdk}`), /at https:\/\/attacker\.example\.net, which is neither the forge's public root nor its own address/],
[() => packument(`https://attacker.example.net/x?u=https://node-c.internal${sdk}`), /query or a fragment/],
[() => packument(`https://node-c.internal${sdk}?x=1`), /query or a fragment/],
[() => packument(`https://node-c.internal${sdk}#frag`), /query or a fragment/],
[() => packument(`https://attacker.example.net/#https://node-c.internal${sdk}`), /query or a fragment/],
[() => packument(`ftp://node-c.internal${sdk}`), /at ftp:\/\/node-c\.internal, which is neither/],
[() => packument(`file:///api/packages/mesh-org/npm/x.tgz`), /which is neither/],
[() => packument(`https://node-c.internal/api/packages/mesh-org/npm/\nregistry=https://attacker.example.net/\nallow-remote=all`), /whitespace or a control character/],
[() => packument(`https://user:pw@node-c.internal${sdk}`), /user or password/],
[() => packument(`https://attacker.example.net/x?u=https://novox.internal${sdk}`), /query or a fragment/],
[() => packument(`https://novox.internal${sdk}?x=1`), /query or a fragment/],
[() => packument(`https://novox.internal${sdk}#frag`), /query or a fragment/],
[() => packument(`https://attacker.example.net/#https://novox.internal${sdk}`), /query or a fragment/],
[() => packument(`ftp://novox.internal${sdk}`), /at ftp:\/\/novox\.internal, which is neither/],
[() => packument(`file:///api/packages/novox/npm/x.tgz`), /which is neither/],
[() => packument(`https://novox.internal/api/packages/novox/npm/\nregistry=https://attacker.example.net/\nallow-remote=all`), /whitespace or a control character/],
[() => packument(`https://user:pw@novox.internal${sdk}`), /user or password/],
] as [() => Response, RegExp][]) {
const got = await npmRegistry("http://node-c.internal:20000", "mesh-org", aRegistry(answer).get);
assert.equal(got.registry, "http://node-c.internal:20000/api/packages/mesh-org/npm/", String(why));
const got = await npmRegistry("http://novox.internal:20000", aRegistry(answer).get);
assert.equal(got.registry, "http://novox.internal:20000/api/packages/novox/npm/", String(why));
assert.match(got.problem ?? "", /did not give an address the lab takes/);
assert.match(got.problem ?? "", why);
}
});
test("a run's npm configuration names the registry it was given, and the forge's own address only without one", () => {
const given = script("/work", "run-x", "http://node-c.internal:20000", "mesh-org", ["t.test.ts"], {}, undefined,
"https://forge.example.org/api/packages/mesh-org/npm/");
assert.match(given, / '@novox:registry=https:\/\/forge\.example\.org\/api\/packages\/mesh-org\/npm\/' > '\/work\/run-x\/\.npmrc'\n/);
assert.doesNotMatch(given, /@novox:registry=http:\/\/node-c\.internal/);
const none = script("/work", "run-x", "http://node-c.internal:20000", "mesh-org", ["t.test.ts"], {});
assert.match(none, /'@novox:registry=http:\/\/node-c\.internal:20000\/api\/packages\/mesh-org\/npm\/'/);
const given = script("/work", "run-x", "http://novox.internal:20000", ["t.test.ts"], {}, undefined,
"https://forge.example.org/api/packages/novox/npm/");
assert.match(given, / '@novox:registry=https:\/\/forge\.example\.org\/api\/packages\/novox\/npm\/' > '\/work\/run-x\/\.npmrc'\n/);
assert.doesNotMatch(given, /@novox:registry=http:\/\/novox\.internal/);
const none = script("/work", "run-x", "http://novox.internal:20000", ["t.test.ts"], {});
assert.match(none, /'@novox:registry=http:\/\/novox\.internal:20000\/api\/packages\/novox\/npm\/'/);
});
// The public root the forge serves in the lab's binding (novox/hq ADR 0346) is the one other origin a
@@ -325,20 +325,20 @@ test("a run's npm configuration names the registry it was given, and the forge's
import { publicRootOf } from "../tools/runs.ts";
test("a tarball is taken at the forge's public root, and at no other port or scheme of its host", async () => {
const sdk = "/api/packages/mesh-org/npm/%40novox%2Fmesh-sdk/-/0.1.14/mesh-sdk-0.1.14.tgz";
const root = "https://git.example.org/api/packages/mesh-org/npm/";
const sdk = "/api/packages/novox/npm/%40novox%2Fmesh-sdk/-/0.1.14/mesh-sdk-0.1.14.tgz";
const root = "https://git.example.org/api/packages/novox/npm/";
const at = (tarball: string, publicRoot?: string) =>
npmRegistry("http://node-c.internal:20000", "mesh-org", aRegistry(() => packument(tarball)).get, publicRoot);
npmRegistry("http://novox.internal:20000", aRegistry(() => packument(tarball)).get, publicRoot);
assert.deepEqual(await at(`https://git.example.org${sdk}`, root), { registry: root });
for (const tarball of [`https://git.example.org:8443${sdk}`, `http://git.example.org${sdk}`, `https://example.org${sdk}`]) {
const got = await at(tarball, root);
assert.equal(got.registry, "http://node-c.internal:20000/api/packages/mesh-org/npm/", tarball);
assert.equal(got.registry, "http://novox.internal:20000/api/packages/novox/npm/", tarball);
assert.match(got.problem ?? "", /neither the forge's public root nor its own address/, tarball);
}
// Without a public root, the forge's public name is not trusted on its own word.
assert.match((await at(`https://git.example.org${sdk}`)).problem ?? "", /neither/);
// And the forge's own address only at its own port.
assert.match((await at(`http://node-c.internal${sdk}`, root)).problem ?? "", /neither/);
assert.match((await at(`http://novox.internal${sdk}`, root)).problem ?? "", /neither/);
});
test("the public root is read from the binding, and only an https root at the npm path is taken", () => {
@@ -346,39 +346,39 @@ test("the public root is read from the binding, and only an https root at the np
const binding = (root: unknown) => {
const f = join(dir, `b-${Math.random()}.json`);
writeFileSync(f, JSON.stringify({ provision: "npm-package-registry", at: "home", serves: { scheme: "http", port: 3000,
"npm-path": "/api/packages/mesh-org/npm/", ...(root === undefined ? {} : { "public-root": root }) } }));
"npm-path": "/api/packages/novox/npm/", ...(root === undefined ? {} : { "public-root": root }) } }));
return f;
};
assert.deepEqual(publicRootOf(binding("https://git.example.org/api/packages/mesh-org/npm/"), "mesh-org"),
{ root: "https://git.example.org/api/packages/mesh-org/npm/" });
assert.deepEqual(publicRootOf(binding("https://git.example.org:8443/api/packages/mesh-org/npm/"), "mesh-org"),
{ root: "https://git.example.org:8443/api/packages/mesh-org/npm/" });
assert.deepEqual(publicRootOf(binding("https://git.example.org/api/packages/novox/npm/")),
{ root: "https://git.example.org/api/packages/novox/npm/" });
assert.deepEqual(publicRootOf(binding("https://git.example.org:8443/api/packages/novox/npm/")),
{ root: "https://git.example.org:8443/api/packages/novox/npm/" });
for (const [root, why] of [
[undefined, /serves no public-root/],
["http://git.example.org/api/packages/mesh-org/npm/", /is not https:\/\/<host>/],
["http://git.example.org/api/packages/novox/npm/", /is not https:\/\/<host>/],
["https://git.example.org/elsewhere/", /is not https:\/\/<host>/],
["https://git.example.org/api/packages/mesh-org/npm/?x=1", /is not https:\/\/<host>/],
["https://u:p@git.example.org/api/packages/mesh-org/npm/", /is not https:\/\/<host>/],
["https://git.example.org/api/packages/mesh-org/npm/\nallow-remote=all", /whitespace or a control character/],
["https://${bound:route:name-web}/api/packages/mesh-org/npm/", /not a URL|is not https/],
["https://git.example.org/api/packages/novox/npm/?x=1", /is not https:\/\/<host>/],
["https://u:p@git.example.org/api/packages/novox/npm/", /is not https:\/\/<host>/],
["https://git.example.org/api/packages/novox/npm/\nallow-remote=all", /whitespace or a control character/],
["https://${bound:route:name-web}/api/packages/novox/npm/", /not a URL|is not https/],
] as [unknown, RegExp][]) {
const got = publicRootOf(binding(root), "mesh-org");
const got = publicRootOf(binding(root));
assert.equal(got.root, undefined, String(root));
assert.match(got.problem ?? "", why, String(root));
}
assert.match(publicRootOf(join(dir, "none.json"), "mesh-org").problem ?? "", /could not be read/);
assert.match(publicRootOf(undefined, "mesh-org").problem ?? "", /no npm-package-registry binding/);
assert.match(publicRootOf(join(dir, "none.json")).problem ?? "", /could not be read/);
assert.match(publicRootOf(undefined).problem ?? "", /no npm-package-registry binding/);
rmSync(dir, { recursive: true, force: true });
});
// A forge setting that is not http(s) is never a trusted origin (review of #280): a `file:` tarball's origin
// is "null", as is a `file:` forge's, and the two must not match.
test("a forge setting that is not http(s) trusts no origin of its own", async () => {
const got = await npmRegistry("file:///srv/forge", "mesh-org", aRegistry(() => packument("file:///api/packages/mesh-org/npm/x.tgz")).get);
const got = await npmRegistry("file:///srv/forge", aRegistry(() => packument("file:///api/packages/novox/npm/x.tgz")).get);
assert.match(got.problem ?? "", /neither the forge's public root nor its own address/);
const root = "https://git.example.org/api/packages/mesh-org/npm/";
const viaRoot = await npmRegistry("ftp://node-c.internal", "mesh-org", aRegistry(() =>
packument("ftp://node-c.internal/api/packages/mesh-org/npm/x.tgz")).get, root);
const root = "https://git.example.org/api/packages/novox/npm/";
const viaRoot = await npmRegistry("ftp://novox.internal", aRegistry(() =>
packument("ftp://novox.internal/api/packages/novox/npm/x.tgz")).get, root);
assert.match(viaRoot.problem ?? "", /neither/);
});
@@ -401,7 +401,7 @@ test("every file takes its text from a template in files/", () => {
});
// 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 node-d wrote it.
// 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:",
+13 -23
View File
@@ -8,7 +8,7 @@ import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
checkDir, repositoryURL, cleanCredentials, clearChecks, credentialArgs, forgeCredential, installPlan, listRuns, npmRegistry, NPM_REPOSITORIES, publicRootOf, readStatus, REPOSITORIES, running, start,
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,
} from "./runs.js";
@@ -19,17 +19,13 @@ export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] {
// ADR 0192): read from the env-file the mesh fills, at each call, so a changed setting is used
// without restarting the runtime. MESH_LAB_FORGE itself still wins, for a hand-run instance.
const forgeOf = (): string => (env.MESH_LAB_FORGE ?? wordIn(env.MESH_LAB_ENV_FILE, "MESH_LAB_FORGE")).replace(/\/+$/, "");
// The owner on the forge the mesh's repositories and packages are under, the operator's too (novox/hq ADR 0507
// §9), read the same way.
const ownerOf = (): string => (env.MESH_LAB_FORGE_OWNER ?? wordIn(env.MESH_LAB_ENV_FILE, "MESH_LAB_FORGE_OWNER")).trim();
const noOwner = "the lab's forge owner is not set: config for lab, {\"forge-owner\": \"<owner>\"}";
// The forge credential the mesh binds and seals to the lab (novox/hq issue 624), read at each call.
// The registry's public root the forge serves in the lab's binding (novox/hq ADR 0346), read at each call.
const bindingOf = (): string | undefined =>
env.MESH_LAB_FORGE_BINDING ?? (wordIn(env.MESH_LAB_ENV_FILE, "MESH_LAB_FORGE_BINDING") || undefined);
const registryOf = async (forge: string, owner: string) => {
const root = publicRootOf(bindingOf(), owner);
const registry = await npmRegistry(forge, owner, fetch, root.root);
const registryOf = async (forge: string) => {
const root = publicRootOf(bindingOf());
const registry = await npmRegistry(forge, fetch, root.root);
return registry.problem && root.problem ? { ...registry, problem: `${registry.problem}; ${root.problem}` } : registry;
};
const credentialOf = (forge: string): string | undefined => forgeCredential(forge,
@@ -43,8 +39,6 @@ export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] {
run: async () => {
const forge = forgeOf();
if (!forge) return { ok: false, output: "the lab's forge is not set: settings for lab, {\"forge\": \"<url>\"}" };
const owner = ownerOf();
if (!owner) return { ok: false, output: noOwner };
// What earlier checks left is cleared, and what could not be is said; this check works in a fresh
// directory of its own either way, and removes it when it is done.
const leftovers = clearChecks(work);
@@ -52,13 +46,13 @@ export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] {
let check: ReturnType<typeof spawnSync>;
let problems: string[];
try {
const clone = spawnSync("git", ["clone", "--quiet", "--depth", "1", repositoryURL(forge, owner, "mesh-lab"), dir], { encoding: "utf8" });
const clone = spawnSync("git", ["clone", "--quiet", "--depth", "1", `${forge}/novox/mesh-lab.git`, dir], { encoding: "utf8" });
if (clone.status !== 0) return { ok: false, output: [String(clone.stderr).trim(), ...leftovers].join("\n") };
spawnSync("npm", ["ci", "--no-audit", "--no-fund", "--loglevel=error"], { cwd: dir, encoding: "utf8" });
check = spawnSync("node", ["--experimental-strip-types", "src/cli.ts", "check"], { cwd: dir, encoding: "utf8" });
problems = setupProblems(forge, owner, `${dir}/setup`, credentialOf(forge));
problems = setupProblems(forge, `${dir}/setup`, credentialOf(forge));
// And the address a run names the package registry by (npmRegistry): said when the registry does not say it.
const registry = await registryOf(forge, owner);
const registry = await registryOf(forge);
if (registry.problem) problems.push(registry.problem);
} finally {
rmSync(dir, { recursive: true, force: true });
@@ -97,8 +91,6 @@ export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] {
run: async (args) => {
const forge = forgeOf();
if (!forge) return { started: false, reason: "the lab's forge is not set: settings for lab, {\"forge\": \"<url>\"}" };
const owner = ownerOf();
if (!owner) return { started: false, reason: noOwner };
const tests = String(args.tests ?? "").split(",").map((s) => s.trim()).filter(Boolean);
if (tests.length === 0) return { started: false, reason: "name at least one bed test file" };
let refs: Record<string, string> = {};
@@ -113,8 +105,8 @@ export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] {
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, owner), base: () => readBaseImage() },
(registry) => start(work, forge, owner, tests, refs, credentialOf(forge), registry));
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 } : {}) };
@@ -142,12 +134,10 @@ export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] {
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>\"}" };
const owner = ownerOf();
if (!owner) return { ...reading, started: false, reason: noOwner };
// 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, owner), base: () => before },
(registry) => startBaseBuild(work, forge, owner, credentialOf(forge), registry));
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 };
},
@@ -184,7 +174,7 @@ export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] {
* answer for, and a checkout it installs with nothing installable (installPlan). Empty when a run's setup
* would go through.
*/
export function setupProblems(forge: string, owner: string, dir: string, credential?: string): string[] {
export function setupProblems(forge: string, dir: string, credential?: string): string[] {
const problems: string[] = [];
spawnSync("rm", ["-rf", dir]);
// The credential a run would offer, in a file of its own outside the check's clones, removed after.
@@ -196,7 +186,7 @@ export function setupProblems(forge: string, owner: string, dir: string, credent
});
try {
for (const repo of REPOSITORIES) {
const url = repositoryURL(forge, owner, repo);
const url = `${forge}/novox/${repo}.git`;
const remote = git(["ls-remote", "--heads", url, "main"]);
if (remote.status !== 0 || !remote.stdout.includes("refs/heads/main")) {
const said = remote.stderr.trim().split("\n")[0] ?? "";
+13 -22
View File
@@ -60,11 +60,8 @@ export function installStep(root: string, build: boolean): string {
fi`;
}
/**
* Where the forge serves the mesh's npm packages, under its own address: gitea's npm registry for the owner the
* mesh's repositories are under, the operator's config value (novox/hq ADR 0507 §9).
*/
export const npmPath = (owner: string): string => `/api/packages/${encodeURIComponent(owner)}/npm/`;
/** Where the forge serves the mesh's npm packages, under its own address (gitea's npm registry for the novox org). */
export const NPM_PATH = "/api/packages/novox/npm/";
/**
* The registry's public root, as the forge serves it in the lab's npm-package-registry binding
@@ -73,8 +70,7 @@ export const npmPath = (owner: string): string => `/api/packages/${encodeURIComp
* nothing else. Undefined, with why, when the binding has none or it is not that shape — a forge on a
* machine with no public domain serves none (ADR 0346 §3).
*/
export function publicRootOf(bindingFile: string | undefined, owner: string): { root?: string; problem?: string } {
const NPM_PATH = npmPath(owner);
export function publicRootOf(bindingFile: string | undefined): { root?: string; problem?: string } {
if (!bindingFile) return { problem: "the lab has no npm-package-registry binding" };
let said: unknown;
try {
@@ -116,9 +112,8 @@ export function publicRootOf(bindingFile: string | undefined, owner: string): {
* reaches the run's npm configuration but a scheme, a host and a port. Anything else falls back to the
* forge's own address and says why, so a run still starts and lab_check names it.
*/
export async function npmRegistry(forge: string, owner: string, get: typeof fetch = fetch, publicRoot?: string):
export async function npmRegistry(forge: string, get: typeof fetch = fetch, publicRoot?: string):
Promise<{ registry: string; problem?: string }> {
const NPM_PATH = npmPath(owner);
const asked = `${forge.replace(/\/+$/, "")}${NPM_PATH}`;
const url = `${asked}@novox%2fmesh-sdk`;
const fallback = (why: string) => ({
@@ -458,10 +453,6 @@ export function running(work: string): RunStatus | undefined {
return listRuns(work).find((s) => !["passed", "failed", "stopped", "lost"].includes(s.state));
}
/** A repository's clone address on the forge: under the owner the mesh's repositories are under (ADR 0507 §9). */
export const repositoryURL = (forge: string, owner: string, repo: string): string =>
`${forge}/${encodeURIComponent(owner)}/${repo}.git`;
const shellQuote = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`;
/**
@@ -470,8 +461,8 @@ const shellQuote = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`;
* The environment is the one the lab's README describes for a run against sibling checkouts, pointed
* at this run's own tree, so what is built and claimed is exactly what was checked out.
*/
export function script(work: string, id: string, forge: string, owner: string, tests: string[], refs: Record<string, string>,
credential?: string, registry = `${forge}${npmPath(owner)}`): string {
export function script(work: string, id: string, forge: string, tests: string[], refs: Record<string, string>,
credential?: string, registry = `${forge}${NPM_PATH}`): string {
const dir = runDir(work, id);
const setState = (state: string) =>
`node -e ${shellQuote(
@@ -480,7 +471,7 @@ export function script(work: string, id: string, forge: string, owner: string, t
const clones = REPOSITORIES.map((repo) => {
const ref = refs[repo] ?? "main";
return [
`git ${credentialArgs(credential)}clone --quiet --depth 50 --branch ${shellQuote(ref)} ${shellQuote(repositoryURL(forge, owner, repo))} ${shellQuote(join(dir, repo))}`,
`git ${credentialArgs(credential)}clone --quiet --depth 50 --branch ${shellQuote(ref)} ${shellQuote(`${forge}/novox/${repo}.git`)} ${shellQuote(join(dir, repo))}`,
`echo "${repo} $(git -C ${shellQuote(join(dir, repo))} rev-parse HEAD)" >> ${shellQuote(join(dir, "commits.txt"))}`,
].join("\n");
}).join("\n");
@@ -524,7 +515,7 @@ node --experimental-strip-types src/cli.ts suite ${tests.map(shellQuote).join("
}
/** start begins a run and returns at once with its status. */
export function start(work: string, forge: string, owner: string, tests: string[], refs: Record<string, string>,
export function start(work: string, forge: string, tests: string[], refs: Record<string, string>,
credential?: string, registry?: string): RunStatus {
const id = `run-${new Date().toISOString().replace(/[:.]/g, "-")}`;
const dir = runDir(work, id);
@@ -534,7 +525,7 @@ export function start(work: string, forge: string, owner: string, tests: string[
// What a crashed or rebooted run left behind goes before this run writes its own.
cleanCredentials(work);
const credentials = writeCredential(credentialPath(work, id), credential);
writeFileSync(join(dir, "run.sh"), script(work, id, forge, owner, tests, refs, credentials, registry), { mode: 0o700 });
writeFileSync(join(dir, "run.sh"), script(work, id, forge, tests, refs, credentials, registry), { mode: 0o700 });
// The wrapper records how the run ended, then removes the checkouts and keeps the log and status: a
// run's tree is its own, and the next run starts from fresh ones (novox/hq ADR 0172).
@@ -724,13 +715,13 @@ export function baseBuildLog(work: string, id: string, lines = 40): string {
* 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, owner: string, credential?: string, registry = `${forge}${npmPath(owner)}`): string {
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(repositoryURL(forge, owner, "mesh-lab"))} ${shellQuote(lab)}
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"))}
@@ -742,7 +733,7 @@ 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, owner: string, credential?: string, registry?: string): BaseBuild {
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 });
@@ -751,7 +742,7 @@ export function startBaseBuild(work: string, forge: string, owner: string, crede
// 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, owner, credentials, registry), { mode: 0o700 });
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))`,
+1 -1
View File
@@ -82,7 +82,7 @@ Steps 1, 2 and 3 are harmless in any order. Step 4 waits for `i3`.
## What changes when it is assigned
| | node-b | node-d |
| | g14 | shanks |
|---|---|---|
| package | nothing until step 1 (`lemurs-git` reads as installed) | the same |
| `/etc/lemurs/config.toml` | the found file kept once, then the module's written | the same |
+9 -9
View File
@@ -14,13 +14,13 @@ import (
"testing"
)
// The environment mailu gives automx on node-c when the fixtures were captured (files/mailu.env): the mail domain,
// The environment mailu gives automx on novox when the fixtures were captured (files/mailu.env): the mail domain,
// the route name for both servers, the ports.
var capturedEnvironment = map[string]string{
"AMX_MAIL_DOMAINS": "example.org",
"AMX_IMAP_ADDRESS": "mail.example.org",
"AMX_MAIL_DOMAINS": "novox.be",
"AMX_IMAP_ADDRESS": "mail.novox.be",
"AMX_IMAP_PORT": "143",
"AMX_SMTP_ADDRESS": "mail.example.org",
"AMX_SMTP_ADDRESS": "mail.novox.be",
"AMX_SMTP_PORT": "587",
"AUTOMX_PORT": "4243",
}
@@ -176,7 +176,7 @@ func TestTheOnlyCorrectedAnswerIsTheImapServersName(t *testing.T) {
}
for _, c := range corrected {
orig := readFile(t, strings.TrimSuffix(c, ".corrected.body")+".body")
want := strings.Replace(orig, "<hostname />", "<hostname>mail.example.org</hostname>", 1)
want := strings.Replace(orig, "<hostname />", "<hostname>mail.novox.be</hostname>", 1)
if strings.Contains(want, "<hostname />") || want == orig {
t.Fatalf("%s: the capture has no single empty hostname to correct", c)
}
@@ -198,12 +198,12 @@ func TestTheImapServerIsNamedFromTheImapAddress(t *testing.T) {
t.Fatal(err)
}
rec := httptest.NewRecorder()
newHandler(cfg).ServeHTTP(rec, httptest.NewRequest("GET", "/mail/config-v1.1.xml?emailaddress=a@example.org", nil))
newHandler(cfg).ServeHTTP(rec, httptest.NewRequest("GET", "/mail/config-v1.1.xml?emailaddress=a@novox.be", nil))
body := rec.Body.String()
if !strings.Contains(body, `<incomingServer type="imap"><hostname>imap.example.test</hostname><port>993</port>`) {
t.Errorf("incoming server not named from AMX_IMAP_ADDRESS and AMX_IMAP_PORT: %s", body)
}
if !strings.Contains(body, `<outgoingServer type="smtp"><hostname>mail.example.org</hostname><port>587</port>`) {
if !strings.Contains(body, `<outgoingServer type="smtp"><hostname>mail.novox.be</hostname><port>587</port>`) {
t.Errorf("outgoing server changed: %s", body)
}
}
@@ -213,13 +213,13 @@ func TestEveryDomainOfTheEnvironmentIsServed(t *testing.T) {
for k, v := range capturedEnvironment {
env[k] = v
}
env["AMX_MAIL_DOMAINS"] = "example.org, example.test"
env["AMX_MAIL_DOMAINS"] = "novox.be, example.test"
cfg, err := configFrom(getenvOf(env))
if err != nil {
t.Fatal(err)
}
h := newHandler(cfg)
for _, d := range []string{"example.org", "example.test"} {
for _, d := range []string{"novox.be", "example.test"} {
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest("GET", "/mail/config-v1.1.xml?emailaddress=a@"+d, nil))
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "<domain>"+d+"</domain>") {
@@ -1 +1 @@
<clientConfig version="1.1"><emailProvider id="automx2-1"><identity /><domain>example.org</domain><displayName>example.org</displayName><displayShortName>example.org</displayShortName><incomingServer type="imap"><hostname /><port>143</port><socketType>STARTTLS</socketType><username>%EMAILADDRESS%</username><authentication>password-cleartext</authentication></incomingServer><outgoingServer type="smtp"><hostname>mail.example.org</hostname><port>587</port><socketType>STARTTLS</socketType><username>%EMAILADDRESS%</username><authentication>password-cleartext</authentication></outgoingServer></emailProvider></clientConfig>
<clientConfig version="1.1"><emailProvider id="automx2-1"><identity /><domain>novox.be</domain><displayName>novox.be</displayName><displayShortName>novox.be</displayShortName><incomingServer type="imap"><hostname /><port>143</port><socketType>STARTTLS</socketType><username>%EMAILADDRESS%</username><authentication>password-cleartext</authentication></incomingServer><outgoingServer type="smtp"><hostname>mail.novox.be</hostname><port>587</port><socketType>STARTTLS</socketType><username>%EMAILADDRESS%</username><authentication>password-cleartext</authentication></outgoingServer></emailProvider></clientConfig>
@@ -1 +1 @@
<clientConfig version="1.1"><emailProvider id="automx2-1"><identity /><domain>example.org</domain><displayName>example.org</displayName><displayShortName>example.org</displayShortName><incomingServer type="imap"><hostname>mail.example.org</hostname><port>143</port><socketType>STARTTLS</socketType><username>%EMAILADDRESS%</username><authentication>password-cleartext</authentication></incomingServer><outgoingServer type="smtp"><hostname>mail.example.org</hostname><port>587</port><socketType>STARTTLS</socketType><username>%EMAILADDRESS%</username><authentication>password-cleartext</authentication></outgoingServer></emailProvider></clientConfig>
<clientConfig version="1.1"><emailProvider id="automx2-1"><identity /><domain>novox.be</domain><displayName>novox.be</displayName><displayShortName>novox.be</displayShortName><incomingServer type="imap"><hostname>mail.novox.be</hostname><port>143</port><socketType>STARTTLS</socketType><username>%EMAILADDRESS%</username><authentication>password-cleartext</authentication></incomingServer><outgoingServer type="smtp"><hostname>mail.novox.be</hostname><port>587</port><socketType>STARTTLS</socketType><username>%EMAILADDRESS%</username><authentication>password-cleartext</authentication></outgoingServer></emailProvider></clientConfig>
@@ -1,3 +1,3 @@
Content-Length: 624
Content-Length: 612
Content-Type: application/xml
Server: Werkzeug/3.1.9 Python/3.9.5
@@ -1,3 +1,3 @@
GET https://autoconfig.example.org/mail/config-v1.1.xml?emailaddress=jochen@example.org
GET https://autoconfig.novox.be/mail/config-v1.1.xml?emailaddress=jochen@novox.be
User-Agent: Thunderbird
Accept: */*
@@ -1 +1 @@
<clientConfig version="1.1"><emailProvider id="automx2-1"><identity /><domain>example.org</domain><displayName>example.org</displayName><displayShortName>example.org</displayShortName><incomingServer type="imap"><hostname /><port>143</port><socketType>STARTTLS</socketType><username>%EMAILADDRESS%</username><authentication>password-cleartext</authentication></incomingServer><outgoingServer type="smtp"><hostname>mail.example.org</hostname><port>587</port><socketType>STARTTLS</socketType><username>%EMAILADDRESS%</username><authentication>password-cleartext</authentication></outgoingServer></emailProvider></clientConfig>
<clientConfig version="1.1"><emailProvider id="automx2-1"><identity /><domain>novox.be</domain><displayName>novox.be</displayName><displayShortName>novox.be</displayShortName><incomingServer type="imap"><hostname /><port>143</port><socketType>STARTTLS</socketType><username>%EMAILADDRESS%</username><authentication>password-cleartext</authentication></incomingServer><outgoingServer type="smtp"><hostname>mail.novox.be</hostname><port>587</port><socketType>STARTTLS</socketType><username>%EMAILADDRESS%</username><authentication>password-cleartext</authentication></outgoingServer></emailProvider></clientConfig>
@@ -1 +1 @@
<clientConfig version="1.1"><emailProvider id="automx2-1"><identity /><domain>example.org</domain><displayName>example.org</displayName><displayShortName>example.org</displayShortName><incomingServer type="imap"><hostname>mail.example.org</hostname><port>143</port><socketType>STARTTLS</socketType><username>%EMAILADDRESS%</username><authentication>password-cleartext</authentication></incomingServer><outgoingServer type="smtp"><hostname>mail.example.org</hostname><port>587</port><socketType>STARTTLS</socketType><username>%EMAILADDRESS%</username><authentication>password-cleartext</authentication></outgoingServer></emailProvider></clientConfig>
<clientConfig version="1.1"><emailProvider id="automx2-1"><identity /><domain>novox.be</domain><displayName>novox.be</displayName><displayShortName>novox.be</displayShortName><incomingServer type="imap"><hostname>mail.novox.be</hostname><port>143</port><socketType>STARTTLS</socketType><username>%EMAILADDRESS%</username><authentication>password-cleartext</authentication></incomingServer><outgoingServer type="smtp"><hostname>mail.novox.be</hostname><port>587</port><socketType>STARTTLS</socketType><username>%EMAILADDRESS%</username><authentication>password-cleartext</authentication></outgoingServer></emailProvider></clientConfig>
@@ -1,3 +1,3 @@
Content-Length: 624
Content-Length: 612
Content-Type: application/xml
Server: Werkzeug/3.1.9 Python/3.9.5
@@ -1,2 +1,2 @@
GET https://automx.example.org/mail/config-v1.1.xml?emailaddress=jochen@example.org
GET https://automx.novox.be/mail/config-v1.1.xml?emailaddress=jochen@novox.be
User-Agent: Thunderbird
@@ -1 +1 @@
<clientConfig version="1.1"><emailProvider id="automx2-1"><identity /><domain>example.org</domain><displayName>example.org</displayName><displayShortName>example.org</displayShortName><incomingServer type="imap"><hostname /><port>143</port><socketType>STARTTLS</socketType><username>%EMAILADDRESS%</username><authentication>password-cleartext</authentication></incomingServer><outgoingServer type="smtp"><hostname>mail.example.org</hostname><port>587</port><socketType>STARTTLS</socketType><username>%EMAILADDRESS%</username><authentication>password-cleartext</authentication></outgoingServer></emailProvider></clientConfig>
<clientConfig version="1.1"><emailProvider id="automx2-1"><identity /><domain>novox.be</domain><displayName>novox.be</displayName><displayShortName>novox.be</displayShortName><incomingServer type="imap"><hostname /><port>143</port><socketType>STARTTLS</socketType><username>%EMAILADDRESS%</username><authentication>password-cleartext</authentication></incomingServer><outgoingServer type="smtp"><hostname>mail.novox.be</hostname><port>587</port><socketType>STARTTLS</socketType><username>%EMAILADDRESS%</username><authentication>password-cleartext</authentication></outgoingServer></emailProvider></clientConfig>
@@ -1 +1 @@
<clientConfig version="1.1"><emailProvider id="automx2-1"><identity /><domain>example.org</domain><displayName>example.org</displayName><displayShortName>example.org</displayShortName><incomingServer type="imap"><hostname>mail.example.org</hostname><port>143</port><socketType>STARTTLS</socketType><username>%EMAILADDRESS%</username><authentication>password-cleartext</authentication></incomingServer><outgoingServer type="smtp"><hostname>mail.example.org</hostname><port>587</port><socketType>STARTTLS</socketType><username>%EMAILADDRESS%</username><authentication>password-cleartext</authentication></outgoingServer></emailProvider></clientConfig>
<clientConfig version="1.1"><emailProvider id="automx2-1"><identity /><domain>novox.be</domain><displayName>novox.be</displayName><displayShortName>novox.be</displayShortName><incomingServer type="imap"><hostname>mail.novox.be</hostname><port>143</port><socketType>STARTTLS</socketType><username>%EMAILADDRESS%</username><authentication>password-cleartext</authentication></incomingServer><outgoingServer type="smtp"><hostname>mail.novox.be</hostname><port>587</port><socketType>STARTTLS</socketType><username>%EMAILADDRESS%</username><authentication>password-cleartext</authentication></outgoingServer></emailProvider></clientConfig>
@@ -1,3 +1,3 @@
Content-Length: 624
Content-Length: 612
Content-Type: application/xml
Server: Werkzeug/3.1.9 Python/3.9.5
@@ -1,2 +1,2 @@
GET https://autodiscover.example.org/mail/config-v1.1.xml?emailaddress=jochen@example.org
GET https://autodiscover.novox.be/mail/config-v1.1.xml?emailaddress=jochen@novox.be
User-Agent: Thunderbird

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