Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea73e713f5 | ||
|
|
e79197d5a1 |
@@ -21,8 +21,3 @@ modules/mesh-knowledge/cmd/mesh-knowledge/mesh-knowledge
|
|||||||
modules/mesh-knowledge/mesh-knowledge
|
modules/mesh-knowledge/mesh-knowledge
|
||||||
modules/mesh-reads/mesh-reads
|
modules/mesh-reads/mesh-reads
|
||||||
modules/mesh-reads/cmd/mesh-reads/mesh-reads
|
modules/mesh-reads/cmd/mesh-reads/mesh-reads
|
||||||
modules/mesh-files/mesh-files
|
|
||||||
modules/mesh-files/cmd/mesh-files/mesh-files
|
|
||||||
|
|
||||||
# The provisioner client built in place (issue 671).
|
|
||||||
modules/mesh-provisioner-client/cmd/mesh-provisioner-client/mesh-provisioner-client
|
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
# Release 57: config and provisioning
|
|
||||||
|
|
||||||
This branch carries the work of hq design 57 (ADR 0348, ADR 0349) in this repository. Each package
|
|
||||||
merges here through its own reviewed pull request; the branch reaches main as one pull request, in one
|
|
||||||
delivery group with the branch of the same name in mesh-controller, mesh-host, mesh-sdk, mesh-catalog
|
|
||||||
and hq, after a rehearsal in the lab. This file is deleted by the last commit before that pull request.
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
module git.novox.be/novox/mesh-catalog/checks/config
|
|
||||||
|
|
||||||
go 1.22
|
|
||||||
@@ -1,201 +0,0 @@
|
|||||||
// Command config refuses the settings form's names in a module that has moved to config (novox/hq ADR 0348
|
|
||||||
// clauses 1 and 11, design 57 package K6, issue 665).
|
|
||||||
//
|
|
||||||
// "Config" replaces "settings": a manifest declares its values under `config`, a mergeable file names the keys it
|
|
||||||
// takes under `takes-config`, and a file asks for a value with `${config:<key>}`. A mergeable file naming no keys at
|
|
||||||
// all is the settings form's last shape: it takes every key of its module's layer and declares none, so mesh-config
|
|
||||||
// could set none of them (ADR 0348 §10, ADR 0326 §3); it is found here like an old name (issue 735). The old names — `settings`,
|
|
||||||
// `takes-settings` and `${setting:<key>}` — stay accepted by the controller as aliases until every module has
|
|
||||||
// moved, and are then refused (ADR 0348 §1). The modules not yet moved are listed in the file `unmoved` beside this
|
|
||||||
// check, one per line: the catalogue's list ADR 0348 §11 names. Every other module says no old name, in its
|
|
||||||
// manifest or in a template file its manifest names; a module on the list that says none is taken off it; and a
|
|
||||||
// line the trunk's copy (the merge base's, given with -base) does not hold is refused, so the list only shrinks;
|
|
||||||
// while the trunk holds no copy, the modules in seed are the trunk's.
|
|
||||||
// When it is empty, the controller refuses the old names itself (design 57 package R1).
|
|
||||||
//
|
|
||||||
// Prose is not judged: a comment in a file's text that says "settings" composes into the machine's file, and
|
|
||||||
// rewording it would change what a node is sent.
|
|
||||||
//
|
|
||||||
// go run . [-base <the trunk's unmoved>] <catalogue root>
|
|
||||||
//
|
|
||||||
// Exits 1 on a finding.
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"encoding/json"
|
|
||||||
"flag"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"regexp"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// The settings form's names (ADR 0348 §1). A key of a JSON object is a quoted string followed by a colon; a
|
|
||||||
// string value is never followed by one.
|
|
||||||
var (
|
|
||||||
settingRef = regexp.MustCompile(`\$\{setting:[a-z0-9][a-z0-9_.-]*\}`)
|
|
||||||
takesSettingsKey = regexp.MustCompile(`"takes-settings"\s*:`)
|
|
||||||
oldPlaceholder = "${setting:…}"
|
|
||||||
oldTakes = "takes-settings"
|
|
||||||
oldDeclaration = "settings"
|
|
||||||
wholeLayer = "taking its whole layer"
|
|
||||||
listFile = "unmoved"
|
|
||||||
listPathInCatalog = "checks/config/" + listFile
|
|
||||||
)
|
|
||||||
|
|
||||||
// Check answers, per module that says any, the settings form's names its manifest and its template files say.
|
|
||||||
func Check(root string) (map[string][]string, error) {
|
|
||||||
manifests, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
sort.Strings(manifests)
|
|
||||||
out := map[string][]string{}
|
|
||||||
for _, path := range manifests {
|
|
||||||
raw, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
var m struct {
|
|
||||||
Module string `json:"module"`
|
|
||||||
Settings json.RawMessage `json:"settings"`
|
|
||||||
Resources []map[string]any `json:"resources"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(raw, &m); err != nil {
|
|
||||||
return nil, fmt.Errorf("%s: %w", path, err)
|
|
||||||
}
|
|
||||||
var said []string
|
|
||||||
if len(m.Settings) > 0 && string(m.Settings) != "null" {
|
|
||||||
said = append(said, oldDeclaration)
|
|
||||||
}
|
|
||||||
if takesSettingsKey.Match(raw) {
|
|
||||||
said = append(said, oldTakes)
|
|
||||||
}
|
|
||||||
if settingRef.Match(raw) {
|
|
||||||
said = append(said, oldPlaceholder)
|
|
||||||
}
|
|
||||||
for _, r := range m.Resources {
|
|
||||||
// A mergeable file naming no keys takes every key of the layer (ADR 0326 §3), declaring none of them.
|
|
||||||
if how, _ := r["merge"].(string); how != "" {
|
|
||||||
_, oldList := r["takes-settings"]
|
|
||||||
_, newList := r["takes-config"]
|
|
||||||
if !oldList && !newList {
|
|
||||||
said = append(said, fmt.Sprintf("%v %s", r["id"], wholeLayer))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
name, _ := r["template"].(string)
|
|
||||||
if name == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
text, err := os.ReadFile(filepath.Join(filepath.Dir(path), filepath.FromSlash(name)))
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("%s names the template %s: %w", m.Module, name, err)
|
|
||||||
}
|
|
||||||
if settingRef.Match(text) {
|
|
||||||
said = append(said, oldPlaceholder+" in "+name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(said) > 0 {
|
|
||||||
sort.Strings(said)
|
|
||||||
out[m.Module] = said
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// seed is the list as it was first written, standing for the trunk's copy until the trunk holds one.
|
|
||||||
// The modules taking a whole layer were found after the list was first written and before the trunk held a copy
|
|
||||||
// (issue 735), so they stand in it too.
|
|
||||||
var seed = map[string]bool{
|
|
||||||
"betterbird": true, "fail2ban": true, "influxdb": true, "keycloak": true, "mailu": true, "searxng": true,
|
|
||||||
"baserow": true, "confluence": true, "gitea": true, "gitlab": true, "grafana": true, "home-assistant": true,
|
|
||||||
"icecast": true, "jira": true, "letta": true, "matrix": true, "nodered": true, "supabase": true, "unifi": true,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unmoved is the modules a list names, one per line; a line starting with # and a blank line are not modules.
|
|
||||||
func Unmoved(path string) (map[string]bool, error) {
|
|
||||||
f, err := os.Open(path)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer f.Close()
|
|
||||||
out := map[string]bool{}
|
|
||||||
s := bufio.NewScanner(f)
|
|
||||||
for s.Scan() {
|
|
||||||
line := strings.TrimSpace(s.Text())
|
|
||||||
if line == "" || strings.HasPrefix(line, "#") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
out[line] = true
|
|
||||||
}
|
|
||||||
return out, s.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Judge answers the modules that say an old name and are not on the list (refused), the modules on the list that
|
|
||||||
// say none (stale), and the modules on the list that the trunk's list does not hold (added).
|
|
||||||
func Judge(found map[string][]string, unmoved, base map[string]bool) (refused, stale, added []string) {
|
|
||||||
for m := range found {
|
|
||||||
if !unmoved[m] {
|
|
||||||
refused = append(refused, m)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for m := range unmoved {
|
|
||||||
if _, says := found[m]; !says {
|
|
||||||
stale = append(stale, m)
|
|
||||||
}
|
|
||||||
if !base[m] {
|
|
||||||
added = append(added, m)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sort.Strings(refused)
|
|
||||||
sort.Strings(stale)
|
|
||||||
sort.Strings(added)
|
|
||||||
return refused, stale, added
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
basePath := flag.String("base", "", "the trunk's copy of unmoved (the merge base's); seed when absent")
|
|
||||||
flag.Parse()
|
|
||||||
root := "."
|
|
||||||
if flag.NArg() > 0 {
|
|
||||||
root = flag.Arg(0)
|
|
||||||
}
|
|
||||||
found, err := Check(root)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintln(os.Stderr, err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
unmoved, err := Unmoved(listFile)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintln(os.Stderr, err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
base := seed
|
|
||||||
if *basePath != "" {
|
|
||||||
if base, err = Unmoved(*basePath); err != nil {
|
|
||||||
fmt.Fprintln(os.Stderr, err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
refused, stale, added := Judge(found, unmoved, base)
|
|
||||||
for _, m := range refused {
|
|
||||||
fmt.Printf("%s says %s, the settings form's names, and is not on %s: write config, takes-config and "+
|
|
||||||
"${config:<key>} throughout, and name the keys each mergeable file takes (novox/hq ADR 0348 §1, §11)\n", m, strings.Join(found[m], ", "), listPathInCatalog)
|
|
||||||
}
|
|
||||||
for _, m := range stale {
|
|
||||||
fmt.Printf("%s names %s, which says no old name: take it off the list\n", listPathInCatalog, m)
|
|
||||||
}
|
|
||||||
for _, m := range added {
|
|
||||||
fmt.Printf("%s gains %s, which the trunk's copy does not hold: the list only shrinks — move the module to "+
|
|
||||||
"config instead (novox/hq ADR 0348 §11)\n", listPathInCatalog, m)
|
|
||||||
}
|
|
||||||
if len(refused) > 0 || len(stale) > 0 || len(added) > 0 {
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
fmt.Printf("config: no moved module says settings, takes-settings or ${setting:<key>}, or takes a whole layer "+
|
|
||||||
"(%d not yet moved)\n",
|
|
||||||
len(unmoved))
|
|
||||||
}
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"reflect"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func module(t *testing.T, root, name, body string, files map[string]string) {
|
|
||||||
t.Helper()
|
|
||||||
dir := filepath.Join(root, "modules", name)
|
|
||||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(filepath.Join(dir, "module.json"), []byte(body), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
for path, text := range files {
|
|
||||||
full := filepath.Join(dir, filepath.FromSlash(path))
|
|
||||||
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(full, []byte(text), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Each of the settings form's names is found where it stands: the declaration, a mergeable file's keys, a
|
|
||||||
// placeholder in the manifest, and a placeholder in a template file the manifest names (novox/hq ADR 0348 §1).
|
|
||||||
func TestEveryOldNameIsFound(t *testing.T) {
|
|
||||||
root := t.TempDir()
|
|
||||||
module(t, root, "declares", `{"module":"declares","settings":{"size":{"kind":"preference","default":1,"why":"w"}},`+
|
|
||||||
`"resources":[{"id":"f","type":"file","content":"${setting:size}"}]}`, nil)
|
|
||||||
module(t, root, "takes", `{"module":"takes","resources":[{"id":"f","type":"file","merge":"json",`+
|
|
||||||
`"takes-settings":["a"],"content":"{}"}]}`, nil)
|
|
||||||
module(t, root, "asks", `{"module":"asks","serves":{"x":{"d":"${setting:domain}"}}}`, nil)
|
|
||||||
module(t, root, "templated", `{"module":"templated","config":{"size":{"type":"number","description":"d",`+
|
|
||||||
`"default":1}},"resources":[{"id":"f","type":"file","template":"files/conf"}]}`,
|
|
||||||
map[string]string{"files/conf": "size = ${setting:size}\n"})
|
|
||||||
module(t, root, "moved", `{"module":"moved","config":{"size":{"type":"number","description":"d","default":1}},`+
|
|
||||||
`"resources":[{"id":"f","type":"file","template":"files/conf"},{"id":"g","type":"file","merge":"json",`+
|
|
||||||
`"takes-config":[],"content":"{\"settings\": 1}"}]}`,
|
|
||||||
map[string]string{"files/conf": "size = ${config:size}\n# change the settings, never this file\n"})
|
|
||||||
module(t, root, "none", `{"module":"none","resources":[{"id":"f","type":"file","content":"no settings here"}]}`, nil)
|
|
||||||
got, err := Check(root)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
want := map[string][]string{
|
|
||||||
"asks": {"${setting:…}"},
|
|
||||||
"declares": {"${setting:…}", "settings"},
|
|
||||||
"takes": {"takes-settings"},
|
|
||||||
"templated": {"${setting:…} in files/conf"},
|
|
||||||
}
|
|
||||||
if !reflect.DeepEqual(got, want) {
|
|
||||||
t.Fatalf("found %v, want %v", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A module not on the list says no old name; one on the list that says none is taken off it; a line the trunk's
|
|
||||||
// list does not hold is refused, so the list only shrinks until it is empty (novox/hq ADR 0348 §11).
|
|
||||||
func TestTheListOfModulesNotYetMovedOnlyShrinks(t *testing.T) {
|
|
||||||
found := map[string][]string{"old": {"settings"}, "forgotten": {"takes-settings"}}
|
|
||||||
unmoved := map[string]bool{"old": true, "done": true, "new": true}
|
|
||||||
base := map[string]bool{"old": true, "done": true, "forgotten": true}
|
|
||||||
refused, stale, added := Judge(found, unmoved, base)
|
|
||||||
if !reflect.DeepEqual(refused, []string{"forgotten"}) || !reflect.DeepEqual(stale, []string{"done", "new"}) ||
|
|
||||||
!reflect.DeepEqual(added, []string{"new"}) {
|
|
||||||
t.Fatalf("refused %v, stale %v, added %v", refused, stale, added)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTheListIsReadWithoutItsComments(t *testing.T) {
|
|
||||||
path := filepath.Join(t.TempDir(), "unmoved")
|
|
||||||
if err := os.WriteFile(path, []byte("# why\n\nmailu\n searxng \n"), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
got, err := Unmoved(path)
|
|
||||||
if err != nil || !reflect.DeepEqual(got, map[string]bool{"mailu": true, "searxng": true}) {
|
|
||||||
t.Fatalf("%v %v", got, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The catalogue itself: every moved module says config alone, and the list names exactly the modules that
|
|
||||||
// still say settings.
|
|
||||||
func TestTheCatalogueSaysConfigInEveryMovedModule(t *testing.T) {
|
|
||||||
found, err := Check(filepath.Join("..", ".."))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
unmoved, err := Unmoved("unmoved")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
refused, stale, added := Judge(found, unmoved, seed)
|
|
||||||
for _, m := range refused {
|
|
||||||
t.Errorf("%s is not on checks/config/unmoved and says %s", m, strings.Join(found[m], ", "))
|
|
||||||
}
|
|
||||||
for _, m := range stale {
|
|
||||||
t.Errorf("checks/config/unmoved names %s, which says no old name", m)
|
|
||||||
}
|
|
||||||
for _, m := range added {
|
|
||||||
t.Errorf("checks/config/unmoved gains %s, which its first copy did not hold", m)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A mergeable file that names no keys takes every key of its module's layer (novox/hq ADR 0326 §3): the settings
|
|
||||||
// form's last shape, which declares none of what it takes, so mesh-config could set none of it (ADR 0348 §10). It
|
|
||||||
// is found like an old name, in a module that says no other (issue 735); one naming its keys, an empty list
|
|
||||||
// included, is not.
|
|
||||||
func TestAMergeableFileTakingTheWholeLayerIsFound(t *testing.T) {
|
|
||||||
root := t.TempDir()
|
|
||||||
module(t, root, "whole", `{"module":"whole","resources":[{"id":"runtime-config","type":"file","merge":"json",`+
|
|
||||||
`"template":"files/config.json"}]}`, map[string]string{"files/config.json": "{}\n"})
|
|
||||||
module(t, root, "names", `{"module":"names","config":{"url":{"type":"string","description":"d","default":""}},`+
|
|
||||||
`"resources":[{"id":"runtime-config","type":"file","merge":"json","takes-config":["url"],"content":"{}"}]}`, nil)
|
|
||||||
module(t, root, "none", `{"module":"none","resources":[{"id":"c","type":"file","merge":"json",`+
|
|
||||||
`"takes-config":[],"content":"{}"}]}`, nil)
|
|
||||||
module(t, root, "plain", `{"module":"plain","resources":[{"id":"c","type":"file","content":"{}"}]}`, nil)
|
|
||||||
got, err := Check(root)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
want := map[string][]string{"whole": {"runtime-config taking its whole layer"}}
|
|
||||||
if !reflect.DeepEqual(got, want) {
|
|
||||||
t.Fatalf("found %v, want %v", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
# The modules still written in the settings form (`settings`, `takes-settings`, `${setting:<key>}`, or a mergeable
|
|
||||||
# file naming no keys, which takes its whole layer), one per line: the catalogue's list of modules not yet moved to
|
|
||||||
# config (novox/hq ADR 0348 §11). Every other module says config alone. This list only shrinks: a module that says no
|
|
||||||
# old name is taken off, and a line the trunk's copy does not hold is refused. When it is empty, the controller
|
|
||||||
# refuses the old names (design 57 package R1).
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
module git.novox.be/novox/mesh-catalog/checks/mailstore
|
|
||||||
|
|
||||||
go 1.22
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
// Command mailstore refuses any manifest but modules/mesh-mail claiming the mail-store seat (novox/hq ADR 0378
|
|
||||||
// §3, issue 800 part A): the operator's mail is read, searched and sent through this one seat, and a second
|
|
||||||
// claimant would be a second path to the same mail that ADR 0378 never accounted for.
|
|
||||||
//
|
|
||||||
// go run . <catalogue root>
|
|
||||||
//
|
|
||||||
// Exits 1 on a finding.
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"sort"
|
|
||||||
)
|
|
||||||
|
|
||||||
// MailStoreSeat is the seat only mesh-mail may claim.
|
|
||||||
const MailStoreSeat = "mail-store"
|
|
||||||
|
|
||||||
// MailStoreModule is the one module allowed to claim it.
|
|
||||||
const MailStoreModule = "mesh-mail"
|
|
||||||
|
|
||||||
// Finding is one manifest refused: a module other than mesh-mail claiming mail-store.
|
|
||||||
type Finding struct {
|
|
||||||
Module string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check reads every manifest under root/modules and answers which ones it refuses.
|
|
||||||
func Check(root string) ([]Finding, error) {
|
|
||||||
manifests, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
sort.Strings(manifests)
|
|
||||||
var out []Finding
|
|
||||||
for _, path := range manifests {
|
|
||||||
raw, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
var m struct {
|
|
||||||
Module string `json:"module"`
|
|
||||||
Claims []struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
} `json:"claims"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(raw, &m); err != nil {
|
|
||||||
return nil, fmt.Errorf("%s: %w", path, err)
|
|
||||||
}
|
|
||||||
if m.Module == MailStoreModule {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, c := range m.Claims {
|
|
||||||
if c.Name == MailStoreSeat {
|
|
||||||
out = append(out, Finding{Module: m.Module})
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
root := "."
|
|
||||||
if len(os.Args) > 1 {
|
|
||||||
root = os.Args[1]
|
|
||||||
}
|
|
||||||
found, err := Check(root)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintln(os.Stderr, err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
for _, f := range found {
|
|
||||||
fmt.Printf("%s claims the mail-store seat: only %s may (novox/hq ADR 0378 §3)\n", f.Module, MailStoreModule)
|
|
||||||
}
|
|
||||||
if len(found) > 0 {
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
fmt.Println("mail-store: claimed only by mesh-mail")
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func manifest(t *testing.T, root, module, claims string) {
|
|
||||||
t.Helper()
|
|
||||||
dir := filepath.Join(root, "modules", module)
|
|
||||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
body := `{"module":"` + module + `","claims":[` + claims + `]}`
|
|
||||||
if err := os.WriteFile(filepath.Join(dir, "module.json"), []byte(body), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAnotherModuleClaimingMailStoreIsRefused(t *testing.T) {
|
|
||||||
root := t.TempDir()
|
|
||||||
manifest(t, root, "mesh-mail", `{"name":"mail-store","scope":"mesh"}`)
|
|
||||||
manifest(t, root, "betterbird", `{"name":"mail-store","scope":"mesh"}`)
|
|
||||||
manifest(t, root, "dunst", `{"name":"node-notifier","scope":"node"}`)
|
|
||||||
found, err := Check(root)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(found) != 1 || found[0].Module != "betterbird" {
|
|
||||||
t.Fatalf("refused %+v", found)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTheCatalogueHasNone(t *testing.T) {
|
|
||||||
found, err := Check("../..")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(found) != 0 {
|
|
||||||
t.Fatalf("%+v", found)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
module git.novox.be/novox/mesh-catalog/checks/predefined
|
|
||||||
|
|
||||||
go 1.22
|
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
// Command predefined refuses a change to predefined.json that removes a key a module still names (novox/hq ADR
|
|
||||||
// 0348 clause 7, design 57 package K2, issue 661).
|
|
||||||
//
|
|
||||||
// Mesh config and node config hold values only for the keys on the catalogue's list of predefined keys, at the
|
|
||||||
// root of the repository; a module's config value may fall back to one, `"predefined": "<key>"`. A key removed
|
|
||||||
// while a manifest still names it would leave that module falling back to nothing: the controller refuses its
|
|
||||||
// build then, at the next merge that touches it. So the removal is refused here, in the pull request that makes
|
|
||||||
// it, naming each module that names the key.
|
|
||||||
//
|
|
||||||
// **What it cannot know.** Whether a key still holds a value at the mesh or a node is mesh-config's to say (design
|
|
||||||
// 57 package K3): it holds the values, and the catalogue's check reads no store. Until mesh-config refuses that
|
|
||||||
// itself, removing a key nobody names is let through here, and said.
|
|
||||||
//
|
|
||||||
// The list it compares with is the trunk's at the merge base, given with -base; without one, nothing was removed.
|
|
||||||
// The controller the mesh runs reads the list itself (type, validation, every manifest against it) in the
|
|
||||||
// module check; this check reads only its keys.
|
|
||||||
//
|
|
||||||
// go run . [-base <the trunk's predefined.json>] <catalogue root>
|
|
||||||
//
|
|
||||||
// Exits 1 on a finding.
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"flag"
|
|
||||||
"fmt"
|
|
||||||
"io/fs"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// File is the list's file at the root of the catalogue.
|
|
||||||
const File = "predefined.json"
|
|
||||||
|
|
||||||
// Keys is the keys of a list, read as a JSON object; an absent file has none.
|
|
||||||
func Keys(path string) (map[string]bool, error) {
|
|
||||||
raw, err := os.ReadFile(path)
|
|
||||||
if errors.Is(err, fs.ErrNotExist) {
|
|
||||||
return map[string]bool{}, nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
var keys map[string]json.RawMessage
|
|
||||||
if err := json.Unmarshal(raw, &keys); err != nil {
|
|
||||||
return nil, fmt.Errorf("%s is not a JSON object of keys: %w", path, err)
|
|
||||||
}
|
|
||||||
out := map[string]bool{}
|
|
||||||
for k := range keys {
|
|
||||||
out[k] = true
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Named is, per predefined key, the modules whose manifests name it: under `config`, or its alias `settings`.
|
|
||||||
func Named(root string) (map[string][]string, error) {
|
|
||||||
manifests, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
sort.Strings(manifests)
|
|
||||||
named := map[string][]string{}
|
|
||||||
for _, path := range manifests {
|
|
||||||
raw, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
var m struct {
|
|
||||||
Module string `json:"module"`
|
|
||||||
Config map[string]json.RawMessage `json:"config"`
|
|
||||||
Settings map[string]json.RawMessage `json:"settings"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(raw, &m); err != nil {
|
|
||||||
return nil, fmt.Errorf("%s: %w", path, err)
|
|
||||||
}
|
|
||||||
for _, declared := range []map[string]json.RawMessage{m.Config, m.Settings} {
|
|
||||||
for _, d := range declared {
|
|
||||||
var value struct {
|
|
||||||
Predefined string `json:"predefined"`
|
|
||||||
}
|
|
||||||
if json.Unmarshal(d, &value) == nil && value.Predefined != "" {
|
|
||||||
named[value.Predefined] = append(named[value.Predefined], m.Module)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return named, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Removed is each key the base list holds and the tree's does not, with the modules still naming it; a removed
|
|
||||||
// key nobody names is answered with none.
|
|
||||||
func Removed(base, tree map[string]bool, named map[string][]string) map[string][]string {
|
|
||||||
out := map[string][]string{}
|
|
||||||
for k := range base {
|
|
||||||
if !tree[k] {
|
|
||||||
out[k] = named[k]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
basePath := flag.String("base", "", "the trunk's predefined.json at the merge base")
|
|
||||||
flag.Parse()
|
|
||||||
if flag.NArg() != 1 {
|
|
||||||
fmt.Fprintln(os.Stderr, "usage: predefined [-base <the trunk's predefined.json>] <catalogue root>")
|
|
||||||
os.Exit(2)
|
|
||||||
}
|
|
||||||
root := flag.Arg(0)
|
|
||||||
tree, err := Keys(filepath.Join(root, File))
|
|
||||||
if err != nil {
|
|
||||||
fmt.Println(err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
if *basePath == "" {
|
|
||||||
fmt.Printf("%s: %d key(s); no trunk list given, so none was removed\n", File, len(tree))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
base, err := Keys(*basePath)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Println(err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
named, err := Named(root)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Println(err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
removed := Removed(base, tree, named)
|
|
||||||
keys := make([]string, 0, len(removed))
|
|
||||||
for k := range removed {
|
|
||||||
keys = append(keys, k)
|
|
||||||
}
|
|
||||||
sort.Strings(keys)
|
|
||||||
failed := false
|
|
||||||
for _, k := range keys {
|
|
||||||
if modules := removed[k]; len(modules) > 0 {
|
|
||||||
failed = true
|
|
||||||
fmt.Printf("%s: the key %q is removed and still named by %s: move each off it first (novox/hq ADR 0348 "+
|
|
||||||
"clause 7)\n", File, k, strings.Join(modules, ", "))
|
|
||||||
} else {
|
|
||||||
fmt.Printf("%s: the key %q is removed and no module names it; whether the mesh or a node still holds a "+
|
|
||||||
"value for it is mesh-config's to refuse, not this check's\n", File, k)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if failed {
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
fmt.Printf("%s: %d key(s), none removed that a module names\n", File, len(tree))
|
|
||||||
}
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// A catalogue whose dunst falls back to font-size, and the trunk's list holding font-size and
|
|
||||||
// agent-managed-settings.
|
|
||||||
func aCatalogue(t *testing.T, list string) (root, base string) {
|
|
||||||
t.Helper()
|
|
||||||
root = t.TempDir()
|
|
||||||
write := func(path, text string) {
|
|
||||||
t.Helper()
|
|
||||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(path, []byte(text), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
write(filepath.Join(root, "modules", "dunst", "module.json"), `{"module": "dunst", "config": {"font-size":
|
|
||||||
{"type": "number", "description": "points", "default": 10, "predefined": "font-size"}}}`)
|
|
||||||
write(filepath.Join(root, "modules", "claude-code", "module.json"), `{"module": "claude-code", "settings": {
|
|
||||||
"managed_settings": {"kind": "preference", "default": "{}", "why": "none"}}}`)
|
|
||||||
write(filepath.Join(root, "modules", "plain", "module.json"), `{"module": "plain"}`)
|
|
||||||
if list != "" {
|
|
||||||
write(filepath.Join(root, File), list)
|
|
||||||
}
|
|
||||||
base = filepath.Join(t.TempDir(), "base.json")
|
|
||||||
write(base, `{"font-size": {"type": "number"}, "agent-managed-settings": {"type": "object"}}`)
|
|
||||||
return root, base
|
|
||||||
}
|
|
||||||
|
|
||||||
func run(t *testing.T, args ...string) (string, error) {
|
|
||||||
t.Helper()
|
|
||||||
out, err := exec.Command("go", append([]string{"run", "."}, args...)...).CombinedOutput()
|
|
||||||
return string(out), err
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestARemovalOfAKeyAModuleStillNamesIsRefused(t *testing.T) {
|
|
||||||
// The planted removal: font-size goes while dunst still falls back to it.
|
|
||||||
root, base := aCatalogue(t, `{"agent-managed-settings": {"type": "object"}}`)
|
|
||||||
out, err := run(t, "-base", base, root)
|
|
||||||
if err == nil || !strings.Contains(out, `the key "font-size" is removed and still named by dunst`) {
|
|
||||||
t.Fatalf("a removal of a named key passed:\n%s", out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestARemovalNobodyNamesPassesAndSaysWhatItCannotKnow(t *testing.T) {
|
|
||||||
root, base := aCatalogue(t, `{"font-size": {"type": "number"}}`)
|
|
||||||
out, err := run(t, "-base", base, root)
|
|
||||||
if err != nil || !strings.Contains(out, `"agent-managed-settings" is removed and no module names it`) ||
|
|
||||||
!strings.Contains(out, "mesh-config's to refuse") {
|
|
||||||
t.Fatalf("a removal nobody names: %v\n%s", err, out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAListUnchangedOrGrownPasses(t *testing.T) {
|
|
||||||
for _, list := range []string{
|
|
||||||
`{"font-size": {"type": "number"}, "agent-managed-settings": {"type": "object"}}`,
|
|
||||||
`{"font-size": {"type": "number"}, "agent-managed-settings": {"type": "object"}, "accent": {"type": "string"}}`,
|
|
||||||
} {
|
|
||||||
root, base := aCatalogue(t, list)
|
|
||||||
if out, err := run(t, "-base", base, root); err != nil || !strings.Contains(out, "none removed") {
|
|
||||||
t.Fatalf("%s: %v\n%s", list, err, out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// No trunk list given: nothing was removed.
|
|
||||||
root, _ := aCatalogue(t, `{}`)
|
|
||||||
if out, err := run(t, root); err != nil || !strings.Contains(out, "none was removed") {
|
|
||||||
t.Fatalf("without a base: %v\n%s", err, out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTheFileRemovedWholeIsARemovalOfEveryKey(t *testing.T) {
|
|
||||||
root, base := aCatalogue(t, "")
|
|
||||||
out, err := run(t, "-base", base, root)
|
|
||||||
if err == nil || !strings.Contains(out, `"font-size" is removed and still named by dunst`) {
|
|
||||||
t.Fatalf("the list removed whole passed:\n%s", out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNamedReadsBothForms(t *testing.T) {
|
|
||||||
root, _ := aCatalogue(t, "")
|
|
||||||
if err := os.WriteFile(filepath.Join(root, "modules", "plain", "module.json"), []byte(`{"module": "plain",
|
|
||||||
"settings": {"size": {"predefined": "font-size"}}}`), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
named, err := Named(root)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if got := strings.Join(named["font-size"], ","); got != "dunst,plain" {
|
|
||||||
t.Fatalf("font-size named by %s", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+1
-11
@@ -7,8 +7,7 @@
|
|||||||
// until somebody removed it by hand. The controller refuses a setting of a key the file keeps as `protected`,
|
// until somebody removed it by hand. The controller refuses a setting of a key the file keeps as `protected`,
|
||||||
// naming the layer and the key, never the value. This check holds every such file to that: each top-level key
|
// naming the layer and the key, never the value. This check holds every such file to that: each top-level key
|
||||||
// whose value holds `${secret:…}`, anywhere below it, is either listed in the file's `protected`, or left out
|
// whose value holds `${secret:…}`, anywhere below it, is either listed in the file's `protected`, or left out
|
||||||
// of the file's `takes-settings` (novox/hq ADR 0326), so that no layer reaches it. A file whose text is a template
|
// of the file's `takes-settings` (novox/hq ADR 0326), so that no layer reaches it.
|
||||||
// in files/ is judged by the template's text.
|
|
||||||
//
|
//
|
||||||
// A secret nested below a key the file takes for its other values (`server.secret_key` below `server`) cannot
|
// A secret nested below a key the file takes for its other values (`server.secret_key` below `server`) cannot
|
||||||
// be protected alone today: the controller protects top-level keys. Such a file is named in the file `known`
|
// be protected alone today: the controller protects top-level keys. Such a file is named in the file `known`
|
||||||
@@ -65,15 +64,6 @@ func Check(root string) ([]Finding, error) {
|
|||||||
}
|
}
|
||||||
id, _ := r["id"].(string)
|
id, _ := r["id"].(string)
|
||||||
content, _ := r["content"].(string)
|
content, _ := r["content"].(string)
|
||||||
// A file whose text is a template in its module's files/ (novox/hq ADR 0349 §2) is judged by that text,
|
|
||||||
// exactly as it would be inline.
|
|
||||||
if name, _ := r["template"].(string); name != "" {
|
|
||||||
text, err := os.ReadFile(filepath.Join(filepath.Dir(path), filepath.FromSlash(name)))
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("%s: %s names the template %s: %w", m.Module, id, name, err)
|
|
||||||
}
|
|
||||||
content = string(text)
|
|
||||||
}
|
|
||||||
var doc map[string]any
|
var doc map[string]any
|
||||||
if strings.TrimSpace(content) == "" {
|
if strings.TrimSpace(content) == "" {
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -51,35 +51,6 @@ func TestASecretASettingCouldReplaceIsRefused(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A mergeable file whose text is a template in files/ (novox/hq ADR 0349 §2) is judged by the template's text,
|
|
||||||
// exactly as the same text inline; a template the module's directory lacks is an error, never a silent pass.
|
|
||||||
func TestAMergeableFilesTemplateIsJudgedAsItsText(t *testing.T) {
|
|
||||||
root := t.TempDir()
|
|
||||||
manifest(t, root, "a", `{"id":"cfg","type":"file","merge":"json","template":"files/cfg.json"}`)
|
|
||||||
manifest(t, root, "c", `{"id":"cfg","type":"file","merge":"json","protected":["password"],"template":"files/cfg.json"}`)
|
|
||||||
for _, m := range []string{"a", "c"} {
|
|
||||||
dir := filepath.Join(root, "modules", m, "files")
|
|
||||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(filepath.Join(dir, "cfg.json"), []byte(`{"password": "${secret:admin}"}`), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
found, err := Check(root)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(found) != 1 || found[0].String() != "a cfg password" {
|
|
||||||
t.Fatalf("found %v, want [a cfg password]", found)
|
|
||||||
}
|
|
||||||
|
|
||||||
manifest(t, root, "g", `{"id":"cfg","type":"file","merge":"json","template":"files/gone.json"}`)
|
|
||||||
if _, err := Check(root); err == nil {
|
|
||||||
t.Fatal("a template the module's directory lacks passed the check")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTheKnownListOnlyShrinks(t *testing.T) {
|
func TestTheKnownListOnlyShrinks(t *testing.T) {
|
||||||
found := []Finding{{"b", "cfg", "server.secret_key"}, {"a", "cfg", "password"}}
|
found := []Finding{{"b", "cfg", "server.secret_key"}, {"a", "cfg", "password"}}
|
||||||
refused, stale := Judge(found, map[string]bool{"b cfg server.secret_key": true, "gone cfg token": true})
|
refused, stale := Judge(found, map[string]bool{"b cfg server.secret_key": true, "gone cfg token": true})
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
module git.novox.be/novox/mesh-catalog/checks/templates
|
|
||||||
|
|
||||||
go 1.22
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
# The modules that still keep a file's text inline in module.json (`content`), one per line: the catalogue's list of
|
|
||||||
# modules whose file text has not yet moved to a template in files/ (novox/hq ADR 0349 §2, design 57), or in the
|
|
||||||
# templates directory its manifest names (`templates`, issue 731). Every other module's file resources name their
|
|
||||||
# template. This list only shrinks: a module that keeps no file text inline is taken off, and a line the trunk's copy
|
|
||||||
# does not hold is refused. When it is empty, no manifest in the catalogue carries file text (design 57 package T5,
|
|
||||||
# issue 658).
|
|
||||||
#
|
|
||||||
# A window-manager addition (`contributions`, such as files/i3/*.conf) is not a file resource and takes no template;
|
|
||||||
# it moves with the collections (design 57 package L2, issue 681) and is not judged here.
|
|
||||||
@@ -1,178 +0,0 @@
|
|||||||
// Command templates refuses a file's text kept inline in a manifest, outside the modules still to move (novox/hq
|
|
||||||
// ADR 0349 §2, design 57 package T5, issue 658).
|
|
||||||
//
|
|
||||||
// A file resource names its text as a template in its module's templates directory (`"template": "<dir>/<name>"`;
|
|
||||||
// the directory is the manifest's `"templates"`, files/ when it says none), which the module's build packs into an
|
|
||||||
// archive; the controller reads each template back into the file's content, so the
|
|
||||||
// mesh holds and composes what it would for the same text inline. A module whose file text has not yet moved is
|
|
||||||
// listed in the file `inline` beside this check, one per line. Every other module's file resources say no
|
|
||||||
// `content`; a module on the list that keeps none inline is taken off it; and a line the trunk's copy (the merge
|
|
||||||
// base's, given with -base) does not hold is refused, so the list only shrinks; while the trunk holds no copy, the
|
|
||||||
// modules in seed are the trunk's. When it is empty, no manifest in the catalogue carries file text.
|
|
||||||
//
|
|
||||||
// Only file resources are judged: a window-manager addition (`contributions`) takes no template and moves with the
|
|
||||||
// collections (design 57 package L2, issue 681).
|
|
||||||
//
|
|
||||||
// go run . [-base <the trunk's inline>] <catalogue root>
|
|
||||||
//
|
|
||||||
// Every refusal names the refused module's own templates directory. Exits 1 on a finding.
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"encoding/json"
|
|
||||||
"flag"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
listFile = "inline"
|
|
||||||
listPathInCatalog = "checks/templates/" + listFile
|
|
||||||
)
|
|
||||||
|
|
||||||
// Check answers, per module that keeps any, the ids of its file resources that say their text inline.
|
|
||||||
func Check(root string) (map[string][]string, error) {
|
|
||||||
manifests, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
sort.Strings(manifests)
|
|
||||||
out := map[string][]string{}
|
|
||||||
for _, path := range manifests {
|
|
||||||
raw, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
var m struct {
|
|
||||||
Module string `json:"module"`
|
|
||||||
Resources []map[string]any `json:"resources"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(raw, &m); err != nil {
|
|
||||||
return nil, fmt.Errorf("%s: %w", path, err)
|
|
||||||
}
|
|
||||||
for _, r := range m.Resources {
|
|
||||||
if _, inline := r["content"]; inline && r["type"] == "file" {
|
|
||||||
out[m.Module] = append(out[m.Module], fmt.Sprint(r["id"]))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// TemplatesDir answers the directory a module keeps its templates in, with a trailing slash: its manifest's
|
|
||||||
// `templates`, files/ when the manifest says none or cannot be read here.
|
|
||||||
func TemplatesDir(root, module string) string {
|
|
||||||
dir := "files/"
|
|
||||||
raw, err := os.ReadFile(filepath.Join(root, "modules", module, "module.json"))
|
|
||||||
if err != nil {
|
|
||||||
return dir
|
|
||||||
}
|
|
||||||
var m struct {
|
|
||||||
Templates string `json:"templates"`
|
|
||||||
}
|
|
||||||
if json.Unmarshal(raw, &m) == nil && m.Templates != "" {
|
|
||||||
dir = strings.TrimSuffix(m.Templates, "/") + "/"
|
|
||||||
}
|
|
||||||
return dir
|
|
||||||
}
|
|
||||||
|
|
||||||
// Refusal is the finding for a module that keeps file text inline and is not on the list.
|
|
||||||
func Refusal(module string, ids []string, dir string) string {
|
|
||||||
return fmt.Sprintf("%s keeps the text of %s inline and is not on %s: keep each file's text in %s and name it as "+
|
|
||||||
"the file's template, packed by the build's archive from %s (novox/hq ADR 0349 §2)",
|
|
||||||
module, strings.Join(ids, ", "), listPathInCatalog, dir, dir)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Gained is the finding for a module the list gains against the trunk's copy.
|
|
||||||
func Gained(module, dir string) string {
|
|
||||||
return fmt.Sprintf("%s gains %s, which the trunk's copy does not hold: the list only shrinks — move its file text "+
|
|
||||||
"to %s instead (novox/hq ADR 0349 §2)", listPathInCatalog, module, dir)
|
|
||||||
}
|
|
||||||
|
|
||||||
// seed is the list as it was first written, standing for the trunk's copy until the trunk holds one.
|
|
||||||
var seed = map[string]bool{"asus-zephyrus-g14": true, "power": true, "showcase": true}
|
|
||||||
|
|
||||||
// Listed is the modules a list names, one per line; a line starting with # and a blank line are not modules.
|
|
||||||
func Listed(path string) (map[string]bool, error) {
|
|
||||||
f, err := os.Open(path)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer f.Close()
|
|
||||||
out := map[string]bool{}
|
|
||||||
s := bufio.NewScanner(f)
|
|
||||||
for s.Scan() {
|
|
||||||
line := strings.TrimSpace(s.Text())
|
|
||||||
if line == "" || strings.HasPrefix(line, "#") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
out[line] = true
|
|
||||||
}
|
|
||||||
return out, s.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Judge answers the modules that keep file text inline and are not on the list (refused), the modules on the list
|
|
||||||
// that keep none (stale), and the modules on the list that the trunk's list does not hold (added).
|
|
||||||
func Judge(found map[string][]string, listed, base map[string]bool) (refused, stale, added []string) {
|
|
||||||
for m := range found {
|
|
||||||
if !listed[m] {
|
|
||||||
refused = append(refused, m)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for m := range listed {
|
|
||||||
if _, keeps := found[m]; !keeps {
|
|
||||||
stale = append(stale, m)
|
|
||||||
}
|
|
||||||
if !base[m] {
|
|
||||||
added = append(added, m)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sort.Strings(refused)
|
|
||||||
sort.Strings(stale)
|
|
||||||
sort.Strings(added)
|
|
||||||
return refused, stale, added
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
basePath := flag.String("base", "", "the trunk's copy of inline (the merge base's); seed when absent")
|
|
||||||
flag.Parse()
|
|
||||||
root := "."
|
|
||||||
if flag.NArg() > 0 {
|
|
||||||
root = flag.Arg(0)
|
|
||||||
}
|
|
||||||
found, err := Check(root)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintln(os.Stderr, err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
listed, err := Listed(listFile)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintln(os.Stderr, err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
base := seed
|
|
||||||
if *basePath != "" {
|
|
||||||
if base, err = Listed(*basePath); err != nil {
|
|
||||||
fmt.Fprintln(os.Stderr, err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
refused, stale, added := Judge(found, listed, base)
|
|
||||||
for _, m := range refused {
|
|
||||||
fmt.Println(Refusal(m, found[m], TemplatesDir(root, m)))
|
|
||||||
}
|
|
||||||
for _, m := range stale {
|
|
||||||
fmt.Printf("%s names %s, which keeps no file text inline: take it off the list\n", listPathInCatalog, m)
|
|
||||||
}
|
|
||||||
for _, m := range added {
|
|
||||||
fmt.Println(Gained(m, TemplatesDir(root, m)))
|
|
||||||
}
|
|
||||||
if len(refused) > 0 || len(stale) > 0 || len(added) > 0 {
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
fmt.Printf("templates: no module keeps a file's text inline but the %d listed\n", len(listed))
|
|
||||||
}
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"reflect"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func module(t *testing.T, root, name, body string) {
|
|
||||||
t.Helper()
|
|
||||||
dir := filepath.Join(root, "modules", name)
|
|
||||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(filepath.Join(dir, "module.json"), []byte(body), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A file resource that says its text inline is found, by module and resource; one naming its template, a
|
|
||||||
// resource that is not a file and a window-manager addition are not (novox/hq ADR 0349 §2).
|
|
||||||
func TestEveryInlineFileTextIsFound(t *testing.T) {
|
|
||||||
root := t.TempDir()
|
|
||||||
module(t, root, "inline", `{"module":"inline","resources":[{"id":"a","type":"file","content":"x"},`+
|
|
||||||
`{"id":"b","type":"file","template":"files/b"},{"id":"c","type":"file","content":""}]}`)
|
|
||||||
module(t, root, "templated", `{"module":"templated","resources":[{"id":"a","type":"file","template":"files/a"},`+
|
|
||||||
`{"id":"d","type":"directory","path":"/x"}],"contributions":{"i3":[{"content":"bindsym x"}]}}`)
|
|
||||||
got, err := Check(root)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
want := map[string][]string{"inline": {"a", "c"}}
|
|
||||||
if !reflect.DeepEqual(got, want) {
|
|
||||||
t.Fatalf("found %v, want %v", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A module not on the list keeps no file text inline; one on the list that keeps none is taken off it; a line the
|
|
||||||
// trunk's list does not hold is refused, so the list only shrinks until it is empty.
|
|
||||||
func TestTheListOfModulesKeepingTextInlineOnlyShrinks(t *testing.T) {
|
|
||||||
found := map[string][]string{"old": {"a"}, "forgotten": {"b"}}
|
|
||||||
inline := map[string]bool{"old": true, "done": true, "new": true}
|
|
||||||
base := map[string]bool{"old": true, "done": true, "forgotten": true}
|
|
||||||
refused, stale, added := Judge(found, inline, base)
|
|
||||||
if !reflect.DeepEqual(refused, []string{"forgotten"}) || !reflect.DeepEqual(stale, []string{"done", "new"}) ||
|
|
||||||
!reflect.DeepEqual(added, []string{"new"}) {
|
|
||||||
t.Fatalf("refused %v, stale %v, added %v", refused, stale, added)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTheListIsReadWithoutItsComments(t *testing.T) {
|
|
||||||
path := filepath.Join(t.TempDir(), "inline")
|
|
||||||
if err := os.WriteFile(path, []byte("# why\n\npower\n showcase \n"), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
got, err := Listed(path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if !reflect.DeepEqual(got, map[string]bool{"power": true, "showcase": true}) {
|
|
||||||
t.Fatalf("read %v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The catalogue itself: no module keeps a file's text inline but those the list names, and the list names none
|
|
||||||
// that keeps it no longer (design 57 package T5, issue 658: the ten modules without a build moved).
|
|
||||||
func TestTheCatalogueKeepsFileTextInlineOnlyWhereListed(t *testing.T) {
|
|
||||||
found, err := Check("../..")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
inline, err := Listed(listFile)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
refused, stale, _ := Judge(found, inline, inline)
|
|
||||||
if len(refused) > 0 || len(stale) > 0 {
|
|
||||||
t.Fatalf("keeping file text inline and not listed: %v; listed and keeping none: %v", refused, stale)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A refusal names the refused module's own templates directory, not files/ when the module names another (issue 809).
|
|
||||||
func TestARefusalNamesTheModulesOwnTemplatesDirectory(t *testing.T) {
|
|
||||||
root := t.TempDir()
|
|
||||||
module(t, root, "own", `{"module":"own","templates":"t/","resources":[{"id":"a","type":"file","content":"x"}]}`)
|
|
||||||
module(t, root, "plain", `{"module":"plain","resources":[{"id":"a","type":"file","content":"x"}]}`)
|
|
||||||
module(t, root, "bare", `{"module":"bare","templates":"templates","resources":[]}`)
|
|
||||||
for module, dir := range map[string]string{"own": "t/", "plain": "files/", "bare": "templates/"} {
|
|
||||||
if got := TemplatesDir(root, module); got != dir {
|
|
||||||
t.Fatalf("%s: directory %q, want %q", module, got, dir)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
msg := Refusal("own", []string{"a"}, TemplatesDir(root, "own"))
|
|
||||||
if !strings.Contains(msg, "t/") || strings.Contains(msg, "files/") {
|
|
||||||
t.Fatalf("refusal %q", msg)
|
|
||||||
}
|
|
||||||
if msg := Gained("own", TemplatesDir(root, "own")); !strings.Contains(msg, "t/") || strings.Contains(msg, "files/") {
|
|
||||||
t.Fatalf("gained %q", msg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
61
|
66
|
||||||
The long-running resources of this catalogue that do not say how they are ready (`health`, novox/hq ADR 0240
|
The long-running resources of this catalogue that do not say how they are ready (`health`, novox/hq ADR 0240
|
||||||
rule 8), as `module check` counts them. It may only go down: merge-check.sh fails a change that raises it, and
|
rule 8), as `module check` counts them. It may only go down: merge-check.sh fails a change that raises it, and
|
||||||
one that lowers it writes the new number on the first line. From 2026-11-18, or once it is 0, a long-running
|
one that lowers it writes the new number on the first line. From 2026-11-18, or once it is 0, a long-running
|
||||||
|
|||||||
@@ -23,51 +23,9 @@
|
|||||||
#
|
#
|
||||||
# By hand: MESH_CHECK_MODULES=gitea sh merge-check-typescript.sh, in the toolchain image (it reads
|
# By hand: MESH_CHECK_MODULES=gitea sh merge-check-typescript.sh, in the toolchain image (it reads
|
||||||
# /app/node_modules), or with TOOLCHAIN_MODULES pointing at a node_modules that holds the same.
|
# /app/node_modules), or with TOOLCHAIN_MODULES pointing at a node_modules that holds the same.
|
||||||
#
|
|
||||||
# **In an agent's session, outside the toolchain image** (novox/hq issue 742), the same node_modules is
|
|
||||||
# assembled first, in a throwaway directory under the check's own workspace, the way the image's `deps` stage
|
|
||||||
# assembles it: the toolchain's own package.json and lock (mesh-tools' node-tools/, read from the forge this
|
|
||||||
# clone came from, so the list is never copied here) installed with `npm ci`, then the SDK the mesh last
|
|
||||||
# published (`@novox/mesh-sdk@latest`), the @novox scope from the forge's package registry, read without a
|
|
||||||
# credential. Node and npm are the dev-tools module's; nothing is installed on the machine, no npm
|
|
||||||
# configuration or cache of the account's is read or written. Only when neither TOOLCHAIN_MODULES nor
|
|
||||||
# /app/node_modules is there: the forge's check, in the image, fetches nothing, as before. The forge is
|
|
||||||
# MESH_FORGE (default: the origin remote's), the owner MESH_FORGE_OWNER (default: the origin's owner).
|
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
work=$(mktemp -d)
|
|
||||||
trap 'rm -rf "$work"' EXIT
|
|
||||||
|
|
||||||
deps="${TOOLCHAIN_MODULES:-/app/node_modules}"
|
deps="${TOOLCHAIN_MODULES:-/app/node_modules}"
|
||||||
if [ -z "${TOOLCHAIN_MODULES:-}" ] && [ ! -f "$deps/typescript/bin/tsc" ]; then
|
|
||||||
origin=$(git remote get-url origin 2>/dev/null || true)
|
|
||||||
forge="${MESH_FORGE:-$(printf '%s\n' "$origin" | sed -n 's#^\(https\{0,1\}://[^/]*\)/.*#\1#p')}"
|
|
||||||
owner="${MESH_FORGE_OWNER:-$(printf '%s\n' "$origin" | sed -n 's#^https\{0,1\}://[^/]*/\([^/]*\)/.*#\1#p')}"
|
|
||||||
if [ -z "$forge" ] || [ -z "$owner" ]; then
|
|
||||||
echo "NOT CHECKED: no TypeScript toolchain at $deps, and no forge to assemble one from (origin is '$origin'; set MESH_FORGE and MESH_FORGE_OWNER, or TOOLCHAIN_MODULES)"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "no TypeScript toolchain at $deps: assembling one from $forge/$owner/mesh-tools node-tools/ and $owner's package registry (novox/hq issue 742)"
|
|
||||||
tc="$work/toolchain"
|
|
||||||
mkdir -p "$tc"
|
|
||||||
printf '@%s:registry=%s/api/packages/%s/npm/\n' "$owner" "$forge" "$owner" >"$work/npmrc"
|
|
||||||
# Read with Node's own fetch (dev-tools declares Node, not curl); a status that is not 2xx fails.
|
|
||||||
if ! { node -e '
|
|
||||||
const [base, dir] = process.argv.slice(1);
|
|
||||||
Promise.all(["package.json", "package-lock.json"].map(async (f) => {
|
|
||||||
const r = await fetch(base + f);
|
|
||||||
if (!r.ok) throw new Error(base + f + ": " + r.status);
|
|
||||||
require("fs").writeFileSync(dir + "/" + f, await r.text());
|
|
||||||
})).catch((e) => { console.error(e.message); process.exit(1); });' \
|
|
||||||
"$forge/$owner/mesh-tools/raw/branch/main/node-tools/" "$tc" &&
|
|
||||||
(cd "$tc" && export npm_config_userconfig="$work/npmrc" npm_config_cache="$work/npm-cache" &&
|
|
||||||
npm ci --ignore-scripts --no-audit --no-fund --loglevel=error &&
|
|
||||||
npm install --ignore-scripts --no-audit --no-fund --no-save --loglevel=error "@$owner/mesh-sdk@latest"); }; then
|
|
||||||
echo "NOT CHECKED: the TypeScript toolchain could not be assembled from $forge (novox/hq issue 742)"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
deps="$tc/node_modules"
|
|
||||||
fi
|
|
||||||
tsc="$deps/typescript/bin/tsc"
|
tsc="$deps/typescript/bin/tsc"
|
||||||
if [ ! -f "$tsc" ]; then
|
if [ ! -f "$tsc" ]; then
|
||||||
echo "NOT CHECKED: no TypeScript compiler at $tsc — this part runs in the TypeScript toolchain"
|
echo "NOT CHECKED: no TypeScript compiler at $tsc — this part runs in the TypeScript toolchain"
|
||||||
@@ -81,6 +39,8 @@ touched=$({
|
|||||||
printf '%s\n' "${MESH_CHECK_CHANGED:-}" | tr ',' '\n' | sed -n 's#^modules/\([^/]*\)/.*#\1#p'
|
printf '%s\n' "${MESH_CHECK_CHANGED:-}" | tr ',' '\n' | sed -n 's#^modules/\([^/]*\)/.*#\1#p'
|
||||||
} | sed '/^$/d' | sort -u)
|
} | sed '/^$/d' | sort -u)
|
||||||
|
|
||||||
|
work=$(mktemp -d)
|
||||||
|
trap 'rm -rf "$work"' EXIT
|
||||||
failed=""
|
failed=""
|
||||||
for m in $touched; do
|
for m in $touched; do
|
||||||
d="modules/$m"
|
d="modules/$m"
|
||||||
|
|||||||
@@ -33,25 +33,11 @@
|
|||||||
# 6. the secrets (novox/hq issue 342): no mergeable file fills a key with a vault secret that a setting could
|
# 6. the secrets (novox/hq issue 342): no mergeable file fills a key with a vault secret that a setting could
|
||||||
# replace — the key is `protected`, or left out of the file's `takes-settings`. checks/secrets/known names
|
# replace — the key is `protected`, or left out of the file's `takes-settings`. checks/secrets/known names
|
||||||
# the nested ones the controller cannot protect alone yet, and only shrinks.
|
# the nested ones the controller cannot protect alone yet, and only shrinks.
|
||||||
# 7. the list of predefined keys (novox/hq ADR 0348 clause 7): a change to predefined.json removes no key a
|
|
||||||
# module still names, judged against the trunk's copy at the merge base. Whether a removed key still holds a
|
|
||||||
# value at the mesh or a node is mesh-config's to refuse; this check reads no store. The list itself and
|
|
||||||
# every manifest against it are judged by the controller's module check in step 1.
|
|
||||||
# 8. the config form (novox/hq ADR 0348 clauses 1 and 11): no module says `settings`, `takes-settings` or
|
|
||||||
# `${setting:<key>}`, in its manifest or a template it names, or has a mergeable file naming no keys (which
|
|
||||||
# takes its whole layer), unless checks/config/unmoved lists it as not yet moved; that list only shrinks,
|
|
||||||
# judged against the trunk's copy at the merge base.
|
|
||||||
# 9. the file text (novox/hq ADR 0349 §2): no module keeps a file's text inline in its manifest (`content`)
|
|
||||||
# unless checks/templates/inline lists it as not yet moved; each names its template in files/. That list
|
|
||||||
# only shrinks, judged against the trunk's copy at the merge base.
|
|
||||||
# 10. the mail-store seat (novox/hq ADR 0378 §3, issue 800): no manifest but modules/mesh-mail claims it.
|
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
(cd checks/words && go test -count=1 . && go run . ../..)
|
(cd checks/words && go test -count=1 . && go run . ../..)
|
||||||
# The agent's home (novox/hq ADR 0266): no archive, and no file written into, under it.
|
# The agent's home (novox/hq ADR 0266): no archive, and no file written into, under it.
|
||||||
(cd checks/agenthome && go test -count=1 . && go run . ../..)
|
(cd checks/agenthome && go test -count=1 . && go run . ../..)
|
||||||
# The mail-store seat (novox/hq ADR 0378 §3): only mesh-mail may claim it.
|
|
||||||
(cd checks/mailstore && go test -count=1 . && go run . ../..)
|
|
||||||
# The secrets (novox/hq issue 342): a password is not a setting, so no setting can replace the vault's. The
|
# The secrets (novox/hq issue 342): a password is not a setting, so no setting can replace the vault's. The
|
||||||
# known list only shrinks: judged against the trunk's copy at the merge base, a line it does not hold is refused.
|
# known list only shrinks: judged against the trunk's copy at the merge base, a line it does not hold is refused.
|
||||||
secrets_base=""
|
secrets_base=""
|
||||||
@@ -65,42 +51,6 @@ else
|
|||||||
echo "NOT COMPARED WITH THE TRUNK: no merge base with $secrets_ref; checks/secrets/known is held to the list it was first written with"
|
echo "NOT COMPARED WITH THE TRUNK: no merge base with $secrets_ref; checks/secrets/known is held to the list it was first written with"
|
||||||
fi
|
fi
|
||||||
(cd checks/secrets && go test -count=1 . && if [ -n "$secrets_base" ]; then go run . -base "$secrets_base" ../..; else go run . ../..; fi)
|
(cd checks/secrets && go test -count=1 . && if [ -n "$secrets_base" ]; then go run . -base "$secrets_base" ../..; else go run . ../..; fi)
|
||||||
# The list of predefined keys (novox/hq ADR 0348 clause 7): no key removed that a module still names. The trunk's
|
|
||||||
# list at the merge base is what it is judged against; a trunk without the file had none.
|
|
||||||
predefined_base=""
|
|
||||||
if merge_base=$(git merge-base HEAD "origin/${MESH_CHECK_BASE:-main}" 2>/dev/null); then
|
|
||||||
predefined_base=$(mktemp)
|
|
||||||
if ! git show "$merge_base:predefined.json" >"$predefined_base" 2>/dev/null; then
|
|
||||||
echo '{}' >"$predefined_base"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
echo "NOT COMPARED WITH THE TRUNK: no merge base with origin/${MESH_CHECK_BASE:-main}; no removal from predefined.json can be seen"
|
|
||||||
fi
|
|
||||||
(cd checks/predefined && go test -count=1 . && if [ -n "$predefined_base" ]; then go run . -base "$predefined_base" ../..; else go run . ../..; fi)
|
|
||||||
# The config form (novox/hq ADR 0348 §1, §11): a moved module says no old name, and the list of modules not yet moved
|
|
||||||
# only shrinks, judged against the trunk's copy at the merge base; a trunk without one is the list's first copy.
|
|
||||||
unmoved_base=""
|
|
||||||
if merge_base=$(git merge-base HEAD "origin/${MESH_CHECK_BASE:-main}" 2>/dev/null); then
|
|
||||||
if git cat-file -e "$merge_base:checks/config/unmoved" 2>/dev/null; then
|
|
||||||
unmoved_base=$(mktemp)
|
|
||||||
git show "$merge_base:checks/config/unmoved" >"$unmoved_base"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
echo "NOT COMPARED WITH THE TRUNK: no merge base with origin/${MESH_CHECK_BASE:-main}; checks/config/unmoved is held to the list it was first written with"
|
|
||||||
fi
|
|
||||||
(cd checks/config && go test -count=1 . && if [ -n "$unmoved_base" ]; then go run . -base "$unmoved_base" ../..; else go run . ../..; fi)
|
|
||||||
# The file text (novox/hq ADR 0349 §2): a file's text is a template in files/, outside the modules not yet moved; the
|
|
||||||
# list of those only shrinks, judged against the trunk's copy at the merge base, a trunk without one being its first.
|
|
||||||
inline_base=""
|
|
||||||
if merge_base=$(git merge-base HEAD "origin/${MESH_CHECK_BASE:-main}" 2>/dev/null); then
|
|
||||||
if git cat-file -e "$merge_base:checks/templates/inline" 2>/dev/null; then
|
|
||||||
inline_base=$(mktemp)
|
|
||||||
git show "$merge_base:checks/templates/inline" >"$inline_base"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
echo "NOT COMPARED WITH THE TRUNK: no merge base with origin/${MESH_CHECK_BASE:-main}; checks/templates/inline is held to the list it was first written with"
|
|
||||||
fi
|
|
||||||
(cd checks/templates && go test -count=1 . && if [ -n "$inline_base" ]; then go run . -base "$inline_base" ../..; else go run . ../..; fi)
|
|
||||||
# The lists that narrow the paths check may not grow unseen: the base branch's copies are what they are
|
# The lists that narrow the paths check may not grow unseen: the base branch's copies are what they are
|
||||||
# judged against (MESH_CHECK_BASE, main when unset), read from the clone the check runs in.
|
# judged against (MESH_CHECK_BASE, main when unset), read from the clone the check runs in.
|
||||||
base_lists=""
|
base_lists=""
|
||||||
|
|||||||
@@ -22,11 +22,11 @@ for GTK, Qt, the portal and the cursor, dark by default. It claims no seat, beca
|
|||||||
|
|
||||||
| path | class | from |
|
| path | class | from |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `~/.config/gtk-3.0/settings.ini` | owned (the found file kept once) | [`files/gtk-settings.ini`](files/gtk-settings.ini) |
|
| `~/.config/gtk-3.0/settings.ini` | owned (the found file kept once) | [`config/gtk-settings.ini`](config/gtk-settings.ini) |
|
||||||
| `~/.config/gtk-4.0/settings.ini` | owned | the same file |
|
| `~/.config/gtk-4.0/settings.ini` | owned | the same file |
|
||||||
| `~/.config/qt6ct/qt6ct.conf` | owned | [`files/qt6ct.conf`](files/qt6ct.conf) |
|
| `~/.config/qt6ct/qt6ct.conf` | owned | [`config/qt6ct.conf`](config/qt6ct.conf) |
|
||||||
| `~/.config/xdg-desktop-portal/portals.conf` | owned | [`files/portals.conf`](files/portals.conf) |
|
| `~/.config/xdg-desktop-portal/portals.conf` | owned | [`config/portals.conf`](config/portals.conf) |
|
||||||
| `~/.icons/default/index.theme` | owned | [`files/cursor-index.theme`](files/cursor-index.theme): the cursor for programs that read neither `XCURSOR_THEME` nor the resources |
|
| `~/.icons/default/index.theme` | owned | [`config/cursor-index.theme`](config/cursor-index.theme): the cursor for programs that read neither `XCURSOR_THEME` nor the resources |
|
||||||
|
|
||||||
**`qt6ct.conf` is the mesh's now.** A change made in qt6ct's own window is replaced at the next push,
|
**`qt6ct.conf` is the mesh's now.** A change made in qt6ct's own window is replaced at the next push,
|
||||||
and the window's saved geometry goes with it. The theme is this module's to say. Settings will make it
|
and the window's saved geometry goes with it. The theme is this module's to say. Settings will make it
|
||||||
|
|||||||
@@ -18,18 +18,10 @@ type manifest struct {
|
|||||||
Environment struct {
|
Environment struct {
|
||||||
Variables map[string]string `json:"variables"`
|
Variables map[string]string `json:"variables"`
|
||||||
} `json:"environment"`
|
} `json:"environment"`
|
||||||
Items []struct {
|
|
||||||
Collection string `json:"collection"`
|
|
||||||
Attributes map[string]string `json:"attributes"`
|
|
||||||
Template string `json:"template"`
|
|
||||||
} `json:"items"`
|
|
||||||
Shell []struct {
|
Shell []struct {
|
||||||
For, Slot, Code string
|
For, Slot, Code string
|
||||||
} `json:"shell"`
|
} `json:"shell"`
|
||||||
Resources []map[string]any `json:"resources"`
|
Resources []map[string]any `json:"resources"`
|
||||||
Build struct {
|
|
||||||
Artifacts []map[string]any `json:"artifacts"`
|
|
||||||
} `json:"build"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func readManifest(t *testing.T) manifest {
|
func readManifest(t *testing.T) manifest {
|
||||||
@@ -41,23 +33,6 @@ func readManifest(t *testing.T) manifest {
|
|||||||
if err := json.Unmarshal(raw, &m); err != nil {
|
if err := json.Unmarshal(raw, &m); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
// The module's code and variables are the templates of its items (design 57 L3, issues 795 and 807).
|
|
||||||
for _, it := range m.Items {
|
|
||||||
text, err := os.ReadFile(filepath.Join("..", "..", it.Template))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
parts := strings.Split(it.Collection, ":")
|
|
||||||
switch {
|
|
||||||
case len(parts) == 3 && parts[0] == "shell":
|
|
||||||
m.Shell = append(m.Shell, struct{ For, Slot, Code string }{parts[1], parts[2], string(text)})
|
|
||||||
case it.Collection == "environment" && it.Attributes["kind"] == "variable":
|
|
||||||
if m.Environment.Variables == nil {
|
|
||||||
m.Environment.Variables = map[string]string{}
|
|
||||||
}
|
|
||||||
m.Environment.Variables[it.Attributes["name"]] = strings.TrimSuffix(string(text), "\n")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,10 +96,9 @@ func TestItOwnsTheFilesItsSourcesHoldAndNoQt5Duplicate(t *testing.T) {
|
|||||||
pkgs = append(pkgs, r["package"].(string))
|
pkgs = append(pkgs, r["package"].(string))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
_, inline := r["content"]
|
raw, _ := os.ReadFile(filepath.Join("..", "..", "config", sources[id]))
|
||||||
if _, err := os.Stat(filepath.Join("..", "..", "files", sources[id])); err != nil || inline ||
|
if r["content"] != string(raw) || r["into"] != nil || r["owner"] != "${machine:account}" {
|
||||||
r["template"] != "files/"+sources[id] || r["into"] != nil || r["owner"] != "${machine:account}" {
|
t.Errorf("%s is not config/%s, whole and the account's", id, sources[id])
|
||||||
t.Errorf("%s is not files/%s, whole, as its template, and the account's", id, sources[id])
|
|
||||||
}
|
}
|
||||||
if strings.Contains(r["path"].(string), "qt5ct") {
|
if strings.Contains(r["path"].(string), "qt5ct") {
|
||||||
t.Error("qt5ct: both workstations run Qt 6 programs through qt6ct; a second tool is a duplicate")
|
t.Error("qt5ct: both workstations run Qt 6 programs through qt6ct; a second tool is a duplicate")
|
||||||
@@ -133,7 +107,7 @@ func TestItOwnsTheFilesItsSourcesHoldAndNoQt5Duplicate(t *testing.T) {
|
|||||||
if strings.Join(pkgs, ",") != "gnome-themes-extra,adwaita-icon-theme,adwaita-cursors,qt6ct,xdg-desktop-portal-gtk" {
|
if strings.Join(pkgs, ",") != "gnome-themes-extra,adwaita-icon-theme,adwaita-cursors,qt6ct,xdg-desktop-portal-gtk" {
|
||||||
t.Fatalf("%v", pkgs)
|
t.Fatalf("%v", pkgs)
|
||||||
}
|
}
|
||||||
qt := readINI(filepath.Join("..", "..", "files", "qt6ct.conf"))
|
qt := readINI(filepath.Join("..", "..", "config", "qt6ct.conf"))
|
||||||
if qt["Appearance"]["style"] != "Fusion" || qt["Appearance"]["custom_palette"] != "true" || !strings.Contains(qt["Fonts"]["general"], "Inter,11") {
|
if qt["Appearance"]["style"] != "Fusion" || qt["Appearance"]["custom_palette"] != "true" || !strings.Contains(qt["Fonts"]["general"], "Inter,11") {
|
||||||
t.Fatalf("%v", qt)
|
t.Fatalf("%v", qt)
|
||||||
}
|
}
|
||||||
@@ -142,7 +116,7 @@ func TestItOwnsTheFilesItsSourcesHoldAndNoQt5Duplicate(t *testing.T) {
|
|||||||
t.Fatalf("qt6ct ships no %s", qt["Appearance"]["color_scheme_path"])
|
t.Fatalf("qt6ct ships no %s", qt["Appearance"]["color_scheme_path"])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
gtk := readINI(filepath.Join("..", "..", "files", "gtk-settings.ini"))["Settings"]
|
gtk := readINI(filepath.Join("..", "..", "config", "gtk-settings.ini"))["Settings"]
|
||||||
if gtk["gtk-theme-name"] != "Adwaita" || gtk["gtk-application-prefer-dark-theme"] != "1" || gtk["gtk-font-name"] != "Inter 11" {
|
if gtk["gtk-theme-name"] != "Adwaita" || gtk["gtk-application-prefer-dark-theme"] != "1" || gtk["gtk-font-name"] != "Inter 11" {
|
||||||
t.Fatalf("%v", gtk)
|
t.Fatalf("%v", gtk)
|
||||||
}
|
}
|
||||||
@@ -270,31 +244,3 @@ func asJSON(v any) string {
|
|||||||
b, _ := json.Marshal(v)
|
b, _ := json.Marshal(v)
|
||||||
return string(b)
|
return string(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
// No file resource carries its text inline: each names its template in files/, and the build packs files/
|
|
||||||
// into the one archive the controller reads the templates back from (novox/hq ADR 0349 §2).
|
|
||||||
func TestEveryFileTakesItsTextFromATemplate(t *testing.T) {
|
|
||||||
m := readManifest(t)
|
|
||||||
for _, r := range m.Resources {
|
|
||||||
if _, inline := r["content"]; inline {
|
|
||||||
t.Errorf("resource %v keeps its text inline: keep it in files/ and name it as the template", r["id"])
|
|
||||||
}
|
|
||||||
if name, ok := r["template"].(string); ok {
|
|
||||||
if _, err := os.Stat(filepath.Join("..", "..", name)); err != nil {
|
|
||||||
t.Errorf("resource %v names %s: %v", r["id"], name, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
packs := 0
|
|
||||||
for _, a := range m.Build.Artifacts {
|
|
||||||
if a["kind"] == "archive" && a["from"] == "files" {
|
|
||||||
packs++
|
|
||||||
if a["name"] != "files" {
|
|
||||||
t.Errorf("the templates archive is named %v, not files", a["name"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if packs != 1 {
|
|
||||||
t.Errorf("the build packs files/ into %d archives, not one", packs)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
Adwaita:dark
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
/usr/share/themes/Adwaita-dark/gtk-2.0/gtkrc
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
qt6ct
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
6
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
Fusion
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
24
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
Adwaita
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
# The appearance (module adwaita): GSettings is where the portal reads dark or light, and the portal is
|
|
||||||
# the only way it reaches Electron, Chromium, Firefox and flatpaks. Set at every session start to the
|
|
||||||
# module's default; adwaita_appearance switches it for a session.
|
|
||||||
gsettings set org.gnome.desktop.interface color-scheme 'prefer-dark' || true
|
|
||||||
gsettings set org.gnome.desktop.interface gtk-theme 'Adwaita' || true
|
|
||||||
gsettings set org.gnome.desktop.interface icon-theme 'Adwaita' || true
|
|
||||||
gsettings set org.gnome.desktop.interface cursor-theme 'Adwaita' || true
|
|
||||||
gsettings set org.gnome.desktop.interface cursor-size 24 || true
|
|
||||||
gsettings set org.gnome.desktop.interface font-name 'Inter 11' || true
|
|
||||||
gsettings set org.gnome.desktop.interface monospace-font-name 'JetBrainsMono Nerd Font 11' || true
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
! adwaita: the cursor, for X programs that take it from the resources.
|
|
||||||
Xcursor.theme: Adwaita
|
|
||||||
Xcursor.size: 24
|
|
||||||
+23
-81
@@ -1,7 +1,6 @@
|
|||||||
{
|
{
|
||||||
"module": "adwaita",
|
"module": "adwaita",
|
||||||
"version": "1",
|
"version": "1",
|
||||||
"moved": true,
|
|
||||||
"capabilities": [
|
"capabilities": [
|
||||||
"package-manager"
|
"package-manager"
|
||||||
],
|
],
|
||||||
@@ -11,79 +10,27 @@
|
|||||||
"adwaita_icons",
|
"adwaita_icons",
|
||||||
"adwaita_portal_check"
|
"adwaita_portal_check"
|
||||||
],
|
],
|
||||||
"items": [
|
"environment": {
|
||||||
|
"variables": {
|
||||||
|
"GTK_THEME": "Adwaita:dark",
|
||||||
|
"GTK2_RC_FILES": "/usr/share/themes/Adwaita-dark/gtk-2.0/gtkrc",
|
||||||
|
"QT_QPA_PLATFORMTHEME": "qt6ct",
|
||||||
|
"QT_STYLE_OVERRIDE": "Fusion",
|
||||||
|
"QT_SELECT": "6",
|
||||||
|
"XCURSOR_THEME": "Adwaita",
|
||||||
|
"XCURSOR_SIZE": "24"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"shell": [
|
||||||
{
|
{
|
||||||
"collection": "environment",
|
"for": "xresources",
|
||||||
"name": "gtk-theme",
|
"slot": "normal",
|
||||||
"attributes": {
|
"code": "! adwaita: the cursor, for X programs that take it from the resources.\nXcursor.theme: Adwaita\nXcursor.size: 24\n"
|
||||||
"kind": "variable",
|
|
||||||
"name": "GTK_THEME"
|
|
||||||
},
|
|
||||||
"template": "files/env-gtk-theme"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"collection": "environment",
|
"for": "xinitrc",
|
||||||
"name": "gtk2-rc-files",
|
"slot": "normal",
|
||||||
"attributes": {
|
"code": "# The appearance (module adwaita): GSettings is where the portal reads dark or light, and the portal is\n# the only way it reaches Electron, Chromium, Firefox and flatpaks. Set at every session start to the\n# module's default; adwaita_appearance switches it for a session.\ngsettings set org.gnome.desktop.interface color-scheme 'prefer-dark' || true\ngsettings set org.gnome.desktop.interface gtk-theme 'Adwaita' || true\ngsettings set org.gnome.desktop.interface icon-theme 'Adwaita' || true\ngsettings set org.gnome.desktop.interface cursor-theme 'Adwaita' || true\ngsettings set org.gnome.desktop.interface cursor-size 24 || true\ngsettings set org.gnome.desktop.interface font-name 'Inter 11' || true\ngsettings set org.gnome.desktop.interface monospace-font-name 'JetBrainsMono Nerd Font 11' || true\n"
|
||||||
"kind": "variable",
|
|
||||||
"name": "GTK2_RC_FILES"
|
|
||||||
},
|
|
||||||
"template": "files/env-gtk2-rc-files"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"collection": "environment",
|
|
||||||
"name": "qt-qpa-platformtheme",
|
|
||||||
"attributes": {
|
|
||||||
"kind": "variable",
|
|
||||||
"name": "QT_QPA_PLATFORMTHEME"
|
|
||||||
},
|
|
||||||
"template": "files/env-qt-qpa-platformtheme"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"collection": "environment",
|
|
||||||
"name": "qt-style-override",
|
|
||||||
"attributes": {
|
|
||||||
"kind": "variable",
|
|
||||||
"name": "QT_STYLE_OVERRIDE"
|
|
||||||
},
|
|
||||||
"template": "files/env-qt-style-override"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"collection": "environment",
|
|
||||||
"name": "qt-select",
|
|
||||||
"attributes": {
|
|
||||||
"kind": "variable",
|
|
||||||
"name": "QT_SELECT"
|
|
||||||
},
|
|
||||||
"template": "files/env-qt-select"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"collection": "environment",
|
|
||||||
"name": "xcursor-theme",
|
|
||||||
"attributes": {
|
|
||||||
"kind": "variable",
|
|
||||||
"name": "XCURSOR_THEME"
|
|
||||||
},
|
|
||||||
"template": "files/env-xcursor-theme"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"collection": "environment",
|
|
||||||
"name": "xcursor-size",
|
|
||||||
"attributes": {
|
|
||||||
"kind": "variable",
|
|
||||||
"name": "XCURSOR_SIZE"
|
|
||||||
},
|
|
||||||
"template": "files/env-xcursor-size"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"collection": "shell:xresources:normal",
|
|
||||||
"name": "xresources-normal",
|
|
||||||
"template": "files/xresources-normal"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"collection": "shell:xinitrc:normal",
|
|
||||||
"name": "xinitrc-normal",
|
|
||||||
"template": "files/xinitrc-normal"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"resources": [
|
"resources": [
|
||||||
@@ -118,7 +65,7 @@
|
|||||||
"path": "${machine:account-home}/.config/gtk-3.0/settings.ini",
|
"path": "${machine:account-home}/.config/gtk-3.0/settings.ini",
|
||||||
"owner": "${machine:account}",
|
"owner": "${machine:account}",
|
||||||
"mode": "0644",
|
"mode": "0644",
|
||||||
"template": "files/gtk-settings.ini"
|
"content": "# Written by the mesh (module adwaita, novox/hq ADR 0208), for GTK 3 and GTK 4 alike. Replaced at\n# every push; adwaita_appearance switches dark and light for the running session.\n[Settings]\ngtk-theme-name=Adwaita\ngtk-icon-theme-name=Adwaita\ngtk-cursor-theme-name=Adwaita\ngtk-cursor-theme-size=24\ngtk-font-name=Inter 11\ngtk-application-prefer-dark-theme=1\n"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "gtk4",
|
"id": "gtk4",
|
||||||
@@ -126,7 +73,7 @@
|
|||||||
"path": "${machine:account-home}/.config/gtk-4.0/settings.ini",
|
"path": "${machine:account-home}/.config/gtk-4.0/settings.ini",
|
||||||
"owner": "${machine:account}",
|
"owner": "${machine:account}",
|
||||||
"mode": "0644",
|
"mode": "0644",
|
||||||
"template": "files/gtk-settings.ini"
|
"content": "# Written by the mesh (module adwaita, novox/hq ADR 0208), for GTK 3 and GTK 4 alike. Replaced at\n# every push; adwaita_appearance switches dark and light for the running session.\n[Settings]\ngtk-theme-name=Adwaita\ngtk-icon-theme-name=Adwaita\ngtk-cursor-theme-name=Adwaita\ngtk-cursor-theme-size=24\ngtk-font-name=Inter 11\ngtk-application-prefer-dark-theme=1\n"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "qt6ct",
|
"id": "qt6ct",
|
||||||
@@ -134,7 +81,7 @@
|
|||||||
"path": "${machine:account-home}/.config/qt6ct/qt6ct.conf",
|
"path": "${machine:account-home}/.config/qt6ct/qt6ct.conf",
|
||||||
"owner": "${machine:account}",
|
"owner": "${machine:account}",
|
||||||
"mode": "0644",
|
"mode": "0644",
|
||||||
"template": "files/qt6ct.conf"
|
"content": "[Appearance]\ncolor_scheme_path=/usr/share/qt6ct/colors/darker.conf\ncustom_palette=true\nicon_theme=Adwaita\nstandard_dialogs=default\nstyle=Fusion\n\n[Fonts]\nfixed=\"JetBrainsMono Nerd Font,11,-1,5,50,0,0,0,0,0\"\ngeneral=\"Inter,11,-1,5,50,0,0,0,0,0\"\n\n[Interface]\nactivate_item_on_single_click=1\nbuttonbox_layout=0\ncursor_flash_time=1000\ndialog_buttons_have_icons=1\ndouble_click_interval=400\ngui_effects=@Invalid()\nkeyboard_scheme=2\nmenus_have_icons=true\nshow_shortcuts_in_context_menus=true\nstylesheets=@Invalid()\ntoolbutton_style=4\nunderline_shortcut=1\nwheel_scroll_lines=3\n\n[Troubleshooting]\nforce_raster_widgets=1\nignored_applications=@Invalid()\n"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "portals",
|
"id": "portals",
|
||||||
@@ -142,7 +89,7 @@
|
|||||||
"path": "${machine:account-home}/.config/xdg-desktop-portal/portals.conf",
|
"path": "${machine:account-home}/.config/xdg-desktop-portal/portals.conf",
|
||||||
"owner": "${machine:account}",
|
"owner": "${machine:account}",
|
||||||
"mode": "0644",
|
"mode": "0644",
|
||||||
"template": "files/portals.conf"
|
"content": "# Written by the mesh (module adwaita, novox/hq ADR 0208). Replaced at every push.\n#\n# Which portal backend answers each interface. i3 is not a desktop xdg-desktop-portal knows, so with\n# no preference it uses whichever backend happens to be installed: fine while gtk is the only one,\n# wrong the day another arrives as somebody else's dependency. Named instead. gtk also serves\n# org.freedesktop.appearance (dark or light) from GSettings, which the session's start sets.\n[preferred]\ndefault=gtk\n# Secrets for sandboxed programs come from the keyring's backend. Without this line no backend answers\n# the interface: gtk does not implement it, and gnome-keyring's names only GNOME as its desktop.\norg.freedesktop.impl.portal.Secret=gnome-keyring\n"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "cursor",
|
"id": "cursor",
|
||||||
@@ -150,7 +97,7 @@
|
|||||||
"path": "${machine:account-home}/.icons/default/index.theme",
|
"path": "${machine:account-home}/.icons/default/index.theme",
|
||||||
"owner": "${machine:account}",
|
"owner": "${machine:account}",
|
||||||
"mode": "0644",
|
"mode": "0644",
|
||||||
"template": "files/cursor-index.theme"
|
"content": "# Written by the mesh (module adwaita, novox/hq ADR 0208): the default cursor theme, for programs that\n# read neither XCURSOR_THEME nor the X resources.\n[Icon Theme]\nName=Default\nInherits=Adwaita\n"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"build": {
|
"build": {
|
||||||
@@ -165,11 +112,6 @@
|
|||||||
"loads": [
|
"loads": [
|
||||||
"adwaita-tools"
|
"adwaita-tools"
|
||||||
]
|
]
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "files",
|
|
||||||
"kind": "archive",
|
|
||||||
"from": "files"
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,64 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// The module's directory, where its manifest and its files/ are.
|
|
||||||
const moduleDir = "../.."
|
|
||||||
|
|
||||||
// templateText is the text a file resource places on the machine: its template's, in the module's files/
|
|
||||||
// (novox/hq ADR 0349 §2). A resource that names no template, or a template that is not there, fails the test.
|
|
||||||
func templateText(t *testing.T, r map[string]any) string {
|
|
||||||
t.Helper()
|
|
||||||
name, _ := r["template"].(string)
|
|
||||||
if name == "" {
|
|
||||||
t.Fatalf("resource %v names no template", r["id"])
|
|
||||||
}
|
|
||||||
raw, err := os.ReadFile(filepath.Join(moduleDir, filepath.FromSlash(name)))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("resource %v names %s: %v", r["id"], name, err)
|
|
||||||
}
|
|
||||||
return string(raw)
|
|
||||||
}
|
|
||||||
|
|
||||||
// No file resource carries its text inline: each names its template in files/, and the build packs files/
|
|
||||||
// into the one archive the controller reads the templates back from (novox/hq ADR 0349 §2).
|
|
||||||
func TestEveryFileTakesItsTextFromATemplate(t *testing.T) {
|
|
||||||
raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
var m struct {
|
|
||||||
Resources []map[string]any `json:"resources"`
|
|
||||||
Build struct {
|
|
||||||
Artifacts []map[string]any `json:"artifacts"`
|
|
||||||
} `json:"build"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(raw, &m); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
for _, r := range m.Resources {
|
|
||||||
if _, inline := r["content"]; inline {
|
|
||||||
t.Errorf("resource %v keeps its text inline: keep it in files/ and name it as the template", r["id"])
|
|
||||||
}
|
|
||||||
if r["type"] == "file" {
|
|
||||||
templateText(t, r)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
packs := 0
|
|
||||||
for _, a := range m.Build.Artifacts {
|
|
||||||
if a["kind"] == "archive" && a["from"] == "files" {
|
|
||||||
packs++
|
|
||||||
if a["name"] != "files" {
|
|
||||||
t.Errorf("the templates archive is named %v, not files", a["name"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if packs != 1 {
|
|
||||||
t.Errorf("the build packs files/ into %d archives, not one", packs)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
# The names the artifact store had before, from the setting older-store-names, written by the mesh.
|
|
||||||
# artifact_store_references reads every definition under these too (novox/hq issue 323).
|
|
||||||
# Replaced on every push; change the setting, never this file.
|
|
||||||
older=${config:older-store-names}
|
|
||||||
@@ -13,14 +13,13 @@
|
|||||||
"artifact_store_references",
|
"artifact_store_references",
|
||||||
"artifact_store_collect"
|
"artifact_store_collect"
|
||||||
],
|
],
|
||||||
"config": {
|
"settings": {
|
||||||
"older-store-names": {
|
"older-store-names": {
|
||||||
"type": "string",
|
"kind": "preference",
|
||||||
"description": "The names the artifact store had before, as host names or host:port separated by spaces, or none when it had no other. artifact_store_references reads every definition under these as well as under the store's current name, so an image a definition pins under one is said to be named by a definition (novox/hq issue 323).",
|
"default": "none",
|
||||||
"pattern": "none|[A-Za-z0-9.:-]+( [A-Za-z0-9.:-]+)*",
|
"why": "the names the artifact store had before, as a definition may still pin an image under them: host names or host:port, separated by spaces; none when it had no other. artifact_store_references reads every definition under these as well as under the store's current name, so an image a definition pins under one is said to be named by a definition (novox/hq issue 323)"
|
||||||
"default": "none"
|
}
|
||||||
}
|
},
|
||||||
},
|
|
||||||
"resources": [
|
"resources": [
|
||||||
{
|
{
|
||||||
"id": "state",
|
"id": "state",
|
||||||
@@ -34,7 +33,7 @@
|
|||||||
"path": "${dir:state}/store-names",
|
"path": "${dir:state}/store-names",
|
||||||
"mode": "0600",
|
"mode": "0600",
|
||||||
"trusted": false,
|
"trusted": false,
|
||||||
"template": "files/store-names"
|
"content": "# The names the artifact store had before, from the setting older-store-names, written by the mesh.\n# artifact_store_references reads every definition under these too (novox/hq issue 323).\n# Replaced on every push; change the setting, never this file.\nolder=${setting:older-store-names}\n"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"build": {
|
"build": {
|
||||||
@@ -53,11 +52,6 @@
|
|||||||
"MESH_ARTIFACT_STORE_CONTAINER": "mesh-registry",
|
"MESH_ARTIFACT_STORE_CONTAINER": "mesh-registry",
|
||||||
"MESH_ARTIFACT_STORE_NAMES_FILE": "${dir:state}/store-names"
|
"MESH_ARTIFACT_STORE_NAMES_FILE": "${dir:state}/store-names"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "files",
|
|
||||||
"kind": "archive",
|
|
||||||
"from": "files"
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,9 +34,11 @@ func TestKeysListsTriggersI3LinesAndFirmwareKeys(t *testing.T) {
|
|||||||
home := "/home/operator"
|
home := "/home/operator"
|
||||||
t.Setenv("MESH_OPERATOR_HOME", home)
|
t.Setenv("MESH_OPERATOR_HOME", home)
|
||||||
// The i3 module's file as the controller composes it: other modules' lines around this one's.
|
// The i3 module's file as the controller composes it: other modules' lines around this one's.
|
||||||
own := m.i3Lines()
|
var own string
|
||||||
if own == "" || len(m.Contributions) != 0 {
|
for _, c := range m.Contributions {
|
||||||
t.Fatalf("the lines for the window manager are one item, and no contribution is left: %q %v", own, m.Contributions)
|
if c.Seat == "node-display-session" {
|
||||||
|
own += c.Content
|
||||||
|
}
|
||||||
}
|
}
|
||||||
put(t, f.root, home+"/"+I3Config, "set $mod Mod4\n# rofi\nbindsym $mod+d exec rofi\n# "+ModuleName+"\n"+own+
|
put(t, f.root, home+"/"+I3Config, "set $mod Mod4\n# rofi\nbindsym $mod+d exec rofi\n# "+ModuleName+"\n"+own+
|
||||||
"# triggerhappy-not-here\nbindsym $mod+Shift+z exec other\n#########################################\ninclude x\n")
|
"# triggerhappy-not-here\nbindsym $mod+Shift+z exec other\n#########################################\ninclude x\n")
|
||||||
|
|||||||
@@ -15,41 +15,19 @@ type manifest struct {
|
|||||||
Tools []string `json:"tools"`
|
Tools []string `json:"tools"`
|
||||||
Emits []string `json:"emits"`
|
Emits []string `json:"emits"`
|
||||||
Resources []map[string]any `json:"resources"`
|
Resources []map[string]any `json:"resources"`
|
||||||
// Items of collections (novox/hq ADR 0349 clause 13): the vendor keys are an item of the node-hotkeys
|
// Contributions to other modules' seats (novox/hq ADR 0212): the vendor keys go to node-hotkeys.
|
||||||
// triggers, its text the template file beside the manifest.
|
Contributions []struct {
|
||||||
Items []struct {
|
Seat string `json:"seat"`
|
||||||
Collection string `json:"collection"`
|
Kind string `json:"kind"`
|
||||||
Template string `json:"template"`
|
Content string `json:"content"`
|
||||||
} `json:"items"`
|
} `json:"contributions"`
|
||||||
// Old-form contributions to other modules' seats (novox/hq ADR 0212): none is left.
|
|
||||||
Contributions []any `json:"contributions"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// i3Lines is the module's lines for the window manager: the text of the template of its item of the collection
|
// triggers is the module's contribution to node-hotkeys: its vendor keys.
|
||||||
// contribution:node-display-session:config (design 57 L3, issue 848).
|
|
||||||
func (m manifest) i3Lines() string {
|
|
||||||
for _, it := range m.Items {
|
|
||||||
if it.Collection == "contribution:node-display-session:config" {
|
|
||||||
raw, err := os.ReadFile(filepath.Join("..", "..", it.Template))
|
|
||||||
if err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return string(raw)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// triggers is the module's contribution to node-hotkeys: its vendor keys, the text of the template of its
|
|
||||||
// item of the collection contribution:node-hotkeys:trigger.
|
|
||||||
func (m manifest) triggers() string {
|
func (m manifest) triggers() string {
|
||||||
for _, it := range m.Items {
|
for _, c := range m.Contributions {
|
||||||
if it.Collection == "contribution:node-hotkeys:trigger" {
|
if c.Seat == "node-hotkeys" && c.Kind == "trigger" {
|
||||||
raw, err := os.ReadFile(filepath.Join("..", "..", it.Template))
|
return c.Content
|
||||||
if err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return string(raw)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@@ -1,83 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Each file's text is a template in templates/, the directory the manifest names (novox/hq ADR 0349 §2, issue 731),
|
|
||||||
// never in files/: files/ is the archive the machine unpacks, and stays exactly what the machine is given.
|
|
||||||
func TestEveryFileTakesItsTextFromATemplateOutsideTheUnpackedFiles(t *testing.T) {
|
|
||||||
raw, err := os.ReadFile("../../module.json")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
var m struct {
|
|
||||||
Templates string `json:"templates"`
|
|
||||||
Resources []map[string]any `json:"resources"`
|
|
||||||
Build struct {
|
|
||||||
Artifacts []map[string]any `json:"artifacts"`
|
|
||||||
} `json:"build"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(raw, &m); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if m.Templates != "templates" {
|
|
||||||
t.Fatalf("the manifest names the templates directory %q, not templates", m.Templates)
|
|
||||||
}
|
|
||||||
files := 0
|
|
||||||
for _, r := range m.Resources {
|
|
||||||
if _, inline := r["content"]; inline {
|
|
||||||
t.Errorf("resource %v keeps its text inline: keep it in templates/ and name it as the template", r["id"])
|
|
||||||
}
|
|
||||||
if r["type"] != "file" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
files++
|
|
||||||
name, _ := r["template"].(string)
|
|
||||||
if !strings.HasPrefix(name, "templates/") {
|
|
||||||
t.Errorf("resource %v names the template %q, not one in templates/", r["id"], name)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if st, err := os.Lstat(filepath.Join("../..", name)); err != nil || !st.Mode().IsRegular() {
|
|
||||||
t.Errorf("resource %v names the template %s, which is not a plain file: %v", r["id"], name, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if files == 0 {
|
|
||||||
t.Fatal("no file resource")
|
|
||||||
}
|
|
||||||
packs := 0
|
|
||||||
for _, a := range m.Build.Artifacts {
|
|
||||||
if a["kind"] == "archive" && a["from"] == "templates" {
|
|
||||||
packs++
|
|
||||||
if a["name"] != "templates" {
|
|
||||||
t.Errorf("the templates archive is named %v, not templates", a["name"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if packs != 1 {
|
|
||||||
t.Fatalf("the build packs templates/ into %d archives, not one", packs)
|
|
||||||
}
|
|
||||||
// The templates archive is read by the controller, never unpacked onto the machine.
|
|
||||||
for _, r := range m.Resources {
|
|
||||||
if r["artifact"] == "templates" {
|
|
||||||
t.Errorf("resource %v places the templates archive on the machine", r["id"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// And files/ holds what the machine runs, no template.
|
|
||||||
err = filepath.WalkDir("../../files", func(at string, d os.DirEntry, err error) error {
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !d.IsDir() && !strings.HasPrefix(filepath.ToSlash(at), "../../files/bin/") {
|
|
||||||
t.Errorf("files/ holds %s, which is not one of the scripts the machine unpacks", at)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
{
|
{
|
||||||
"module": "asus-zephyrus-g14",
|
"module": "asus-zephyrus-g14",
|
||||||
"version": "1",
|
"version": "1",
|
||||||
"moved": true,
|
|
||||||
"templates": "templates",
|
|
||||||
"capabilities": [
|
"capabilities": [
|
||||||
"package-manager",
|
"package-manager",
|
||||||
"service-manager"
|
"service-manager"
|
||||||
@@ -61,14 +59,14 @@
|
|||||||
"type": "file",
|
"type": "file",
|
||||||
"path": "/etc/modprobe.d/g14-nvidia-power.conf",
|
"path": "/etc/modprobe.d/g14-nvidia-power.conf",
|
||||||
"mode": "0644",
|
"mode": "0644",
|
||||||
"template": "templates/modprobe.d/g14-nvidia-power.conf"
|
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The discrete GPU's driver options on the ROG Zephyrus G14 (GA403, RTX 40 series, hybrid graphics).\n#\n# NVreg_DynamicPowerManagement=0x00 turns runtime D3 off. With it on, a change of power source sends\n# the driver an ACPI notification it fails to handle on this model (\"RmHandleDNotifierEvent: Failed to\n# handle ACPI D-Notifier event, status=0x62\"), and the GPU stops making progress until the machine is\n# powered off. Off costs a few idle watts in hybrid mode and keeps the machine up.\n#\n# NVreg_PreserveVideoMemoryAllocations=1 saves video memory across suspend, so what used the GPU still\n# works after waking. It needs nvidia-suspend, -hibernate and -resume to run around a sleep, which this\n# module's drop-ins on the sleep services ask for.\n#\n# A change here applies when the driver next loads: at the next boot.\noptions nvidia NVreg_PreserveVideoMemoryAllocations=1\noptions nvidia NVreg_DynamicPowerManagement=0x00\n"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "video-options",
|
"id": "video-options",
|
||||||
"type": "file",
|
"type": "file",
|
||||||
"path": "/etc/modprobe.d/video-brightness-switch.conf",
|
"path": "/etc/modprobe.d/video-brightness-switch.conf",
|
||||||
"mode": "0644",
|
"mode": "0644",
|
||||||
"template": "templates/modprobe.d/video-brightness-switch.conf"
|
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The ACPI video driver does not change the backlight itself on the brightness keys: on this model it\n# moves the wrong one. The keys are triggerhappy's (see /etc/triggerhappy/triggers.d/asus-g14.conf).\n# Applies when the module next loads: at the next boot.\noptions video brightness_switch_enabled=0\n"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "suspend-drop-ins",
|
"id": "suspend-drop-ins",
|
||||||
@@ -81,7 +79,7 @@
|
|||||||
"type": "file",
|
"type": "file",
|
||||||
"path": "/etc/systemd/system/systemd-suspend.service.d/asus-zephyrus-g14-nvidia.conf",
|
"path": "/etc/systemd/system/systemd-suspend.service.d/asus-zephyrus-g14-nvidia.conf",
|
||||||
"mode": "0644",
|
"mode": "0644",
|
||||||
"template": "templates/systemd/system/systemd-suspend.service.d/asus-zephyrus-g14-nvidia.conf"
|
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The NVIDIA driver's own sleep actions, asked for by the sleep itself rather than enabled as\n# links: the mesh declares files and never makes links (novox/hq ADR 0012), and the host's service\n# shape must not start these units by hand, which would put the GPU to sleep with the machine awake.\n[Unit]\nWants=nvidia-suspend.service nvidia-resume.service\n"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "hibernate-drop-ins",
|
"id": "hibernate-drop-ins",
|
||||||
@@ -94,7 +92,7 @@
|
|||||||
"type": "file",
|
"type": "file",
|
||||||
"path": "/etc/systemd/system/systemd-hibernate.service.d/asus-zephyrus-g14-nvidia.conf",
|
"path": "/etc/systemd/system/systemd-hibernate.service.d/asus-zephyrus-g14-nvidia.conf",
|
||||||
"mode": "0644",
|
"mode": "0644",
|
||||||
"template": "templates/systemd/system/systemd-hibernate.service.d/asus-zephyrus-g14-nvidia.conf"
|
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The NVIDIA driver's own sleep actions, asked for by the sleep itself rather than enabled as\n# links: the mesh declares files and never makes links (novox/hq ADR 0012), and the host's service\n# shape must not start these units by hand, which would put the GPU to sleep with the machine awake.\n[Unit]\nWants=nvidia-hibernate.service nvidia-resume.service\n"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "suspend-then-hibernate-drop-ins",
|
"id": "suspend-then-hibernate-drop-ins",
|
||||||
@@ -107,7 +105,7 @@
|
|||||||
"type": "file",
|
"type": "file",
|
||||||
"path": "/etc/systemd/system/systemd-suspend-then-hibernate.service.d/asus-zephyrus-g14-nvidia.conf",
|
"path": "/etc/systemd/system/systemd-suspend-then-hibernate.service.d/asus-zephyrus-g14-nvidia.conf",
|
||||||
"mode": "0644",
|
"mode": "0644",
|
||||||
"template": "templates/systemd/system/systemd-suspend-then-hibernate.service.d/asus-zephyrus-g14-nvidia.conf"
|
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The NVIDIA driver's own sleep actions, asked for by the sleep itself rather than enabled as\n# links: the mesh declares files and never makes links (novox/hq ADR 0012), and the host's service\n# shape must not start these units by hand, which would put the GPU to sleep with the machine awake.\n[Unit]\nWants=nvidia-suspend-then-hibernate.service nvidia-resume.service\n"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "powerd-drop-ins",
|
"id": "powerd-drop-ins",
|
||||||
@@ -120,14 +118,14 @@
|
|||||||
"type": "file",
|
"type": "file",
|
||||||
"path": "/etc/systemd/system/nvidia-powerd.service.d/asus-zephyrus-g14.conf",
|
"path": "/etc/systemd/system/nvidia-powerd.service.d/asus-zephyrus-g14.conf",
|
||||||
"mode": "0644",
|
"mode": "0644",
|
||||||
"template": "templates/systemd/system/nvidia-powerd.service.d/asus-zephyrus-g14.conf"
|
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# nvidia-powerd (Dynamic Boost) was the first error in the chain that hung this model's GPU on a change\n# of power source, and asusd starts it on mains. It runs only when the kernel command line says\n# zephyrus.nvidia-powerd — an explicit opt-in, at boot.\n[Unit]\nConditionKernelCommandLine=zephyrus.nvidia-powerd\n"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "backlight-rule",
|
"id": "backlight-rule",
|
||||||
"type": "file",
|
"type": "file",
|
||||||
"path": "/etc/udev/rules.d/90-backlight.rules",
|
"path": "/etc/udev/rules.d/90-backlight.rules",
|
||||||
"mode": "0644",
|
"mode": "0644",
|
||||||
"template": "templates/udev/rules.d/90-backlight.rules"
|
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The backlights are writable by the video group, so the brightness keys and the module's brightness tool\n# move the panel without root.\nACTION==\"add\", SUBSYSTEM==\"backlight\", RUN+=\"/usr/bin/chgrp video /sys/class/backlight/%k/brightness\", RUN+=\"/usr/bin/chmod g+w /sys/class/backlight/%k/brightness\"\n"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "udev",
|
"id": "udev",
|
||||||
@@ -153,7 +151,7 @@
|
|||||||
"type": "file",
|
"type": "file",
|
||||||
"path": "/etc/UPower/UPower.conf.d/50-asus-zephyrus-g14.conf",
|
"path": "/etc/UPower/UPower.conf.d/50-asus-zephyrus-g14.conf",
|
||||||
"mode": "0644",
|
"mode": "0644",
|
||||||
"template": "templates/UPower/UPower.conf.d/50-asus-zephyrus-g14.conf"
|
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# On low battery the machine suspends rather than powering off, at 7 % — s2idle still draws a little,\n# so it leaves headroom. A drop-in over the package's own UPower.conf, which stays the package's.\n[UPower]\nUsePercentageForPolicy=true\nPercentageLow=15.0\nPercentageCritical=10.0\nPercentageAction=7.0\nCriticalPowerAction=Suspend\nAllowRiskyCriticalPowerAction=true\n"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "upower",
|
"id": "upower",
|
||||||
@@ -176,22 +174,7 @@
|
|||||||
"type": "file",
|
"type": "file",
|
||||||
"path": "/etc/X11/xorg.conf.d/30-asus-zephyrus-g14-touchpad.conf",
|
"path": "/etc/X11/xorg.conf.d/30-asus-zephyrus-g14-touchpad.conf",
|
||||||
"mode": "0644",
|
"mode": "0644",
|
||||||
"template": "templates/X11/xorg.conf.d/30-asus-zephyrus-g14-touchpad.conf"
|
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The touchpad's settings, applied by X every time the device appears — at login and after every\n# resume, when the device is initialised again. This replaces the predecessor's sleep hook, which ran\n# xinput after a resume as a named person on a guessed display.\nSection \"InputClass\"\n Identifier \"asus-zephyrus-g14 touchpad\"\n MatchIsTouchpad \"on\"\n Option \"Tapping\" \"on\"\n Option \"NaturalScrolling\" \"true\"\n Option \"AccelSpeed\" \"0.15\"\nEndSection\n"
|
||||||
}
|
|
||||||
],
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"collection": "shell:after-wake:normal",
|
|
||||||
"template": "templates/after-wake"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"collection": "contribution:node-hotkeys:trigger",
|
|
||||||
"template": "templates/hotkeys-triggers"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"collection": "contribution:node-display-session:config",
|
|
||||||
"name": "display-session",
|
|
||||||
"template": "templates/display-session"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"build": {
|
"build": {
|
||||||
@@ -211,12 +194,31 @@
|
|||||||
"name": "scripts",
|
"name": "scripts",
|
||||||
"kind": "archive",
|
"kind": "archive",
|
||||||
"from": "files"
|
"from": "files"
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "templates",
|
|
||||||
"kind": "archive",
|
|
||||||
"from": "templates"
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
},
|
||||||
|
"contributions": [
|
||||||
|
{
|
||||||
|
"seat": "node-hotkeys",
|
||||||
|
"kind": "trigger",
|
||||||
|
"content": "# The ROG Zephyrus G14's vendor keys, which reach no X client: media (the M-keys), panel brightness,\n# and the touchpad key. Each runs this module's own script, as the operator's account.\nKEY_PROG1\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media play-pause\nKEY_PROG3\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media previous\nKEY_PROG4\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media next\nKEY_BRIGHTNESSDOWN\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight -\nKEY_BRIGHTNESSDOWN\t2\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight -\nKEY_BRIGHTNESSUP\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight +\nKEY_BRIGHTNESSUP\t2\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight +\nKEY_F21\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-touchpad reset\n"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"seat": "node-display-session",
|
||||||
|
"kind": "config",
|
||||||
|
"content": "# The laptop's own lines in i3 (module asus-zephyrus-g14, novox/hq ADR 0208, ADR 0210). Owned by the\n# mesh: replaced at every push. Once the controller places contributions to node-display-session\n# (ADR 0210), these become the module's contribution instead of a file in i3's directory.\n#\n# The keys the firmware turns into ordinary key presses. The vendor keys that reach no X client are\n# triggerhappy's (/etc/triggerhappy/triggers.d/asus-g14.conf): M4 and Fn+F4/F5 for media, Fn+F7/F8 for\n# the panel, Fn+F10 for the touchpad. The keyboard backlight (Fn+F2/F3) is the firmware's and asusd's.\n\n# Fn+F6, the screenshot key: the firmware sends Super+Shift+S. Released before it runs, because the\n# screenshot grabs the pointer to select a region, which fails while the key is still held.\nbindsym --release $mod+Shift+s exec --no-startup-id $XDG_CONFIG_HOME/i3/scripts/screenshot.sh\n\n# Fn+F9, the display key: the firmware sends Super+P. Odd workspaces to the panel, even ones to the\n# external output.\nbindsym $mod+p exec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-display order\n\n# The keyboard backlight's level, shown when it changes.\nexec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-kbd-notify\n"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"seat": "node-display-session",
|
||||||
|
"kind": "config",
|
||||||
|
"content": "# The laptop's own lines in i3 (module asus-zephyrus-g14, novox/hq ADR 0208, ADR 0210). Owned by the\n# mesh: replaced at every push. Once the controller places contributions to node-display-session\n# (ADR 0210), these become the module's contribution instead of a file in i3's directory.\n#\n# The model's panel and touchpad.\n\n# The internal panel is the primary output, where the bars' tray goes. Which monitors are on and where\n# is the display server's (autorandr, run at every session start).\nexec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-display primary\n\n# The touchpad's settings again, by hand. X applies them itself whenever the device appears.\nbindsym $mod+Shift+x exec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-touchpad reset\n"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"shell": [
|
||||||
|
{
|
||||||
|
"for": "after-wake",
|
||||||
|
"slot": "normal",
|
||||||
|
"code": "# The touchpad's settings again after waking, in the operator's session: X applies the module's\n# input class when the device appears, and this covers a wake that does not initialise it again.\n# Runs as root from the power module; the reset itself runs as the session's owner.\nsleep 2\nowner=$(ps -o user= -C i3 | head -n 1)\n[ -n \"$owner\" ] && runuser -u \"$owner\" -- /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-touchpad reset\ntrue\n"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.
|
|
||||||
#
|
|
||||||
# On low battery the machine suspends rather than powering off, at 7 % — s2idle still draws a little,
|
|
||||||
# so it leaves headroom. A drop-in over the package's own UPower.conf, which stays the package's.
|
|
||||||
[UPower]
|
|
||||||
UsePercentageForPolicy=true
|
|
||||||
PercentageLow=15.0
|
|
||||||
PercentageCritical=10.0
|
|
||||||
PercentageAction=7.0
|
|
||||||
CriticalPowerAction=Suspend
|
|
||||||
AllowRiskyCriticalPowerAction=true
|
|
||||||
-12
@@ -1,12 +0,0 @@
|
|||||||
# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.
|
|
||||||
#
|
|
||||||
# The touchpad's settings, applied by X every time the device appears — at login and after every
|
|
||||||
# resume, when the device is initialised again. This replaces the predecessor's sleep hook, which ran
|
|
||||||
# xinput after a resume as a named person on a guessed display.
|
|
||||||
Section "InputClass"
|
|
||||||
Identifier "asus-zephyrus-g14 touchpad"
|
|
||||||
MatchIsTouchpad "on"
|
|
||||||
Option "Tapping" "on"
|
|
||||||
Option "NaturalScrolling" "true"
|
|
||||||
Option "AccelSpeed" "0.15"
|
|
||||||
EndSection
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
# The touchpad's settings again after waking, in the operator's session: X applies the module's
|
|
||||||
# input class when the device appears, and this covers a wake that does not initialise it again.
|
|
||||||
# Runs as root from the power module; the reset itself runs as the session's owner.
|
|
||||||
sleep 2
|
|
||||||
owner=$(ps -o user= -C i3 | head -n 1)
|
|
||||||
[ -n "$owner" ] && runuser -u "$owner" -- /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-touchpad reset
|
|
||||||
true
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
# The laptop's own lines in i3 (module asus-zephyrus-g14, novox/hq ADR 0208, ADR 0210). Owned by the
|
|
||||||
# mesh: replaced at every push. Once the controller places contributions to node-display-session
|
|
||||||
# (ADR 0210), these become the module's contribution instead of a file in i3's directory.
|
|
||||||
#
|
|
||||||
# The keys the firmware turns into ordinary key presses. The vendor keys that reach no X client are
|
|
||||||
# triggerhappy's (/etc/triggerhappy/triggers.d/asus-g14.conf): M4 and Fn+F4/F5 for media, Fn+F7/F8 for
|
|
||||||
# the panel, Fn+F10 for the touchpad. The keyboard backlight (Fn+F2/F3) is the firmware's and asusd's.
|
|
||||||
|
|
||||||
# Fn+F6, the screenshot key: the firmware sends Super+Shift+S. Released before it runs, because the
|
|
||||||
# screenshot grabs the pointer to select a region, which fails while the key is still held.
|
|
||||||
bindsym --release $mod+Shift+s exec --no-startup-id $XDG_CONFIG_HOME/i3/scripts/screenshot.sh
|
|
||||||
|
|
||||||
# Fn+F9, the display key: the firmware sends Super+P. Odd workspaces to the panel, even ones to the
|
|
||||||
# external output.
|
|
||||||
bindsym $mod+p exec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-display order
|
|
||||||
|
|
||||||
# The keyboard backlight's level, shown when it changes.
|
|
||||||
exec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-kbd-notify
|
|
||||||
# The laptop's own lines in i3 (module asus-zephyrus-g14, novox/hq ADR 0208, ADR 0210). Owned by the
|
|
||||||
# mesh: replaced at every push. Once the controller places contributions to node-display-session
|
|
||||||
# (ADR 0210), these become the module's contribution instead of a file in i3's directory.
|
|
||||||
#
|
|
||||||
# The model's panel and touchpad.
|
|
||||||
|
|
||||||
# The internal panel is the primary output, where the bars' tray goes. Which monitors are on and where
|
|
||||||
# is the display server's (autorandr, run at every session start).
|
|
||||||
exec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-display primary
|
|
||||||
|
|
||||||
# The touchpad's settings again, by hand. X applies them itself whenever the device appears.
|
|
||||||
bindsym $mod+Shift+x exec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-touchpad reset
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
# The ROG Zephyrus G14's vendor keys, which reach no X client: media (the M-keys), panel brightness,
|
|
||||||
# and the touchpad key. Each runs this module's own script, as the operator's account.
|
|
||||||
KEY_PROG1 1 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media play-pause
|
|
||||||
KEY_PROG3 1 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media previous
|
|
||||||
KEY_PROG4 1 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media next
|
|
||||||
KEY_BRIGHTNESSDOWN 1 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight -
|
|
||||||
KEY_BRIGHTNESSDOWN 2 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight -
|
|
||||||
KEY_BRIGHTNESSUP 1 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight +
|
|
||||||
KEY_BRIGHTNESSUP 2 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight +
|
|
||||||
KEY_F21 1 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-touchpad reset
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.
|
|
||||||
#
|
|
||||||
# The discrete GPU's driver options on the ROG Zephyrus G14 (GA403, RTX 40 series, hybrid graphics).
|
|
||||||
#
|
|
||||||
# NVreg_DynamicPowerManagement=0x00 turns runtime D3 off. With it on, a change of power source sends
|
|
||||||
# the driver an ACPI notification it fails to handle on this model ("RmHandleDNotifierEvent: Failed to
|
|
||||||
# handle ACPI D-Notifier event, status=0x62"), and the GPU stops making progress until the machine is
|
|
||||||
# powered off. Off costs a few idle watts in hybrid mode and keeps the machine up.
|
|
||||||
#
|
|
||||||
# NVreg_PreserveVideoMemoryAllocations=1 saves video memory across suspend, so what used the GPU still
|
|
||||||
# works after waking. It needs nvidia-suspend, -hibernate and -resume to run around a sleep, which this
|
|
||||||
# module's drop-ins on the sleep services ask for.
|
|
||||||
#
|
|
||||||
# A change here applies when the driver next loads: at the next boot.
|
|
||||||
options nvidia NVreg_PreserveVideoMemoryAllocations=1
|
|
||||||
options nvidia NVreg_DynamicPowerManagement=0x00
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.
|
|
||||||
#
|
|
||||||
# The ACPI video driver does not change the backlight itself on the brightness keys: on this model it
|
|
||||||
# moves the wrong one. The keys are triggerhappy's (see /etc/triggerhappy/triggers.d/asus-g14.conf).
|
|
||||||
# Applies when the module next loads: at the next boot.
|
|
||||||
options video brightness_switch_enabled=0
|
|
||||||
-7
@@ -1,7 +0,0 @@
|
|||||||
# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.
|
|
||||||
#
|
|
||||||
# nvidia-powerd (Dynamic Boost) was the first error in the chain that hung this model's GPU on a change
|
|
||||||
# of power source, and asusd starts it on mains. It runs only when the kernel command line says
|
|
||||||
# zephyrus.nvidia-powerd — an explicit opt-in, at boot.
|
|
||||||
[Unit]
|
|
||||||
ConditionKernelCommandLine=zephyrus.nvidia-powerd
|
|
||||||
-7
@@ -1,7 +0,0 @@
|
|||||||
# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.
|
|
||||||
#
|
|
||||||
# The NVIDIA driver's own sleep actions, asked for by the sleep itself rather than enabled as
|
|
||||||
# links: the mesh declares files and never makes links (novox/hq ADR 0012), and the host's service
|
|
||||||
# shape must not start these units by hand, which would put the GPU to sleep with the machine awake.
|
|
||||||
[Unit]
|
|
||||||
Wants=nvidia-hibernate.service nvidia-resume.service
|
|
||||||
-7
@@ -1,7 +0,0 @@
|
|||||||
# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.
|
|
||||||
#
|
|
||||||
# The NVIDIA driver's own sleep actions, asked for by the sleep itself rather than enabled as
|
|
||||||
# links: the mesh declares files and never makes links (novox/hq ADR 0012), and the host's service
|
|
||||||
# shape must not start these units by hand, which would put the GPU to sleep with the machine awake.
|
|
||||||
[Unit]
|
|
||||||
Wants=nvidia-suspend-then-hibernate.service nvidia-resume.service
|
|
||||||
-7
@@ -1,7 +0,0 @@
|
|||||||
# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.
|
|
||||||
#
|
|
||||||
# The NVIDIA driver's own sleep actions, asked for by the sleep itself rather than enabled as
|
|
||||||
# links: the mesh declares files and never makes links (novox/hq ADR 0012), and the host's service
|
|
||||||
# shape must not start these units by hand, which would put the GPU to sleep with the machine awake.
|
|
||||||
[Unit]
|
|
||||||
Wants=nvidia-suspend.service nvidia-resume.service
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.
|
|
||||||
#
|
|
||||||
# The backlights are writable by the video group, so the brightness keys and the module's brightness tool
|
|
||||||
# move the panel without root.
|
|
||||||
ACTION=="add", SUBSYSTEM=="backlight", RUN+="/usr/bin/chgrp video /sys/class/backlight/%k/brightness", RUN+="/usr/bin/chmod g+w /sys/class/backlight/%k/brightness"
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
{
|
|
||||||
"password": "${secret:admin}",
|
|
||||||
"host": "${bound:route:name}"
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
DATABASE_HOST=${bound:postgres-database:at}
|
|
||||||
DATABASE_PORT=${bound:postgres-database:port}
|
|
||||||
DATABASE_NAME=${bound:postgres-database:as}
|
|
||||||
DATABASE_USER=${bound:postgres-database:as}
|
|
||||||
DATABASE_PASSWORD_FILE=/run/secrets/database
|
|
||||||
DISABLE_EMBEDDED_PSQL=true
|
|
||||||
BASEROW_PUBLIC_URL=https://${bound:route:name}
|
|
||||||
@@ -46,26 +46,6 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"config": {
|
|
||||||
"host": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "The Host header the module's tools send to Baserow, a host name; the file's default is the name the module's route was given.",
|
|
||||||
"trusted": true,
|
|
||||||
"default": "${bound:route:name}"
|
|
||||||
},
|
|
||||||
"email": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "The email address of the Baserow account the module's tools sign in as; unset, the code falls back to its environment.",
|
|
||||||
"trusted": true,
|
|
||||||
"default": ""
|
|
||||||
},
|
|
||||||
"url": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "The address the module's tools reach Baserow at, as a URL; unset, the code falls back to its environment, else this machine's Baserow on port 80.",
|
|
||||||
"trusted": true,
|
|
||||||
"default": ""
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"resources": [
|
"resources": [
|
||||||
{
|
{
|
||||||
"id": "mesh-state",
|
"id": "mesh-state",
|
||||||
@@ -90,7 +70,7 @@
|
|||||||
"type": "file",
|
"type": "file",
|
||||||
"path": "${dir:state}/server.env",
|
"path": "${dir:state}/server.env",
|
||||||
"mode": "0600",
|
"mode": "0600",
|
||||||
"template": "files/server.env"
|
"content": "DATABASE_HOST=${bound:postgres-database:at}\nDATABASE_PORT=${bound:postgres-database:port}\nDATABASE_NAME=${bound:postgres-database:as}\nDATABASE_USER=${bound:postgres-database:as}\nDATABASE_PASSWORD_FILE=/run/secrets/database\nDISABLE_EMBEDDED_PSQL=true\nBASEROW_PUBLIC_URL=https://${bound:route:name}\n"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "net",
|
"id": "net",
|
||||||
@@ -122,9 +102,8 @@
|
|||||||
"type": "file",
|
"type": "file",
|
||||||
"path": "${dir:mesh-state}/config.json",
|
"path": "${dir:mesh-state}/config.json",
|
||||||
"mode": "0600",
|
"mode": "0600",
|
||||||
"template": "files/config.json",
|
"content": "{\n \"password\": \"${secret:admin}\",\n \"host\": \"${bound:route:name}\"\n}\n",
|
||||||
"merge": "json",
|
"merge": "json",
|
||||||
"takes-config": ["host", "email", "url"],
|
|
||||||
"protected": ["password"]
|
"protected": ["password"]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -144,11 +123,6 @@
|
|||||||
"MESH_BASEROW_URL": "http://127.0.0.1:${port:80}",
|
"MESH_BASEROW_URL": "http://127.0.0.1:${port:80}",
|
||||||
"MESH_BASEROW_CONFIG_FILE": "${dir:mesh-state}/config.json"
|
"MESH_BASEROW_CONFIG_FILE": "${dir:mesh-state}/config.json"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "files",
|
|
||||||
"kind": "archive",
|
|
||||||
"from": "files"
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -208,7 +208,7 @@ node set up from nothing, the declared accounts are written at the second start
|
|||||||
|
|
||||||
Five more settings, mesh-wide with a node's layer over each (an entry set to `null` in a node's layer is not on
|
Five more settings, mesh-wide with a node's layer over each (an entry set to `null` in a node's layer is not on
|
||||||
that node). `identities`, `calendars`, `address-books` and `filters` reach the machine as
|
that node). `identities`, `calendars`, `address-books` and `filters` reach the machine as
|
||||||
`~/.config/betterbird/configuration.json`, trusted as `accounts` is; `tags` as `tags.json`, trusted too since issue 751 (the operator's decision), so a change to it is the operator's to approve.
|
`~/.config/betterbird/configuration.json`, trusted as `accounts` is; `tags` as `tags.json`, not trusted (§5).
|
||||||
**No value holds a password, a token or a cookie**: a key shaped like one is refused, and so is an address whose
|
**No value holds a password, a token or a cookie**: a key shaped like one is refused, and so is an address whose
|
||||||
user part carries a password (§2). A setting holds no line break, so a signature is a list of its lines.
|
user part carries a password (§2). A setting holds no line break, so a signature is a list of its lines.
|
||||||
|
|
||||||
|
|||||||
@@ -7,8 +7,7 @@ package main
|
|||||||
//
|
//
|
||||||
// - ~/.config/betterbird/configuration.json: identities, calendars, address-books and filters, trusted as
|
// - ~/.config/betterbird/configuration.json: identities, calendars, address-books and filters, trusted as
|
||||||
// accounts is (§5), because each names where a password, a reply or forwarded mail goes;
|
// accounts is (§5), because each names where a password, a reply or forwarded mail goes;
|
||||||
// - ~/.config/betterbird/tags.json: tags, which names no server and sends no mail; trusted too since issue 751,
|
// - ~/.config/betterbird/tags.json: tags, which names no server and sends no mail, and is not trusted.
|
||||||
// so a change to it is the operator's to approve.
|
|
||||||
//
|
//
|
||||||
// An entry is keyed by a name of the operator's (lower-case letters and digits, as an account's), a tag by its
|
// An entry is keyed by a name of the operator's (lower-case letters and digits, as an account's), a tag by its
|
||||||
// own key. A node's layer may set an entry to null to take it out on that node, and may give an identity, a
|
// own key. A node's layer may set an entry to null to take it out on that node, and may give an identity, a
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ type manifest struct {
|
|||||||
Upgrade map[string]string `json:"upgrade"`
|
Upgrade map[string]string `json:"upgrade"`
|
||||||
Requires []string `json:"requires"`
|
Requires []string `json:"requires"`
|
||||||
Invokes []string `json:"invokes"`
|
Invokes []string `json:"invokes"`
|
||||||
Config map[string]any `json:"config"`
|
Settings map[string]any `json:"settings"`
|
||||||
Tools []string `json:"tools"`
|
Tools []string `json:"tools"`
|
||||||
Resources []map[string]any `json:"resources"`
|
Resources []map[string]any `json:"resources"`
|
||||||
Build struct {
|
Build struct {
|
||||||
@@ -114,21 +114,13 @@ func TestItPlacesBetterbirdWhereTheCommunityPackageDidAndOnlyAfterItIsGone(t *te
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ADR 0329 §4–§6, ADR 0326, issue 586, ADR 0348: every value reaches the machine as a mergeable file of the
|
// ADR 0329 §4–§6, ADR 0326, issue 586: every setting reaches the machine as a mergeable file of the account's that
|
||||||
// account's that names the keys it takes, and every one is trusted, tags too since issue 751 (the operator's
|
// names the keys it takes; four kinds and accounts and prefs are trusted, tags is not; no setting has a default in the
|
||||||
// decision); each is an object whose default is the file's own, empty, never required, since a node without the
|
// definition, since each is the operator's own value; and each file's default declares nothing.
|
||||||
// operator's setup still runs Betterbird; and each file's default declares nothing.
|
func TestTheSettingsAreMergeableFilesThatNameTheirKeysAndTagsAloneIsUntrusted(t *testing.T) {
|
||||||
func TestTheValuesAreTrustedMergeableFilesThatNameTheirKeys(t *testing.T) {
|
|
||||||
m, _ := readManifest(t)
|
m, _ := readManifest(t)
|
||||||
if _, said := m.Config["tags"]; !said || len(m.Config) != 7 {
|
if len(m.Settings) != 0 {
|
||||||
t.Fatalf("the seven values are declared under config: %v", m.Config)
|
t.Fatalf("no setting has a default in the definition: the operator's setup is the settings' mesh-wide layer (issue 586, ADR 0262): %v", m.Settings)
|
||||||
}
|
|
||||||
for key, raw := range m.Config {
|
|
||||||
d, _ := raw.(map[string]any)
|
|
||||||
def, _ := d["default"].(map[string]any)
|
|
||||||
if d["type"] != "object" || d["trusted"] != true || d["required"] != nil || def == nil || len(def) != 0 {
|
|
||||||
t.Errorf("%s is a trusted object whose default is empty and which is not required: %v", key, d)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
files := map[string]struct {
|
files := map[string]struct {
|
||||||
path, mode string
|
path, mode string
|
||||||
@@ -138,7 +130,7 @@ func TestTheValuesAreTrustedMergeableFilesThatNameTheirKeys(t *testing.T) {
|
|||||||
"prefs": {prefsFile, "0644", []any{"prefs"}, true},
|
"prefs": {prefsFile, "0644", []any{"prefs"}, true},
|
||||||
"accounts": {accountsFile, "0600", []any{"accounts"}, true},
|
"accounts": {accountsFile, "0600", []any{"accounts"}, true},
|
||||||
"configuration": {configurationFile, "0600", []any{"identities", "calendars", "address-books", "filters"}, true},
|
"configuration": {configurationFile, "0600", []any{"identities", "calendars", "address-books", "filters"}, true},
|
||||||
"tags": {tagsFile, "0644", []any{"tags"}, true},
|
"tags": {tagsFile, "0644", []any{"tags"}, false},
|
||||||
}
|
}
|
||||||
merging := 0
|
merging := 0
|
||||||
for _, r := range m.Resources {
|
for _, r := range m.Resources {
|
||||||
@@ -152,27 +144,27 @@ func TestTheValuesAreTrustedMergeableFilesThatNameTheirKeys(t *testing.T) {
|
|||||||
for id, want := range files {
|
for id, want := range files {
|
||||||
r := resource(t, m, id)
|
r := resource(t, m, id)
|
||||||
if r["path"] != "${machine:account-home}/"+want.path || r["merge"] != "json" || r["trusted"] != want.trusted ||
|
if r["path"] != "${machine:account-home}/"+want.path || r["merge"] != "json" || r["trusted"] != want.trusted ||
|
||||||
r["mode"] != want.mode || r["owner"] != "${machine:account}" || !reflect.DeepEqual(r["takes-config"], want.takes) {
|
r["mode"] != want.mode || r["owner"] != "${machine:account}" || !reflect.DeepEqual(r["takes-settings"], want.takes) {
|
||||||
t.Errorf("%s: %v", id, r)
|
t.Errorf("%s: %v", id, r)
|
||||||
}
|
}
|
||||||
var content map[string]any
|
var content map[string]any
|
||||||
if err := json.Unmarshal([]byte(templateText(t, r)), &content); err != nil || len(content) != len(want.takes) {
|
if err := json.Unmarshal([]byte(r["content"].(string)), &content); err != nil || len(content) != len(want.takes) {
|
||||||
t.Errorf("%s's default holds each key it takes, empty: %v %v", id, content, err)
|
t.Errorf("%s's default holds each key it takes, empty: %v %v", id, content, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
got, err := parseAccounts([]byte(templateText(t, resource(t, m, "accounts"))))
|
got, err := parseAccounts([]byte(resource(t, m, "accounts")["content"].(string)))
|
||||||
if err != nil || len(got) != 0 {
|
if err != nil || len(got) != 0 {
|
||||||
t.Fatalf("its default declares no account: %v %v", got, err)
|
t.Fatalf("its default declares no account: %v %v", got, err)
|
||||||
}
|
}
|
||||||
prefs, err := parseDeclaredPrefs([]byte(templateText(t, resource(t, m, "prefs"))))
|
prefs, err := parseDeclaredPrefs([]byte(resource(t, m, "prefs")["content"].(string)))
|
||||||
if err != nil || len(prefs) != 0 {
|
if err != nil || len(prefs) != 0 {
|
||||||
t.Fatalf("its default declares no preference: %v %v", prefs, err)
|
t.Fatalf("its default declares no preference: %v %v", prefs, err)
|
||||||
}
|
}
|
||||||
var c Configuration
|
var c Configuration
|
||||||
if err := decodeInto([]byte(templateText(t, resource(t, m, "configuration"))), configurationFile, &c); err != nil {
|
if err := decodeInto([]byte(resource(t, m, "configuration")["content"].(string)), configurationFile, &c); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := decodeInto([]byte(templateText(t, resource(t, m, "tags"))), tagsFile, &c); err != nil {
|
if err := decodeInto([]byte(resource(t, m, "tags")["content"].(string)), tagsFile, &c); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -276,13 +268,13 @@ func TestThePolicyTurnsTheUpdaterOffAndInstallsTheMeshsOwlEverywhere(t *testing.
|
|||||||
func TestTheStartPreparesThenStartsBetterbirdAndTheLauncherUsesIt(t *testing.T) {
|
func TestTheStartPreparesThenStartsBetterbirdAndTheLauncherUsesIt(t *testing.T) {
|
||||||
m, _ := readManifest(t)
|
m, _ := readManifest(t)
|
||||||
start := resource(t, m, "start")
|
start := resource(t, m, "start")
|
||||||
content := templateText(t, start)
|
content := start["content"].(string)
|
||||||
if start["path"] != "/usr/local/bin/betterbird" || start["mode"] != "0755" ||
|
if start["path"] != "/usr/local/bin/betterbird" || start["mode"] != "0755" ||
|
||||||
!strings.Contains(content, "\n"+preparation+" prepare ||") || !strings.HasSuffix(content, "\nexec "+startBin+" \"$@\"\n") {
|
!strings.Contains(content, "\n"+preparation+" prepare ||") || !strings.HasSuffix(content, "\nexec "+startBin+" \"$@\"\n") {
|
||||||
t.Fatalf("the start runs the preparation, says when it did not complete, and starts Betterbird either way: %v", start)
|
t.Fatalf("the start runs the preparation, says when it did not complete, and starts Betterbird either way: %v", start)
|
||||||
}
|
}
|
||||||
launcher := resource(t, m, "launcher")
|
launcher := resource(t, m, "launcher")
|
||||||
lc := templateText(t, launcher)
|
lc := launcher["content"].(string)
|
||||||
if launcher["path"] != "/usr/local/share/applications/eu.betterbird.Betterbird.desktop" ||
|
if launcher["path"] != "/usr/local/share/applications/eu.betterbird.Betterbird.desktop" ||
|
||||||
!strings.Contains(lc, "\nExec=/usr/local/bin/betterbird %u\n") || !strings.Contains(lc, "\nIcon="+installDir+"/") ||
|
!strings.Contains(lc, "\nExec=/usr/local/bin/betterbird %u\n") || !strings.Contains(lc, "\nIcon="+installDir+"/") ||
|
||||||
!strings.Contains(lc, "\nStartupWMClass=eu.betterbird.Betterbird\n") {
|
!strings.Contains(lc, "\nStartupWMClass=eu.betterbird.Betterbird\n") {
|
||||||
|
|||||||
@@ -1,64 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// The module's directory, where its manifest and its files/ are.
|
|
||||||
const moduleDir = "../.."
|
|
||||||
|
|
||||||
// templateText is the text a file resource places on the machine: its template's, in the module's files/
|
|
||||||
// (novox/hq ADR 0349 §2). A resource that names no template, or a template that is not there, fails the test.
|
|
||||||
func templateText(t *testing.T, r map[string]any) string {
|
|
||||||
t.Helper()
|
|
||||||
name, _ := r["template"].(string)
|
|
||||||
if name == "" {
|
|
||||||
t.Fatalf("resource %v names no template", r["id"])
|
|
||||||
}
|
|
||||||
raw, err := os.ReadFile(filepath.Join(moduleDir, filepath.FromSlash(name)))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("resource %v names %s: %v", r["id"], name, err)
|
|
||||||
}
|
|
||||||
return string(raw)
|
|
||||||
}
|
|
||||||
|
|
||||||
// No file resource carries its text inline: each names its template in files/, and the build packs files/
|
|
||||||
// into the one archive the controller reads the templates back from (novox/hq ADR 0349 §2).
|
|
||||||
func TestEveryFileTakesItsTextFromATemplate(t *testing.T) {
|
|
||||||
raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
var m struct {
|
|
||||||
Resources []map[string]any `json:"resources"`
|
|
||||||
Build struct {
|
|
||||||
Artifacts []map[string]any `json:"artifacts"`
|
|
||||||
} `json:"build"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(raw, &m); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
for _, r := range m.Resources {
|
|
||||||
if _, inline := r["content"]; inline {
|
|
||||||
t.Errorf("resource %v keeps its text inline: keep it in files/ and name it as the template", r["id"])
|
|
||||||
}
|
|
||||||
if r["type"] == "file" {
|
|
||||||
templateText(t, r)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
packs := 0
|
|
||||||
for _, a := range m.Build.Artifacts {
|
|
||||||
if a["kind"] == "archive" && a["from"] == "files" {
|
|
||||||
packs++
|
|
||||||
if a["name"] != "files" {
|
|
||||||
t.Errorf("the templates archive is named %v, not files", a["name"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if packs != 1 {
|
|
||||||
t.Errorf("the build packs files/ into %d archives, not one", packs)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"accounts": {}
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
# Betterbird's start, written by the mesh (module betterbird, novox/hq ADR 0316). Replaced at every push.
|
|
||||||
#
|
|
||||||
# Before Betterbird starts, the module's tools place its user.js in the profile (the settings prefs, accounts, identities, calendars,
|
|
||||||
# address-books and tags) and write what Betterbird rewrites itself: the account lists and each declared account's filters. They do
|
|
||||||
# it only while no Betterbird of this account runs, because Betterbird rewrites its saved preferences when it quits; a
|
|
||||||
# second start while one runs only hands over to it. What the preparation could not do it says here, and
|
|
||||||
# betterbird_check says it too. Betterbird starts either way: its configuration never holds the mail back.
|
|
||||||
/usr/local/lib/mesh-betterbird/betterbird-tools prepare || echo "betterbird: the mesh's preparation did not complete; betterbird_check says why" >&2
|
|
||||||
exec /opt/betterbird/betterbird "$@"
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"identities": {},
|
|
||||||
"calendars": {},
|
|
||||||
"address-books": {},
|
|
||||||
"filters": {}
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
[Desktop Entry]
|
|
||||||
Version=1.0
|
|
||||||
Type=Application
|
|
||||||
Name=Betterbird
|
|
||||||
GenericName=Mail Client
|
|
||||||
Comment=Send and receive mail with Betterbird
|
|
||||||
Exec=/usr/local/bin/betterbird %u
|
|
||||||
Icon=/opt/betterbird/chrome/icons/default/default128.png
|
|
||||||
Terminal=false
|
|
||||||
Categories=Network;Email;
|
|
||||||
MimeType=message/rfc822;x-scheme-handler/mailto;application/x-xpinstall;application/x-extension-ics;text/calendar;text/vcard;text/x-vcard;x-scheme-handler/webcal;x-scheme-handler/webcals;x-scheme-handler/mid;
|
|
||||||
StartupNotify=true
|
|
||||||
StartupWMClass=eu.betterbird.Betterbird
|
|
||||||
Keywords=Email;E-mail;Calendar;Thunderbird;
|
|
||||||
Actions=ComposeMessage;OpenAddressBook;
|
|
||||||
# Written by the mesh (module betterbird, novox/hq ADR 0316 §3), in place of the community package's entry:
|
|
||||||
# it starts Betterbird through the module's start, and names the icon in the installation, with no link.
|
|
||||||
|
|
||||||
[Desktop Action ComposeMessage]
|
|
||||||
Name=Write new message
|
|
||||||
Exec=/usr/local/bin/betterbird -compose
|
|
||||||
|
|
||||||
[Desktop Action OpenAddressBook]
|
|
||||||
Name=Open address book
|
|
||||||
Exec=/usr/local/bin/betterbird -addressbook
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"prefs": {}
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"tags": {}
|
|
||||||
}
|
|
||||||
@@ -33,50 +33,6 @@
|
|||||||
"betterbird_drift",
|
"betterbird_drift",
|
||||||
"betterbird_reminder_test"
|
"betterbird_reminder_test"
|
||||||
],
|
],
|
||||||
"config": {
|
|
||||||
"prefs": {
|
|
||||||
"type": "object",
|
|
||||||
"description": "Betterbird's preferences the operator sets, by their own names (mailnews.*, calendar.* and the like), laid into prefs.json; an account's, a server's and a tag's are refused, as accounts, the four kinds and tags own them (novox/hq ADR 0329 §6).",
|
|
||||||
"trusted": true,
|
|
||||||
"default": {}
|
|
||||||
},
|
|
||||||
"accounts": {
|
|
||||||
"type": "object",
|
|
||||||
"description": "The mail accounts Betterbird is set up with, each by a name of the operator's: its address, its sender's name and its incoming and outgoing servers; no password, token or cookie (novox/hq ADR 0329 §2).",
|
|
||||||
"trusted": true,
|
|
||||||
"default": {}
|
|
||||||
},
|
|
||||||
"identities": {
|
|
||||||
"type": "object",
|
|
||||||
"description": "The identities beside an account's own, each by a name of the operator's: the address and name it sends as, its signature as a list of lines, and the profile's key to adopt on a node.",
|
|
||||||
"trusted": true,
|
|
||||||
"default": {}
|
|
||||||
},
|
|
||||||
"calendars": {
|
|
||||||
"type": "object",
|
|
||||||
"description": "The calendars Betterbird shows, each by a name of the operator's: where it is and how it is reached, and the profile's key to adopt on a node.",
|
|
||||||
"trusted": true,
|
|
||||||
"default": {}
|
|
||||||
},
|
|
||||||
"address-books": {
|
|
||||||
"type": "object",
|
|
||||||
"description": "The address books Betterbird uses, each by a name of the operator's: where it is and how it is reached, and the profile's key to adopt on a node.",
|
|
||||||
"trusted": true,
|
|
||||||
"default": {}
|
|
||||||
},
|
|
||||||
"filters": {
|
|
||||||
"type": "object",
|
|
||||||
"description": "The message filters of each account, by account: what each matches and what it does with the mail.",
|
|
||||||
"trusted": true,
|
|
||||||
"default": {}
|
|
||||||
},
|
|
||||||
"tags": {
|
|
||||||
"type": "object",
|
|
||||||
"description": "The tags Betterbird offers, each by its own key: its name, colour and order; trusted since issue 751, so a change to it is the operator's to approve.",
|
|
||||||
"trusted": true,
|
|
||||||
"default": {}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"resources": [
|
"resources": [
|
||||||
{
|
{
|
||||||
"id": "configuration-dir",
|
"id": "configuration-dir",
|
||||||
@@ -92,10 +48,10 @@
|
|||||||
"owner": "${machine:account}",
|
"owner": "${machine:account}",
|
||||||
"mode": "0644",
|
"mode": "0644",
|
||||||
"merge": "json",
|
"merge": "json",
|
||||||
"takes-config": [
|
"takes-settings": [
|
||||||
"prefs"
|
"prefs"
|
||||||
],
|
],
|
||||||
"template": "files/prefs.json",
|
"content": "{\n \"prefs\": {}\n}\n",
|
||||||
"trusted": true
|
"trusted": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -105,10 +61,10 @@
|
|||||||
"owner": "${machine:account}",
|
"owner": "${machine:account}",
|
||||||
"mode": "0600",
|
"mode": "0600",
|
||||||
"merge": "json",
|
"merge": "json",
|
||||||
"takes-config": [
|
"takes-settings": [
|
||||||
"accounts"
|
"accounts"
|
||||||
],
|
],
|
||||||
"template": "files/accounts.json",
|
"content": "{\n \"accounts\": {}\n}\n",
|
||||||
"trusted": true
|
"trusted": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -118,13 +74,13 @@
|
|||||||
"owner": "${machine:account}",
|
"owner": "${machine:account}",
|
||||||
"mode": "0600",
|
"mode": "0600",
|
||||||
"merge": "json",
|
"merge": "json",
|
||||||
"takes-config": [
|
"takes-settings": [
|
||||||
"identities",
|
"identities",
|
||||||
"calendars",
|
"calendars",
|
||||||
"address-books",
|
"address-books",
|
||||||
"filters"
|
"filters"
|
||||||
],
|
],
|
||||||
"template": "files/configuration.json",
|
"content": "{\n \"identities\": {},\n \"calendars\": {},\n \"address-books\": {},\n \"filters\": {}\n}\n",
|
||||||
"trusted": true
|
"trusted": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -134,11 +90,11 @@
|
|||||||
"owner": "${machine:account}",
|
"owner": "${machine:account}",
|
||||||
"mode": "0644",
|
"mode": "0644",
|
||||||
"merge": "json",
|
"merge": "json",
|
||||||
"takes-config": [
|
"takes-settings": [
|
||||||
"tags"
|
"tags"
|
||||||
],
|
],
|
||||||
"template": "files/tags.json",
|
"content": "{\n \"tags\": {}\n}\n",
|
||||||
"trusted": true
|
"trusted": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "community-package",
|
"id": "community-package",
|
||||||
@@ -181,14 +137,14 @@
|
|||||||
"type": "file",
|
"type": "file",
|
||||||
"path": "/usr/local/bin/betterbird",
|
"path": "/usr/local/bin/betterbird",
|
||||||
"mode": "0755",
|
"mode": "0755",
|
||||||
"template": "files/betterbird"
|
"content": "#!/bin/sh\n# Betterbird's start, written by the mesh (module betterbird, novox/hq ADR 0316). Replaced at every push.\n#\n# Before Betterbird starts, the module's tools place its user.js in the profile (the settings prefs, accounts, identities, calendars,\n# address-books and tags) and write what Betterbird rewrites itself: the account lists and each declared account's filters. They do\n# it only while no Betterbird of this account runs, because Betterbird rewrites its saved preferences when it quits; a\n# second start while one runs only hands over to it. What the preparation could not do it says here, and\n# betterbird_check says it too. Betterbird starts either way: its configuration never holds the mail back.\n/usr/local/lib/mesh-betterbird/betterbird-tools prepare || echo \"betterbird: the mesh's preparation did not complete; betterbird_check says why\" >&2\nexec /opt/betterbird/betterbird \"$@\"\n"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "launcher",
|
"id": "launcher",
|
||||||
"type": "file",
|
"type": "file",
|
||||||
"path": "/usr/local/share/applications/eu.betterbird.Betterbird.desktop",
|
"path": "/usr/local/share/applications/eu.betterbird.Betterbird.desktop",
|
||||||
"mode": "0644",
|
"mode": "0644",
|
||||||
"template": "files/eu.betterbird.Betterbird.desktop"
|
"content": "[Desktop Entry]\nVersion=1.0\nType=Application\nName=Betterbird\nGenericName=Mail Client\nComment=Send and receive mail with Betterbird\nExec=/usr/local/bin/betterbird %u\nIcon=/opt/betterbird/chrome/icons/default/default128.png\nTerminal=false\nCategories=Network;Email;\nMimeType=message/rfc822;x-scheme-handler/mailto;application/x-xpinstall;application/x-extension-ics;text/calendar;text/vcard;text/x-vcard;x-scheme-handler/webcal;x-scheme-handler/webcals;x-scheme-handler/mid;\nStartupNotify=true\nStartupWMClass=eu.betterbird.Betterbird\nKeywords=Email;E-mail;Calendar;Thunderbird;\nActions=ComposeMessage;OpenAddressBook;\n# Written by the mesh (module betterbird, novox/hq ADR 0316 §3), in place of the community package's entry:\n# it starts Betterbird through the module's start, and names the icon in the installation, with no link.\n\n[Desktop Action ComposeMessage]\nName=Write new message\nExec=/usr/local/bin/betterbird -compose\n\n[Desktop Action OpenAddressBook]\nName=Open address book\nExec=/usr/local/bin/betterbird -addressbook\n"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"build": {
|
"build": {
|
||||||
@@ -235,11 +191,6 @@
|
|||||||
"name": "defaults",
|
"name": "defaults",
|
||||||
"kind": "archive",
|
"kind": "archive",
|
||||||
"from": "install/defaults-pref"
|
"from": "install/defaults-pref"
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "files",
|
|
||||||
"kind": "archive",
|
|
||||||
"from": "files"
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ WORKDIR /src
|
|||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -buildvcs=false -ldflags=-buildid= -o /mesh-builder ./cmd/mesh-builder
|
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -o /mesh-builder ./cmd/mesh-builder
|
||||||
|
|
||||||
# Unlike mesh-controller's own FROM scratch (ADR 0006: nothing to audit but one binary), the build
|
# Unlike mesh-controller's own FROM scratch (ADR 0006: nothing to audit but one binary), the build
|
||||||
# machine's whole job is shelling out to git and docker — it needs a real userland to do that in,
|
# machine's whole job is shelling out to git and docker — it needs a real userland to do that in,
|
||||||
|
|||||||
@@ -13,11 +13,6 @@ that repository's main (`build.artifacts[].context`); this module ships the pack
|
|||||||
Assign it to every machine with a container runtime. It replaces `builder`, the one-holder form of the
|
Assign it to every machine with a container runtime. It replaces `builder`, the one-holder form of the
|
||||||
same thing; retire that once this is assigned where it was.
|
same thing; retire that once this is assigned where it was.
|
||||||
|
|
||||||
A send that replaces the agent's container does not throw away the build it runs: the container declares
|
|
||||||
`busy` as `/usr/local/bin/mesh-builder busy` (novox/hq ADR 0335). The node-engine asks it before replacing it; from that
|
|
||||||
ask the agent takes no new build, the node-engine leaves it running while it says a build is in hand, and
|
|
||||||
replaces it once it says idle, or after 15 minutes at the latest.
|
|
||||||
|
|
||||||
## The package registry (novox/hq ADR 0336)
|
## The package registry (novox/hq ADR 0336)
|
||||||
|
|
||||||
The package registry (`npm-package-registry`, provided by the gitea module) is an optional requirement.
|
The package registry (`npm-package-registry`, provided by the gitea module) is an optional requirement.
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
MESH_BROKER_FILE=/run/mesh/broker
|
|
||||||
MESH_NODE=${machine:name}
|
|
||||||
MESH_REGISTRY=${bound:artifact-store:at}:${bound:artifact-store:port}
|
|
||||||
MESH_PACKAGE_BINDING=/run/mesh/package-registry.json
|
|
||||||
MESH_NPM_TOKEN_FILE=/run/mesh/package-registry.secret
|
|
||||||
MESH_GENESIS_PACKAGE_REGISTRY=/run/mesh/genesis-package-registry.json
|
|
||||||
MESH_WORKSPACE=${dir:workspace}
|
|
||||||
MESH_JUDGE_CACHE=${dir:judge-cache}
|
|
||||||
@@ -75,10 +75,7 @@ func TestTheRegistrysBindsAndSecretsAreOnlyWhenBound(t *testing.T) {
|
|||||||
t.Fatalf("the agent is not told where the registry is once it binds: binds %v, secrets %v", m.Binds, m.Secrets)
|
t.Fatalf("the agent is not told where the registry is once it binds: binds %v, secrets %v", m.Binds, m.Secrets)
|
||||||
}
|
}
|
||||||
for _, r := range m.Resources {
|
for _, r := range m.Resources {
|
||||||
if r["type"] != "file" {
|
content, _ := r["content"].(string)
|
||||||
continue
|
|
||||||
}
|
|
||||||
content := templateText(t, r)
|
|
||||||
if strings.Contains(content, "${bound:npm-package-registry:") || strings.Contains(content, "${secret:npm-package-registry}") {
|
if strings.Contains(content, "${bound:npm-package-registry:") || strings.Contains(content, "${secret:npm-package-registry}") {
|
||||||
if r["only-when-bound"] != "npm-package-registry" {
|
if r["only-when-bound"] != "npm-package-registry" {
|
||||||
t.Errorf("%v names the registry's binding and is delivered while it is absent", r["id"])
|
t.Errorf("%v names the registry's binding and is delivered while it is absent", r["id"])
|
||||||
@@ -109,7 +106,7 @@ func TestTheGenesisDocumentIsDeclaredAndIssuedOutside(t *testing.T) {
|
|||||||
inside := "/run/mesh/" + strings.TrimPrefix(own.Path, "${dir:mesh-state}/")
|
inside := "/run/mesh/" + strings.TrimPrefix(own.Path, "${dir:mesh-state}/")
|
||||||
for _, r := range m.Resources {
|
for _, r := range m.Resources {
|
||||||
if r["id"] == "agent-env" {
|
if r["id"] == "agent-env" {
|
||||||
content := templateText(t, r)
|
content, _ := r["content"].(string)
|
||||||
if !strings.Contains(content, "MESH_GENESIS_PACKAGE_REGISTRY="+inside+"\n") {
|
if !strings.Contains(content, "MESH_GENESIS_PACKAGE_REGISTRY="+inside+"\n") {
|
||||||
t.Errorf("the agent is not told where the genesis document is (%s):\n%s", inside, content)
|
t.Errorf("the agent is not told where the genesis document is (%s):\n%s", inside, content)
|
||||||
}
|
}
|
||||||
@@ -118,57 +115,3 @@ func TestTheGenesisDocumentIsDeclaredAndIssuedOutside(t *testing.T) {
|
|||||||
}
|
}
|
||||||
t.Fatal("the build agent has no environment file")
|
t.Fatal("the build agent has no environment file")
|
||||||
}
|
}
|
||||||
|
|
||||||
// **The judge cache is the agent's own, beside its workspace and never in it** (novox/hq ADR 0358 §6, issue 704): a
|
|
||||||
// directory resource of its own, kept as the agent's data, mounted into the agent at the same path as on the host —
|
|
||||||
// the container runtime resolves a judge build's mounts on the host — and handed to the agent as MESH_JUDGE_CACHE.
|
|
||||||
func TestTheJudgeCacheIsADirectoryOfItsOwnMountedAtTheSamePath(t *testing.T) {
|
|
||||||
raw, err := os.ReadFile("module.json")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
var m struct {
|
|
||||||
Data struct {
|
|
||||||
Own []struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Path string `json:"path"`
|
|
||||||
Class string `json:"class"`
|
|
||||||
} `json:"own"`
|
|
||||||
} `json:"data"`
|
|
||||||
Resources []struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Type string `json:"type"`
|
|
||||||
Template string `json:"template"`
|
|
||||||
Volumes []string `json:"volumes"`
|
|
||||||
} `json:"resources"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(raw, &m); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
dir, data, env, mounted := false, false, false, false
|
|
||||||
for _, d := range m.Data.Own {
|
|
||||||
if d.ID == "judge-cache" && d.Path == "${dir:judge-cache}" && d.Class == "cache" {
|
|
||||||
data = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, r := range m.Resources {
|
|
||||||
switch {
|
|
||||||
case r.ID == "judge-cache" && r.Type == "directory":
|
|
||||||
dir = true
|
|
||||||
case r.ID == "agent-env":
|
|
||||||
env = strings.Contains(templateText(t, map[string]any{"id": r.ID, "template": r.Template}), "MESH_JUDGE_CACHE=${dir:judge-cache}\n")
|
|
||||||
case r.ID == "server":
|
|
||||||
for _, v := range r.Volumes {
|
|
||||||
if v == "${dir:judge-cache}:${dir:judge-cache}" {
|
|
||||||
mounted = true
|
|
||||||
}
|
|
||||||
if strings.HasPrefix(v, "${dir:workspace}") && strings.Contains(v, "judge-cache") {
|
|
||||||
t.Errorf("the judge cache is mounted inside the workspace: %s", v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !dir || !data || !env || !mounted {
|
|
||||||
t.Fatalf("the judge cache: directory %v, data %v, env %v, mounted at the same path %v", dir, data, env, mounted)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -39,12 +39,6 @@
|
|||||||
"path": "${dir:workspace}",
|
"path": "${dir:workspace}",
|
||||||
"class": "cache",
|
"class": "cache",
|
||||||
"why": "a build's working copy, cloned again for every build"
|
"why": "a build's working copy, cloned again for every build"
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "judge-cache",
|
|
||||||
"path": "${dir:judge-cache}",
|
|
||||||
"class": "cache",
|
|
||||||
"why": "the Go cache every merge check's judge is built with and the release-branch judges kept per head; mounted into judge builds only, never into a check's containers (novox/hq ADR 0358 §6)"
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -60,17 +54,12 @@
|
|||||||
"type": "directory",
|
"type": "directory",
|
||||||
"mode": "0700"
|
"mode": "0700"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"id": "judge-cache",
|
|
||||||
"type": "directory",
|
|
||||||
"mode": "0700"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"id": "agent-env",
|
"id": "agent-env",
|
||||||
"type": "file",
|
"type": "file",
|
||||||
"path": "${dir:mesh-state}/build-agent.env",
|
"path": "${dir:mesh-state}/build-agent.env",
|
||||||
"mode": "0600",
|
"mode": "0600",
|
||||||
"template": "files/build-agent.env"
|
"content": "MESH_BROKER_FILE=/run/mesh/broker\nMESH_NODE=${machine:name}\nMESH_REGISTRY=${bound:artifact-store:at}:${bound:artifact-store:port}\nMESH_PACKAGE_BINDING=/run/mesh/package-registry.json\nMESH_NPM_TOKEN_FILE=/run/mesh/package-registry.secret\nMESH_GENESIS_PACKAGE_REGISTRY=/run/mesh/genesis-package-registry.json\nMESH_WORKSPACE=${dir:workspace}\n"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "server",
|
"id": "server",
|
||||||
@@ -83,7 +72,6 @@
|
|||||||
"volumes": [
|
"volumes": [
|
||||||
"${dir:mesh-state}:/run/mesh:ro",
|
"${dir:mesh-state}:/run/mesh:ro",
|
||||||
"${dir:workspace}:${dir:workspace}",
|
"${dir:workspace}:${dir:workspace}",
|
||||||
"${dir:judge-cache}:${dir:judge-cache}",
|
|
||||||
"/var/run/docker.sock:/var/run/docker.sock"
|
"/var/run/docker.sock:/var/run/docker.sock"
|
||||||
],
|
],
|
||||||
"restart-on": [
|
"restart-on": [
|
||||||
@@ -97,9 +85,6 @@
|
|||||||
"timeout": "5s",
|
"timeout": "5s",
|
||||||
"looks": 3,
|
"looks": 3,
|
||||||
"grace": "60s"
|
"grace": "60s"
|
||||||
},
|
|
||||||
"busy": {
|
|
||||||
"command": "/usr/local/bin/mesh-builder busy"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -115,11 +100,6 @@
|
|||||||
"repository": "novox/mesh-controller",
|
"repository": "novox/mesh-controller",
|
||||||
"ref": "main"
|
"ref": "main"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "files",
|
|
||||||
"kind": "archive",
|
|
||||||
"from": "files"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"on": [
|
"on": [
|
||||||
|
|||||||
@@ -1,64 +0,0 @@
|
|||||||
package buildagent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// The module's directory, where its manifest and its files/ are.
|
|
||||||
const moduleDir = "."
|
|
||||||
|
|
||||||
// templateText is the text a file resource places on the machine: its template's, in the module's files/
|
|
||||||
// (novox/hq ADR 0349 §2). A resource that names no template, or a template that is not there, fails the test.
|
|
||||||
func templateText(t *testing.T, r map[string]any) string {
|
|
||||||
t.Helper()
|
|
||||||
name, _ := r["template"].(string)
|
|
||||||
if name == "" {
|
|
||||||
t.Fatalf("resource %v names no template", r["id"])
|
|
||||||
}
|
|
||||||
raw, err := os.ReadFile(filepath.Join(moduleDir, filepath.FromSlash(name)))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("resource %v names %s: %v", r["id"], name, err)
|
|
||||||
}
|
|
||||||
return string(raw)
|
|
||||||
}
|
|
||||||
|
|
||||||
// No file resource carries its text inline: each names its template in files/, and the build packs files/
|
|
||||||
// into the one archive the controller reads the templates back from (novox/hq ADR 0349 §2).
|
|
||||||
func TestEveryFileTakesItsTextFromATemplate(t *testing.T) {
|
|
||||||
raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
var m struct {
|
|
||||||
Resources []map[string]any `json:"resources"`
|
|
||||||
Build struct {
|
|
||||||
Artifacts []map[string]any `json:"artifacts"`
|
|
||||||
} `json:"build"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(raw, &m); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
for _, r := range m.Resources {
|
|
||||||
if _, inline := r["content"]; inline {
|
|
||||||
t.Errorf("resource %v keeps its text inline: keep it in files/ and name it as the template", r["id"])
|
|
||||||
}
|
|
||||||
if r["type"] == "file" {
|
|
||||||
templateText(t, r)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
packs := 0
|
|
||||||
for _, a := range m.Build.Artifacts {
|
|
||||||
if a["kind"] == "archive" && a["from"] == "files" {
|
|
||||||
packs++
|
|
||||||
if a["name"] != "files" {
|
|
||||||
t.Errorf("the templates archive is named %v, not files", a["name"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if packs != 1 {
|
|
||||||
t.Errorf("the build packs files/ into %d archives, not one", packs)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
# The mesh's internal certificate authority, trusted by this machine.
|
|
||||||
#
|
|
||||||
# Written by the mesh from the ca-trust module's manifest (novox/hq ADR 0147).
|
|
||||||
# Editing it here lasts until the next apply.
|
|
||||||
#
|
|
||||||
# There is no prior trust to verify the fetch against — this is the thing that
|
|
||||||
# establishes it — so it is made over the mesh's own private network, which is
|
|
||||||
# what authenticates it (novox/hq ADR 0098, the same reasoning that lets the
|
|
||||||
# route proxy fetch this root for itself). What comes back is checked here: a
|
|
||||||
# body that is not a certificate is refused now, rather than believed and then
|
|
||||||
# failed by whatever reads the trust store next.
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
ROOTS='https://${bound:internal-acme-ca:at}:${bound:internal-acme-ca:port}${bound:internal-acme-ca:roots}'
|
|
||||||
ANCHORS=/etc/ca-certificates/trust-source/anchors
|
|
||||||
ANCHOR="$ANCHORS/mesh-internal-ca.crt"
|
|
||||||
|
|
||||||
# Arch's layout, said out loud rather than assumed: a machine that keeps its
|
|
||||||
# anchors elsewhere fails here, visibly, instead of writing a file nothing
|
|
||||||
# reads. That failure is the signal that this belongs in the host, where one
|
|
||||||
# operating system's difference lives (novox/hq ADR 0147, option 2).
|
|
||||||
[ -d "$ANCHORS" ] || {
|
|
||||||
echo "this machine keeps no trust anchors in $ANCHORS; ca-trust is written for that layout" >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
case "${1:-}" in
|
|
||||||
install)
|
|
||||||
tmp=$(mktemp)
|
|
||||||
trap 'rm -f "$tmp"' EXIT
|
|
||||||
# The authority may still be starting, or this machine may have come up
|
|
||||||
# before it: two minutes of asking, then an honest failure.
|
|
||||||
n=0
|
|
||||||
while [ "$n" -lt 60 ]; do
|
|
||||||
if curl --fail --silent --show-error --insecure --max-time 10 \
|
|
||||||
--output "$tmp" "$ROOTS" &&
|
|
||||||
grep -q 'BEGIN CERTIFICATE' "$tmp"; then
|
|
||||||
install -m 0644 "$tmp" "$ANCHOR"
|
|
||||||
update-ca-trust
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
n=$((n + 1))
|
|
||||||
sleep 2
|
|
||||||
done
|
|
||||||
echo "the authority at $ROOTS did not serve a certificate within two minutes" >&2
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
remove)
|
|
||||||
# What stopping the unit does, and therefore what being unassigned does.
|
|
||||||
rm -f "$ANCHOR"
|
|
||||||
update-ca-trust
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "usage: $(basename "$0") install|remove" >&2
|
|
||||||
exit 2
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=The mesh's internal certificate authority, trusted by this machine
|
|
||||||
# novox/hq ADR 0147. Starting this unit places the mesh's root among this
|
|
||||||
# machine's trust anchors; stopping it takes the root away again, which is what
|
|
||||||
# the host does when the module is no longer assigned here.
|
|
||||||
Wants=network-online.target
|
|
||||||
After=network-online.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=oneshot
|
|
||||||
RemainAfterExit=yes
|
|
||||||
ExecStart=${dir:state}/anchor install
|
|
||||||
ExecStop=${dir:state}/anchor remove
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
@@ -32,14 +32,14 @@
|
|||||||
"type": "file",
|
"type": "file",
|
||||||
"path": "${dir:state}/anchor",
|
"path": "${dir:state}/anchor",
|
||||||
"mode": "0755",
|
"mode": "0755",
|
||||||
"template": "files/anchor"
|
"content": "#!/bin/sh\n# The mesh's internal certificate authority, trusted by this machine.\n#\n# Written by the mesh from the ca-trust module's manifest (novox/hq ADR 0147).\n# Editing it here lasts until the next apply.\n#\n# There is no prior trust to verify the fetch against \u2014 this is the thing that\n# establishes it \u2014 so it is made over the mesh's own private network, which is\n# what authenticates it (novox/hq ADR 0098, the same reasoning that lets the\n# route proxy fetch this root for itself). What comes back is checked here: a\n# body that is not a certificate is refused now, rather than believed and then\n# failed by whatever reads the trust store next.\nset -eu\n\nROOTS='https://${bound:internal-acme-ca:at}:${bound:internal-acme-ca:port}${bound:internal-acme-ca:roots}'\nANCHORS=/etc/ca-certificates/trust-source/anchors\nANCHOR=\"$ANCHORS/mesh-internal-ca.crt\"\n\n# Arch's layout, said out loud rather than assumed: a machine that keeps its\n# anchors elsewhere fails here, visibly, instead of writing a file nothing\n# reads. That failure is the signal that this belongs in the host, where one\n# operating system's difference lives (novox/hq ADR 0147, option 2).\n[ -d \"$ANCHORS\" ] || {\n\techo \"this machine keeps no trust anchors in $ANCHORS; ca-trust is written for that layout\" >&2\n\texit 1\n}\n\ncase \"${1:-}\" in\ninstall)\n\ttmp=$(mktemp)\n\ttrap 'rm -f \"$tmp\"' EXIT\n\t# The authority may still be starting, or this machine may have come up\n\t# before it: two minutes of asking, then an honest failure.\n\tn=0\n\twhile [ \"$n\" -lt 60 ]; do\n\t\tif curl --fail --silent --show-error --insecure --max-time 10 \\\n\t\t\t--output \"$tmp\" \"$ROOTS\" &&\n\t\t\tgrep -q 'BEGIN CERTIFICATE' \"$tmp\"; then\n\t\t\tinstall -m 0644 \"$tmp\" \"$ANCHOR\"\n\t\t\tupdate-ca-trust\n\t\t\texit 0\n\t\tfi\n\t\tn=$((n + 1))\n\t\tsleep 2\n\tdone\n\techo \"the authority at $ROOTS did not serve a certificate within two minutes\" >&2\n\texit 1\n\t;;\nremove)\n\t# What stopping the unit does, and therefore what being unassigned does.\n\trm -f \"$ANCHOR\"\n\tupdate-ca-trust\n\t;;\n*)\n\techo \"usage: $(basename \"$0\") install|remove\" >&2\n\texit 2\n\t;;\nesac\n"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "unit",
|
"id": "unit",
|
||||||
"type": "file",
|
"type": "file",
|
||||||
"path": "/etc/systemd/system/mesh-ca-trust.service",
|
"path": "/etc/systemd/system/mesh-ca-trust.service",
|
||||||
"mode": "0644",
|
"mode": "0644",
|
||||||
"template": "files/mesh-ca-trust.service"
|
"content": "[Unit]\nDescription=The mesh's internal certificate authority, trusted by this machine\n# novox/hq ADR 0147. Starting this unit places the mesh's root among this\n# machine's trust anchors; stopping it takes the root away again, which is what\n# the host does when the module is no longer assigned here.\nWants=network-online.target\nAfter=network-online.target\n\n[Service]\nType=oneshot\nRemainAfterExit=yes\nExecStart=${dir:state}/anchor install\nExecStop=${dir:state}/anchor remove\n\n[Install]\nWantedBy=multi-user.target\n"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "trust",
|
"id": "trust",
|
||||||
@@ -52,14 +52,5 @@
|
|||||||
"unit"
|
"unit"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
"build": {
|
|
||||||
"artifacts": [
|
|
||||||
{
|
|
||||||
"name": "files",
|
|
||||||
"kind": "archive",
|
|
||||||
"from": "files"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,9 +11,8 @@ Two directories, declared, so the mesh refuses a second module owning either:
|
|||||||
- `~/.claude` under the home of the account agents run as (the operator's, unless the machine names an agent
|
- `~/.claude` under the home of the account agents run as (the operator's, unless the machine names an agent
|
||||||
account of its own), that account's, `0700`. The module owns the directory —
|
account of its own), that account's, `0700`. The module owns the directory —
|
||||||
that it exists, who owns it, its mode — and of what is inside only what it writes. Everything else
|
that it exists, who owns it, its mode — and of what is inside only what it writes. Everything else
|
||||||
in it (history, projects, local settings, a person's own rules and skills) is the person's
|
in it (memory, history, projects, local settings, a person's own rules and skills) is the person's
|
||||||
and is never read or written (hq ADR 0182) — but for each project's memory folder, which the module
|
and is never read or written (hq ADR 0182). Unassigned, the module leaves the directory: the host
|
||||||
carries between nodes (hq ADR 0343, below). Unassigned, the module leaves the directory: the host
|
|
||||||
removes a directory only when it is empty.
|
removes a directory only when it is empty.
|
||||||
|
|
||||||
And, for where the agent runs as an account of its own (hq ADR 0266):
|
And, for where the agent runs as an account of its own (hq ADR 0266):
|
||||||
@@ -43,7 +42,7 @@ whenever the node's tool runtime collects the module's tools:
|
|||||||
| file | holds |
|
| file | holds |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `managed-mcp.json` | the tool servers every session loads: the mesh's console as `mesh`, and the servers set in this module's `mcp_servers` setting. **Exclusive**: a server not listed here does not load — not one added with `claude mcp add`, not a project's `.mcp.json`, not a plugin's |
|
| `managed-mcp.json` | the tool servers every session loads: the mesh's console as `mesh`, and the servers set in this module's `mcp_servers` setting. **Exclusive**: a server not listed here does not load — not one added with `claude mcp add`, not a project's `.mcp.json`, not a plugin's |
|
||||||
| `managed-settings.json` | the keys set in this module's `managed_settings` setting — at the controller's terminal alone (hq issue 340) — under the mesh's own keys: the repositories' attribution convention, the claude.ai connectors kept beside the managed servers, the key-helper while the node holds an API-key licence, git's credential file while the node holds the forge's read credential (below), and the two that name the `nox-mesh` marketplace and enable its plugin |
|
| `managed-settings.json` | the keys set in this module's `managed_settings` setting — at the controller's terminal alone (hq issue 340) — under the mesh's own keys: the repositories' attribution convention, the claude.ai connectors kept beside the managed servers, the key-helper while the node holds an API-key licence, and the two that name the `nox-mesh` marketplace and enable its plugin |
|
||||||
| `CLAUDE.md` | how a session on this mesh works, this node's name and role, the conventions, the **"instead of" table** — then the instruction sections registered for every node and for this one |
|
| `CLAUDE.md` | how a session on this mesh works, this node's name and role, the conventions, the **"instead of" table** — then the instruction sections registered for every node and for this one |
|
||||||
| `marketplace/` | the `nox-mesh` plugin (hq ADR 0216): the skills, subagents, commands and output styles registered for every node and for this one, offered in a session as `nox-mesh:<name>` — and the **guard on the agent's shell** (`guard/`), the mesh's own hook and the plugin's only one (hq issue 340). Replaced whole, staged beside and swapped in |
|
| `marketplace/` | the `nox-mesh` plugin (hq ADR 0216): the skills, subagents, commands and output styles registered for every node and for this one, offered in a session as `nox-mesh:<name>` — and the **guard on the agent's shell** (`guard/`), the mesh's own hook and the plugin's only one (hq issue 340). Replaced whole, staged beside and swapped in |
|
||||||
|
|
||||||
@@ -55,17 +54,6 @@ same content, and never through a directory that is a symbolic link. A placed fi
|
|||||||
alone, and one the person deleted stays deleted until the item is unregistered (hq ADR 0182). The status tool reads the rest of the home's
|
alone, and one the person deleted stays deleted until the item is unregistered (hq ADR 0182). The status tool reads the rest of the home's
|
||||||
items to report them; nothing else is read or written.
|
items to report them; nothing else is read or written.
|
||||||
|
|
||||||
**The forge's read credential** (hq ADR 0365 §4, issue 737). The module requires `gitea-read`, optionally: the forge
|
|
||||||
mints this node's agent a token of its own account, `agent-<node>`, scoped to read repositories alone, and the vault
|
|
||||||
keeps it for this module on this machine alone (issue 749); it arrives in the state as `gitea-read.secret`, git's
|
|
||||||
credential-store lines. Every few seconds the module places them at `~/.config/mesh/forge.credentials` in the agent's
|
|
||||||
home, mode 0600, written as the agent's account; puts the file back when git's store helper or a person changed it;
|
|
||||||
and takes it back when none is held. A value that is not credential lines (one the mesh made before the forge was
|
|
||||||
asked) is never placed. While one is held, `managed-settings.json` gives every session's environment
|
|
||||||
`GIT_CONFIG_COUNT`, `GIT_CONFIG_KEY_0=credential.helper` and `GIT_CONFIG_VALUE_0=store --file='<that path>'`: the
|
|
||||||
path, never the token. So `git clone` and `git fetch` of what the agent's account may read work with nothing typed,
|
|
||||||
printed or put on a command line; pushing stays `gitea_commit`'s, and the forge refuses the token a push.
|
|
||||||
|
|
||||||
## Over NATS
|
## Over NATS
|
||||||
|
|
||||||
Everything between this module and the rest of the mesh is NATS, in three kinds: an **event** says that
|
Everything between this module and the rest of the mesh is NATS, in three kinds: an **event** says that
|
||||||
@@ -81,37 +69,8 @@ must see, a node that joins later included — kept, so it carries no secret eit
|
|||||||
| an MCP server registered through this module before registering was refused (hq issue 340) | a key in the module's `servers` state — `all.<server>` for every node, `<node>.<server>` for one; every node watches it and renders what applies to it, a node's own entry over the one for every node. A node that joins later, or was off, reads the whole current set at start; unregistering is a delete. Nothing adds a key any more: a new tool server is the operator's, in the `mcp_servers` setting at the controller's terminal |
|
| an MCP server registered through this module before registering was refused (hq issue 340) | a key in the module's `servers` state — `all.<server>` for every node, `<node>.<server>` for one; every node watches it and renders what applies to it, a node's own entry over the one for every node. A node that joins later, or was off, reads the whole current set at start; unregistering is a delete. Nothing adds a key any more: a new tool server is the operator's, in the `mcp_servers` setting at the controller's terminal |
|
||||||
| a proposed instruction section (hq ADR 0274) | a key in the module's `proposals` state per proposal, the ask published on `operator-channel` under this module's name, and the router's record of it read under that name alone; what a node approved is its own file, never the bus |
|
| a proposed instruction section (hq ADR 0274) | a key in the module's `proposals` state per proposal, the ask published on `operator-channel` under this module's name, and the router's record of it read under that name alone; what a node approved is its own file, never the bus |
|
||||||
| a session's question to the operator (hq ADR 0332) | a key `q-…` in the module's `questions` state per question, the ask published on `operator-channel` under this module's name, and the router's record of it read under that name alone; the router's word that it ended without a choice is kept on the question; two slot keys `slot-1`, `slot-2` name the questions open |
|
| a session's question to the operator (hq ADR 0332) | a key `q-…` in the module's `questions` state per question, the ask published on `operator-channel` under this module's name, and the router's record of it read under that name alone; the router's word that it ended without a choice is kept on the question; two slot keys `slot-1`, `slot-2` name the questions open |
|
||||||
| a project's memory (hq ADR 0343) | a key in the module's `memory` state per file — `<kind>.<project and path, base64url>`, `operator` for the operator's own homes and `agent` for an agent account's — holding the text, its digest, the digest of the version it replaced, the node and when it changed; a removal is a value marked removed, never a deleted key. Written at the revision read, so of two nodes writing one file only one wins. Every node of the same kind watches it. No event and no tool answer carries memory text |
|
|
||||||
| the agent's configuration (hq ADR 0216) | a key in the module's `config` state per registration — `mesh.<kind>.<name>` for every node, `node.<node>.<kind>.<name>` for one, `home.<node>.<kind>.<name>` for one account's own directory — the item and its files in one value, at most 256 KiB. Every node watches it and renders what applies to it, a node item over a mesh item of the same kind and name. A hook, settings, or an item that would run something by itself is never taken, whenever it was put there (hq issue 340) |
|
| the agent's configuration (hq ADR 0216) | a key in the module's `config` state per registration — `mesh.<kind>.<name>` for every node, `node.<node>.<kind>.<name>` for one, `home.<node>.<kind>.<name>` for one account's own directory — the item and its files in one value, at most 256 KiB. Every node watches it and renders what applies to it, a node item over a mesh item of the same kind and name. A hook, settings, or an item that would run something by itself is never taken, whenever it was put there (hq issue 340) |
|
||||||
|
|
||||||
## A session's memory follows the operator (hq ADR 0343)
|
|
||||||
|
|
||||||
A session started on another node starts with what the sessions on every other node learned (hq issue 367). The
|
|
||||||
module's long-running process carries each project's memory folder, `~/.claude/projects/<project>/memory/`, and
|
|
||||||
nothing else of the home. A project is one entry of the projects directory and a path is relative, no part empty,
|
|
||||||
`.` or `..`: a value in the state naming anything else is never taken, and nothing is touched for it.
|
|
||||||
|
|
||||||
- **Picked up** every ten seconds: a file whose text differs from the version last carried goes into the `memory`
|
|
||||||
state. Not carried, and listed with why: a symbolic link, a file over 128 KiB or too large for one value once
|
|
||||||
encoded, and a file holding a key block, a
|
|
||||||
signed token, a known token prefix or a password in an address.
|
|
||||||
- **Written** on every other node of the same home kind, only where its file is still the version the change
|
|
||||||
replaced, or the version it last carried (a node that was away), or there is none; staged in the folder and renamed in, never through a symbolic link. The version
|
|
||||||
replaced is kept first in `memory-copies/` in the module's state directory, 30 days.
|
|
||||||
- **A file changed on two nodes** becomes one file holding both, the newer first, then a line naming each version's
|
|
||||||
node and time, then the older (two versions too large for one file: the newer in it, the older beside it as
|
|
||||||
`<name>.older-from-<node>`); what was written is carried, so every node ends on it, and the operator is warned through
|
|
||||||
the operator channel's `notify` (a warning, cleared when the file next changes without a conflict).
|
|
||||||
- **A removal** of a carried file, from a folder that still exists, is a marker in the state for 30 days: a node still
|
|
||||||
holding the removed version moves it into the copies; a node that changed it keeps it and carries it again. A
|
|
||||||
missing folder, a key the state lost and a deleted key remove nothing, and neither does a file or folder that
|
|
||||||
cannot be read on a look: it is kept everywhere and listed as not carried.
|
|
||||||
- **The two home kinds never mix**: the operator's own homes carry among themselves, an agent account's among
|
|
||||||
themselves (hq ADR 0266), so a confined agent's notes never reach a session run as the operator.
|
|
||||||
- **No tool writes memory** (hq issue 340): `claude_code_memory` only lists what is carried — fingerprints, never the
|
|
||||||
text — the files not carried, the open conflicts and the copies kept. A value put straight into the state by a
|
|
||||||
holder of a tool runner's credential is not yet told apart (hq issue 638).
|
|
||||||
|
|
||||||
## The mesh's tools first (hq ADR 0245)
|
## The mesh's tools first (hq ADR 0245)
|
||||||
|
|
||||||
The agent kept reaching for `ssh <machine> journalctl` while the service manager's `journal` verb existed. So:
|
The agent kept reaching for `ssh <machine> journalctl` while the service manager's `journal` verb existed. So:
|
||||||
@@ -190,12 +149,9 @@ said in the render's answer. What was registered before is still rendered and ma
|
|||||||
hand-over, the context a later session needs — or with `remove` for a section approved before. Nothing becomes
|
hand-over, the context a later session needs — or with `remove` for a section approved before. Nothing becomes
|
||||||
active: the proposal is kept in the module's `proposals` state and the operator is asked through the operator
|
active: the proposal is kept in the module's `proposals` state and the operator is asked through the operator
|
||||||
channel (`operator-channel`, hq ADR 0259) with Approve and Decline, both at the level approve, so only a channel that
|
channel (`operator-channel`, hq ADR 0259) with Approve and Decline, both at the level approve, so only a channel that
|
||||||
proves who answered (Telegram) carries either. The ask reads at a glance (hq issue 639): its explanation is one
|
proves who answered (Telegram) carries either. The phone shows the whole text when it is short and every line may
|
||||||
line saying what the proposal does — the proposer's `summary`, at most 300 characters, or the module's own words —
|
leave the mesh, else its first lines and a fingerprint; the whole text is read at the controller's terminal with
|
||||||
and its Details, which the phone shows collapsed after the answers, carry the text (whole when it is short and every
|
`mesh-cli ask claude-code claude_code_proposals '{"id": "<id>"}'`. Decline, or no answer within a day, discards it.
|
||||||
line may leave the mesh, else its first lines), where it came from and its fingerprint; the whole text is read at the
|
|
||||||
controller's terminal with `mesh-cli ask claude-code claude_code_proposals '{"id": "<id>"}'`. A proposal made before
|
|
||||||
that change is asked, and its warrant held, in the form it was made in (`form`). Decline, or no answer within a day, discards it.
|
|
||||||
**Every node proves the approval itself**: it reads the router's own record of the ask under this module's name,
|
**Every node proves the approval itself**: it reads the router's own record of the ask under this module's name,
|
||||||
judges the record as this module would have made it, composes the ask again from the proposal and holds the warrant to it (the same ask digest, the option's bound act,
|
judges the record as this module would have made it, composes the ask again from the proposal and holds the warrant to it (the same ask digest, the option's bound act,
|
||||||
the text's digest, in time), and only then writes the section into its own `approved.json`, which the managed
|
the text's digest, in time), and only then writes the section into its own `approved.json`, which the managed
|
||||||
|
|||||||
@@ -285,25 +285,6 @@ func TestANewerGenerationFetchesTheTokenOnceByTheSeatsVerb(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The node's line names the generation it applied and why the manager moved it, and who asked (novox/hq issue 625).
|
|
||||||
func TestAnAppliedBindingSaysWhyAndWho(t *testing.T) {
|
|
||||||
p, w := node(t, "laptop")
|
|
||||||
var asked []string
|
|
||||||
done, err := OnBinding(p, &BindingState{Licence: "personal", Kind: "subscription", Generation: 7,
|
|
||||||
Why: "the switch verb", By: "g14.node-tools"}, seat(t, "personal", "at-7", 7, &asked), writer(w))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
for _, want := range []string{`"generation":7`, `"why":"the switch verb"`, `"by":"g14.node-tools"`} {
|
|
||||||
if !strings.Contains(done, want) {
|
|
||||||
t.Fatalf("the line %s lacks %s", done, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if strings.Contains(done, "at-7") {
|
|
||||||
t.Fatal("the line carries the token")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTheTokenANodeIsHandedReplacesALoginsGrantAndLeavesNoRefreshToken(t *testing.T) {
|
func TestTheTokenANodeIsHandedReplacesALoginsGrantAndLeavesNoRefreshToken(t *testing.T) {
|
||||||
p, w := node(t, "laptop")
|
p, w := node(t, "laptop")
|
||||||
writeFile(t, p.credentials(), `{"claudeAiOauth":{"accessToken":"at-old","refreshToken":"rt-spent","expiresAt":`+
|
writeFile(t, p.credentials(), `{"claudeAiOauth":{"accessToken":"at-old","refreshToken":"rt-spent","expiresAt":`+
|
||||||
|
|||||||
@@ -1,142 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
// The forge's read credential in the agent's home (novox/hq ADR 0365 §4, issue 737).
|
|
||||||
//
|
|
||||||
// The forge's module mints each node's agent a token of its own account, `agent-<node>`, scoped to read
|
|
||||||
// repositories alone, and the vault keeps it for this module on this machine alone (provision `gitea-read`, issue
|
|
||||||
// 749). What arrives in this module's state is git's credential-store lines for it, one per origin the forge is
|
|
||||||
// reached at. This module places them in the agent's home, a file of mode 0600 written as the agent's account (ADR
|
|
||||||
// 0266: no other module writes there), and points every session's git at it through the managed settings'
|
|
||||||
// environment: the file's path, never its content. So an agent clones and fetches what its account may read, and no
|
|
||||||
// token is printed, typed on a command line or put in an environment a process listing shows.
|
|
||||||
//
|
|
||||||
// Git's own `store` helper reads the file; on a token the forge rejects it erases that line, and on a password a
|
|
||||||
// person typed elsewhere it adds one. Both are undone at the next look (every few seconds): the file holds what the
|
|
||||||
// forge issued, and nothing else. A value that is not credential lines — one the mesh made while the forge had not
|
|
||||||
// been asked yet — is not placed, and what was placed before is taken back.
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io/fs"
|
|
||||||
"net/url"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ForgeProvision is the provision whose credential this is, and the file this module's manifest has it delivered to.
|
|
||||||
const (
|
|
||||||
ForgeProvision = "gitea-read"
|
|
||||||
forgeSecret = "gitea-read.secret"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (p Paths) forgeSecret() string { return filepath.Join(p.State, forgeSecret) }
|
|
||||||
|
|
||||||
// forgeCredentials is where the agent's git reads the credential: under the agent's home, in a directory of the
|
|
||||||
// mesh's own.
|
|
||||||
func (p Paths) forgeCredentials() string {
|
|
||||||
return filepath.Join(p.Home, ".config", "mesh", "forge.credentials")
|
|
||||||
}
|
|
||||||
|
|
||||||
// ForgeCredentialLines are the credential lines held, checked: each an http(s) origin with an account and a token,
|
|
||||||
// and nothing else. Anything else is refused, said without the value.
|
|
||||||
func ForgeCredentialLines(raw string) (string, error) {
|
|
||||||
var lines []string
|
|
||||||
for _, line := range strings.Split(raw, "\n") {
|
|
||||||
line = strings.TrimSpace(line)
|
|
||||||
if line == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
u, err := url.Parse(line)
|
|
||||||
if err != nil || (u.Scheme != "https" && u.Scheme != "http") || u.Host == "" || u.User == nil ||
|
|
||||||
u.User.Username() == "" || u.Path != "" || u.RawQuery != "" || u.Fragment != "" {
|
|
||||||
return "", errors.New("what the mesh holds for the forge's read credential is not git credential lines " +
|
|
||||||
"(the forge has not issued it yet, or something else was given)")
|
|
||||||
}
|
|
||||||
if pw, ok := u.User.Password(); !ok || pw == "" {
|
|
||||||
return "", errors.New("a forge credential line names no token")
|
|
||||||
}
|
|
||||||
lines = append(lines, line)
|
|
||||||
}
|
|
||||||
if len(lines) == 0 {
|
|
||||||
return "", errors.New("the forge's read credential is empty")
|
|
||||||
}
|
|
||||||
return strings.Join(lines, "\n") + "\n", nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ForgeCredentialHeld says whether this machine holds a forge read credential to place.
|
|
||||||
func ForgeCredentialHeld(p Paths) bool {
|
|
||||||
raw, err := os.ReadFile(p.forgeSecret())
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
_, err = ForgeCredentialLines(string(raw))
|
|
||||||
return err == nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// PlaceForgeCredential puts the forge's read credential in the agent's home as it is held — written, rewritten when
|
|
||||||
// it differs, taken back when none is held — and says what it did, or "" when nothing changed. Never the value.
|
|
||||||
func PlaceForgeCredential(p Paths) (string, error) {
|
|
||||||
path := p.forgeCredentials()
|
|
||||||
raw, err := os.ReadFile(p.forgeSecret())
|
|
||||||
var lines string
|
|
||||||
var why error
|
|
||||||
switch {
|
|
||||||
case errors.Is(err, fs.ErrNotExist):
|
|
||||||
why = errors.New("this machine holds no forge read credential")
|
|
||||||
case err != nil:
|
|
||||||
return "", fmt.Errorf("the forge's read credential cannot be read: %w", err)
|
|
||||||
default:
|
|
||||||
lines, why = ForgeCredentialLines(string(raw))
|
|
||||||
}
|
|
||||||
if why != nil {
|
|
||||||
if _, err := fsys.Stat(path); errors.Is(err, fs.ErrNotExist) {
|
|
||||||
return "", nil
|
|
||||||
}
|
|
||||||
if err := fsys.Remove(path); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
|
||||||
return "", fmt.Errorf("the forge credential at %s could not be taken back: %w", path, err)
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("forge credential: taken back from %s: %v", path, why), nil
|
|
||||||
}
|
|
||||||
if held, err := fsys.ReadFile(path); err == nil && bytes.Equal(held, []byte(lines)) {
|
|
||||||
if info, err := fsys.Stat(path); err == nil && info.Mode().Perm() == 0o600 {
|
|
||||||
return "", nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := fsys.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
|
||||||
return "", fmt.Errorf("the directory for the forge credential could not be made: %w", err)
|
|
||||||
}
|
|
||||||
tmp := path + ".mesh-tmp"
|
|
||||||
if err := fsys.WriteFile(tmp, []byte(lines), 0o600); err != nil {
|
|
||||||
return "", fmt.Errorf("the forge credential could not be written: %w", err)
|
|
||||||
}
|
|
||||||
if err := fsys.Chmod(tmp, 0o600); err != nil {
|
|
||||||
_ = fsys.Remove(tmp)
|
|
||||||
return "", fmt.Errorf("the forge credential could not be made 0600: %w", err)
|
|
||||||
}
|
|
||||||
if err := fsys.Rename(tmp, path); err != nil {
|
|
||||||
_ = fsys.Remove(tmp)
|
|
||||||
return "", fmt.Errorf("the forge credential could not be put in place: %w", err)
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("forge credential: placed at %s for %d origin(s)", path, strings.Count(lines, "\n")), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// forgeEnv is what every session's environment carries for git to read the credential: the file's path in git's
|
|
||||||
// configuration from the environment, never the credential. Laid over the operator's `env`, the mesh's keys winning.
|
|
||||||
func forgeEnv(managed map[string]any, credentials string) {
|
|
||||||
if credentials == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
env := map[string]any{}
|
|
||||||
if held, ok := managed["env"].(map[string]any); ok {
|
|
||||||
for k, v := range held {
|
|
||||||
env[k] = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
env["GIT_CONFIG_COUNT"] = "1"
|
|
||||||
env["GIT_CONFIG_KEY_0"] = "credential.helper"
|
|
||||||
env["GIT_CONFIG_VALUE_0"] = "store --file=" + shellQuote(credentials)
|
|
||||||
managed["env"] = env
|
|
||||||
}
|
|
||||||
@@ -1,262 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"net/http/cgi"
|
|
||||||
"net/http/httptest"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
const agentToken = "0123456789abcdef0123456789abcdef01234567"
|
|
||||||
|
|
||||||
// privateForge is a forge serving one private repository over git's smart http, to agent-anchor with its token
|
|
||||||
// alone, as the forge does for an account a team lets read it.
|
|
||||||
func privateForge(t *testing.T) (origin, bare string) {
|
|
||||||
t.Helper()
|
|
||||||
backend := filepath.Join(gitExecPath(t), "git-http-backend")
|
|
||||||
if _, err := os.Stat(backend); err != nil {
|
|
||||||
t.Skipf("git-http-backend is not installed: %v", err)
|
|
||||||
}
|
|
||||||
root := t.TempDir()
|
|
||||||
bare = filepath.Join(root, "private.git")
|
|
||||||
work := t.TempDir()
|
|
||||||
runGit(t, work, nil, "git", "init", "-q", "-b", "main", ".")
|
|
||||||
commit(t, work, "one")
|
|
||||||
runGit(t, root, nil, "git", "clone", "-q", "--bare", work, bare)
|
|
||||||
cgiHandler := &cgi.Handler{Path: backend, Env: []string{"GIT_PROJECT_ROOT=" + root, "GIT_HTTP_EXPORT_ALL=1"}}
|
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
user, pass, ok := r.BasicAuth()
|
|
||||||
if !ok || user != "agent-anchor" || pass != agentToken {
|
|
||||||
w.Header().Set("WWW-Authenticate", `Basic realm="Gitea"`)
|
|
||||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "git-receive-pack") {
|
|
||||||
http.Error(w, "read only", http.StatusForbidden)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
cgiHandler.ServeHTTP(w, r)
|
|
||||||
}))
|
|
||||||
t.Cleanup(srv.Close)
|
|
||||||
return srv.URL, bare
|
|
||||||
}
|
|
||||||
|
|
||||||
func gitExecPath(t *testing.T) string {
|
|
||||||
t.Helper()
|
|
||||||
out, err := exec.Command("git", "--exec-path").Output()
|
|
||||||
if err != nil {
|
|
||||||
t.Skipf("git is not installed: %v", err)
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(string(out))
|
|
||||||
}
|
|
||||||
|
|
||||||
func runGit(t *testing.T, dir string, env []string, name string, args ...string) string {
|
|
||||||
t.Helper()
|
|
||||||
out, err := runErr(dir, env, name, args...)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("%s %v: %v\n%s", name, args, err, out)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func runErr(dir string, env []string, name string, args ...string) (string, error) {
|
|
||||||
cmd := exec.Command(name, args...)
|
|
||||||
cmd.Dir = dir
|
|
||||||
cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t", "GIT_COMMITTER_NAME=t",
|
|
||||||
"GIT_COMMITTER_EMAIL=t@t", "GIT_TERMINAL_PROMPT=0", "GIT_ASKPASS=", "SSH_ASKPASS=")
|
|
||||||
cmd.Env = append(cmd.Env, env...)
|
|
||||||
out, err := cmd.CombinedOutput()
|
|
||||||
return string(out), err
|
|
||||||
}
|
|
||||||
|
|
||||||
func commit(t *testing.T, dir, text string) {
|
|
||||||
t.Helper()
|
|
||||||
if err := os.WriteFile(filepath.Join(dir, "file"), []byte(text+"\n"), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
runGit(t, dir, nil, "git", "add", "file")
|
|
||||||
runGit(t, dir, nil, "git", "commit", "-q", "-m", text)
|
|
||||||
}
|
|
||||||
|
|
||||||
// sessionEnv is the environment a session starts with: the managed settings' `env`, in a home with no git
|
|
||||||
// configuration of its own.
|
|
||||||
func sessionEnv(t *testing.T, home, managedSettings string) []string {
|
|
||||||
t.Helper()
|
|
||||||
var settings struct {
|
|
||||||
Env map[string]string `json:"env"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal([]byte(managedSettings), &settings); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
env := []string{"HOME=" + home, "XDG_CONFIG_HOME=" + filepath.Join(home, ".config"), "GIT_CONFIG_NOSYSTEM=1"}
|
|
||||||
for k, v := range settings.Env {
|
|
||||||
env = append(env, k+"="+v)
|
|
||||||
}
|
|
||||||
return env
|
|
||||||
}
|
|
||||||
|
|
||||||
func forgePaths(t *testing.T) Paths {
|
|
||||||
t.Helper()
|
|
||||||
return Paths{State: t.TempDir(), Home: t.TempDir(), Node: "anchor"}
|
|
||||||
}
|
|
||||||
|
|
||||||
func managedFor(p Paths) string {
|
|
||||||
facts := Facts{Node: p.Node, Console: "http://console"}
|
|
||||||
if ForgeCredentialHeld(p) {
|
|
||||||
facts.ForgeCredentials = p.forgeCredentials()
|
|
||||||
}
|
|
||||||
return Render(facts, Settings{}, nil, "", nil, Config{})["managed-settings.json"]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Issue 737's done-when, as far as a machine of its own proves it: an agent session clones a private repository and
|
|
||||||
// fetches it again, and no credential appears in a log, a command line or the session.
|
|
||||||
func TestAnAgentClonesAPrivateRepositoryAndFetchesItAgainWithTheCredentialPlaced(t *testing.T) {
|
|
||||||
origin, bare := privateForge(t)
|
|
||||||
p := forgePaths(t)
|
|
||||||
repo := origin + "/private.git"
|
|
||||||
|
|
||||||
// Before the forge issued anything, the session's git has no credential: the clone is refused, as today.
|
|
||||||
before := sessionEnv(t, p.Home, managedFor(p))
|
|
||||||
if out, err := runErr(t.TempDir(), before, "git", "clone", "-q", repo, "x"); err == nil {
|
|
||||||
t.Fatalf("a private repository was cloned with no credential:\n%s", out)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The vault hands this module what the forge issued for this machine's agent.
|
|
||||||
lines := strings.Replace(origin, "http://", "http://agent-anchor:"+agentToken+"@", 1) + "\n" +
|
|
||||||
"https://agent-anchor:" + agentToken + "@git.example.test\n"
|
|
||||||
if err := os.WriteFile(p.forgeSecret(), []byte(lines), 0o600); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
said, err := PlaceForgeCredential(p)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
info, err := os.Stat(p.forgeCredentials())
|
|
||||||
if err != nil || info.Mode().Perm() != 0o600 {
|
|
||||||
t.Fatalf("the credential file: %v %v, want mode 0600", info, err)
|
|
||||||
}
|
|
||||||
if again, err := PlaceForgeCredential(p); err != nil || again != "" {
|
|
||||||
t.Fatalf("placed again with nothing changed: %q %v", again, err)
|
|
||||||
}
|
|
||||||
managed := managedFor(p)
|
|
||||||
env := sessionEnv(t, p.Home, managed)
|
|
||||||
|
|
||||||
work := t.TempDir()
|
|
||||||
cloneOut := runGit(t, work, env, "git", "clone", repo, "clone")
|
|
||||||
clone := filepath.Join(work, "clone")
|
|
||||||
if got := runGit(t, clone, env, "git", "log", "--format=%s"); strings.TrimSpace(got) != "one" {
|
|
||||||
t.Fatalf("cloned %q", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The forge's repository moves on; the agent fetches it again.
|
|
||||||
other := t.TempDir()
|
|
||||||
runGit(t, other, nil, "git", "clone", "-q", bare, "w")
|
|
||||||
commit(t, filepath.Join(other, "w"), "two")
|
|
||||||
runGit(t, filepath.Join(other, "w"), nil, "git", "push", "-q", "origin", "main")
|
|
||||||
fetchOut := runGit(t, clone, env, "git", "fetch", "origin")
|
|
||||||
if got := runGit(t, clone, env, "git", "log", "--format=%s", "origin/main"); !strings.HasPrefix(got, "two") {
|
|
||||||
t.Fatalf("fetched %q", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
// A push is the forge's to refuse (ADR 0365 §5): the token reads, and nothing writes with it.
|
|
||||||
commit(t, clone, "three")
|
|
||||||
if out, err := runErr(clone, env, "git", "push", "origin", "main"); err == nil {
|
|
||||||
t.Fatalf("a push with the agent's credential went through:\n%s", out)
|
|
||||||
}
|
|
||||||
|
|
||||||
// No credential in what the session saw, its command lines, its environment or the module's words.
|
|
||||||
remote := runGit(t, clone, env, "git", "remote", "get-url", "origin")
|
|
||||||
for what, text := range map[string]string{"the clone's output": cloneOut, "the fetch's output": fetchOut,
|
|
||||||
"the remote's url": remote, "the managed settings": managed, "the session's environment": strings.Join(env, "\n"),
|
|
||||||
"what the module said": said, "the clone's configuration": readOrEmpty(filepath.Join(clone, ".git", "config"))} {
|
|
||||||
if strings.Contains(text, agentToken) {
|
|
||||||
t.Errorf("the token appears in %s", what)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func readOrEmpty(path string) string {
|
|
||||||
raw, _ := os.ReadFile(path)
|
|
||||||
return string(raw)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWhatGitChangedInTheFileIsPutBackAndAValueNotIssuedIsNeverPlaced(t *testing.T) {
|
|
||||||
p := forgePaths(t)
|
|
||||||
// A value the mesh made while the forge had not been asked: not credential lines, never placed.
|
|
||||||
if err := os.WriteFile(p.forgeSecret(), []byte("Zm9yZ2VkLWJ5LXRoZS1tZXNo\n"), 0o600); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if said, err := PlaceForgeCredential(p); err != nil || said != "" {
|
|
||||||
t.Fatalf("%q %v", said, err)
|
|
||||||
}
|
|
||||||
if _, err := os.Stat(p.forgeCredentials()); err == nil {
|
|
||||||
t.Fatal("a value the forge did not issue was placed")
|
|
||||||
}
|
|
||||||
if ForgeCredentialHeld(p) || strings.Contains(managedFor(p), "GIT_CONFIG") {
|
|
||||||
t.Fatal("the sessions' git is pointed at a credential that is not held")
|
|
||||||
}
|
|
||||||
|
|
||||||
issued := "https://agent-anchor:" + agentToken + "@git.example.test\n"
|
|
||||||
if err := os.WriteFile(p.forgeSecret(), []byte(issued), 0o600); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if _, err := PlaceForgeCredential(p); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
// git's store helper erased the line after a rejection, and a person's password for another host was stored.
|
|
||||||
if err := os.WriteFile(p.forgeCredentials(), []byte("https://me:mine@example.org\n"), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if said, err := PlaceForgeCredential(p); err != nil || !strings.Contains(said, "placed") {
|
|
||||||
t.Fatalf("%q %v", said, err)
|
|
||||||
}
|
|
||||||
if got := readOrEmpty(p.forgeCredentials()); got != issued {
|
|
||||||
t.Fatalf("the file holds %q", strings.ReplaceAll(got, agentToken, "<token>"))
|
|
||||||
}
|
|
||||||
if info, _ := os.Stat(p.forgeCredentials()); info.Mode().Perm() != 0o600 {
|
|
||||||
t.Fatalf("mode %v", info.Mode().Perm())
|
|
||||||
}
|
|
||||||
|
|
||||||
// The credential withdrawn: taken back, and the sessions' git no longer pointed at it.
|
|
||||||
if err := os.Remove(p.forgeSecret()); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if said, err := PlaceForgeCredential(p); err != nil || !strings.Contains(said, "taken back") || strings.Contains(said, agentToken) {
|
|
||||||
t.Fatalf("%q %v", said, err)
|
|
||||||
}
|
|
||||||
if _, err := os.Stat(p.forgeCredentials()); err == nil {
|
|
||||||
t.Fatal("a withdrawn credential stayed in the agent's home")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTheSessionsGitIsPointedAtTheFileUnderTheOperatorsOwnEnvironment(t *testing.T) {
|
|
||||||
managed := map[string]any{"env": map[string]any{"FOO": "bar", "GIT_CONFIG_COUNT": "7"}}
|
|
||||||
forgeEnv(managed, "/home/agent/.config/mesh/forge.credentials")
|
|
||||||
env := managed["env"].(map[string]any)
|
|
||||||
if env["FOO"] != "bar" || env["GIT_CONFIG_COUNT"] != "1" || env["GIT_CONFIG_KEY_0"] != "credential.helper" ||
|
|
||||||
env["GIT_CONFIG_VALUE_0"] != "store --file='/home/agent/.config/mesh/forge.credentials'" {
|
|
||||||
t.Fatalf("%v", env)
|
|
||||||
}
|
|
||||||
none := map[string]any{}
|
|
||||||
forgeEnv(none, "")
|
|
||||||
if _, set := none["env"]; set {
|
|
||||||
t.Fatal("an environment was set with no credential held")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestForgeCredentialLinesAreReadStrictly(t *testing.T) {
|
|
||||||
for _, bad := range []string{"", "\n", "token", "https://git.example.test", "https://agent-anchor@git.example.test",
|
|
||||||
"https://agent-anchor:t@git.example.test/novox/hq.git", "ssh://agent-anchor:t@git.example.test",
|
|
||||||
"https://agent-anchor:t@git.example.test\nnot a line"} {
|
|
||||||
if _, err := ForgeCredentialLines(bad); err == nil {
|
|
||||||
t.Errorf("%q was taken", bad)
|
|
||||||
} else if strings.Contains(err.Error(), "t@git") {
|
|
||||||
t.Errorf("the refusal said the value: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -152,36 +152,6 @@ func (s stateOf) Create(key string, value any) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
func (s stateOf) Keys() ([]string, error) { return s.s.Keys() }
|
func (s stateOf) Keys() ([]string, error) { return s.s.Keys() }
|
||||||
|
|
||||||
// memoryStateOf adapts the SDK's state to the memory carrier: every write at the revision read (ADR 0343).
|
|
||||||
type memoryStateOf struct{ s stdio.KeptState }
|
|
||||||
|
|
||||||
func (s memoryStateOf) Get(key string) (json.RawMessage, uint64, bool, error) {
|
|
||||||
e, err := s.s.Get(key)
|
|
||||||
if err != nil || e == nil {
|
|
||||||
return nil, 0, false, err
|
|
||||||
}
|
|
||||||
return e.Value, e.Revision, true, nil
|
|
||||||
}
|
|
||||||
func (s memoryStateOf) Create(key string, value any) error {
|
|
||||||
_, err := s.s.Create(key, value)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
func (s memoryStateOf) Update(key string, value any, revision uint64) error {
|
|
||||||
_, err := s.s.Update(key, value, revision)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
func (s memoryStateOf) Keys() ([]string, error) { return s.s.Keys() }
|
|
||||||
func (s memoryStateOf) Delete(key string) error { return s.s.Delete(key) }
|
|
||||||
|
|
||||||
// memoryCarrier carries this node's memory folders (ADR 0343); nil until the long-running half starts it.
|
|
||||||
var memoryCarrier *MemoryCarrier
|
|
||||||
|
|
||||||
// notifyOperator tells the operator through the operator channel's notify verb (ADR 0253).
|
|
||||||
func notifyOperator(args map[string]any) error {
|
|
||||||
_, err := ask("seat:operator-channel.notify", args)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
func (s stateOf) Get(key string) (json.RawMessage, bool, error) {
|
func (s stateOf) Get(key string) (json.RawMessage, bool, error) {
|
||||||
e, err := s.s.Get(key)
|
e, err := s.s.Get(key)
|
||||||
if err != nil || e == nil {
|
if err != nil || e == nil {
|
||||||
@@ -384,9 +354,6 @@ func tools(p Paths, servers ServerState, view *ServerView, config ConfigState, c
|
|||||||
out = append(out, stdio.Tool{Name: "claude_code_registrations",
|
out = append(out, stdio.Tool{Name: "claude_code_registrations",
|
||||||
Description: "Read-only: everything registered through this module that a Claude Code session runs or obeys — the tool servers, the hooks, the settings and the skills, subagents, commands, output styles and instructions — by key, with who registered it, when, and whether it is still rendered (novox/hq issue 340), beside what the operator set for this node at the controller's terminal (managed_settings, mcp_servers). `by` is the node whose tool runner served the call, never the caller.",
|
Description: "Read-only: everything registered through this module that a Claude Code session runs or obeys — the tool servers, the hooks, the settings and the skills, subagents, commands, output styles and instructions — by key, with who registered it, when, and whether it is still rendered (novox/hq issue 340), beside what the operator set for this node at the controller's terminal (managed_settings, mcp_servers). `by` is the node whose tool runner served the call, never the caller.",
|
||||||
Run: func(map[string]any) (any, error) { return Registrations(p, servers, config) }})
|
Run: func(map[string]any) (any, error) { return Registrations(p, servers, config) }})
|
||||||
out = append(out, stdio.Tool{Name: "claude_code_memory",
|
|
||||||
Description: "Read-only: what this node carries of the agent's memory between nodes (novox/hq ADR 0343) — the home kind it carries for (the operator's own homes, or an agent account's), each project's memory files with their fingerprint and the node and time of their last version, the files not carried and why, the conflicts still open (a file changed on two nodes, kept twice in one file, the newer first), and how many replaced or removed versions are kept. Fingerprints only, never the text; it changes nothing.",
|
|
||||||
Run: func(map[string]any) (any, error) { return memoryCarrier.Status(), nil }})
|
|
||||||
out = append(out, proposalTools(p)...)
|
out = append(out, proposalTools(p)...)
|
||||||
judge := NewJudge(p)
|
judge := NewJudge(p)
|
||||||
out = append(out, judgeTool(judge), judgedTool(judge))
|
out = append(out, judgeTool(judge), judgedTool(judge))
|
||||||
@@ -463,12 +430,6 @@ func main() {
|
|||||||
proposing = &Proposing{Store: stateOf{stdio.State(ProposalsState)}, Bus: sdkAskBus{}, Record: recordWarrant,
|
proposing = &Proposing{Store: stateOf{stdio.State(ProposalsState)}, Bus: sdkAskBus{}, Record: recordWarrant,
|
||||||
Now: time.Now, Write: writeManaged}
|
Now: time.Now, Write: writeManaged}
|
||||||
go proposing.watch(p)
|
go proposing.watch(p)
|
||||||
memoryCarrier = NewMemoryCarrier(p, memoryStateOf{stdio.State(MemoryState)}, notifyOperator)
|
|
||||||
go memoryCarrier.Run(func(handle func(key, op string, value json.RawMessage, revision uint64) error) error {
|
|
||||||
return stdio.State(MemoryState).Watch("", func(c stdio.StateChange) error {
|
|
||||||
return handle(c.Key, c.Op, c.Value, c.Revision)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
go run(p, view, configView)
|
go run(p, view, configView)
|
||||||
if err := stdio.Serve("", tools(p, servers, view, config, configView)); err != nil {
|
if err := stdio.Serve("", tools(p, servers, view, config, configView)); err != nil {
|
||||||
say("%v", err)
|
say("%v", err)
|
||||||
@@ -585,25 +546,7 @@ func run(p Paths, view *ServerView, configView *ConfigView) {
|
|||||||
|
|
||||||
report()
|
report()
|
||||||
var last string
|
var last string
|
||||||
// The forge's read credential (forge.go, ADR 0365 §4): placed as held, and put back when git or a person changed
|
|
||||||
// it; the sessions' environment names it only while one is held.
|
|
||||||
forgeHeld := ForgeCredentialHeld(p)
|
|
||||||
placeForge := func() {
|
|
||||||
if line, err := PlaceForgeCredential(p); err != nil {
|
|
||||||
say("%v", err)
|
|
||||||
} else if line != "" {
|
|
||||||
say("%s", line)
|
|
||||||
}
|
|
||||||
if held := ForgeCredentialHeld(p); held != forgeHeld {
|
|
||||||
forgeHeld = held
|
|
||||||
if _, err := RenderNow(p, writeManaged); err != nil {
|
|
||||||
say("rendering after the forge's read credential changed: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
placeForge()
|
|
||||||
for range time.Tick(5 * time.Second) {
|
for range time.Tick(5 * time.Second) {
|
||||||
placeForge()
|
|
||||||
info, err := fsys.Stat(p.credentials())
|
info, err := fsys.Stat(p.credentials())
|
||||||
now := "absent"
|
now := "absent"
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,968 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
// A session's memory follows the operator (novox/hq ADR 0343, issue 367). Every test runs against temporary homes
|
|
||||||
// and a state in memory with the bus's watch semantics: never the live memory folders.
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// memState is the `memory` state in memory: revisions, create and compare-and-set, and every change handed to every
|
|
||||||
// watcher, the writer included, as the bus does.
|
|
||||||
type memState struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
values map[string]json.RawMessage
|
|
||||||
revs map[string]uint64
|
|
||||||
rev uint64
|
|
||||||
pending []change
|
|
||||||
watchers []*MemoryCarrier
|
|
||||||
}
|
|
||||||
|
|
||||||
type change struct {
|
|
||||||
key, op string
|
|
||||||
value json.RawMessage
|
|
||||||
rev uint64
|
|
||||||
}
|
|
||||||
|
|
||||||
func newMemState() *memState {
|
|
||||||
return &memState{values: map[string]json.RawMessage{}, revs: map[string]uint64{}}
|
|
||||||
}
|
|
||||||
|
|
||||||
var errChanged = errors.New("wrong last revision")
|
|
||||||
|
|
||||||
func (s *memState) Get(key string) (json.RawMessage, uint64, bool, error) {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
v, ok := s.values[key]
|
|
||||||
return v, s.revs[key], ok, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *memState) write(key string, value any) {
|
|
||||||
raw, _ := json.Marshal(value)
|
|
||||||
s.rev++
|
|
||||||
s.values[key], s.revs[key] = raw, s.rev
|
|
||||||
s.pending = append(s.pending, change{key, "put", raw, s.rev})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *memState) Create(key string, value any) error {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
if _, ok := s.values[key]; ok {
|
|
||||||
return errChanged
|
|
||||||
}
|
|
||||||
s.write(key, value)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *memState) Update(key string, value any, revision uint64) error {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
if s.revs[key] != revision {
|
|
||||||
return errChanged
|
|
||||||
}
|
|
||||||
s.write(key, value)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *memState) Keys() ([]string, error) {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
var keys []string
|
|
||||||
for k := range s.values {
|
|
||||||
keys = append(keys, k)
|
|
||||||
}
|
|
||||||
sort.Strings(keys)
|
|
||||||
return keys, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *memState) Delete(key string) error {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
delete(s.values, key)
|
|
||||||
delete(s.revs, key)
|
|
||||||
s.rev++
|
|
||||||
s.pending = append(s.pending, change{key, "delete", nil, s.rev})
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// pump hands every change so far to every watcher, until none is left.
|
|
||||||
func (s *memState) pump(t *testing.T) {
|
|
||||||
t.Helper()
|
|
||||||
for i := 0; i < 50; i++ {
|
|
||||||
s.mu.Lock()
|
|
||||||
todo := s.pending
|
|
||||||
s.pending = nil
|
|
||||||
s.mu.Unlock()
|
|
||||||
if len(todo) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for _, c := range todo {
|
|
||||||
for _, w := range s.watchers {
|
|
||||||
if err := w.Take(c.key, c.op, c.value, c.rev); err != nil {
|
|
||||||
t.Logf("%s taking %s: %v", w.Node, c.key, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, w := range s.watchers {
|
|
||||||
if err := w.Drain(); err != nil && !errors.Is(err, errChanged) {
|
|
||||||
t.Logf("%s applying: %v", w.Node, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
t.Fatal("the state never settled")
|
|
||||||
}
|
|
||||||
|
|
||||||
// clock is a clock a test moves.
|
|
||||||
type clock struct{ now time.Time }
|
|
||||||
|
|
||||||
func (c *clock) Now() time.Time { return c.now }
|
|
||||||
|
|
||||||
// memNode is one node's carrier over its own temporary home and state directory.
|
|
||||||
type memNode struct {
|
|
||||||
*MemoryCarrier
|
|
||||||
notices []map[string]any
|
|
||||||
}
|
|
||||||
|
|
||||||
func newMemNode(t *testing.T, root, name, kind string, state *memState, c *clock) *memNode {
|
|
||||||
t.Helper()
|
|
||||||
n := &memNode{}
|
|
||||||
home := filepath.Join(root, name, "home")
|
|
||||||
stateDir := filepath.Join(root, name, "state")
|
|
||||||
for _, d := range []string{filepath.Join(home, ".claude", "projects"), stateDir} {
|
|
||||||
if err := os.MkdirAll(d, 0o700); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
n.MemoryCarrier = &MemoryCarrier{Home: home, Node: name, State: stateDir, Kind: kind, Store: state, Now: c.Now,
|
|
||||||
wake: make(chan struct{}, 1),
|
|
||||||
Notify: func(a map[string]any) error { n.notices = append(n.notices, a); return nil }}
|
|
||||||
state.watchers = append(state.watchers, n.MemoryCarrier)
|
|
||||||
return n
|
|
||||||
}
|
|
||||||
|
|
||||||
func (n *memNode) path(project, file string) string {
|
|
||||||
return filepath.Join(n.Home, ".claude", "projects", project, "memory", filepath.FromSlash(file))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (n *memNode) write(t *testing.T, project, file, text string, at time.Time) {
|
|
||||||
t.Helper()
|
|
||||||
p := n.path(project, file)
|
|
||||||
if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(p, []byte(text), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
_ = os.Chtimes(p, at, at)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (n *memNode) read(t *testing.T, project, file string) (string, bool) {
|
|
||||||
t.Helper()
|
|
||||||
raw, err := os.ReadFile(n.path(project, file))
|
|
||||||
if os.IsNotExist(err) {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
return string(raw), true
|
|
||||||
}
|
|
||||||
|
|
||||||
func (n *memNode) look(t *testing.T) {
|
|
||||||
t.Helper()
|
|
||||||
if err := n.Look(); err != nil && !errors.Is(err, errChanged) {
|
|
||||||
t.Fatalf("%s's look: %v", n.Node, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// copies is every text kept in the node's copies.
|
|
||||||
func (n *memNode) copies(t *testing.T) []string {
|
|
||||||
t.Helper()
|
|
||||||
var out []string
|
|
||||||
_ = filepath.WalkDir(n.MemoryCarrier.copies(), func(p string, d os.DirEntry, err error) error {
|
|
||||||
if err == nil && !d.IsDir() {
|
|
||||||
raw, _ := os.ReadFile(p)
|
|
||||||
out = append(out, string(raw))
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func holdsText(list []string, s string) bool {
|
|
||||||
for _, x := range list {
|
|
||||||
if x == s {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const project = "-home-op-projects-novox-hq"
|
|
||||||
|
|
||||||
func twoNodes(t *testing.T) (*memState, *clock, *memNode, *memNode) {
|
|
||||||
state, c := newMemState(), &clock{now: time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC)}
|
|
||||||
root := t.TempDir()
|
|
||||||
return state, c, newMemNode(t, root, "g14", HomeOperator, state, c), newMemNode(t, root, "shanks", HomeOperator, state, c)
|
|
||||||
}
|
|
||||||
|
|
||||||
// carried writes a file on g14 and carries it to shanks.
|
|
||||||
func carriedOnBoth(t *testing.T, state *memState, c *clock, a, b *memNode, file, text string) {
|
|
||||||
t.Helper()
|
|
||||||
a.write(t, project, file, text, c.now)
|
|
||||||
a.look(t)
|
|
||||||
state.pump(t)
|
|
||||||
b.look(t)
|
|
||||||
state.pump(t)
|
|
||||||
if got, _ := b.read(t, project, file); got != text {
|
|
||||||
t.Fatalf("not carried to %s: %q", b.Node, got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// §5, §6: a memory file written on one node appears unchanged on another, at any depth, and is carried once.
|
|
||||||
func TestAMemoryFileWrittenOnOneNodeAppearsUnchangedOnAnother(t *testing.T) {
|
|
||||||
state, c, g14, shanks := twoNodes(t)
|
|
||||||
g14.write(t, project, "MEMORY.md", "- [state](state.md) — where things stand\n", c.now)
|
|
||||||
g14.write(t, project, "notes/state.md", "ADR 0343 proposed.\n", c.now)
|
|
||||||
g14.look(t)
|
|
||||||
state.pump(t)
|
|
||||||
for file, want := range map[string]string{"MEMORY.md": "- [state](state.md) — where things stand\n", "notes/state.md": "ADR 0343 proposed.\n"} {
|
|
||||||
if got, ok := shanks.read(t, project, file); !ok || got != want {
|
|
||||||
t.Fatalf("%s on shanks: %q, %v", file, got, ok)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
before := state.rev
|
|
||||||
shanks.look(t)
|
|
||||||
g14.look(t)
|
|
||||||
state.pump(t)
|
|
||||||
if state.rev != before {
|
|
||||||
t.Fatalf("a carried file was carried again: %d changes", state.rev-before)
|
|
||||||
}
|
|
||||||
// A change on shanks comes back to g14, over the version it replaced, which g14 keeps.
|
|
||||||
c.now = c.now.Add(time.Minute)
|
|
||||||
shanks.write(t, project, "notes/state.md", "ADR 0343 accepted.\n", c.now)
|
|
||||||
shanks.look(t)
|
|
||||||
state.pump(t)
|
|
||||||
if got, _ := g14.read(t, project, "notes/state.md"); got != "ADR 0343 accepted.\n" {
|
|
||||||
t.Fatalf("the change did not reach g14: %q", got)
|
|
||||||
}
|
|
||||||
if !holdsText(g14.copies(t), "ADR 0343 proposed.\n") {
|
|
||||||
t.Fatal("the version replaced on g14 was not kept")
|
|
||||||
}
|
|
||||||
if len(g14.notices)+len(shanks.notices) != 0 {
|
|
||||||
t.Fatalf("a plain change warned: %v %v", g14.notices, shanks.notices)
|
|
||||||
}
|
|
||||||
// No value in the state is anything but a version of a memory file, and no tool answer carries the text.
|
|
||||||
raw, _ := json.Marshal(g14.Status())
|
|
||||||
if strings.Contains(string(raw), "accepted") {
|
|
||||||
t.Fatalf("the memory tool answered the text: %s", raw)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// §1, §2: only the memory folders are carried; a symbolic link, an oversized file and a secret's shape are not, and are
|
|
||||||
// listed with why.
|
|
||||||
func TestOnlyTheMemoryFoldersAreCarried(t *testing.T) {
|
|
||||||
state, c, g14, shanks := twoNodes(t)
|
|
||||||
projectDir := filepath.Join(g14.Home, ".claude", "projects", project)
|
|
||||||
g14.write(t, project, "MEMORY.md", "index\n", c.now)
|
|
||||||
for path, text := range map[string]string{
|
|
||||||
filepath.Join(projectDir, "session.jsonl"): "{}\n",
|
|
||||||
filepath.Join(g14.Home, ".claude", "settings.json"): "{}\n",
|
|
||||||
filepath.Join(g14.Home, ".claude", "history.jsonl"): "{}\n",
|
|
||||||
} {
|
|
||||||
if err := os.WriteFile(path, []byte(text), 0o600); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
g14.write(t, project, "key.md", "-----BEGIN OPENSSH PRIVATE KEY-----\nabc\n", c.now)
|
|
||||||
g14.write(t, project, "url.md", "https://op:hunter2@git.example.org/x\n", c.now)
|
|
||||||
g14.write(t, project, "big.md", strings.Repeat("x", MemoryMostBytes+1), c.now)
|
|
||||||
g14.write(t, project, "hash.md", "head 1fe6ea4ee47379007598e163784dbd7ac50b7f79\n", c.now)
|
|
||||||
outside := filepath.Join(t.TempDir(), "elsewhere.md")
|
|
||||||
_ = os.WriteFile(outside, []byte("not memory\n"), 0o600)
|
|
||||||
if err := os.Symlink(outside, g14.path(project, "link.md")); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
g14.look(t)
|
|
||||||
state.pump(t)
|
|
||||||
for _, file := range []string{"key.md", "url.md", "big.md", "link.md"} {
|
|
||||||
if _, ok := shanks.read(t, project, file); ok {
|
|
||||||
t.Errorf("%s was carried", file)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, file := range []string{"MEMORY.md", "hash.md"} {
|
|
||||||
if _, ok := shanks.read(t, project, file); !ok {
|
|
||||||
t.Errorf("%s was not carried", file)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if _, err := os.Stat(filepath.Join(shanks.Home, ".claude", "projects", project, "session.jsonl")); err == nil {
|
|
||||||
t.Error("a session was carried")
|
|
||||||
}
|
|
||||||
for _, f := range []string{"settings.json", "history.jsonl"} {
|
|
||||||
if _, err := os.Stat(filepath.Join(shanks.Home, ".claude", f)); err == nil {
|
|
||||||
t.Errorf("%s was carried", f)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
keys, _ := state.Keys()
|
|
||||||
if len(keys) != 2 {
|
|
||||||
t.Fatalf("the state holds %d keys, not the two memory files: %v", len(keys), keys)
|
|
||||||
}
|
|
||||||
not, _ := g14.Status()["not carried"].(map[string]string)
|
|
||||||
for _, file := range []string{"key.md", "url.md", "big.md", "link.md"} {
|
|
||||||
if not[project+"/"+file] == "" {
|
|
||||||
t.Errorf("%s is not listed as not carried: %v", file, not)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// §4: the operator's homes and an agent account's homes never take each other's memory.
|
|
||||||
func TestHomesOfTheTwoKindsNeverTakeEachOthersMemory(t *testing.T) {
|
|
||||||
state, c := newMemState(), &clock{now: time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC)}
|
|
||||||
root := t.TempDir()
|
|
||||||
laptop := newMemNode(t, root, "g14", HomeOperator, state, c)
|
|
||||||
control := newMemNode(t, root, "novox", HomeAgent, state, c)
|
|
||||||
control.write(t, project, "MEMORY.md", "from a confined agent\n", c.now)
|
|
||||||
control.look(t)
|
|
||||||
state.pump(t)
|
|
||||||
if _, ok := laptop.read(t, project, "MEMORY.md"); ok {
|
|
||||||
t.Fatal("a confined agent's memory reached the operator's home")
|
|
||||||
}
|
|
||||||
laptop.write(t, project, "MEMORY.md", "the operator's\n", c.now)
|
|
||||||
laptop.look(t)
|
|
||||||
state.pump(t)
|
|
||||||
if got, _ := control.read(t, project, "MEMORY.md"); got != "from a confined agent\n" {
|
|
||||||
t.Fatalf("the operator's memory reached the agent account's home: %q", got)
|
|
||||||
}
|
|
||||||
// A value of the other kind put under this kind's key is refused too.
|
|
||||||
key := MemoryKey(HomeOperator, project, "x.md")
|
|
||||||
raw, _ := json.Marshal(MemoryVersion{Kind: HomeAgent, Project: project, Path: "x.md", Text: "x", Digest: digestOf([]byte("x")), Node: "novox"})
|
|
||||||
if err := laptop.Take(key, "put", raw, 99); err == nil {
|
|
||||||
t.Fatal("a value of another kind was taken")
|
|
||||||
}
|
|
||||||
// Which kind a node carries for.
|
|
||||||
if k, _ := HomeKind(Paths{Agent: "jochen", Operator: "jochen", Home: "/home/jochen"}); k != HomeOperator {
|
|
||||||
t.Fatalf("the operator's own home is %q", k)
|
|
||||||
}
|
|
||||||
if k, _ := HomeKind(Paths{Agent: "agent", Operator: "jochen", Home: "/home/agent", AgentHome: "/home/agent", OperatorHome: "/home/jochen"}); k != HomeAgent {
|
|
||||||
t.Fatalf("an agent account's home is %q", k)
|
|
||||||
}
|
|
||||||
if k, why := HomeKind(Paths{Agent: "agent", Operator: "jochen", Home: "/home/jochen", AgentHome: "/home/agent", OperatorHome: "/home/jochen"}); k != "" || why == "" {
|
|
||||||
t.Fatalf("a confined agent in the operator's home carries %q", k)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// §7, §8: a file changed on two nodes since it was carried is kept twice in one file on both, the newer first, with
|
|
||||||
// one warning; it clears once the file is tidied.
|
|
||||||
func TestAFileChangedOnTwoNodesIsKeptTwiceTheNewerFirstWithAWarning(t *testing.T) {
|
|
||||||
state, c, g14, shanks := twoNodes(t)
|
|
||||||
carriedOnBoth(t, state, c, g14, shanks, "MEMORY.md", "- base\n")
|
|
||||||
g14.write(t, project, "MEMORY.md", "- base\n- learned on g14\n", c.now.Add(time.Minute))
|
|
||||||
shanks.write(t, project, "MEMORY.md", "- base\n- learned on shanks\n", c.now.Add(2*time.Minute))
|
|
||||||
c.now = c.now.Add(3 * time.Minute)
|
|
||||||
g14.look(t)
|
|
||||||
shanks.look(t)
|
|
||||||
state.pump(t)
|
|
||||||
g14.look(t)
|
|
||||||
shanks.look(t)
|
|
||||||
state.pump(t)
|
|
||||||
a, _ := g14.read(t, project, "MEMORY.md")
|
|
||||||
b, _ := shanks.read(t, project, "MEMORY.md")
|
|
||||||
if a != b {
|
|
||||||
t.Fatalf("the nodes did not end on one text:\n%s\n----\n%s", a, b)
|
|
||||||
}
|
|
||||||
newer, older := strings.Index(a, "learned on shanks"), strings.Index(a, "learned on g14")
|
|
||||||
marker := strings.Index(a, "<!-- novox mesh: this file was changed on two nodes")
|
|
||||||
if newer < 0 || older < 0 || marker < 0 || !(newer < marker && marker < older) {
|
|
||||||
t.Fatalf("not both versions, the newer first, with the line between:\n%s", a)
|
|
||||||
}
|
|
||||||
if !strings.Contains(a[marker:], "changed on shanks") || !strings.Contains(a[marker:], "changed on g14") {
|
|
||||||
t.Fatalf("the line does not name both nodes:\n%s", a)
|
|
||||||
}
|
|
||||||
notices := append(append([]map[string]any{}, g14.notices...), shanks.notices...)
|
|
||||||
if len(notices) != 1 || notices[0]["severity"] != "warning" || notices[0]["clear"] != nil {
|
|
||||||
t.Fatalf("not one warning: %v", notices)
|
|
||||||
}
|
|
||||||
for _, field := range []string{"headline", "explanation", "summary", "resolved"} {
|
|
||||||
s, _ := notices[0][field].(string)
|
|
||||||
if s == "" || strings.ContainsAny(s, "/~:") && field != "explanation" || strings.Contains(s, ".md") {
|
|
||||||
t.Errorf("the warning's %s is empty or names a path: %q", field, s)
|
|
||||||
}
|
|
||||||
// The plain rule (ADR 0253 §3): it never sends the operator elsewhere.
|
|
||||||
if field != "summary" && (strings.Contains(strings.ToLower(s), "agent") || strings.Contains(strings.ToLower(s), "session")) {
|
|
||||||
t.Errorf("the warning's %s sends the operator elsewhere: %q", field, s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if e, _ := notices[0]["explanation"].(string); !strings.HasPrefix(e, "Needs you: ") {
|
|
||||||
t.Errorf("the explanation does not open with its verdict: %q", e)
|
|
||||||
}
|
|
||||||
if h, _ := notices[0]["headline"].(string); len([]rune(h)) > 60 {
|
|
||||||
t.Errorf("the headline is longer than sixty characters: %q", h)
|
|
||||||
}
|
|
||||||
all := append(g14.copies(t), shanks.copies(t)...)
|
|
||||||
for _, v := range []string{"- base\n- learned on g14\n", "- base\n- learned on shanks\n"} {
|
|
||||||
if !holdsText(all, v) {
|
|
||||||
t.Errorf("the version %q was not kept", v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if st := g14.Status(); len(st["conflicts"].([]conflict))+len(shanks.Status()["conflicts"].([]conflict)) != 1 {
|
|
||||||
t.Fatalf("the conflict is not listed: %v", st)
|
|
||||||
}
|
|
||||||
// Tidied on either node, the warning clears.
|
|
||||||
c.now = c.now.Add(time.Minute)
|
|
||||||
g14.write(t, project, "MEMORY.md", "- base\n- learned on g14\n- learned on shanks\n", c.now)
|
|
||||||
g14.look(t)
|
|
||||||
state.pump(t)
|
|
||||||
cleared := false
|
|
||||||
for _, n := range append(g14.notices, shanks.notices...) {
|
|
||||||
if n["clear"] == true {
|
|
||||||
cleared = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !cleared {
|
|
||||||
t.Fatal("the warning did not clear once the file was tidied")
|
|
||||||
}
|
|
||||||
if got, _ := shanks.read(t, project, "MEMORY.md"); got != "- base\n- learned on g14\n- learned on shanks\n" {
|
|
||||||
t.Fatalf("the tidied file did not reach shanks: %q", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The order of the two versions: the later change first, equal times by node name.
|
|
||||||
func TestMergeVersionsPutsTheNewerFirst(t *testing.T) {
|
|
||||||
at := time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC)
|
|
||||||
m := string(MergeVersions([]byte("old\n"), "g14", at, []byte("new\n"), "shanks", at.Add(time.Second)))
|
|
||||||
if strings.Index(m, "new") > strings.Index(m, "old") {
|
|
||||||
t.Fatalf("the newer is not first: %s", m)
|
|
||||||
}
|
|
||||||
m = string(MergeVersions([]byte("from shanks\n"), "shanks", at, []byte("from ace\n"), "ace", at))
|
|
||||||
if strings.Index(m, "from ace") > strings.Index(m, "from shanks") {
|
|
||||||
t.Fatalf("equal times are not ordered by node name: %s", m)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// §9: a removal moves the other node's unchanged file into the copies.
|
|
||||||
func TestARemovalMovesTheUnchangedFileIntoTheCopies(t *testing.T) {
|
|
||||||
state, c, g14, shanks := twoNodes(t)
|
|
||||||
carriedOnBoth(t, state, c, g14, shanks, "old.md", "an old note\n")
|
|
||||||
carriedOnBoth(t, state, c, g14, shanks, "MEMORY.md", "index\n")
|
|
||||||
if err := os.Remove(g14.path(project, "old.md")); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
g14.look(t)
|
|
||||||
state.pump(t)
|
|
||||||
if _, ok := shanks.read(t, project, "old.md"); ok {
|
|
||||||
t.Fatal("the removal did not reach shanks")
|
|
||||||
}
|
|
||||||
if !holdsText(shanks.copies(t), "an old note\n") {
|
|
||||||
t.Fatal("the removed version was not kept on shanks")
|
|
||||||
}
|
|
||||||
raw, _, ok, _ := state.Get(MemoryKey(HomeOperator, project, "old.md"))
|
|
||||||
var v MemoryVersion
|
|
||||||
_ = json.Unmarshal(raw, &v)
|
|
||||||
if !ok || !v.Removed || v.Text != "" {
|
|
||||||
t.Fatalf("the removal is not a marker in the state: %s", raw)
|
|
||||||
}
|
|
||||||
if got, _ := shanks.read(t, project, "MEMORY.md"); got != "index\n" {
|
|
||||||
t.Fatal("another file was touched")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// §9: a change outlives a removal, whichever reaches the state first.
|
|
||||||
func TestAChangeOutlivesARemoval(t *testing.T) {
|
|
||||||
for _, removalFirst := range []bool{true, false} {
|
|
||||||
state, c, g14, shanks := twoNodes(t)
|
|
||||||
carriedOnBoth(t, state, c, g14, shanks, "note.md", "v1\n")
|
|
||||||
_ = os.Remove(g14.path(project, "note.md"))
|
|
||||||
shanks.write(t, project, "note.md", "v2\n", c.now.Add(time.Minute))
|
|
||||||
if removalFirst {
|
|
||||||
g14.look(t)
|
|
||||||
state.pump(t)
|
|
||||||
shanks.look(t)
|
|
||||||
} else {
|
|
||||||
shanks.look(t)
|
|
||||||
g14.look(t)
|
|
||||||
}
|
|
||||||
state.pump(t)
|
|
||||||
g14.look(t)
|
|
||||||
shanks.look(t)
|
|
||||||
state.pump(t)
|
|
||||||
for _, n := range []*memNode{g14, shanks} {
|
|
||||||
if got, _ := n.read(t, project, "note.md"); got != "v2\n" {
|
|
||||||
t.Fatalf("removal first %v: the change did not outlive the removal on %s: %q", removalFirst, n.Node, got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// §9: a missing folder, a state that lost its keys and a deleted key remove nothing.
|
|
||||||
func TestAMissingFolderOrAnAbsentKeyRemovesNothing(t *testing.T) {
|
|
||||||
state, c, g14, shanks := twoNodes(t)
|
|
||||||
carriedOnBoth(t, state, c, g14, shanks, "MEMORY.md", "index\n")
|
|
||||||
key := MemoryKey(HomeOperator, project, "MEMORY.md")
|
|
||||||
// The folder gone on g14.
|
|
||||||
if err := os.RemoveAll(filepath.Join(g14.Home, ".claude", "projects", project, "memory")); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
g14.look(t)
|
|
||||||
state.pump(t)
|
|
||||||
if got, _ := shanks.read(t, project, "MEMORY.md"); got != "index\n" {
|
|
||||||
t.Fatal("a missing folder removed the file elsewhere")
|
|
||||||
}
|
|
||||||
// A deleted key.
|
|
||||||
_ = state.Delete(key)
|
|
||||||
state.pump(t)
|
|
||||||
if got, _ := shanks.read(t, project, "MEMORY.md"); got != "index\n" {
|
|
||||||
t.Fatal("a deleted key removed the file")
|
|
||||||
}
|
|
||||||
// The state lost: the file is carried again, not removed.
|
|
||||||
shanks.look(t)
|
|
||||||
state.pump(t)
|
|
||||||
if _, _, ok, _ := state.Get(key); !ok {
|
|
||||||
t.Fatal("a file whose key the state lost was not carried again")
|
|
||||||
}
|
|
||||||
if got, _ := shanks.read(t, project, "MEMORY.md"); got != "index\n" {
|
|
||||||
t.Fatalf("the file was touched on shanks: %q", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// §10: on a first carrying, two nodes' different versions are both kept, and each node's own files reach the other.
|
|
||||||
func TestTheFirstCarryingKeepsEveryVersion(t *testing.T) {
|
|
||||||
state, c, g14, shanks := twoNodes(t)
|
|
||||||
g14.write(t, project, "MEMORY.md", "- laptop's index\n", c.now)
|
|
||||||
g14.write(t, project, "laptop.md", "only on the laptop\n", c.now)
|
|
||||||
shanks.write(t, project, "MEMORY.md", "- workstation's index\n", c.now.Add(time.Hour))
|
|
||||||
shanks.write(t, project, "workstation.md", "only on the workstation\n", c.now)
|
|
||||||
shanks.write(t, project, "same.md", "same\n", c.now)
|
|
||||||
g14.write(t, project, "same.md", "same\n", c.now)
|
|
||||||
g14.look(t)
|
|
||||||
state.pump(t)
|
|
||||||
shanks.look(t)
|
|
||||||
state.pump(t)
|
|
||||||
g14.look(t)
|
|
||||||
state.pump(t)
|
|
||||||
for _, n := range []*memNode{g14, shanks} {
|
|
||||||
index, _ := n.read(t, project, "MEMORY.md")
|
|
||||||
if !strings.Contains(index, "laptop's index") || !strings.Contains(index, "workstation's index") ||
|
|
||||||
strings.Index(index, "workstation's index") > strings.Index(index, "laptop's index") {
|
|
||||||
t.Fatalf("%s's index does not hold both, the newer first:\n%s", n.Node, index)
|
|
||||||
}
|
|
||||||
for file, want := range map[string]string{"laptop.md": "only on the laptop\n", "workstation.md": "only on the workstation\n", "same.md": "same\n"} {
|
|
||||||
if got, _ := n.read(t, project, file); got != want {
|
|
||||||
t.Fatalf("%s's %s: %q", n.Node, file, got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if n := len(g14.notices) + len(shanks.notices); n != 1 {
|
|
||||||
t.Fatalf("%d warnings for one conflict", n)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Nothing is written through a symbolic link: a memory folder that is one is neither read nor written.
|
|
||||||
func TestNothingIsWrittenThroughASymbolicLink(t *testing.T) {
|
|
||||||
state, c, g14, shanks := twoNodes(t)
|
|
||||||
elsewhere := t.TempDir()
|
|
||||||
if err := os.MkdirAll(filepath.Join(shanks.Home, ".claude", "projects", project), 0o700); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := os.Symlink(elsewhere, filepath.Join(shanks.Home, ".claude", "projects", project, "memory")); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
g14.write(t, project, "MEMORY.md", "index\n", c.now)
|
|
||||||
g14.look(t)
|
|
||||||
state.pump(t)
|
|
||||||
if entries, _ := os.ReadDir(elsewhere); len(entries) != 0 {
|
|
||||||
t.Fatalf("written through a symbolic link: %v", entries)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The manifest declares the state and the tool; the tool reads and never writes.
|
|
||||||
func TestTheManifestDeclaresTheMemoryState(t *testing.T) {
|
|
||||||
raw, err := os.ReadFile("../../module.json")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
var m struct {
|
|
||||||
State []string `json:"state"`
|
|
||||||
}
|
|
||||||
_ = json.Unmarshal(raw, &m)
|
|
||||||
if !holdsText(m.State, MemoryState) {
|
|
||||||
t.Fatalf("the manifest does not declare the %q state: %v", MemoryState, m.State)
|
|
||||||
}
|
|
||||||
var carrier *MemoryCarrier
|
|
||||||
if st := carrier.Status(); st["carrying"] != false {
|
|
||||||
t.Fatalf("a carrier not started says it carries: %v", st)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// replay hands one node the whole current set, as a watch begins when the node comes back.
|
|
||||||
func (s *memState) replay(t *testing.T, n *memNode) {
|
|
||||||
t.Helper()
|
|
||||||
keys, _ := s.Keys()
|
|
||||||
for _, k := range keys {
|
|
||||||
raw, rev, _, _ := s.Get(k)
|
|
||||||
if err := n.Take(k, "put", raw, rev); err != nil {
|
|
||||||
t.Logf("%s taking %s: %v", n.Node, k, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := n.Drain(); err != nil && !errors.Is(err, errChanged) {
|
|
||||||
t.Logf("%s applying: %v", n.Node, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// §6: a node that was away while a file changed twice takes the newest version, whose base it never saw, because
|
|
||||||
// its own file holds no change it did not carry; the version replaced is kept.
|
|
||||||
func TestANodeThatWasAwayTakesTheNewestVersion(t *testing.T) {
|
|
||||||
state, c, g14, shanks := twoNodes(t)
|
|
||||||
carriedOnBoth(t, state, c, g14, shanks, "MEMORY.md", "v1\n")
|
|
||||||
state.watchers = []*MemoryCarrier{g14.MemoryCarrier}
|
|
||||||
for _, v := range []string{"v2\n", "v3\n"} {
|
|
||||||
c.now = c.now.Add(time.Minute)
|
|
||||||
g14.write(t, project, "MEMORY.md", v, c.now)
|
|
||||||
g14.look(t)
|
|
||||||
state.pump(t)
|
|
||||||
}
|
|
||||||
state.watchers = append(state.watchers, shanks.MemoryCarrier)
|
|
||||||
state.replay(t, shanks)
|
|
||||||
if got, _ := shanks.read(t, project, "MEMORY.md"); got != "v3\n" {
|
|
||||||
t.Fatalf("the node that was away holds %q", got)
|
|
||||||
}
|
|
||||||
if !holdsText(shanks.copies(t), "v1\n") {
|
|
||||||
t.Fatal("the version replaced was not kept")
|
|
||||||
}
|
|
||||||
if len(g14.notices)+len(shanks.notices) != 0 {
|
|
||||||
t.Fatal("taking a newer version warned")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// §2, §7: two versions too large to share one file are kept in two: the newer in the file, the older beside it,
|
|
||||||
// both carried; and a text that would not fit in one value of the state is not carried.
|
|
||||||
func TestTwoVersionsTooLargeForOneFileAreKeptInTwo(t *testing.T) {
|
|
||||||
state, c, g14, shanks := twoNodes(t)
|
|
||||||
carriedOnBoth(t, state, c, g14, shanks, "big.md", "base\n")
|
|
||||||
older := strings.Repeat("from g14 ", MemoryMostBytes/9-100) + "\n"
|
|
||||||
newer := strings.Repeat("from shanks ", MemoryMostBytes/12-100) + "\n"
|
|
||||||
g14.write(t, project, "big.md", older, c.now.Add(time.Minute))
|
|
||||||
shanks.write(t, project, "big.md", newer, c.now.Add(2*time.Minute))
|
|
||||||
c.now = c.now.Add(3 * time.Minute)
|
|
||||||
g14.look(t)
|
|
||||||
shanks.look(t)
|
|
||||||
state.pump(t)
|
|
||||||
g14.look(t)
|
|
||||||
shanks.look(t)
|
|
||||||
state.pump(t)
|
|
||||||
beside := OlderBeside("big.md", "g14")
|
|
||||||
if beside != "big.older-from-g14.md" {
|
|
||||||
t.Fatalf("the older is named %q", beside)
|
|
||||||
}
|
|
||||||
for _, n := range []*memNode{g14, shanks} {
|
|
||||||
if got, _ := n.read(t, project, "big.md"); got != newer {
|
|
||||||
t.Fatalf("%s's file does not hold the newer version", n.Node)
|
|
||||||
}
|
|
||||||
if got, _ := n.read(t, project, beside); got != older {
|
|
||||||
t.Fatalf("%s does not hold the older version beside it", n.Node)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
notices := append(append([]map[string]any{}, g14.notices...), shanks.notices...)
|
|
||||||
if len(notices) != 1 || !strings.Contains(notices[0]["explanation"].(string), "second file beside it") {
|
|
||||||
t.Fatalf("not one warning saying the older is beside it: %v", notices)
|
|
||||||
}
|
|
||||||
// Under the size limit, but too large once encoded: not carried, and said.
|
|
||||||
g14.write(t, project, "angles.md", strings.Repeat("<", MemoryMostBytes-10), c.now)
|
|
||||||
g14.look(t)
|
|
||||||
state.pump(t)
|
|
||||||
if _, ok := shanks.read(t, project, "angles.md"); ok {
|
|
||||||
t.Fatal("a text too large for one value was carried")
|
|
||||||
}
|
|
||||||
not, _ := g14.Status()["not carried"].(map[string]string)
|
|
||||||
if not[project+"/angles.md"] == "" {
|
|
||||||
t.Fatalf("it is not listed as not carried: %v", not)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// stateHolds says whether any value of the state holds s.
|
|
||||||
func (s *memState) holds(sub string) bool {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
for _, v := range s.values {
|
|
||||||
var mv MemoryVersion
|
|
||||||
_ = json.Unmarshal(v, &mv)
|
|
||||||
if strings.Contains(mv.Text, sub) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// putRaw puts a value straight into the state, as a holder of a tool runner's credential could.
|
|
||||||
func (s *memState) putRaw(t *testing.T, v MemoryVersion) string {
|
|
||||||
t.Helper()
|
|
||||||
key := MemoryKey(v.Kind, v.Project, v.Path)
|
|
||||||
s.mu.Lock()
|
|
||||||
s.write(key, v)
|
|
||||||
s.mu.Unlock()
|
|
||||||
return key
|
|
||||||
}
|
|
||||||
|
|
||||||
// Review of #276, 1: a value whose project or path leads out of a memory folder is never taken, and nothing is
|
|
||||||
// written for it.
|
|
||||||
func TestAValueLeadingOutOfAMemoryFolderIsNeverTaken(t *testing.T) {
|
|
||||||
state, c, g14, shanks := twoNodes(t)
|
|
||||||
root := filepath.Dir(filepath.Dir(shanks.Home))
|
|
||||||
for _, bad := range []struct{ project, path string }{
|
|
||||||
{"../../../escaped", "x.md"}, {"..", "x.md"}, {"a/b", "x.md"}, {"", "x.md"},
|
|
||||||
{project, "../x.md"}, {project, "/abs.md"}, {project, "a//x.md"}, {project, "./x.md"}, {project, "a/../../x.md"},
|
|
||||||
} {
|
|
||||||
text := "planted\n"
|
|
||||||
state.putRaw(t, MemoryVersion{Kind: HomeOperator, Project: bad.project, Path: bad.path, Text: text,
|
|
||||||
Digest: digestOf([]byte(text)), Node: "elsewhere", Changed: c.now})
|
|
||||||
if NameProblem(bad.project, bad.path) == "" {
|
|
||||||
t.Errorf("%q %q is taken as a name", bad.project, bad.path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
state.pump(t)
|
|
||||||
g14.look(t)
|
|
||||||
shanks.look(t)
|
|
||||||
_ = filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error {
|
|
||||||
if err == nil && !d.IsDir() {
|
|
||||||
if raw, _ := os.ReadFile(p); string(raw) == "planted\n" {
|
|
||||||
t.Errorf("a planted value was written at %s", p)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
if NameProblem(project, "notes/a.md") != "" || NameProblem(project, "MEMORY.md") != "" {
|
|
||||||
t.Fatal("a memory file's own name is refused")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Review of #276, 2: a file or a folder that cannot be read is kept everywhere, never carried as a removal, and is
|
|
||||||
// said as not carried.
|
|
||||||
func TestWhatCannotBeReadIsNeverTakenForGone(t *testing.T) {
|
|
||||||
if os.Geteuid() == 0 {
|
|
||||||
t.Skip("root reads a file whatever its mode")
|
|
||||||
}
|
|
||||||
state, c, g14, shanks := twoNodes(t)
|
|
||||||
carriedOnBoth(t, state, c, g14, shanks, "MEMORY.md", "index\n")
|
|
||||||
carriedOnBoth(t, state, c, g14, shanks, "notes/deep.md", "deep\n")
|
|
||||||
file, folder := g14.path(project, "MEMORY.md"), filepath.Dir(g14.path(project, "notes/deep.md"))
|
|
||||||
_ = os.Chmod(file, 0)
|
|
||||||
_ = os.Chmod(folder, 0)
|
|
||||||
t.Cleanup(func() { _ = os.Chmod(file, 0o644); _ = os.Chmod(folder, 0o755) })
|
|
||||||
g14.look(t)
|
|
||||||
state.pump(t)
|
|
||||||
for _, f := range []string{"MEMORY.md", "notes/deep.md"} {
|
|
||||||
if _, ok := shanks.read(t, project, f); !ok {
|
|
||||||
t.Errorf("%s, unreadable on g14, was removed on shanks", f)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
not, _ := g14.Status()["not carried"].(map[string]string)
|
|
||||||
if len(not) == 0 {
|
|
||||||
t.Fatalf("nothing is said as not carried: %v", not)
|
|
||||||
}
|
|
||||||
keys, _ := state.Keys()
|
|
||||||
for _, k := range keys {
|
|
||||||
raw, _, _, _ := state.Get(k)
|
|
||||||
var v MemoryVersion
|
|
||||||
_ = json.Unmarshal(raw, &v)
|
|
||||||
if v.Removed {
|
|
||||||
t.Errorf("%s/%s was carried as a removal", v.Project, v.Path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Review of #276, 3: no text reaches the state without being screened, on the removal and the conflict paths too.
|
|
||||||
func TestNoUnscreenedTextReachesTheState(t *testing.T) {
|
|
||||||
token := "ghp_" + strings.Repeat("A1b2C3d4", 5) + "\n"
|
|
||||||
big := strings.Repeat("x", MemoryMostBytes+10) + "\n"
|
|
||||||
for _, text := range []string{token, big} {
|
|
||||||
// The removal path: removed on g14, changed on shanks to what is never carried.
|
|
||||||
state, c, g14, shanks := twoNodes(t)
|
|
||||||
carriedOnBoth(t, state, c, g14, shanks, "note.md", "v1\n")
|
|
||||||
_ = os.Remove(g14.path(project, "note.md"))
|
|
||||||
shanks.write(t, project, "note.md", text, c.now.Add(time.Minute))
|
|
||||||
g14.look(t)
|
|
||||||
state.pump(t)
|
|
||||||
if state.holds(text[:20]) {
|
|
||||||
t.Errorf("the removal path put text that is never carried into the state")
|
|
||||||
}
|
|
||||||
if got, _ := shanks.read(t, project, "note.md"); got != text {
|
|
||||||
t.Errorf("the change on shanks was not kept")
|
|
||||||
}
|
|
||||||
// The conflict path: changed on g14, and on shanks to what is never carried.
|
|
||||||
state, c, g14, shanks = twoNodes(t)
|
|
||||||
carriedOnBoth(t, state, c, g14, shanks, "note.md", "v1\n")
|
|
||||||
g14.write(t, project, "note.md", "v2\n", c.now.Add(time.Minute))
|
|
||||||
shanks.write(t, project, "note.md", text, c.now.Add(2*time.Minute))
|
|
||||||
g14.look(t)
|
|
||||||
state.pump(t)
|
|
||||||
shanks.look(t)
|
|
||||||
state.pump(t)
|
|
||||||
if state.holds(text[:20]) {
|
|
||||||
t.Errorf("the conflict path put text that is never carried into the state")
|
|
||||||
}
|
|
||||||
if got, _ := shanks.read(t, project, "note.md"); got != text {
|
|
||||||
t.Errorf("shanks's own version was not kept as it was")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Review of #276, 4: a conflict whose file cannot be written leaves the state as it was, says so once, and merges
|
|
||||||
// once when the folder is writable again.
|
|
||||||
func TestAConflictThatCannotBeWrittenIsNotMergedAgainAndAgain(t *testing.T) {
|
|
||||||
if os.Geteuid() == 0 {
|
|
||||||
t.Skip("root writes a folder whatever its mode")
|
|
||||||
}
|
|
||||||
state, c, g14, shanks := twoNodes(t)
|
|
||||||
carriedOnBoth(t, state, c, g14, shanks, "MEMORY.md", "base\n")
|
|
||||||
g14.write(t, project, "MEMORY.md", "base\nfrom g14\n", c.now.Add(time.Minute))
|
|
||||||
shanks.write(t, project, "MEMORY.md", "base\nfrom shanks\n", c.now.Add(2*time.Minute))
|
|
||||||
folder := filepath.Dir(shanks.path(project, "MEMORY.md"))
|
|
||||||
_ = os.Chmod(folder, 0o500)
|
|
||||||
t.Cleanup(func() { _ = os.Chmod(folder, 0o700) })
|
|
||||||
g14.look(t)
|
|
||||||
state.pump(t)
|
|
||||||
for i := 0; i < 5; i++ {
|
|
||||||
_ = shanks.Look()
|
|
||||||
state.pump(t)
|
|
||||||
}
|
|
||||||
if state.holds("<!-- novox mesh") {
|
|
||||||
t.Fatal("a merge that could not be written here reached the state")
|
|
||||||
}
|
|
||||||
unwritten := 0
|
|
||||||
for _, n := range shanks.notices {
|
|
||||||
if strings.HasSuffix(n["key"].(string), ".unwritten") {
|
|
||||||
unwritten++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if unwritten != 1 {
|
|
||||||
t.Fatalf("%d warnings that the file could not be written", unwritten)
|
|
||||||
}
|
|
||||||
_ = os.Chmod(folder, 0o700)
|
|
||||||
shanks.look(t)
|
|
||||||
state.pump(t)
|
|
||||||
g14.look(t)
|
|
||||||
state.pump(t)
|
|
||||||
for _, n := range []*memNode{g14, shanks} {
|
|
||||||
got, _ := n.read(t, project, "MEMORY.md")
|
|
||||||
if strings.Count(got, "<!-- novox mesh") != 1 || !strings.Contains(got, "from g14") || !strings.Contains(got, "from shanks") {
|
|
||||||
t.Fatalf("%s does not hold both versions once:\n%s", n.Node, got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Review of #276, minor: a file that changed between its reading and its writing is not written over; text that is
|
|
||||||
// not UTF-8 is not carried.
|
|
||||||
func TestAFileChangedBeforeItsWritingIsNotWrittenOver(t *testing.T) {
|
|
||||||
state, c, g14, shanks := twoNodes(t)
|
|
||||||
_ = state
|
|
||||||
g14.write(t, project, "a.md", "read\n", c.now)
|
|
||||||
g14.write(t, project, "a.md", "changed meanwhile\n", c.now)
|
|
||||||
err := g14.writeLocal(project, "a.md", []byte("incoming\n"), "replaced", digestOf([]byte("read\n")))
|
|
||||||
if !errors.Is(err, errChangedHere) {
|
|
||||||
t.Fatalf("written over a change: %v", err)
|
|
||||||
}
|
|
||||||
if got, _ := g14.read(t, project, "a.md"); got != "changed meanwhile\n" {
|
|
||||||
t.Fatalf("the change was replaced: %q", got)
|
|
||||||
}
|
|
||||||
g14.write(t, project, "latin1.md", "caf\xe9\n", c.now)
|
|
||||||
g14.look(t)
|
|
||||||
state.pump(t)
|
|
||||||
if _, ok := shanks.read(t, project, "latin1.md"); ok {
|
|
||||||
t.Fatal("text that is not UTF-8 was carried")
|
|
||||||
}
|
|
||||||
not, _ := g14.Status()["not carried"].(map[string]string)
|
|
||||||
if !strings.Contains(not[project+"/latin1.md"], "UTF-8") {
|
|
||||||
t.Fatalf("it is not said why: %v", not)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// §9: a node that was away takes a removal of a version newer than the one it holds, when it holds no change of
|
|
||||||
// its own: the removed file does not come back.
|
|
||||||
func TestARemovalReachesANodeThatWasAway(t *testing.T) {
|
|
||||||
state, c, g14, shanks := twoNodes(t)
|
|
||||||
carriedOnBoth(t, state, c, g14, shanks, "old.md", "v1\n")
|
|
||||||
state.watchers = []*MemoryCarrier{g14.MemoryCarrier}
|
|
||||||
c.now = c.now.Add(time.Minute)
|
|
||||||
g14.write(t, project, "old.md", "v2\n", c.now)
|
|
||||||
g14.look(t)
|
|
||||||
state.pump(t)
|
|
||||||
_ = os.Remove(g14.path(project, "old.md"))
|
|
||||||
g14.look(t)
|
|
||||||
state.pump(t)
|
|
||||||
state.watchers = append(state.watchers, shanks.MemoryCarrier)
|
|
||||||
state.replay(t, shanks)
|
|
||||||
shanks.look(t)
|
|
||||||
state.pump(t)
|
|
||||||
g14.look(t)
|
|
||||||
if _, ok := shanks.read(t, project, "old.md"); ok {
|
|
||||||
t.Fatal("the removal did not reach the node that was away")
|
|
||||||
}
|
|
||||||
if _, ok := g14.read(t, project, "old.md"); ok {
|
|
||||||
t.Fatal("the removed file came back")
|
|
||||||
}
|
|
||||||
if !holdsText(shanks.copies(t), "v1\n") {
|
|
||||||
t.Fatal("the version removed on the node that was away was not kept")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// slowState answers every read only once it is let go, as a state a whole bus away does under load.
|
|
||||||
type slowState struct {
|
|
||||||
*memState
|
|
||||||
gate chan struct{}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s slowState) Get(key string) (json.RawMessage, uint64, bool, error) {
|
|
||||||
<-s.gate
|
|
||||||
return s.memState.Get(key)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Issue 367, seen live: the runtime hands a watch its current values one at a time and gives them all ten seconds;
|
|
||||||
// shanks's handler read the state and waited for its look for each of a few hundred values, so the watch never
|
|
||||||
// started and nothing written on g14 reached it. The handler now only notes a change: it returns at once while a
|
|
||||||
// look holds the carrier and while the state is slow to answer, and Drain applies what it noted.
|
|
||||||
func TestTheWatchHandlerReturnsAtOnceAndDrainAppliesWhatItNoted(t *testing.T) {
|
|
||||||
state, c, g14, shanks := twoNodes(t)
|
|
||||||
g14.write(t, project, "MEMORY.md", "index\n", c.now)
|
|
||||||
g14.look(t)
|
|
||||||
state.mu.Lock()
|
|
||||||
state.pending = nil
|
|
||||||
state.mu.Unlock()
|
|
||||||
slow := slowState{memState: state, gate: make(chan struct{})}
|
|
||||||
shanks.Store = slow
|
|
||||||
key := MemoryKey(HomeOperator, project, "MEMORY.md")
|
|
||||||
raw, rev, _, _ := state.Get(key)
|
|
||||||
shanks.mu.Lock() // a look running
|
|
||||||
done := make(chan error, 1)
|
|
||||||
start := time.Now()
|
|
||||||
for i := 0; i < 400; i++ {
|
|
||||||
go func() { done <- shanks.Take(key, "put", raw, rev) }()
|
|
||||||
}
|
|
||||||
for i := 0; i < 400; i++ {
|
|
||||||
select {
|
|
||||||
case err := <-done:
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
case <-time.After(2 * time.Second):
|
|
||||||
t.Fatalf("the watch handler waited for the look or the state (%d of 400 returned)", i)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if took := time.Since(start); took > time.Second {
|
|
||||||
t.Fatalf("400 values took %s to hand over", took)
|
|
||||||
}
|
|
||||||
shanks.mu.Unlock()
|
|
||||||
close(slow.gate)
|
|
||||||
if err := shanks.Drain(); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if got, _ := shanks.read(t, project, "MEMORY.md"); got != "index\n" {
|
|
||||||
t.Fatalf("what the watch noted was not applied: %q", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -216,9 +216,6 @@ func RenderNow(p Paths, write WriteManaged) ([]string, error) {
|
|||||||
if !readJSON(p.Facts, &facts) || facts.Console == "" {
|
if !readJSON(p.Facts, &facts) || facts.Console == "" {
|
||||||
return nil, fmt.Errorf("the mesh has not rendered %s yet; nothing to write", p.Facts)
|
return nil, fmt.Errorf("the mesh has not rendered %s yet; nothing to write", p.Facts)
|
||||||
}
|
}
|
||||||
if ForgeCredentialHeld(p) {
|
|
||||||
facts.ForgeCredentials = p.forgeCredentials()
|
|
||||||
}
|
|
||||||
var settings Settings
|
var settings Settings
|
||||||
readJSON(p.Settings, &settings)
|
readJSON(p.Settings, &settings)
|
||||||
var binding *Binding
|
var binding *Binding
|
||||||
@@ -301,9 +298,6 @@ type BindingState struct {
|
|||||||
Licence string `json:"licence"`
|
Licence string `json:"licence"`
|
||||||
Kind string `json:"kind"`
|
Kind string `json:"kind"`
|
||||||
Generation int64 `json:"generation"`
|
Generation int64 `json:"generation"`
|
||||||
// Why and By are what moved the binding and who asked, as the manager recorded it (novox/hq issue 625).
|
|
||||||
Why string `json:"why,omitempty"`
|
|
||||||
By string `json:"by,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Current is what the seat answers to `current`: the licence this node is bound to and its token, sealed.
|
// Current is what the seat answers to `current`: the licence this node is bound to and its token, sealed.
|
||||||
@@ -439,15 +433,6 @@ func OnBinding(p Paths, b *BindingState, ask Ask, write WriteManaged) (string, e
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
// The line says which binding this was and why the manager moved it, so a node's journal alone answers
|
|
||||||
// what changed here and who asked (novox/hq issue 625).
|
|
||||||
out["generation"] = b.Generation
|
|
||||||
if b.Why != "" {
|
|
||||||
out["why"] = b.Why
|
|
||||||
}
|
|
||||||
if b.By != "" {
|
|
||||||
out["by"] = b.By
|
|
||||||
}
|
|
||||||
raw, _ := json.Marshal(out)
|
raw, _ := json.Marshal(out)
|
||||||
return string(raw), nil
|
return string(raw), nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ func proposalTools(p Paths) []stdio.Tool {
|
|||||||
text, _ = a["content"].(string)
|
text, _ = a["content"].(string)
|
||||||
}
|
}
|
||||||
return Propose(p, ProposeInput{Scope: scopeOf(a), Nodes: nodesOf(a["nodes"]), Name: strArg(a, "name"),
|
return Propose(p, ProposeInput{Scope: scopeOf(a), Nodes: nodesOf(a["nodes"]), Name: strArg(a, "name"),
|
||||||
Text: text, Remove: boolArg(a, "remove"), Summary: strArg(a, "summary")}, proposing.Store, proposing.Bus, proposing.Now())
|
Text: text, Remove: boolArg(a, "remove")}, proposing.Store, proposing.Bus, proposing.Now())
|
||||||
}},
|
}},
|
||||||
{Name: "claude_code_proposals",
|
{Name: "claude_code_proposals",
|
||||||
Description: "The instruction sections proposed for the operator's approval (novox/hq ADR 0274), newest first: what " +
|
Description: "The instruction sections proposed for the operator's approval (novox/hq ADR 0274), newest first: what " +
|
||||||
@@ -164,14 +164,14 @@ func questionTools(p Paths, notLaunched error) []stdio.Tool {
|
|||||||
"operator's word to you; it authorises no call that needs the operator's approval.",
|
"operator's word to you; it authorises no call that needs the operator's approval.",
|
||||||
Input: map[string]any{
|
Input: map[string]any{
|
||||||
"headline": str("what is decided, in a few plain words, at most 60 characters, e.g. 'Merge the resolver fix tonight?'"),
|
"headline": str("what is decided, in a few plain words, at most 60 characters, e.g. 'Merge the resolver fix tonight?'"),
|
||||||
"context": str("what the operator decides and why it matters, in plain words: two or three short sentences, one line, at most 300 characters"),
|
"context": str("what the operator decides and why it matters, in plain words, at most 1500 bytes"),
|
||||||
"options": map[string]any{"type": "array", "description": "two to four answers; exactly one recommended",
|
"options": map[string]any{"type": "array", "description": "two to four answers; exactly one recommended",
|
||||||
"items": map[string]any{"type": "object", "properties": map[string]any{
|
"items": map[string]any{"type": "object", "properties": map[string]any{
|
||||||
"label": str("the button's words, at most 24 characters"),
|
"label": str("the button's words, at most 24 characters"),
|
||||||
"means": str("what you do when it is chosen, in plain words"),
|
"means": str("what you do when it is chosen, in plain words"),
|
||||||
"recommended": map[string]any{"type": "boolean", "description": "true on the one you recommend"},
|
"recommended": map[string]any{"type": "boolean", "description": "true on the one you recommend"},
|
||||||
}, "required": []string{"label", "means"}}},
|
}, "required": []string{"label", "means"}}},
|
||||||
"why": str("why the recommended option is the one you recommend, at most 120 bytes"),
|
"why": str("why the recommended option is the one you recommend"),
|
||||||
"on_expiry": str("what you do if nobody answers before it expires, e.g. 'I leave the pull request open'"),
|
"on_expiry": str("what you do if nobody answers before it expires, e.g. 'I leave the pull request open'"),
|
||||||
"by": str("required: your session's name, as who@node or who, e.g. fix-589@g14"),
|
"by": str("required: your session's name, as who@node or who, e.g. fix-589@g14"),
|
||||||
"expires_minutes": map[string]any{"type": "integer",
|
"expires_minutes": map[string]any{"type": "integer",
|
||||||
|
|||||||
@@ -104,12 +104,6 @@ type Proposal struct {
|
|||||||
Digest string `json:"digest"`
|
Digest string `json:"digest"`
|
||||||
From string `json:"from"`
|
From string `json:"from"`
|
||||||
At time.Time `json:"at"`
|
At time.Time `json:"at"`
|
||||||
// Summary is what the operator's phone shows of it, at a glance, in its proposer's words; empty, the module
|
|
||||||
// says what it does (novox/hq issue 639).
|
|
||||||
Summary string `json:"summary,omitempty"`
|
|
||||||
// Form is how its ask is composed, so every node composes the same one again to hold the warrant to it: 0 for
|
|
||||||
// a proposal made before issue 639 (its text in the explanation), AskGlance for one read at a glance.
|
|
||||||
Form int `json:"form,omitempty"`
|
|
||||||
// Asked is empty once the ask was published, or says why it was not; nothing composes from it.
|
// Asked is empty once the ask was published, or says why it was not; nothing composes from it.
|
||||||
Asked string `json:"asked,omitempty"`
|
Asked string `json:"asked,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -170,109 +164,9 @@ func (p Proposal) Act(option string) asks.Act {
|
|||||||
"at": p.At.UTC().Format(time.RFC3339Nano)}
|
"at": p.At.UTC().Format(time.RFC3339Nano)}
|
||||||
}
|
}
|
||||||
|
|
||||||
// AskGlance is the form of an ask read at a glance (Proposal.Form, Question.Form; novox/hq issue 639).
|
|
||||||
const AskGlance = 1
|
|
||||||
|
|
||||||
// Ask is the ask for a proposal, composed from it alone: every node composes the same one, and holds the
|
// Ask is the ask for a proposal, composed from it alone: every node composes the same one, and holds the
|
||||||
// router's warrant to it — in the form it was asked in.
|
// router's warrant to it.
|
||||||
func (p Proposal) Ask() asks.Ask {
|
func (p Proposal) Ask() asks.Ask {
|
||||||
if p.Form == AskGlance {
|
|
||||||
return p.glanceAsk()
|
|
||||||
}
|
|
||||||
return p.listedAsk()
|
|
||||||
}
|
|
||||||
|
|
||||||
// glanceAsk is the ask read at a glance on the phone (novox/hq issue 639): its headline, and as its explanation
|
|
||||||
// the summary of what it does; the text, where it came from, its fingerprint and where to read all of it are its
|
|
||||||
// Details, which the router notes after the answers, collapsed where the channel can.
|
|
||||||
func (p Proposal) glanceAsk() asks.Ask {
|
|
||||||
verb := map[string]string{ActionAdd: "Add", ActionReplace: "Replace", ActionRemove: "Remove"}[p.Action]
|
|
||||||
shown, whole := excerpt(p.Text)
|
|
||||||
part := ""
|
|
||||||
if p.Action != ActionRemove && !whole {
|
|
||||||
part = " Part shown"
|
|
||||||
}
|
|
||||||
headline := fmt.Sprintf("%s Claude instructions '%s'?%s", verb, p.Name, part)
|
|
||||||
if len([]rune(headline)) > asks.HeadlineLength {
|
|
||||||
headline = verb + " Claude instructions?" + part
|
|
||||||
}
|
|
||||||
var d strings.Builder
|
|
||||||
if p.Action == ActionRemove {
|
|
||||||
fmt.Fprintf(&d, "Proposed through the claude-code module on %s at %s UTC. The approved text it removes has the fingerprint %s.",
|
|
||||||
p.From, p.At.UTC().Format("15:04 on 2 Jan"), p.Fingerprint())
|
|
||||||
} else {
|
|
||||||
if whole {
|
|
||||||
d.WriteString("The text:\n" + shown + "\n")
|
|
||||||
} else {
|
|
||||||
d.WriteString("NOT SHOWN IN FULL here. Its first lines:\n" + shown + "\n")
|
|
||||||
}
|
|
||||||
fmt.Fprintf(&d, "Proposed through the claude-code module on %s at %s UTC: %d lines, %d bytes, fingerprint %s.",
|
|
||||||
p.From, p.At.UTC().Format("15:04 on 2 Jan"), p.Lines(), len(p.Text), p.Fingerprint())
|
|
||||||
}
|
|
||||||
fmt.Fprintf(&d, "\nRead all of it at the controller's terminal: mesh-cli ask claude-code claude_code_proposals '{\"id\": \"%s\"}'.", p.ID)
|
|
||||||
does := "the text becomes part of those sessions' instructions"
|
|
||||||
if p.Action == ActionRemove {
|
|
||||||
does = "the instructions are removed from those sessions"
|
|
||||||
}
|
|
||||||
return p.withOptions(asks.Ask{ID: p.ID, Headline: headline, Explanation: p.summary(), Details: d.String()}, does)
|
|
||||||
}
|
|
||||||
|
|
||||||
// summary is what the proposal does, at a glance: its proposer's words, or the module's.
|
|
||||||
func (p Proposal) summary() string {
|
|
||||||
if s := strings.TrimSpace(p.Summary); s != "" {
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
lines := fmt.Sprintf("these %d lines", p.Lines())
|
|
||||||
if p.Lines() == 1 {
|
|
||||||
lines = "this line"
|
|
||||||
}
|
|
||||||
where := strings.ToUpper(p.where()[:1]) + p.where()[1:]
|
|
||||||
switch p.Action {
|
|
||||||
case ActionRemove:
|
|
||||||
return fmt.Sprintf("%s stops reading the instructions '%s' from its next start.", where, p.Name)
|
|
||||||
case ActionReplace:
|
|
||||||
return fmt.Sprintf("%s reads %s in place of the instructions '%s', as the mesh's own word, from its next start until "+
|
|
||||||
"they are removed the same way.", where, lines, p.Name)
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%s reads %s as the mesh's own word, from its next start until they are removed the same way.", where, lines)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SummaryProblem says why a proposer's summary cannot be shown at a glance, or "".
|
|
||||||
func SummaryProblem(summary string) string {
|
|
||||||
switch s := strings.TrimSpace(summary); {
|
|
||||||
case s == "":
|
|
||||||
return ""
|
|
||||||
case strings.Contains(s, "\n"):
|
|
||||||
return "the summary is more than one line"
|
|
||||||
case len([]rune(s)) > asks.SummaryLength:
|
|
||||||
return fmt.Sprintf("the summary is %d characters, more than the %d a phone shows at a glance", len([]rune(s)), asks.SummaryLength)
|
|
||||||
case !shownLine(s):
|
|
||||||
return "the summary names a path, an address, a host name or a long word, which a phone is not shown"
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// withOptions is an ask with what every form of it carries: who answers, its expiry, what it is about, and its two
|
|
||||||
// options, each binding its act.
|
|
||||||
func (p Proposal) withOptions(a asks.Ask, does string) asks.Ask {
|
|
||||||
a.Who, a.Expires, a.OnExpiry = asks.Operator, p.At.Add(ProposalLasts), "the proposal is discarded; nothing changes"
|
|
||||||
a.About = "instructions." + p.Scope + "." + strings.Join(p.Nodes, "+") + "." + p.Name
|
|
||||||
if len(p.Nodes) == 0 {
|
|
||||||
a.About = "instructions." + p.Scope + "." + p.Name
|
|
||||||
}
|
|
||||||
for _, o := range []struct{ id, label, does string }{
|
|
||||||
{OptionApprove, "Approve", does},
|
|
||||||
{OptionDecline, "Decline", "nothing changes; the proposal is discarded"},
|
|
||||||
} {
|
|
||||||
binds, _ := asks.ActDigest(p.Act(o.id))
|
|
||||||
a.Options = append(a.Options, asks.Option{ID: o.id, Label: o.label, Does: o.does, Level: asks.Approve, Binds: binds})
|
|
||||||
}
|
|
||||||
return a
|
|
||||||
}
|
|
||||||
|
|
||||||
// listedAsk is the ask as it was composed before issue 639, the text in its explanation: composed only to hold the
|
|
||||||
// warrant of a proposal made then.
|
|
||||||
func (p Proposal) listedAsk() asks.Ask {
|
|
||||||
verb := map[string]string{ActionAdd: "Add", ActionReplace: "Replace", ActionRemove: "Remove"}[p.Action]
|
verb := map[string]string{ActionAdd: "Add", ActionReplace: "Replace", ActionRemove: "Remove"}[p.Action]
|
||||||
shown, whole := excerpt(p.Text)
|
shown, whole := excerpt(p.Text)
|
||||||
part := ""
|
part := ""
|
||||||
@@ -723,8 +617,6 @@ type ProposeInput struct {
|
|||||||
Name string
|
Name string
|
||||||
Text string
|
Text string
|
||||||
Remove bool
|
Remove bool
|
||||||
// Summary is what the operator's phone shows of it, in one line (novox/hq issue 639); empty, the module says it.
|
|
||||||
Summary string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// AtTheTerminalInstead is the other way, said whenever a proposal is refused for want of a channel.
|
// AtTheTerminalInstead is the other way, said whenever a proposal is refused for want of a channel.
|
||||||
@@ -804,11 +696,7 @@ func Propose(p Paths, in ProposeInput, store ProposalStore, bus AskBus, now time
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
active, isActive := approved.Sections[sectionKey(in.Scope, nodes, in.Name)]
|
active, isActive := approved.Sections[sectionKey(in.Scope, nodes, in.Name)]
|
||||||
if problem := SummaryProblem(in.Summary); problem != "" {
|
prop := Proposal{ID: newProposalID(), Scope: in.Scope, Nodes: nodes, Name: in.Name, From: p.Node, At: now.UTC()}
|
||||||
return refuse(problem)
|
|
||||||
}
|
|
||||||
prop := Proposal{ID: newProposalID(), Scope: in.Scope, Nodes: nodes, Name: in.Name, From: p.Node, At: now.UTC(),
|
|
||||||
Summary: strings.TrimSpace(in.Summary), Form: AskGlance}
|
|
||||||
switch {
|
switch {
|
||||||
case in.Remove:
|
case in.Remove:
|
||||||
if !isActive {
|
if !isActive {
|
||||||
|
|||||||
@@ -147,17 +147,14 @@ func TestAProposalAsksTheOperatorAtTheLevelApproveAndChangesNothing(t *testing.T
|
|||||||
if err := a.Check(t0); err != nil {
|
if err := a.Check(t0); err != nil {
|
||||||
t.Fatalf("the router would refuse the ask: %v", err)
|
t.Fatalf("the router would refuse the ask: %v", err)
|
||||||
}
|
}
|
||||||
// At a glance (issue 639): what it does is the explanation, one line; the text is the Details, which the
|
if !strings.HasPrefix(a.Explanation, "Add instructions 'handoff-from-g14' to every Claude session on the mesh?") {
|
||||||
// router notes collapsed after the answers.
|
t.Errorf("the phone does not say what is asked first: %q", a.Explanation)
|
||||||
if a.Explanation != "Every Claude session on the mesh reads these 4 lines as the mesh's own word, from its next start until they are removed the same way." ||
|
|
||||||
asks.Glance(a.Explanation) != nil {
|
|
||||||
t.Errorf("the phone does not say what is asked at a glance: %q", a.Explanation)
|
|
||||||
}
|
}
|
||||||
if a.Headline != "Add Claude instructions 'handoff-from-g14'?" {
|
if a.Headline != "Add Claude instructions 'handoff-from-g14'?" {
|
||||||
t.Errorf("headline: %q", a.Headline)
|
t.Errorf("headline: %q", a.Headline)
|
||||||
}
|
}
|
||||||
if !strings.HasPrefix(a.Details, "The text:\n"+strings.TrimRight(handover, "\n")+"\nProposed through the claude-code module on g14 at ") {
|
if !strings.Contains(a.Explanation, "The whole text:\n\n"+strings.TrimRight(handover, "\n")) {
|
||||||
t.Errorf("a short text is not shown whole: %q", a.Details)
|
t.Errorf("a short text is shown whole: %q", a.Explanation)
|
||||||
}
|
}
|
||||||
if len(a.Options) != 2 {
|
if len(a.Options) != 2 {
|
||||||
t.Fatalf("options: %+v", a.Options)
|
t.Fatalf("options: %+v", a.Options)
|
||||||
@@ -297,7 +294,7 @@ func TestARemovalNeedsTheSameApprovalAndARecordDeletedRemovesNothing(t *testing.
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
rid := out["proposed"].(string)
|
rid := out["proposed"].(string)
|
||||||
if r.published[rid].Explanation != "Every Claude session on the mesh stops reading the instructions 'handoff-from-g14' from its next start." {
|
if !strings.HasPrefix(r.published[rid].Explanation, "Remove the instructions 'handoff-from-g14' from every Claude session on the mesh?") {
|
||||||
t.Errorf("removal ask: %q", r.published[rid].Explanation)
|
t.Errorf("removal ask: %q", r.published[rid].Explanation)
|
||||||
}
|
}
|
||||||
if !strings.Contains(renderedInstructions(t, p), "### handoff-from-g14") {
|
if !strings.Contains(renderedInstructions(t, p), "### handoff-from-g14") {
|
||||||
@@ -329,7 +326,7 @@ func TestANodeScopedSectionReachesOnlyItsNodes(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
id := out["proposed"].(string)
|
id := out["proposed"].(string)
|
||||||
if !strings.HasPrefix(r.published[id].Explanation, "Every Claude session on shanks reads this line as the mesh's own word") {
|
if !strings.HasPrefix(r.published[id].Explanation, "Add instructions 'builds' to every Claude session on shanks?") {
|
||||||
t.Errorf("%q", r.published[id].Explanation)
|
t.Errorf("%q", r.published[id].Explanation)
|
||||||
}
|
}
|
||||||
r.close(r.chose(id, OptionApprove, t0.Add(time.Hour)))
|
r.close(r.chose(id, OptionApprove, t0.Add(time.Hour)))
|
||||||
@@ -420,16 +417,15 @@ func TestALongTextIsShownInPartAndReadWholeAtTheTerminal(t *testing.T) {
|
|||||||
}
|
}
|
||||||
id := out["proposed"].(string)
|
id := out["proposed"].(string)
|
||||||
a := r.published[id]
|
a := r.published[id]
|
||||||
if strings.Contains(a.Details, "/etc/claude-code") || !strings.Contains(a.Details, "[1 line(s) not shown here") ||
|
if strings.Contains(a.Explanation, "/etc/claude-code") || !strings.Contains(a.Explanation, "[1 line(s) not shown here") {
|
||||||
!strings.HasPrefix(a.Details, "NOT SHOWN IN FULL here.") || !strings.HasSuffix(a.Headline, "Part shown") {
|
t.Errorf("a line with a path was shown: %q", a.Explanation)
|
||||||
t.Errorf("a line with a path was shown, or the cut not said: %q", a.Details)
|
|
||||||
}
|
}
|
||||||
if !strings.Contains(a.Details, "mesh-cli ask claude-code claude_code_proposals") ||
|
if !strings.Contains(a.Explanation, "mesh-cli ask claude-code claude_code_proposals") ||
|
||||||
!strings.Contains(a.Details, "fingerprint "+out["fingerprint"].(string)) {
|
!strings.Contains(a.Explanation, "fingerprint there must be "+out["fingerprint"].(string)) {
|
||||||
t.Errorf("the phone does not say where to read all of it: %q", a.Details)
|
t.Errorf("the phone does not say where to read all of it: %q", a.Explanation)
|
||||||
}
|
}
|
||||||
if len(a.Details) > 3000 || asks.Glance(a.Explanation) != nil {
|
if len(a.Explanation) > 3000 {
|
||||||
t.Errorf("the details are %d bytes; the explanation %q", len(a.Details), a.Explanation)
|
t.Errorf("the explanation is %d bytes", len(a.Explanation))
|
||||||
}
|
}
|
||||||
list, err := ListProposals(p, store, r, id, t0)
|
list, err := ListProposals(p, store, r, id, t0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -743,33 +739,3 @@ func TestAnAskTakenAfterItsRefusalIsStillAnswered(t *testing.T) {
|
|||||||
t.Errorf("decided %q", got)
|
t.Errorf("decided %q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A proposer's summary is the explanation, held to a glance; a proposal made before issue 639 is composed as it was
|
|
||||||
// asked, so the warrant for it still holds after the change.
|
|
||||||
func TestAProposalIsAskedAtAGlanceAndAnOlderOneAsItWasAsked(t *testing.T) {
|
|
||||||
p, _ := node(t, "g14")
|
|
||||||
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
|
|
||||||
out, err := Propose(p, ProposeInput{Scope: ScopeMesh, Name: "handoff-from-g14", Text: handover,
|
|
||||||
Summary: "Every session learns how hand-overs are written, so the next one reads them the same way."}, store, r, t0)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
id := out["proposed"].(string)
|
|
||||||
a := r.published[id]
|
|
||||||
if a.Explanation != "Every session learns how hand-overs are written, so the next one reads them the same way." {
|
|
||||||
t.Errorf("the proposer's summary is not the explanation: %q", a.Explanation)
|
|
||||||
}
|
|
||||||
for _, bad := range []string{strings.Repeat("word ", 70), "one\ntwo", "Read it in /etc/claude-code first."} {
|
|
||||||
if _, err := Propose(p, ProposeInput{Scope: ScopeMesh, Name: "other", Text: handover, Summary: bad}, store, r, t0); err == nil ||
|
|
||||||
!strings.Contains(err.Error(), "the summary") {
|
|
||||||
t.Errorf("the summary %q was taken: %v", bad, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
prop, _ := readProposal(store, id)
|
|
||||||
older := prop
|
|
||||||
older.Form = 0
|
|
||||||
listed := older.Ask()
|
|
||||||
if !strings.Contains(listed.Explanation, "The whole text:") || listed.Details != "" || listed.Digest() == prop.Ask().Digest() {
|
|
||||||
t.Errorf("a proposal made before is not composed as it was asked: %q", listed.Explanation)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -69,9 +69,9 @@ const (
|
|||||||
// The bounds of what a question says.
|
// The bounds of what a question says.
|
||||||
const (
|
const (
|
||||||
FewestQuestionOptions = 2
|
FewestQuestionOptions = 2
|
||||||
MostContextBytes = asks.SummaryLength
|
MostContextBytes = 1500
|
||||||
MostMeansBytes = 200
|
MostMeansBytes = 200
|
||||||
MostWhyBytes = 120
|
MostWhyBytes = 300
|
||||||
MostOnExpiryBytes = 200
|
MostOnExpiryBytes = 200
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -131,8 +131,6 @@ type Question struct {
|
|||||||
From string `json:"from"`
|
From string `json:"from"`
|
||||||
At time.Time `json:"at"`
|
At time.Time `json:"at"`
|
||||||
Expires time.Time `json:"expires"`
|
Expires time.Time `json:"expires"`
|
||||||
// Form is how its ask is composed (AskGlance since novox/hq issue 639), so the answer is held to the ask asked.
|
|
||||||
Form int `json:"form,omitempty"`
|
|
||||||
// Asked is empty once the ask was published, or says why it was not; nothing composes from it.
|
// Asked is empty once the ask was published, or says why it was not; nothing composes from it.
|
||||||
Asked string `json:"asked,omitempty"`
|
Asked string `json:"asked,omitempty"`
|
||||||
// Ended is the router's word that the ask ended without a choice, as heard on the decided subject: a refused ask
|
// Ended is the router's word that the ask ended without a choice, as heard on the decided subject: a refused ask
|
||||||
@@ -162,41 +160,8 @@ func (q Question) Act(option string) asks.Act {
|
|||||||
return act
|
return act
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ask is the ask for a question, composed from it alone, in the form it was asked in.
|
// Ask is the ask for a question, composed from it alone.
|
||||||
func (q Question) Ask() asks.Ask {
|
func (q Question) Ask() asks.Ask {
|
||||||
if q.Form == AskGlance {
|
|
||||||
return q.glanceAsk()
|
|
||||||
}
|
|
||||||
return q.listedAsk()
|
|
||||||
}
|
|
||||||
|
|
||||||
// glanceAsk is the question read at a glance on the phone (novox/hq issue 639): its context, then the recommended
|
|
||||||
// answer and why as its one point; who asked and what the answer does are its Details.
|
|
||||||
func (q Question) glanceAsk() asks.Ask {
|
|
||||||
explanation := strings.TrimSpace(q.Context)
|
|
||||||
if rec, ok := q.recommended(); ok {
|
|
||||||
explanation += fmt.Sprintf("\n• Recommended: %s, because %s.", rec.Label, strings.TrimRight(strings.TrimSpace(q.Why), "."))
|
|
||||||
}
|
|
||||||
details := fmt.Sprintf("Asked by the agent session %s on %s through the claude-code module, at %s UTC. Your answer is told "+
|
|
||||||
"to that session; it performs nothing by itself.", q.By, q.From, q.At.UTC().Format("15:04 on 2 Jan"))
|
|
||||||
return q.withOptions(asks.Ask{ID: q.ID, Headline: q.Headline, Explanation: explanation, Details: details})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (q Question) withOptions(a asks.Ask) asks.Ask {
|
|
||||||
a.Who, a.Expires, a.OnExpiry = asks.Operator, q.Expires, q.OnExpiry
|
|
||||||
for _, o := range q.Options {
|
|
||||||
does := o.Means
|
|
||||||
if o.Recommended {
|
|
||||||
does = "Recommended. " + does
|
|
||||||
}
|
|
||||||
binds, _ := asks.ActDigest(q.Act(o.ID))
|
|
||||||
a.Options = append(a.Options, asks.Option{ID: o.ID, Label: o.Label, Does: does, Level: asks.Approve, Binds: binds})
|
|
||||||
}
|
|
||||||
return a
|
|
||||||
}
|
|
||||||
|
|
||||||
// listedAsk is the ask as it was composed before issue 639: composed only to hold the answer to a question asked then.
|
|
||||||
func (q Question) listedAsk() asks.Ask {
|
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
b.WriteString(strings.TrimSpace(q.Context))
|
b.WriteString(strings.TrimSpace(q.Context))
|
||||||
if rec, ok := q.recommended(); ok {
|
if rec, ok := q.recommended(); ok {
|
||||||
@@ -234,10 +199,8 @@ func QuestionProblem(q Question) string {
|
|||||||
switch {
|
switch {
|
||||||
case strings.TrimSpace(q.Context) == "":
|
case strings.TrimSpace(q.Context) == "":
|
||||||
add("it gives no context: say in plain words what the operator decides and why it matters")
|
add("it gives no context: say in plain words what the operator decides and why it matters")
|
||||||
case len([]rune(q.Context)) > MostContextBytes:
|
case len(q.Context) > MostContextBytes:
|
||||||
add("its context is %d characters, more than the %d a phone shows in one glance", len([]rune(q.Context)), MostContextBytes)
|
add("its context is %d bytes, more than the %d a phone shows in one glance", len(q.Context), MostContextBytes)
|
||||||
case strings.Contains(strings.TrimSpace(q.Context), "\n"):
|
|
||||||
add("its context is more than one line: a phone shows it at a glance as one short paragraph")
|
|
||||||
}
|
}
|
||||||
if n := len(q.Options); n < FewestQuestionOptions || n > asks.MostOptions {
|
if n := len(q.Options); n < FewestQuestionOptions || n > asks.MostOptions {
|
||||||
add("it offers %d options; a question offers %d to %d", n, FewestQuestionOptions, asks.MostOptions)
|
add("it offers %d options; a question offers %d to %d", n, FewestQuestionOptions, asks.MostOptions)
|
||||||
@@ -359,7 +322,7 @@ func AskQuestion(p Paths, in QuestionInput, store QuestionStore, bus AskBus, now
|
|||||||
}
|
}
|
||||||
q := Question{ID: newQuestionID(), Headline: strings.TrimSpace(in.Headline), Context: strings.TrimSpace(in.Context),
|
q := Question{ID: newQuestionID(), Headline: strings.TrimSpace(in.Headline), Context: strings.TrimSpace(in.Context),
|
||||||
Why: strings.TrimSpace(in.Why), OnExpiry: strings.TrimSpace(in.OnExpiry), By: by, From: p.Node, At: now.UTC(),
|
Why: strings.TrimSpace(in.Why), OnExpiry: strings.TrimSpace(in.OnExpiry), By: by, From: p.Node, At: now.UTC(),
|
||||||
Expires: now.UTC().Add(life), Form: AskGlance}
|
Expires: now.UTC().Add(life)}
|
||||||
for i, o := range in.Options {
|
for i, o := range in.Options {
|
||||||
q.Options = append(q.Options, QuestionOption{ID: fmt.Sprintf("o%d", i+1), Label: strings.TrimSpace(o.Label),
|
q.Options = append(q.Options, QuestionOption{ID: fmt.Sprintf("o%d", i+1), Label: strings.TrimSpace(o.Label),
|
||||||
Means: strings.TrimSpace(o.Means), Recommended: o.Recommended})
|
Means: strings.TrimSpace(o.Means), Recommended: o.Recommended})
|
||||||
|
|||||||
@@ -68,13 +68,10 @@ func TestAQuestionIsAskedUnderTheModulesNameAtTheLevelApprove(t *testing.T) {
|
|||||||
if !strings.HasPrefix(a.Options[0].Does, "Recommended. ") || strings.HasPrefix(a.Options[1].Does, "Recommended") {
|
if !strings.HasPrefix(a.Options[0].Does, "Recommended. ") || strings.HasPrefix(a.Options[1].Does, "Recommended") {
|
||||||
t.Errorf("the recommendation is not on the recommended option alone: %+v", a.Options)
|
t.Errorf("the recommendation is not on the recommended option alone: %+v", a.Options)
|
||||||
}
|
}
|
||||||
// At a glance (issue 639): the context and the recommendation are the explanation; who asks is the Details.
|
for _, want := range []string{"Recommended: Merge now, because it is reviewed and the checks are green.",
|
||||||
if !strings.HasSuffix(a.Explanation, "\n• Recommended: Merge now, because it is reviewed and the checks are green.") || asks.Glance(a.Explanation) != nil {
|
"the agent session fix-589 on g14", "performs nothing by itself"} {
|
||||||
t.Errorf("the explanation does not read at a glance:\n%s", a.Explanation)
|
if !strings.Contains(a.Explanation, want) {
|
||||||
}
|
t.Errorf("the explanation does not say %q:\n%s", want, a.Explanation)
|
||||||
for _, want := range []string{"the agent session fix-589 on g14", "performs nothing by itself"} {
|
|
||||||
if !strings.Contains(a.Details, want) || strings.Contains(a.Explanation, want) {
|
|
||||||
t.Errorf("the details do not say %q:\n%s", want, a.Details)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := a.Check(t0); err != nil {
|
if err := a.Check(t0); err != nil {
|
||||||
|
|||||||
@@ -34,9 +34,6 @@ const meshEntry = "mesh"
|
|||||||
type Facts struct {
|
type Facts struct {
|
||||||
Node string `json:"node"`
|
Node string `json:"node"`
|
||||||
Console string `json:"console"`
|
Console string `json:"console"`
|
||||||
// ForgeCredentials is the forge's read credential placed in the agent's home, when this machine holds one
|
|
||||||
// (forge.go): its path, which every session's git is pointed at. Never rendered by the mesh.
|
|
||||||
ForgeCredentials string `json:"-"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Settings are the operator's, for the mesh or this node.
|
// Settings are the operator's, for the mesh or this node.
|
||||||
@@ -142,7 +139,6 @@ func RenderWithMesh(facts Facts, settings Settings, binding *Binding, helperPath
|
|||||||
}
|
}
|
||||||
managed[key] = entries
|
managed[key] = entries
|
||||||
}
|
}
|
||||||
forgeEnv(managed, facts.ForgeCredentials)
|
|
||||||
if binding != nil && binding.Kind == "api-key" {
|
if binding != nil && binding.Kind == "api-key" {
|
||||||
managed["apiKeyHelper"] = helperPath
|
managed["apiKeyHelper"] = helperPath
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,12 +19,11 @@ const agentAccountFile = "/etc/claude-code/agent-account"
|
|||||||
|
|
||||||
type manifestShell struct {
|
type manifestShell struct {
|
||||||
Resources []map[string]any `json:"resources"`
|
Resources []map[string]any `json:"resources"`
|
||||||
// The zsh code is an item of the collection shell:zsh:<slot> (novox/hq ADR 0349 clause 13), its text the
|
Shell []struct {
|
||||||
// template file beside the manifest.
|
For string `json:"for"`
|
||||||
Items []struct {
|
Slot string `json:"slot"`
|
||||||
Collection string `json:"collection"`
|
Code string `json:"code"`
|
||||||
Template string `json:"template"`
|
} `json:"shell"`
|
||||||
} `json:"items"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func readManifestShell(t *testing.T) manifestShell {
|
func readManifestShell(t *testing.T) manifestShell {
|
||||||
@@ -55,13 +54,9 @@ func resourceByID(t *testing.T, m manifestShell, id string) map[string]any {
|
|||||||
func zshCode(t *testing.T, m manifestShell) string {
|
func zshCode(t *testing.T, m manifestShell) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
var code []string
|
var code []string
|
||||||
for _, it := range m.Items {
|
for _, s := range m.Shell {
|
||||||
if strings.HasPrefix(it.Collection, "shell:zsh:") {
|
if s.For == "zsh" {
|
||||||
text, err := os.ReadFile(filepath.Join("..", "..", it.Template))
|
code = append(code, s.Code)
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
code = append(code, string(text))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(code) != 1 {
|
if len(code) != 1 {
|
||||||
@@ -76,8 +71,8 @@ func TestTheAccountAgentsRunAsIsWrittenForTheShell(t *testing.T) {
|
|||||||
if r["type"] != "file" || r["path"] != agentAccountFile || r["mode"] != "0644" {
|
if r["type"] != "file" || r["path"] != agentAccountFile || r["mode"] != "0644" {
|
||||||
t.Fatalf("the account's file is %v", r)
|
t.Fatalf("the account's file is %v", r)
|
||||||
}
|
}
|
||||||
if text := templateText(t, r); text != "${machine:agent-account}\n" {
|
if r["content"] != "${machine:agent-account}\n" {
|
||||||
t.Fatalf("the account's file holds %q", text)
|
t.Fatalf("the account's file holds %q", r["content"])
|
||||||
}
|
}
|
||||||
if _, has := r["owner"]; has {
|
if _, has := r["owner"]; has {
|
||||||
t.Fatal("the account's file must be root's, as claude-agent is")
|
t.Fatal("the account's file must be root's, as claude-agent is")
|
||||||
@@ -168,7 +163,7 @@ func TestClaudeAgentRunsTheRealClaudeUnderTheAlias(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
start := templateText(t, resourceByID(t, readManifestShell(t), "start"))
|
start, _ := resourceByID(t, readManifestShell(t), "start")["content"].(string)
|
||||||
if !strings.Contains(start, "exec claude \"$@\"") {
|
if !strings.Contains(start, "exec claude \"$@\"") {
|
||||||
t.Fatalf("claude-agent no longer runs claude itself:\n%s", start)
|
t.Fatalf("claude-agent no longer runs claude itself:\n%s", start)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,64 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// The module's directory, where its manifest and its files/ are.
|
|
||||||
const moduleDir = "../.."
|
|
||||||
|
|
||||||
// templateText is the text a file resource places on the machine: its template's, in the module's files/
|
|
||||||
// (novox/hq ADR 0349 §2). A resource that names no template, or a template that is not there, fails the test.
|
|
||||||
func templateText(t *testing.T, r map[string]any) string {
|
|
||||||
t.Helper()
|
|
||||||
name, _ := r["template"].(string)
|
|
||||||
if name == "" {
|
|
||||||
t.Fatalf("resource %v names no template", r["id"])
|
|
||||||
}
|
|
||||||
raw, err := os.ReadFile(filepath.Join(moduleDir, filepath.FromSlash(name)))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("resource %v names %s: %v", r["id"], name, err)
|
|
||||||
}
|
|
||||||
return string(raw)
|
|
||||||
}
|
|
||||||
|
|
||||||
// No file resource carries its text inline: each names its template in files/, and the build packs files/
|
|
||||||
// into the one archive the controller reads the templates back from (novox/hq ADR 0349 §2).
|
|
||||||
func TestEveryFileTakesItsTextFromATemplate(t *testing.T) {
|
|
||||||
raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
var m struct {
|
|
||||||
Resources []map[string]any `json:"resources"`
|
|
||||||
Build struct {
|
|
||||||
Artifacts []map[string]any `json:"artifacts"`
|
|
||||||
} `json:"build"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(raw, &m); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
for _, r := range m.Resources {
|
|
||||||
if _, inline := r["content"]; inline {
|
|
||||||
t.Errorf("resource %v keeps its text inline: keep it in files/ and name it as the template", r["id"])
|
|
||||||
}
|
|
||||||
if r["type"] == "file" {
|
|
||||||
templateText(t, r)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
packs := 0
|
|
||||||
for _, a := range m.Build.Artifacts {
|
|
||||||
if a["kind"] == "archive" && a["from"] == "files" {
|
|
||||||
packs++
|
|
||||||
if a["name"] != "files" {
|
|
||||||
t.Errorf("the templates archive is named %v, not files", a["name"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if packs != 1 {
|
|
||||||
t.Errorf("the build packs files/ into %d archives, not one", packs)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
${machine:agent-account}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
# The mesh's (module claude-code, novox/hq ADR 0266): start the agent as the account agents run as on this
|
|
||||||
# machine. Written whole at every push: an edit here is overwritten.
|
|
||||||
agent='${machine:agent-account}'
|
|
||||||
if [ "$(id -un)" = "$agent" ]; then
|
|
||||||
exec claude "$@"
|
|
||||||
fi
|
|
||||||
# Another account, the operator's, becomes the agent's through its own sudo: the person is the authority. The
|
|
||||||
# operator's override of the guard travels only when it is set in this shell, as it would reach claude.
|
|
||||||
if [ -n "$MESH_GUARD_OVERRIDE" ]; then
|
|
||||||
exec sudo -iu "$agent" env MESH_GUARD_OVERRIDE="$MESH_GUARD_OVERRIDE" claude "$@"
|
|
||||||
fi
|
|
||||||
exec sudo -iu "$agent" claude "$@"
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
{
|
|
||||||
"node": "${machine:name}",
|
|
||||||
"console": "http://127.0.0.1:${bound:mcp-endpoint:port}/mcp"
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
{
|
|
||||||
"role": "",
|
|
||||||
"mcp_servers": {},
|
|
||||||
"managed_settings": {},
|
|
||||||
"instructions": {},
|
|
||||||
"output_styles": {},
|
|
||||||
"skills": {},
|
|
||||||
"commands": {},
|
|
||||||
"agents": {}
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
# The agent's session (module claude-code, novox/hq ADR 0266): where this machine names an account of the
|
|
||||||
# agents' own, claude in an interactive zsh is claude-agent, which starts the session as that account. Where
|
|
||||||
# agents run as the operator's account the file names that account, and nothing is added. claude-agent runs
|
|
||||||
# the real claude: exec, sudo and its sh see no alias.
|
|
||||||
if [[ -r /etc/claude-code/agent-account ]]; then
|
|
||||||
() {
|
|
||||||
local agent=$(</etc/claude-code/agent-account)
|
|
||||||
if [[ -n $agent && $agent != $USERNAME ]]; then
|
|
||||||
alias claude=claude-agent
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
fi
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user