Compare commits
118
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3db85a0dde | ||
|
|
a31ad48b02 | ||
|
|
0260ae92f6 | ||
|
|
ee1b15d7f0 | ||
|
|
8efe499db9 | ||
|
|
173c5d5ef4 | ||
|
|
c7c784161d | ||
|
|
0fdacb9839 | ||
|
|
b0e4a12c88 | ||
|
|
a76b0742ff | ||
|
|
21a499ba59 | ||
|
|
de9d29dc65 | ||
|
|
62ec68355c | ||
|
|
6a5e6510ca | ||
|
|
3f97d2d50d | ||
|
|
6112233649 | ||
|
|
8c2d7e5f97 | ||
|
|
21ebbffed4 | ||
|
|
31120ed580 | ||
|
|
cf304d8629 | ||
|
|
12d7852082 | ||
|
|
c9848f4fb8 | ||
|
|
c070bea782 | ||
|
|
7443938fef | ||
|
|
aee97bf8ad | ||
|
|
c816cafc00 | ||
|
|
2353350d79 | ||
|
|
d4b8b24701 | ||
|
|
de6657c846 | ||
|
|
f75e1a91d9 | ||
|
|
ebfec39937 | ||
|
|
24d05701d3 | ||
|
|
a95b3fa053 | ||
|
|
c2cd2e1eb1 | ||
|
|
aa62de2e78 | ||
|
|
66713c5a1e | ||
|
|
b771794f1c | ||
|
|
6c63a17cfa | ||
|
|
ed66a2e873 | ||
|
|
cc73b38671 | ||
|
|
0993c31ad4 | ||
|
|
82475691b2 | ||
|
|
89046e6613 | ||
|
|
dc89995d96 | ||
|
|
6c10288f0b | ||
|
|
789b0a9474 | ||
|
|
eb97a3d9a2 | ||
|
|
8b2e2300d8 | ||
|
|
2f2daea415 | ||
|
|
3b48f5962a | ||
|
|
2b1678f315 | ||
|
|
ab739d02dd | ||
|
|
a60020b51a | ||
|
|
a8d84d1730 | ||
|
|
9e8521afb5 | ||
|
|
8c60604eb7 | ||
|
|
13cd710071 | ||
|
|
0914a33fba | ||
|
|
b5417c28d0 | ||
|
|
5f6a41146c | ||
|
|
c71c513027 | ||
|
|
2e32f47e45 | ||
|
|
9492278d93 | ||
|
|
5ce2d07c7f | ||
|
|
373f339750 | ||
|
|
48f0662fde | ||
|
|
7913b56d66 | ||
|
|
ab1811d003 | ||
|
|
c79337579d | ||
|
|
3a84044ece | ||
|
|
c51357f8af | ||
|
|
2833a049aa | ||
|
|
6f7f07635e | ||
|
|
0f76b6e232 | ||
|
|
3e16c055be | ||
|
|
0361c44a03 | ||
|
|
d1debdcce8 | ||
|
|
0e7d9e74d2 | ||
|
|
be53f5167a | ||
|
|
1d8a244ba4 | ||
|
|
2edef5a00a | ||
|
|
2130f99612 | ||
|
|
ed5690cee4 | ||
|
|
fadf5025cc | ||
|
|
baa5ef802e | ||
|
|
5b20e419ec | ||
|
|
7e3e62062b | ||
|
|
7e0001e304 | ||
|
|
d94764f001 | ||
|
|
482232e8c5 | ||
|
|
70c1110c53 | ||
|
|
e223262408 | ||
|
|
dbe2949f84 | ||
|
|
b7d9590fdc | ||
|
|
7ee2869d60 | ||
|
|
0b74c04aa8 | ||
|
|
e1c8965ddb | ||
|
|
47ef2644ac | ||
|
|
cb7bf94a77 | ||
|
|
8e821ef449 | ||
|
|
07b9bd0ed4 | ||
|
|
02427a60ef | ||
|
|
8661bcfa22 | ||
|
|
6049537655 | ||
|
|
bf3e801f67 | ||
|
|
50861c74f8 | ||
|
|
b15846dd6e | ||
|
|
a44d490d97 | ||
|
|
af5cf53a34 | ||
|
|
2bb5d344f6 | ||
|
|
e846c289c8 | ||
|
|
0fcc560fec | ||
|
|
acf8a76646 | ||
|
|
b62bb15839 | ||
|
|
ff9d3ee6a0 | ||
|
|
c8255eb15d | ||
|
|
4ded07bc63 | ||
|
|
054dc9618c |
@@ -21,3 +21,6 @@ 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
|
||||||
|
|
||||||
|
# The provisioner client built in place (issue 671).
|
||||||
|
modules/mesh-provisioner-client/cmd/mesh-provisioner-client/mesh-provisioner-client
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# Release 57: config and provisioning
|
||||||
|
|
||||||
|
This branch carries the work of hq design 57 (ADR 0348, ADR 0349) in this repository. Each package
|
||||||
|
merges here through its own reviewed pull request; the branch reaches main as one pull request, in one
|
||||||
|
delivery group with the branch of the same name in mesh-controller, mesh-host, mesh-sdk, mesh-catalog
|
||||||
|
and hq, after a rehearsal in the lab. This file is deleted by the last commit before that pull request.
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
module git.novox.be/novox/mesh-catalog/checks/config
|
||||||
|
|
||||||
|
go 1.22
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
// Command config refuses the settings form's names in a module that has moved to config (novox/hq ADR 0348
|
||||||
|
// clauses 1 and 11, design 57 package K6, issue 665).
|
||||||
|
//
|
||||||
|
// "Config" replaces "settings": a manifest declares its values under `config`, a mergeable file names the keys it
|
||||||
|
// takes under `takes-config`, and a file asks for a value with `${config:<key>}`. A mergeable file naming no keys at
|
||||||
|
// all is the settings form's last shape: it takes every key of its module's layer and declares none, so mesh-config
|
||||||
|
// could set none of them (ADR 0348 §10, ADR 0326 §3); it is found here like an old name (issue 735). The old names — `settings`,
|
||||||
|
// `takes-settings` and `${setting:<key>}` — stay accepted by the controller as aliases until every module has
|
||||||
|
// moved, and are then refused (ADR 0348 §1). The modules not yet moved are listed in the file `unmoved` beside this
|
||||||
|
// check, one per line: the catalogue's list ADR 0348 §11 names. Every other module says no old name, in its
|
||||||
|
// manifest or in a template file its manifest names; a module on the list that says none is taken off it; and a
|
||||||
|
// line the trunk's copy (the merge base's, given with -base) does not hold is refused, so the list only shrinks;
|
||||||
|
// while the trunk holds no copy, the modules in seed are the trunk's.
|
||||||
|
// When it is empty, the controller refuses the old names itself (design 57 package R1).
|
||||||
|
//
|
||||||
|
// Prose is not judged: a comment in a file's text that says "settings" composes into the machine's file, and
|
||||||
|
// rewording it would change what a node is sent.
|
||||||
|
//
|
||||||
|
// go run . [-base <the trunk's unmoved>] <catalogue root>
|
||||||
|
//
|
||||||
|
// Exits 1 on a finding.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The settings form's names (ADR 0348 §1). A key of a JSON object is a quoted string followed by a colon; a
|
||||||
|
// string value is never followed by one.
|
||||||
|
var (
|
||||||
|
settingRef = regexp.MustCompile(`\$\{setting:[a-z0-9][a-z0-9_.-]*\}`)
|
||||||
|
takesSettingsKey = regexp.MustCompile(`"takes-settings"\s*:`)
|
||||||
|
oldPlaceholder = "${setting:…}"
|
||||||
|
oldTakes = "takes-settings"
|
||||||
|
oldDeclaration = "settings"
|
||||||
|
wholeLayer = "taking its whole layer"
|
||||||
|
listFile = "unmoved"
|
||||||
|
listPathInCatalog = "checks/config/" + listFile
|
||||||
|
)
|
||||||
|
|
||||||
|
// Check answers, per module that says any, the settings form's names its manifest and its template files say.
|
||||||
|
func Check(root string) (map[string][]string, error) {
|
||||||
|
manifests, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sort.Strings(manifests)
|
||||||
|
out := map[string][]string{}
|
||||||
|
for _, path := range manifests {
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var m struct {
|
||||||
|
Module string `json:"module"`
|
||||||
|
Settings json.RawMessage `json:"settings"`
|
||||||
|
Resources []map[string]any `json:"resources"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &m); err != nil {
|
||||||
|
return nil, fmt.Errorf("%s: %w", path, err)
|
||||||
|
}
|
||||||
|
var said []string
|
||||||
|
if len(m.Settings) > 0 && string(m.Settings) != "null" {
|
||||||
|
said = append(said, oldDeclaration)
|
||||||
|
}
|
||||||
|
if takesSettingsKey.Match(raw) {
|
||||||
|
said = append(said, oldTakes)
|
||||||
|
}
|
||||||
|
if settingRef.Match(raw) {
|
||||||
|
said = append(said, oldPlaceholder)
|
||||||
|
}
|
||||||
|
for _, r := range m.Resources {
|
||||||
|
// A mergeable file naming no keys takes every key of the layer (ADR 0326 §3), declaring none of them.
|
||||||
|
if how, _ := r["merge"].(string); how != "" {
|
||||||
|
_, oldList := r["takes-settings"]
|
||||||
|
_, newList := r["takes-config"]
|
||||||
|
if !oldList && !newList {
|
||||||
|
said = append(said, fmt.Sprintf("%v %s", r["id"], wholeLayer))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
name, _ := r["template"].(string)
|
||||||
|
if name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
text, err := os.ReadFile(filepath.Join(filepath.Dir(path), filepath.FromSlash(name)))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%s names the template %s: %w", m.Module, name, err)
|
||||||
|
}
|
||||||
|
if settingRef.Match(text) {
|
||||||
|
said = append(said, oldPlaceholder+" in "+name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(said) > 0 {
|
||||||
|
sort.Strings(said)
|
||||||
|
out[m.Module] = said
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// seed is the list as it was first written, standing for the trunk's copy until the trunk holds one.
|
||||||
|
// The modules taking a whole layer were found after the list was first written and before the trunk held a copy
|
||||||
|
// (issue 735), so they stand in it too.
|
||||||
|
var seed = map[string]bool{
|
||||||
|
"betterbird": true, "fail2ban": true, "influxdb": true, "keycloak": true, "mailu": true, "searxng": true,
|
||||||
|
"baserow": true, "confluence": true, "gitea": true, "gitlab": true, "grafana": true, "home-assistant": true,
|
||||||
|
"icecast": true, "jira": true, "letta": true, "matrix": true, "nodered": true, "supabase": true, "unifi": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unmoved is the modules a list names, one per line; a line starting with # and a blank line are not modules.
|
||||||
|
func Unmoved(path string) (map[string]bool, error) {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
out := map[string]bool{}
|
||||||
|
s := bufio.NewScanner(f)
|
||||||
|
for s.Scan() {
|
||||||
|
line := strings.TrimSpace(s.Text())
|
||||||
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out[line] = true
|
||||||
|
}
|
||||||
|
return out, s.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Judge answers the modules that say an old name and are not on the list (refused), the modules on the list that
|
||||||
|
// say none (stale), and the modules on the list that the trunk's list does not hold (added).
|
||||||
|
func Judge(found map[string][]string, unmoved, base map[string]bool) (refused, stale, added []string) {
|
||||||
|
for m := range found {
|
||||||
|
if !unmoved[m] {
|
||||||
|
refused = append(refused, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for m := range unmoved {
|
||||||
|
if _, says := found[m]; !says {
|
||||||
|
stale = append(stale, m)
|
||||||
|
}
|
||||||
|
if !base[m] {
|
||||||
|
added = append(added, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(refused)
|
||||||
|
sort.Strings(stale)
|
||||||
|
sort.Strings(added)
|
||||||
|
return refused, stale, added
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
basePath := flag.String("base", "", "the trunk's copy of unmoved (the merge base's); seed when absent")
|
||||||
|
flag.Parse()
|
||||||
|
root := "."
|
||||||
|
if flag.NArg() > 0 {
|
||||||
|
root = flag.Arg(0)
|
||||||
|
}
|
||||||
|
found, err := Check(root)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
unmoved, err := Unmoved(listFile)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
base := seed
|
||||||
|
if *basePath != "" {
|
||||||
|
if base, err = Unmoved(*basePath); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
refused, stale, added := Judge(found, unmoved, base)
|
||||||
|
for _, m := range refused {
|
||||||
|
fmt.Printf("%s says %s, the settings form's names, and is not on %s: write config, takes-config and "+
|
||||||
|
"${config:<key>} throughout, and name the keys each mergeable file takes (novox/hq ADR 0348 §1, §11)\n", m, strings.Join(found[m], ", "), listPathInCatalog)
|
||||||
|
}
|
||||||
|
for _, m := range stale {
|
||||||
|
fmt.Printf("%s names %s, which says no old name: take it off the list\n", listPathInCatalog, m)
|
||||||
|
}
|
||||||
|
for _, m := range added {
|
||||||
|
fmt.Printf("%s gains %s, which the trunk's copy does not hold: the list only shrinks — move the module to "+
|
||||||
|
"config instead (novox/hq ADR 0348 §11)\n", listPathInCatalog, m)
|
||||||
|
}
|
||||||
|
if len(refused) > 0 || len(stale) > 0 || len(added) > 0 {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Printf("config: no moved module says settings, takes-settings or ${setting:<key>}, or takes a whole layer "+
|
||||||
|
"(%d not yet moved)\n",
|
||||||
|
len(unmoved))
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func module(t *testing.T, root, name, body string, files map[string]string) {
|
||||||
|
t.Helper()
|
||||||
|
dir := filepath.Join(root, "modules", name)
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "module.json"), []byte(body), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for path, text := range files {
|
||||||
|
full := filepath.Join(dir, filepath.FromSlash(path))
|
||||||
|
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(full, []byte(text), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each of the settings form's names is found where it stands: the declaration, a mergeable file's keys, a
|
||||||
|
// placeholder in the manifest, and a placeholder in a template file the manifest names (novox/hq ADR 0348 §1).
|
||||||
|
func TestEveryOldNameIsFound(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
module(t, root, "declares", `{"module":"declares","settings":{"size":{"kind":"preference","default":1,"why":"w"}},`+
|
||||||
|
`"resources":[{"id":"f","type":"file","content":"${setting:size}"}]}`, nil)
|
||||||
|
module(t, root, "takes", `{"module":"takes","resources":[{"id":"f","type":"file","merge":"json",`+
|
||||||
|
`"takes-settings":["a"],"content":"{}"}]}`, nil)
|
||||||
|
module(t, root, "asks", `{"module":"asks","serves":{"x":{"d":"${setting:domain}"}}}`, nil)
|
||||||
|
module(t, root, "templated", `{"module":"templated","config":{"size":{"type":"number","description":"d",`+
|
||||||
|
`"default":1}},"resources":[{"id":"f","type":"file","template":"files/conf"}]}`,
|
||||||
|
map[string]string{"files/conf": "size = ${setting:size}\n"})
|
||||||
|
module(t, root, "moved", `{"module":"moved","config":{"size":{"type":"number","description":"d","default":1}},`+
|
||||||
|
`"resources":[{"id":"f","type":"file","template":"files/conf"},{"id":"g","type":"file","merge":"json",`+
|
||||||
|
`"takes-config":[],"content":"{\"settings\": 1}"}]}`,
|
||||||
|
map[string]string{"files/conf": "size = ${config:size}\n# change the settings, never this file\n"})
|
||||||
|
module(t, root, "none", `{"module":"none","resources":[{"id":"f","type":"file","content":"no settings here"}]}`, nil)
|
||||||
|
got, err := Check(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := map[string][]string{
|
||||||
|
"asks": {"${setting:…}"},
|
||||||
|
"declares": {"${setting:…}", "settings"},
|
||||||
|
"takes": {"takes-settings"},
|
||||||
|
"templated": {"${setting:…} in files/conf"},
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("found %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A module not on the list says no old name; one on the list that says none is taken off it; a line the trunk's
|
||||||
|
// list does not hold is refused, so the list only shrinks until it is empty (novox/hq ADR 0348 §11).
|
||||||
|
func TestTheListOfModulesNotYetMovedOnlyShrinks(t *testing.T) {
|
||||||
|
found := map[string][]string{"old": {"settings"}, "forgotten": {"takes-settings"}}
|
||||||
|
unmoved := map[string]bool{"old": true, "done": true, "new": true}
|
||||||
|
base := map[string]bool{"old": true, "done": true, "forgotten": true}
|
||||||
|
refused, stale, added := Judge(found, unmoved, base)
|
||||||
|
if !reflect.DeepEqual(refused, []string{"forgotten"}) || !reflect.DeepEqual(stale, []string{"done", "new"}) ||
|
||||||
|
!reflect.DeepEqual(added, []string{"new"}) {
|
||||||
|
t.Fatalf("refused %v, stale %v, added %v", refused, stale, added)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheListIsReadWithoutItsComments(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "unmoved")
|
||||||
|
if err := os.WriteFile(path, []byte("# why\n\nmailu\n searxng \n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, err := Unmoved(path)
|
||||||
|
if err != nil || !reflect.DeepEqual(got, map[string]bool{"mailu": true, "searxng": true}) {
|
||||||
|
t.Fatalf("%v %v", got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The catalogue itself: every moved module says config alone, and the list names exactly the modules that
|
||||||
|
// still say settings.
|
||||||
|
func TestTheCatalogueSaysConfigInEveryMovedModule(t *testing.T) {
|
||||||
|
found, err := Check(filepath.Join("..", ".."))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
unmoved, err := Unmoved("unmoved")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
refused, stale, added := Judge(found, unmoved, seed)
|
||||||
|
for _, m := range refused {
|
||||||
|
t.Errorf("%s is not on checks/config/unmoved and says %s", m, strings.Join(found[m], ", "))
|
||||||
|
}
|
||||||
|
for _, m := range stale {
|
||||||
|
t.Errorf("checks/config/unmoved names %s, which says no old name", m)
|
||||||
|
}
|
||||||
|
for _, m := range added {
|
||||||
|
t.Errorf("checks/config/unmoved gains %s, which its first copy did not hold", m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A mergeable file that names no keys takes every key of its module's layer (novox/hq ADR 0326 §3): the settings
|
||||||
|
// form's last shape, which declares none of what it takes, so mesh-config could set none of it (ADR 0348 §10). It
|
||||||
|
// is found like an old name, in a module that says no other (issue 735); one naming its keys, an empty list
|
||||||
|
// included, is not.
|
||||||
|
func TestAMergeableFileTakingTheWholeLayerIsFound(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
module(t, root, "whole", `{"module":"whole","resources":[{"id":"runtime-config","type":"file","merge":"json",`+
|
||||||
|
`"template":"files/config.json"}]}`, map[string]string{"files/config.json": "{}\n"})
|
||||||
|
module(t, root, "names", `{"module":"names","config":{"url":{"type":"string","description":"d","default":""}},`+
|
||||||
|
`"resources":[{"id":"runtime-config","type":"file","merge":"json","takes-config":["url"],"content":"{}"}]}`, nil)
|
||||||
|
module(t, root, "none", `{"module":"none","resources":[{"id":"c","type":"file","merge":"json",`+
|
||||||
|
`"takes-config":[],"content":"{}"}]}`, nil)
|
||||||
|
module(t, root, "plain", `{"module":"plain","resources":[{"id":"c","type":"file","content":"{}"}]}`, nil)
|
||||||
|
got, err := Check(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := map[string][]string{"whole": {"runtime-config taking its whole layer"}}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("found %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# The modules still written in the settings form (`settings`, `takes-settings`, `${setting:<key>}`, or a mergeable
|
||||||
|
# file naming no keys, which takes its whole layer), one per line: the catalogue's list of modules not yet moved to
|
||||||
|
# config (novox/hq ADR 0348 §11). Every other module says config alone. This list only shrinks: a module that says no
|
||||||
|
# old name is taken off, and a line the trunk's copy does not hold is refused. When it is empty, the controller
|
||||||
|
# refuses the old names (design 57 package R1).
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
module git.novox.be/novox/mesh-catalog/checks/mailstore
|
||||||
|
|
||||||
|
go 1.22
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
// Command mailstore refuses any manifest but modules/mesh-mail claiming the mail-store seat (novox/hq ADR 0378
|
||||||
|
// §3, issue 800 part A): the operator's mail is read, searched and sent through this one seat, and a second
|
||||||
|
// claimant would be a second path to the same mail that ADR 0378 never accounted for.
|
||||||
|
//
|
||||||
|
// go run . <catalogue root>
|
||||||
|
//
|
||||||
|
// Exits 1 on a finding.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MailStoreSeat is the seat only mesh-mail may claim.
|
||||||
|
const MailStoreSeat = "mail-store"
|
||||||
|
|
||||||
|
// MailStoreModule is the one module allowed to claim it.
|
||||||
|
const MailStoreModule = "mesh-mail"
|
||||||
|
|
||||||
|
// Finding is one manifest refused: a module other than mesh-mail claiming mail-store.
|
||||||
|
type Finding struct {
|
||||||
|
Module string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check reads every manifest under root/modules and answers which ones it refuses.
|
||||||
|
func Check(root string) ([]Finding, error) {
|
||||||
|
manifests, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sort.Strings(manifests)
|
||||||
|
var out []Finding
|
||||||
|
for _, path := range manifests {
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var m struct {
|
||||||
|
Module string `json:"module"`
|
||||||
|
Claims []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"claims"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &m); err != nil {
|
||||||
|
return nil, fmt.Errorf("%s: %w", path, err)
|
||||||
|
}
|
||||||
|
if m.Module == MailStoreModule {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, c := range m.Claims {
|
||||||
|
if c.Name == MailStoreSeat {
|
||||||
|
out = append(out, Finding{Module: m.Module})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
root := "."
|
||||||
|
if len(os.Args) > 1 {
|
||||||
|
root = os.Args[1]
|
||||||
|
}
|
||||||
|
found, err := Check(root)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
for _, f := range found {
|
||||||
|
fmt.Printf("%s claims the mail-store seat: only %s may (novox/hq ADR 0378 §3)\n", f.Module, MailStoreModule)
|
||||||
|
}
|
||||||
|
if len(found) > 0 {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Println("mail-store: claimed only by mesh-mail")
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func manifest(t *testing.T, root, module, claims string) {
|
||||||
|
t.Helper()
|
||||||
|
dir := filepath.Join(root, "modules", module)
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
body := `{"module":"` + module + `","claims":[` + claims + `]}`
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "module.json"), []byte(body), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnotherModuleClaimingMailStoreIsRefused(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
manifest(t, root, "mesh-mail", `{"name":"mail-store","scope":"mesh"}`)
|
||||||
|
manifest(t, root, "betterbird", `{"name":"mail-store","scope":"mesh"}`)
|
||||||
|
manifest(t, root, "dunst", `{"name":"node-notifier","scope":"node"}`)
|
||||||
|
found, err := Check(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(found) != 1 || found[0].Module != "betterbird" {
|
||||||
|
t.Fatalf("refused %+v", found)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheCatalogueHasNone(t *testing.T) {
|
||||||
|
found, err := Check("../..")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(found) != 0 {
|
||||||
|
t.Fatalf("%+v", found)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
module git.novox.be/novox/mesh-catalog/checks/predefined
|
||||||
|
|
||||||
|
go 1.22
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
// Command predefined refuses a change to predefined.json that removes a key a module still names (novox/hq ADR
|
||||||
|
// 0348 clause 7, design 57 package K2, issue 661).
|
||||||
|
//
|
||||||
|
// Mesh config and node config hold values only for the keys on the catalogue's list of predefined keys, at the
|
||||||
|
// root of the repository; a module's config value may fall back to one, `"predefined": "<key>"`. A key removed
|
||||||
|
// while a manifest still names it would leave that module falling back to nothing: the controller refuses its
|
||||||
|
// build then, at the next merge that touches it. So the removal is refused here, in the pull request that makes
|
||||||
|
// it, naming each module that names the key.
|
||||||
|
//
|
||||||
|
// **What it cannot know.** Whether a key still holds a value at the mesh or a node is mesh-config's to say (design
|
||||||
|
// 57 package K3): it holds the values, and the catalogue's check reads no store. Until mesh-config refuses that
|
||||||
|
// itself, removing a key nobody names is let through here, and said.
|
||||||
|
//
|
||||||
|
// The list it compares with is the trunk's at the merge base, given with -base; without one, nothing was removed.
|
||||||
|
// The controller the mesh runs reads the list itself (type, validation, every manifest against it) in the
|
||||||
|
// module check; this check reads only its keys.
|
||||||
|
//
|
||||||
|
// go run . [-base <the trunk's predefined.json>] <catalogue root>
|
||||||
|
//
|
||||||
|
// Exits 1 on a finding.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// File is the list's file at the root of the catalogue.
|
||||||
|
const File = "predefined.json"
|
||||||
|
|
||||||
|
// Keys is the keys of a list, read as a JSON object; an absent file has none.
|
||||||
|
func Keys(path string) (map[string]bool, error) {
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
|
return map[string]bool{}, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var keys map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(raw, &keys); err != nil {
|
||||||
|
return nil, fmt.Errorf("%s is not a JSON object of keys: %w", path, err)
|
||||||
|
}
|
||||||
|
out := map[string]bool{}
|
||||||
|
for k := range keys {
|
||||||
|
out[k] = true
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Named is, per predefined key, the modules whose manifests name it: under `config`, or its alias `settings`.
|
||||||
|
func Named(root string) (map[string][]string, error) {
|
||||||
|
manifests, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sort.Strings(manifests)
|
||||||
|
named := map[string][]string{}
|
||||||
|
for _, path := range manifests {
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var m struct {
|
||||||
|
Module string `json:"module"`
|
||||||
|
Config map[string]json.RawMessage `json:"config"`
|
||||||
|
Settings map[string]json.RawMessage `json:"settings"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &m); err != nil {
|
||||||
|
return nil, fmt.Errorf("%s: %w", path, err)
|
||||||
|
}
|
||||||
|
for _, declared := range []map[string]json.RawMessage{m.Config, m.Settings} {
|
||||||
|
for _, d := range declared {
|
||||||
|
var value struct {
|
||||||
|
Predefined string `json:"predefined"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(d, &value) == nil && value.Predefined != "" {
|
||||||
|
named[value.Predefined] = append(named[value.Predefined], m.Module)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return named, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Removed is each key the base list holds and the tree's does not, with the modules still naming it; a removed
|
||||||
|
// key nobody names is answered with none.
|
||||||
|
func Removed(base, tree map[string]bool, named map[string][]string) map[string][]string {
|
||||||
|
out := map[string][]string{}
|
||||||
|
for k := range base {
|
||||||
|
if !tree[k] {
|
||||||
|
out[k] = named[k]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
basePath := flag.String("base", "", "the trunk's predefined.json at the merge base")
|
||||||
|
flag.Parse()
|
||||||
|
if flag.NArg() != 1 {
|
||||||
|
fmt.Fprintln(os.Stderr, "usage: predefined [-base <the trunk's predefined.json>] <catalogue root>")
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
root := flag.Arg(0)
|
||||||
|
tree, err := Keys(filepath.Join(root, File))
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if *basePath == "" {
|
||||||
|
fmt.Printf("%s: %d key(s); no trunk list given, so none was removed\n", File, len(tree))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
base, err := Keys(*basePath)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
named, err := Named(root)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
removed := Removed(base, tree, named)
|
||||||
|
keys := make([]string, 0, len(removed))
|
||||||
|
for k := range removed {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
failed := false
|
||||||
|
for _, k := range keys {
|
||||||
|
if modules := removed[k]; len(modules) > 0 {
|
||||||
|
failed = true
|
||||||
|
fmt.Printf("%s: the key %q is removed and still named by %s: move each off it first (novox/hq ADR 0348 "+
|
||||||
|
"clause 7)\n", File, k, strings.Join(modules, ", "))
|
||||||
|
} else {
|
||||||
|
fmt.Printf("%s: the key %q is removed and no module names it; whether the mesh or a node still holds a "+
|
||||||
|
"value for it is mesh-config's to refuse, not this check's\n", File, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if failed {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Printf("%s: %d key(s), none removed that a module names\n", File, len(tree))
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A catalogue whose dunst falls back to font-size, and the trunk's list holding font-size and
|
||||||
|
// agent-managed-settings.
|
||||||
|
func aCatalogue(t *testing.T, list string) (root, base string) {
|
||||||
|
t.Helper()
|
||||||
|
root = t.TempDir()
|
||||||
|
write := func(path, text string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, []byte(text), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
write(filepath.Join(root, "modules", "dunst", "module.json"), `{"module": "dunst", "config": {"font-size":
|
||||||
|
{"type": "number", "description": "points", "default": 10, "predefined": "font-size"}}}`)
|
||||||
|
write(filepath.Join(root, "modules", "claude-code", "module.json"), `{"module": "claude-code", "settings": {
|
||||||
|
"managed_settings": {"kind": "preference", "default": "{}", "why": "none"}}}`)
|
||||||
|
write(filepath.Join(root, "modules", "plain", "module.json"), `{"module": "plain"}`)
|
||||||
|
if list != "" {
|
||||||
|
write(filepath.Join(root, File), list)
|
||||||
|
}
|
||||||
|
base = filepath.Join(t.TempDir(), "base.json")
|
||||||
|
write(base, `{"font-size": {"type": "number"}, "agent-managed-settings": {"type": "object"}}`)
|
||||||
|
return root, base
|
||||||
|
}
|
||||||
|
|
||||||
|
func run(t *testing.T, args ...string) (string, error) {
|
||||||
|
t.Helper()
|
||||||
|
out, err := exec.Command("go", append([]string{"run", "."}, args...)...).CombinedOutput()
|
||||||
|
return string(out), err
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestARemovalOfAKeyAModuleStillNamesIsRefused(t *testing.T) {
|
||||||
|
// The planted removal: font-size goes while dunst still falls back to it.
|
||||||
|
root, base := aCatalogue(t, `{"agent-managed-settings": {"type": "object"}}`)
|
||||||
|
out, err := run(t, "-base", base, root)
|
||||||
|
if err == nil || !strings.Contains(out, `the key "font-size" is removed and still named by dunst`) {
|
||||||
|
t.Fatalf("a removal of a named key passed:\n%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestARemovalNobodyNamesPassesAndSaysWhatItCannotKnow(t *testing.T) {
|
||||||
|
root, base := aCatalogue(t, `{"font-size": {"type": "number"}}`)
|
||||||
|
out, err := run(t, "-base", base, root)
|
||||||
|
if err != nil || !strings.Contains(out, `"agent-managed-settings" is removed and no module names it`) ||
|
||||||
|
!strings.Contains(out, "mesh-config's to refuse") {
|
||||||
|
t.Fatalf("a removal nobody names: %v\n%s", err, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAListUnchangedOrGrownPasses(t *testing.T) {
|
||||||
|
for _, list := range []string{
|
||||||
|
`{"font-size": {"type": "number"}, "agent-managed-settings": {"type": "object"}}`,
|
||||||
|
`{"font-size": {"type": "number"}, "agent-managed-settings": {"type": "object"}, "accent": {"type": "string"}}`,
|
||||||
|
} {
|
||||||
|
root, base := aCatalogue(t, list)
|
||||||
|
if out, err := run(t, "-base", base, root); err != nil || !strings.Contains(out, "none removed") {
|
||||||
|
t.Fatalf("%s: %v\n%s", list, err, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// No trunk list given: nothing was removed.
|
||||||
|
root, _ := aCatalogue(t, `{}`)
|
||||||
|
if out, err := run(t, root); err != nil || !strings.Contains(out, "none was removed") {
|
||||||
|
t.Fatalf("without a base: %v\n%s", err, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheFileRemovedWholeIsARemovalOfEveryKey(t *testing.T) {
|
||||||
|
root, base := aCatalogue(t, "")
|
||||||
|
out, err := run(t, "-base", base, root)
|
||||||
|
if err == nil || !strings.Contains(out, `"font-size" is removed and still named by dunst`) {
|
||||||
|
t.Fatalf("the list removed whole passed:\n%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNamedReadsBothForms(t *testing.T) {
|
||||||
|
root, _ := aCatalogue(t, "")
|
||||||
|
if err := os.WriteFile(filepath.Join(root, "modules", "plain", "module.json"), []byte(`{"module": "plain",
|
||||||
|
"settings": {"size": {"predefined": "font-size"}}}`), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
named, err := Named(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := strings.Join(named["font-size"], ","); got != "dunst,plain" {
|
||||||
|
t.Fatalf("font-size named by %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
+11
-1
@@ -7,7 +7,8 @@
|
|||||||
// 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.
|
// of the file's `takes-settings` (novox/hq ADR 0326), so that no layer reaches it. A file whose text is a template
|
||||||
|
// in files/ is judged by the template's text.
|
||||||
//
|
//
|
||||||
// A secret nested below a key the file takes for its other values (`server.secret_key` below `server`) cannot
|
// 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`
|
||||||
@@ -64,6 +65,15 @@ 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,6 +51,35 @@ func TestASecretASettingCouldReplaceIsRefused(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A mergeable file whose text is a template in files/ (novox/hq ADR 0349 §2) is judged by the template's text,
|
||||||
|
// exactly as the same text inline; a template the module's directory lacks is an error, never a silent pass.
|
||||||
|
func TestAMergeableFilesTemplateIsJudgedAsItsText(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
manifest(t, root, "a", `{"id":"cfg","type":"file","merge":"json","template":"files/cfg.json"}`)
|
||||||
|
manifest(t, root, "c", `{"id":"cfg","type":"file","merge":"json","protected":["password"],"template":"files/cfg.json"}`)
|
||||||
|
for _, m := range []string{"a", "c"} {
|
||||||
|
dir := filepath.Join(root, "modules", m, "files")
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "cfg.json"), []byte(`{"password": "${secret:admin}"}`), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
found, err := Check(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(found) != 1 || found[0].String() != "a cfg password" {
|
||||||
|
t.Fatalf("found %v, want [a cfg password]", found)
|
||||||
|
}
|
||||||
|
|
||||||
|
manifest(t, root, "g", `{"id":"cfg","type":"file","merge":"json","template":"files/gone.json"}`)
|
||||||
|
if _, err := Check(root); err == nil {
|
||||||
|
t.Fatal("a template the module's directory lacks passed the check")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestTheKnownListOnlyShrinks(t *testing.T) {
|
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})
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
module git.novox.be/novox/mesh-catalog/checks/templates
|
||||||
|
|
||||||
|
go 1.22
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# The modules that still keep a file's text inline in module.json (`content`), one per line: the catalogue's list of
|
||||||
|
# modules whose file text has not yet moved to a template in files/ (novox/hq ADR 0349 §2, design 57), or in the
|
||||||
|
# templates directory its manifest names (`templates`, issue 731). Every other module's file resources name their
|
||||||
|
# template. This list only shrinks: a module that keeps no file text inline is taken off, and a line the trunk's copy
|
||||||
|
# does not hold is refused. When it is empty, no manifest in the catalogue carries file text (design 57 package T5,
|
||||||
|
# issue 658).
|
||||||
|
#
|
||||||
|
# A window-manager addition (`contributions`, such as files/i3/*.conf) is not a file resource and takes no template;
|
||||||
|
# it moves with the collections (design 57 package L2, issue 681) and is not judged here.
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
// Command templates refuses a file's text kept inline in a manifest, outside the modules still to move (novox/hq
|
||||||
|
// ADR 0349 §2, design 57 package T5, issue 658).
|
||||||
|
//
|
||||||
|
// A file resource names its text as a template in its module's templates directory (`"template": "<dir>/<name>"`;
|
||||||
|
// the directory is the manifest's `"templates"`, files/ when it says none), which the module's build packs into an
|
||||||
|
// archive; the controller reads each template back into the file's content, so the
|
||||||
|
// mesh holds and composes what it would for the same text inline. A module whose file text has not yet moved is
|
||||||
|
// listed in the file `inline` beside this check, one per line. Every other module's file resources say no
|
||||||
|
// `content`; a module on the list that keeps none inline is taken off it; and a line the trunk's copy (the merge
|
||||||
|
// base's, given with -base) does not hold is refused, so the list only shrinks; while the trunk holds no copy, the
|
||||||
|
// modules in seed are the trunk's. When it is empty, no manifest in the catalogue carries file text.
|
||||||
|
//
|
||||||
|
// Only file resources are judged: a window-manager addition (`contributions`) takes no template and moves with the
|
||||||
|
// collections (design 57 package L2, issue 681).
|
||||||
|
//
|
||||||
|
// go run . [-base <the trunk's inline>] <catalogue root>
|
||||||
|
//
|
||||||
|
// Every refusal names the refused module's own templates directory. Exits 1 on a finding.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
listFile = "inline"
|
||||||
|
listPathInCatalog = "checks/templates/" + listFile
|
||||||
|
)
|
||||||
|
|
||||||
|
// Check answers, per module that keeps any, the ids of its file resources that say their text inline.
|
||||||
|
func Check(root string) (map[string][]string, error) {
|
||||||
|
manifests, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sort.Strings(manifests)
|
||||||
|
out := map[string][]string{}
|
||||||
|
for _, path := range manifests {
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var m struct {
|
||||||
|
Module string `json:"module"`
|
||||||
|
Resources []map[string]any `json:"resources"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &m); err != nil {
|
||||||
|
return nil, fmt.Errorf("%s: %w", path, err)
|
||||||
|
}
|
||||||
|
for _, r := range m.Resources {
|
||||||
|
if _, inline := r["content"]; inline && r["type"] == "file" {
|
||||||
|
out[m.Module] = append(out[m.Module], fmt.Sprint(r["id"]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TemplatesDir answers the directory a module keeps its templates in, with a trailing slash: its manifest's
|
||||||
|
// `templates`, files/ when the manifest says none or cannot be read here.
|
||||||
|
func TemplatesDir(root, module string) string {
|
||||||
|
dir := "files/"
|
||||||
|
raw, err := os.ReadFile(filepath.Join(root, "modules", module, "module.json"))
|
||||||
|
if err != nil {
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
var m struct {
|
||||||
|
Templates string `json:"templates"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(raw, &m) == nil && m.Templates != "" {
|
||||||
|
dir = strings.TrimSuffix(m.Templates, "/") + "/"
|
||||||
|
}
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refusal is the finding for a module that keeps file text inline and is not on the list.
|
||||||
|
func Refusal(module string, ids []string, dir string) string {
|
||||||
|
return fmt.Sprintf("%s keeps the text of %s inline and is not on %s: keep each file's text in %s and name it as "+
|
||||||
|
"the file's template, packed by the build's archive from %s (novox/hq ADR 0349 §2)",
|
||||||
|
module, strings.Join(ids, ", "), listPathInCatalog, dir, dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gained is the finding for a module the list gains against the trunk's copy.
|
||||||
|
func Gained(module, dir string) string {
|
||||||
|
return fmt.Sprintf("%s gains %s, which the trunk's copy does not hold: the list only shrinks — move its file text "+
|
||||||
|
"to %s instead (novox/hq ADR 0349 §2)", listPathInCatalog, module, dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// seed is the list as it was first written, standing for the trunk's copy until the trunk holds one.
|
||||||
|
var seed = map[string]bool{"asus-zephyrus-g14": true, "power": true, "showcase": true}
|
||||||
|
|
||||||
|
// Listed is the modules a list names, one per line; a line starting with # and a blank line are not modules.
|
||||||
|
func Listed(path string) (map[string]bool, error) {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
out := map[string]bool{}
|
||||||
|
s := bufio.NewScanner(f)
|
||||||
|
for s.Scan() {
|
||||||
|
line := strings.TrimSpace(s.Text())
|
||||||
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out[line] = true
|
||||||
|
}
|
||||||
|
return out, s.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Judge answers the modules that keep file text inline and are not on the list (refused), the modules on the list
|
||||||
|
// that keep none (stale), and the modules on the list that the trunk's list does not hold (added).
|
||||||
|
func Judge(found map[string][]string, listed, base map[string]bool) (refused, stale, added []string) {
|
||||||
|
for m := range found {
|
||||||
|
if !listed[m] {
|
||||||
|
refused = append(refused, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for m := range listed {
|
||||||
|
if _, keeps := found[m]; !keeps {
|
||||||
|
stale = append(stale, m)
|
||||||
|
}
|
||||||
|
if !base[m] {
|
||||||
|
added = append(added, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(refused)
|
||||||
|
sort.Strings(stale)
|
||||||
|
sort.Strings(added)
|
||||||
|
return refused, stale, added
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
basePath := flag.String("base", "", "the trunk's copy of inline (the merge base's); seed when absent")
|
||||||
|
flag.Parse()
|
||||||
|
root := "."
|
||||||
|
if flag.NArg() > 0 {
|
||||||
|
root = flag.Arg(0)
|
||||||
|
}
|
||||||
|
found, err := Check(root)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
listed, err := Listed(listFile)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
base := seed
|
||||||
|
if *basePath != "" {
|
||||||
|
if base, err = Listed(*basePath); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
refused, stale, added := Judge(found, listed, base)
|
||||||
|
for _, m := range refused {
|
||||||
|
fmt.Println(Refusal(m, found[m], TemplatesDir(root, m)))
|
||||||
|
}
|
||||||
|
for _, m := range stale {
|
||||||
|
fmt.Printf("%s names %s, which keeps no file text inline: take it off the list\n", listPathInCatalog, m)
|
||||||
|
}
|
||||||
|
for _, m := range added {
|
||||||
|
fmt.Println(Gained(m, TemplatesDir(root, m)))
|
||||||
|
}
|
||||||
|
if len(refused) > 0 || len(stale) > 0 || len(added) > 0 {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Printf("templates: no module keeps a file's text inline but the %d listed\n", len(listed))
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func module(t *testing.T, root, name, body string) {
|
||||||
|
t.Helper()
|
||||||
|
dir := filepath.Join(root, "modules", name)
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "module.json"), []byte(body), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A file resource that says its text inline is found, by module and resource; one naming its template, a
|
||||||
|
// resource that is not a file and a window-manager addition are not (novox/hq ADR 0349 §2).
|
||||||
|
func TestEveryInlineFileTextIsFound(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
module(t, root, "inline", `{"module":"inline","resources":[{"id":"a","type":"file","content":"x"},`+
|
||||||
|
`{"id":"b","type":"file","template":"files/b"},{"id":"c","type":"file","content":""}]}`)
|
||||||
|
module(t, root, "templated", `{"module":"templated","resources":[{"id":"a","type":"file","template":"files/a"},`+
|
||||||
|
`{"id":"d","type":"directory","path":"/x"}],"contributions":{"i3":[{"content":"bindsym x"}]}}`)
|
||||||
|
got, err := Check(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := map[string][]string{"inline": {"a", "c"}}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("found %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A module not on the list keeps no file text inline; one on the list that keeps none is taken off it; a line the
|
||||||
|
// trunk's list does not hold is refused, so the list only shrinks until it is empty.
|
||||||
|
func TestTheListOfModulesKeepingTextInlineOnlyShrinks(t *testing.T) {
|
||||||
|
found := map[string][]string{"old": {"a"}, "forgotten": {"b"}}
|
||||||
|
inline := map[string]bool{"old": true, "done": true, "new": true}
|
||||||
|
base := map[string]bool{"old": true, "done": true, "forgotten": true}
|
||||||
|
refused, stale, added := Judge(found, inline, base)
|
||||||
|
if !reflect.DeepEqual(refused, []string{"forgotten"}) || !reflect.DeepEqual(stale, []string{"done", "new"}) ||
|
||||||
|
!reflect.DeepEqual(added, []string{"new"}) {
|
||||||
|
t.Fatalf("refused %v, stale %v, added %v", refused, stale, added)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheListIsReadWithoutItsComments(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "inline")
|
||||||
|
if err := os.WriteFile(path, []byte("# why\n\npower\n showcase \n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, err := Listed(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(got, map[string]bool{"power": true, "showcase": true}) {
|
||||||
|
t.Fatalf("read %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The catalogue itself: no module keeps a file's text inline but those the list names, and the list names none
|
||||||
|
// that keeps it no longer (design 57 package T5, issue 658: the ten modules without a build moved).
|
||||||
|
func TestTheCatalogueKeepsFileTextInlineOnlyWhereListed(t *testing.T) {
|
||||||
|
found, err := Check("../..")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
inline, err := Listed(listFile)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
refused, stale, _ := Judge(found, inline, inline)
|
||||||
|
if len(refused) > 0 || len(stale) > 0 {
|
||||||
|
t.Fatalf("keeping file text inline and not listed: %v; listed and keeping none: %v", refused, stale)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A refusal names the refused module's own templates directory, not files/ when the module names another (issue 809).
|
||||||
|
func TestARefusalNamesTheModulesOwnTemplatesDirectory(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
module(t, root, "own", `{"module":"own","templates":"t/","resources":[{"id":"a","type":"file","content":"x"}]}`)
|
||||||
|
module(t, root, "plain", `{"module":"plain","resources":[{"id":"a","type":"file","content":"x"}]}`)
|
||||||
|
module(t, root, "bare", `{"module":"bare","templates":"templates","resources":[]}`)
|
||||||
|
for module, dir := range map[string]string{"own": "t/", "plain": "files/", "bare": "templates/"} {
|
||||||
|
if got := TemplatesDir(root, module); got != dir {
|
||||||
|
t.Fatalf("%s: directory %q, want %q", module, got, dir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
msg := Refusal("own", []string{"a"}, TemplatesDir(root, "own"))
|
||||||
|
if !strings.Contains(msg, "t/") || strings.Contains(msg, "files/") {
|
||||||
|
t.Fatalf("refusal %q", msg)
|
||||||
|
}
|
||||||
|
if msg := Gained("own", TemplatesDir(root, "own")); !strings.Contains(msg, "t/") || strings.Contains(msg, "files/") {
|
||||||
|
t.Fatalf("gained %q", msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
66
|
61
|
||||||
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,9 +23,51 @@
|
|||||||
#
|
#
|
||||||
# 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"
|
||||||
@@ -39,8 +81,6 @@ 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,11 +33,25 @@
|
|||||||
# 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=""
|
||||||
@@ -51,6 +65,42 @@ else
|
|||||||
echo "NOT COMPARED WITH THE TRUNK: no merge base with $secrets_ref; checks/secrets/known is held to the list it was first written with"
|
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) | [`config/gtk-settings.ini`](config/gtk-settings.ini) |
|
| `~/.config/gtk-3.0/settings.ini` | owned (the found file kept once) | [`files/gtk-settings.ini`](files/gtk-settings.ini) |
|
||||||
| `~/.config/gtk-4.0/settings.ini` | owned | the same file |
|
| `~/.config/gtk-4.0/settings.ini` | owned | the same file |
|
||||||
| `~/.config/qt6ct/qt6ct.conf` | owned | [`config/qt6ct.conf`](config/qt6ct.conf) |
|
| `~/.config/qt6ct/qt6ct.conf` | owned | [`files/qt6ct.conf`](files/qt6ct.conf) |
|
||||||
| `~/.config/xdg-desktop-portal/portals.conf` | owned | [`config/portals.conf`](config/portals.conf) |
|
| `~/.config/xdg-desktop-portal/portals.conf` | owned | [`files/portals.conf`](files/portals.conf) |
|
||||||
| `~/.icons/default/index.theme` | owned | [`config/cursor-index.theme`](config/cursor-index.theme): the cursor for programs that read neither `XCURSOR_THEME` nor the resources |
|
| `~/.icons/default/index.theme` | owned | [`files/cursor-index.theme`](files/cursor-index.theme): the cursor for programs that read neither `XCURSOR_THEME` nor the resources |
|
||||||
|
|
||||||
**`qt6ct.conf` is the mesh's now.** A change made in qt6ct's own window is replaced at the next push,
|
**`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,10 +18,18 @@ 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 {
|
||||||
@@ -33,6 +41,23 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,9 +121,10 @@ func TestItOwnsTheFilesItsSourcesHoldAndNoQt5Duplicate(t *testing.T) {
|
|||||||
pkgs = append(pkgs, r["package"].(string))
|
pkgs = append(pkgs, r["package"].(string))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
raw, _ := os.ReadFile(filepath.Join("..", "..", "config", sources[id]))
|
_, inline := r["content"]
|
||||||
if r["content"] != string(raw) || r["into"] != nil || r["owner"] != "${machine:account}" {
|
if _, err := os.Stat(filepath.Join("..", "..", "files", sources[id])); err != nil || inline ||
|
||||||
t.Errorf("%s is not config/%s, whole and the account's", id, sources[id])
|
r["template"] != "files/"+sources[id] || r["into"] != nil || r["owner"] != "${machine:account}" {
|
||||||
|
t.Errorf("%s is not files/%s, whole, as its template, and the account's", id, sources[id])
|
||||||
}
|
}
|
||||||
if strings.Contains(r["path"].(string), "qt5ct") {
|
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")
|
||||||
@@ -107,7 +133,7 @@ func TestItOwnsTheFilesItsSourcesHoldAndNoQt5Duplicate(t *testing.T) {
|
|||||||
if strings.Join(pkgs, ",") != "gnome-themes-extra,adwaita-icon-theme,adwaita-cursors,qt6ct,xdg-desktop-portal-gtk" {
|
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("..", "..", "config", "qt6ct.conf"))
|
qt := readINI(filepath.Join("..", "..", "files", "qt6ct.conf"))
|
||||||
if qt["Appearance"]["style"] != "Fusion" || qt["Appearance"]["custom_palette"] != "true" || !strings.Contains(qt["Fonts"]["general"], "Inter,11") {
|
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)
|
||||||
}
|
}
|
||||||
@@ -116,7 +142,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("..", "..", "config", "gtk-settings.ini"))["Settings"]
|
gtk := readINI(filepath.Join("..", "..", "files", "gtk-settings.ini"))["Settings"]
|
||||||
if gtk["gtk-theme-name"] != "Adwaita" || gtk["gtk-application-prefer-dark-theme"] != "1" || gtk["gtk-font-name"] != "Inter 11" {
|
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)
|
||||||
}
|
}
|
||||||
@@ -244,3 +270,31 @@ 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
Adwaita:dark
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/usr/share/themes/Adwaita-dark/gtk-2.0/gtkrc
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
qt6ct
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
6
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Fusion
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
24
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Adwaita
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# The appearance (module adwaita): GSettings is where the portal reads dark or light, and the portal is
|
||||||
|
# the only way it reaches Electron, Chromium, Firefox and flatpaks. Set at every session start to the
|
||||||
|
# module's default; adwaita_appearance switches it for a session.
|
||||||
|
gsettings set org.gnome.desktop.interface color-scheme 'prefer-dark' || true
|
||||||
|
gsettings set org.gnome.desktop.interface gtk-theme 'Adwaita' || true
|
||||||
|
gsettings set org.gnome.desktop.interface icon-theme 'Adwaita' || true
|
||||||
|
gsettings set org.gnome.desktop.interface cursor-theme 'Adwaita' || true
|
||||||
|
gsettings set org.gnome.desktop.interface cursor-size 24 || true
|
||||||
|
gsettings set org.gnome.desktop.interface font-name 'Inter 11' || true
|
||||||
|
gsettings set org.gnome.desktop.interface monospace-font-name 'JetBrainsMono Nerd Font 11' || true
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
! adwaita: the cursor, for X programs that take it from the resources.
|
||||||
|
Xcursor.theme: Adwaita
|
||||||
|
Xcursor.size: 24
|
||||||
+81
-23
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"module": "adwaita",
|
"module": "adwaita",
|
||||||
"version": "1",
|
"version": "1",
|
||||||
|
"moved": true,
|
||||||
"capabilities": [
|
"capabilities": [
|
||||||
"package-manager"
|
"package-manager"
|
||||||
],
|
],
|
||||||
@@ -10,27 +11,79 @@
|
|||||||
"adwaita_icons",
|
"adwaita_icons",
|
||||||
"adwaita_portal_check"
|
"adwaita_portal_check"
|
||||||
],
|
],
|
||||||
"environment": {
|
"items": [
|
||||||
"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": [
|
|
||||||
{
|
{
|
||||||
"for": "xresources",
|
"collection": "environment",
|
||||||
"slot": "normal",
|
"name": "gtk-theme",
|
||||||
"code": "! adwaita: the cursor, for X programs that take it from the resources.\nXcursor.theme: Adwaita\nXcursor.size: 24\n"
|
"attributes": {
|
||||||
|
"kind": "variable",
|
||||||
|
"name": "GTK_THEME"
|
||||||
|
},
|
||||||
|
"template": "files/env-gtk-theme"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"for": "xinitrc",
|
"collection": "environment",
|
||||||
"slot": "normal",
|
"name": "gtk2-rc-files",
|
||||||
"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"
|
"attributes": {
|
||||||
|
"kind": "variable",
|
||||||
|
"name": "GTK2_RC_FILES"
|
||||||
|
},
|
||||||
|
"template": "files/env-gtk2-rc-files"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"collection": "environment",
|
||||||
|
"name": "qt-qpa-platformtheme",
|
||||||
|
"attributes": {
|
||||||
|
"kind": "variable",
|
||||||
|
"name": "QT_QPA_PLATFORMTHEME"
|
||||||
|
},
|
||||||
|
"template": "files/env-qt-qpa-platformtheme"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"collection": "environment",
|
||||||
|
"name": "qt-style-override",
|
||||||
|
"attributes": {
|
||||||
|
"kind": "variable",
|
||||||
|
"name": "QT_STYLE_OVERRIDE"
|
||||||
|
},
|
||||||
|
"template": "files/env-qt-style-override"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"collection": "environment",
|
||||||
|
"name": "qt-select",
|
||||||
|
"attributes": {
|
||||||
|
"kind": "variable",
|
||||||
|
"name": "QT_SELECT"
|
||||||
|
},
|
||||||
|
"template": "files/env-qt-select"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"collection": "environment",
|
||||||
|
"name": "xcursor-theme",
|
||||||
|
"attributes": {
|
||||||
|
"kind": "variable",
|
||||||
|
"name": "XCURSOR_THEME"
|
||||||
|
},
|
||||||
|
"template": "files/env-xcursor-theme"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"collection": "environment",
|
||||||
|
"name": "xcursor-size",
|
||||||
|
"attributes": {
|
||||||
|
"kind": "variable",
|
||||||
|
"name": "XCURSOR_SIZE"
|
||||||
|
},
|
||||||
|
"template": "files/env-xcursor-size"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"collection": "shell:xresources:normal",
|
||||||
|
"name": "xresources-normal",
|
||||||
|
"template": "files/xresources-normal"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"collection": "shell:xinitrc:normal",
|
||||||
|
"name": "xinitrc-normal",
|
||||||
|
"template": "files/xinitrc-normal"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"resources": [
|
"resources": [
|
||||||
@@ -65,7 +118,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",
|
||||||
"content": "# Written by the mesh (module adwaita, novox/hq ADR 0208), for GTK 3 and GTK 4 alike. Replaced at\n# every push; adwaita_appearance switches dark and light for the running session.\n[Settings]\ngtk-theme-name=Adwaita\ngtk-icon-theme-name=Adwaita\ngtk-cursor-theme-name=Adwaita\ngtk-cursor-theme-size=24\ngtk-font-name=Inter 11\ngtk-application-prefer-dark-theme=1\n"
|
"template": "files/gtk-settings.ini"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "gtk4",
|
"id": "gtk4",
|
||||||
@@ -73,7 +126,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",
|
||||||
"content": "# Written by the mesh (module adwaita, novox/hq ADR 0208), for GTK 3 and GTK 4 alike. Replaced at\n# every push; adwaita_appearance switches dark and light for the running session.\n[Settings]\ngtk-theme-name=Adwaita\ngtk-icon-theme-name=Adwaita\ngtk-cursor-theme-name=Adwaita\ngtk-cursor-theme-size=24\ngtk-font-name=Inter 11\ngtk-application-prefer-dark-theme=1\n"
|
"template": "files/gtk-settings.ini"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "qt6ct",
|
"id": "qt6ct",
|
||||||
@@ -81,7 +134,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",
|
||||||
"content": "[Appearance]\ncolor_scheme_path=/usr/share/qt6ct/colors/darker.conf\ncustom_palette=true\nicon_theme=Adwaita\nstandard_dialogs=default\nstyle=Fusion\n\n[Fonts]\nfixed=\"JetBrainsMono Nerd Font,11,-1,5,50,0,0,0,0,0\"\ngeneral=\"Inter,11,-1,5,50,0,0,0,0,0\"\n\n[Interface]\nactivate_item_on_single_click=1\nbuttonbox_layout=0\ncursor_flash_time=1000\ndialog_buttons_have_icons=1\ndouble_click_interval=400\ngui_effects=@Invalid()\nkeyboard_scheme=2\nmenus_have_icons=true\nshow_shortcuts_in_context_menus=true\nstylesheets=@Invalid()\ntoolbutton_style=4\nunderline_shortcut=1\nwheel_scroll_lines=3\n\n[Troubleshooting]\nforce_raster_widgets=1\nignored_applications=@Invalid()\n"
|
"template": "files/qt6ct.conf"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "portals",
|
"id": "portals",
|
||||||
@@ -89,7 +142,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",
|
||||||
"content": "# Written by the mesh (module adwaita, novox/hq ADR 0208). Replaced at every push.\n#\n# Which portal backend answers each interface. i3 is not a desktop xdg-desktop-portal knows, so with\n# no preference it uses whichever backend happens to be installed: fine while gtk is the only one,\n# wrong the day another arrives as somebody else's dependency. Named instead. gtk also serves\n# org.freedesktop.appearance (dark or light) from GSettings, which the session's start sets.\n[preferred]\ndefault=gtk\n# Secrets for sandboxed programs come from the keyring's backend. Without this line no backend answers\n# the interface: gtk does not implement it, and gnome-keyring's names only GNOME as its desktop.\norg.freedesktop.impl.portal.Secret=gnome-keyring\n"
|
"template": "files/portals.conf"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "cursor",
|
"id": "cursor",
|
||||||
@@ -97,7 +150,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",
|
||||||
"content": "# Written by the mesh (module adwaita, novox/hq ADR 0208): the default cursor theme, for programs that\n# read neither XCURSOR_THEME nor the X resources.\n[Icon Theme]\nName=Default\nInherits=Adwaita\n"
|
"template": "files/cursor-index.theme"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"build": {
|
"build": {
|
||||||
@@ -112,6 +165,11 @@
|
|||||||
"loads": [
|
"loads": [
|
||||||
"adwaita-tools"
|
"adwaita-tools"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "files",
|
||||||
|
"kind": "archive",
|
||||||
|
"from": "files"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The module's directory, where its manifest and its files/ are.
|
||||||
|
const moduleDir = "../.."
|
||||||
|
|
||||||
|
// templateText is the text a file resource places on the machine: its template's, in the module's files/
|
||||||
|
// (novox/hq ADR 0349 §2). A resource that names no template, or a template that is not there, fails the test.
|
||||||
|
func templateText(t *testing.T, r map[string]any) string {
|
||||||
|
t.Helper()
|
||||||
|
name, _ := r["template"].(string)
|
||||||
|
if name == "" {
|
||||||
|
t.Fatalf("resource %v names no template", r["id"])
|
||||||
|
}
|
||||||
|
raw, err := os.ReadFile(filepath.Join(moduleDir, filepath.FromSlash(name)))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resource %v names %s: %v", r["id"], name, err)
|
||||||
|
}
|
||||||
|
return string(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
// No file resource carries its text inline: each names its template in files/, and the build packs files/
|
||||||
|
// into the one archive the controller reads the templates back from (novox/hq ADR 0349 §2).
|
||||||
|
func TestEveryFileTakesItsTextFromATemplate(t *testing.T) {
|
||||||
|
raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var m struct {
|
||||||
|
Resources []map[string]any `json:"resources"`
|
||||||
|
Build struct {
|
||||||
|
Artifacts []map[string]any `json:"artifacts"`
|
||||||
|
} `json:"build"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &m); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, r := range m.Resources {
|
||||||
|
if _, inline := r["content"]; inline {
|
||||||
|
t.Errorf("resource %v keeps its text inline: keep it in files/ and name it as the template", r["id"])
|
||||||
|
}
|
||||||
|
if r["type"] == "file" {
|
||||||
|
templateText(t, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
packs := 0
|
||||||
|
for _, a := range m.Build.Artifacts {
|
||||||
|
if a["kind"] == "archive" && a["from"] == "files" {
|
||||||
|
packs++
|
||||||
|
if a["name"] != "files" {
|
||||||
|
t.Errorf("the templates archive is named %v, not files", a["name"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if packs != 1 {
|
||||||
|
t.Errorf("the build packs files/ into %d archives, not one", packs)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# The names the artifact store had before, from the setting older-store-names, written by the mesh.
|
||||||
|
# artifact_store_references reads every definition under these too (novox/hq issue 323).
|
||||||
|
# Replaced on every push; change the setting, never this file.
|
||||||
|
older=${config:older-store-names}
|
||||||
@@ -13,13 +13,14 @@
|
|||||||
"artifact_store_references",
|
"artifact_store_references",
|
||||||
"artifact_store_collect"
|
"artifact_store_collect"
|
||||||
],
|
],
|
||||||
"settings": {
|
"config": {
|
||||||
"older-store-names": {
|
"older-store-names": {
|
||||||
"kind": "preference",
|
"type": "string",
|
||||||
"default": "none",
|
"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).",
|
||||||
"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)"
|
"pattern": "none|[A-Za-z0-9.:-]+( [A-Za-z0-9.:-]+)*",
|
||||||
}
|
"default": "none"
|
||||||
},
|
}
|
||||||
|
},
|
||||||
"resources": [
|
"resources": [
|
||||||
{
|
{
|
||||||
"id": "state",
|
"id": "state",
|
||||||
@@ -33,7 +34,7 @@
|
|||||||
"path": "${dir:state}/store-names",
|
"path": "${dir:state}/store-names",
|
||||||
"mode": "0600",
|
"mode": "0600",
|
||||||
"trusted": false,
|
"trusted": false,
|
||||||
"content": "# The names the artifact store had before, from the setting older-store-names, written by the mesh.\n# artifact_store_references reads every definition under these too (novox/hq issue 323).\n# Replaced on every push; change the setting, never this file.\nolder=${setting:older-store-names}\n"
|
"template": "files/store-names"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"build": {
|
"build": {
|
||||||
@@ -52,6 +53,11 @@
|
|||||||
"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,11 +34,9 @@ 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.
|
||||||
var own string
|
own := m.i3Lines()
|
||||||
for _, c := range m.Contributions {
|
if own == "" || len(m.Contributions) != 0 {
|
||||||
if c.Seat == "node-display-session" {
|
t.Fatalf("the lines for the window manager are one item, and no contribution is left: %q %v", own, m.Contributions)
|
||||||
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,19 +15,41 @@ 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"`
|
||||||
// Contributions to other modules' seats (novox/hq ADR 0212): the vendor keys go to node-hotkeys.
|
// Items of collections (novox/hq ADR 0349 clause 13): the vendor keys are an item of the node-hotkeys
|
||||||
Contributions []struct {
|
// triggers, its text the template file beside the manifest.
|
||||||
Seat string `json:"seat"`
|
Items []struct {
|
||||||
Kind string `json:"kind"`
|
Collection string `json:"collection"`
|
||||||
Content string `json:"content"`
|
Template string `json:"template"`
|
||||||
} `json:"contributions"`
|
} `json:"items"`
|
||||||
|
// Old-form contributions to other modules' seats (novox/hq ADR 0212): none is left.
|
||||||
|
Contributions []any `json:"contributions"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// triggers is the module's contribution to node-hotkeys: its vendor keys.
|
// i3Lines is the module's lines for the window manager: the text of the template of its item of the collection
|
||||||
|
// contribution:node-display-session:config (design 57 L3, issue 848).
|
||||||
|
func (m manifest) i3Lines() string {
|
||||||
|
for _, it := range m.Items {
|
||||||
|
if it.Collection == "contribution:node-display-session:config" {
|
||||||
|
raw, err := os.ReadFile(filepath.Join("..", "..", it.Template))
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return string(raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// triggers is the module's contribution to node-hotkeys: its vendor keys, the text of the template of its
|
||||||
|
// item of the collection contribution:node-hotkeys:trigger.
|
||||||
func (m manifest) triggers() string {
|
func (m manifest) triggers() string {
|
||||||
for _, c := range m.Contributions {
|
for _, it := range m.Items {
|
||||||
if c.Seat == "node-hotkeys" && c.Kind == "trigger" {
|
if it.Collection == "contribution:node-hotkeys:trigger" {
|
||||||
return c.Content
|
raw, err := os.ReadFile(filepath.Join("..", "..", it.Template))
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return string(raw)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Each file's text is a template in templates/, the directory the manifest names (novox/hq ADR 0349 §2, issue 731),
|
||||||
|
// never in files/: files/ is the archive the machine unpacks, and stays exactly what the machine is given.
|
||||||
|
func TestEveryFileTakesItsTextFromATemplateOutsideTheUnpackedFiles(t *testing.T) {
|
||||||
|
raw, err := os.ReadFile("../../module.json")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var m struct {
|
||||||
|
Templates string `json:"templates"`
|
||||||
|
Resources []map[string]any `json:"resources"`
|
||||||
|
Build struct {
|
||||||
|
Artifacts []map[string]any `json:"artifacts"`
|
||||||
|
} `json:"build"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &m); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if m.Templates != "templates" {
|
||||||
|
t.Fatalf("the manifest names the templates directory %q, not templates", m.Templates)
|
||||||
|
}
|
||||||
|
files := 0
|
||||||
|
for _, r := range m.Resources {
|
||||||
|
if _, inline := r["content"]; inline {
|
||||||
|
t.Errorf("resource %v keeps its text inline: keep it in templates/ and name it as the template", r["id"])
|
||||||
|
}
|
||||||
|
if r["type"] != "file" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
files++
|
||||||
|
name, _ := r["template"].(string)
|
||||||
|
if !strings.HasPrefix(name, "templates/") {
|
||||||
|
t.Errorf("resource %v names the template %q, not one in templates/", r["id"], name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if st, err := os.Lstat(filepath.Join("../..", name)); err != nil || !st.Mode().IsRegular() {
|
||||||
|
t.Errorf("resource %v names the template %s, which is not a plain file: %v", r["id"], name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if files == 0 {
|
||||||
|
t.Fatal("no file resource")
|
||||||
|
}
|
||||||
|
packs := 0
|
||||||
|
for _, a := range m.Build.Artifacts {
|
||||||
|
if a["kind"] == "archive" && a["from"] == "templates" {
|
||||||
|
packs++
|
||||||
|
if a["name"] != "templates" {
|
||||||
|
t.Errorf("the templates archive is named %v, not templates", a["name"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if packs != 1 {
|
||||||
|
t.Fatalf("the build packs templates/ into %d archives, not one", packs)
|
||||||
|
}
|
||||||
|
// The templates archive is read by the controller, never unpacked onto the machine.
|
||||||
|
for _, r := range m.Resources {
|
||||||
|
if r["artifact"] == "templates" {
|
||||||
|
t.Errorf("resource %v places the templates archive on the machine", r["id"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// And files/ holds what the machine runs, no template.
|
||||||
|
err = filepath.WalkDir("../../files", func(at string, d os.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !d.IsDir() && !strings.HasPrefix(filepath.ToSlash(at), "../../files/bin/") {
|
||||||
|
t.Errorf("files/ holds %s, which is not one of the scripts the machine unpacks", at)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
{
|
{
|
||||||
"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"
|
||||||
@@ -59,14 +61,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",
|
||||||
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The discrete GPU's driver options on the ROG Zephyrus G14 (GA403, RTX 40 series, hybrid graphics).\n#\n# NVreg_DynamicPowerManagement=0x00 turns runtime D3 off. With it on, a change of power source sends\n# the driver an ACPI notification it fails to handle on this model (\"RmHandleDNotifierEvent: Failed to\n# handle ACPI D-Notifier event, status=0x62\"), and the GPU stops making progress until the machine is\n# powered off. Off costs a few idle watts in hybrid mode and keeps the machine up.\n#\n# NVreg_PreserveVideoMemoryAllocations=1 saves video memory across suspend, so what used the GPU still\n# works after waking. It needs nvidia-suspend, -hibernate and -resume to run around a sleep, which this\n# module's drop-ins on the sleep services ask for.\n#\n# A change here applies when the driver next loads: at the next boot.\noptions nvidia NVreg_PreserveVideoMemoryAllocations=1\noptions nvidia NVreg_DynamicPowerManagement=0x00\n"
|
"template": "templates/modprobe.d/g14-nvidia-power.conf"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "video-options",
|
"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",
|
||||||
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The ACPI video driver does not change the backlight itself on the brightness keys: on this model it\n# moves the wrong one. The keys are triggerhappy's (see /etc/triggerhappy/triggers.d/asus-g14.conf).\n# Applies when the module next loads: at the next boot.\noptions video brightness_switch_enabled=0\n"
|
"template": "templates/modprobe.d/video-brightness-switch.conf"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "suspend-drop-ins",
|
"id": "suspend-drop-ins",
|
||||||
@@ -79,7 +81,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",
|
||||||
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The NVIDIA driver's own sleep actions, asked for by the sleep itself rather than enabled as\n# links: the mesh declares files and never makes links (novox/hq ADR 0012), and the host's service\n# shape must not start these units by hand, which would put the GPU to sleep with the machine awake.\n[Unit]\nWants=nvidia-suspend.service nvidia-resume.service\n"
|
"template": "templates/systemd/system/systemd-suspend.service.d/asus-zephyrus-g14-nvidia.conf"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "hibernate-drop-ins",
|
"id": "hibernate-drop-ins",
|
||||||
@@ -92,7 +94,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",
|
||||||
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The NVIDIA driver's own sleep actions, asked for by the sleep itself rather than enabled as\n# links: the mesh declares files and never makes links (novox/hq ADR 0012), and the host's service\n# shape must not start these units by hand, which would put the GPU to sleep with the machine awake.\n[Unit]\nWants=nvidia-hibernate.service nvidia-resume.service\n"
|
"template": "templates/systemd/system/systemd-hibernate.service.d/asus-zephyrus-g14-nvidia.conf"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "suspend-then-hibernate-drop-ins",
|
"id": "suspend-then-hibernate-drop-ins",
|
||||||
@@ -105,7 +107,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",
|
||||||
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The NVIDIA driver's own sleep actions, asked for by the sleep itself rather than enabled as\n# links: the mesh declares files and never makes links (novox/hq ADR 0012), and the host's service\n# shape must not start these units by hand, which would put the GPU to sleep with the machine awake.\n[Unit]\nWants=nvidia-suspend-then-hibernate.service nvidia-resume.service\n"
|
"template": "templates/systemd/system/systemd-suspend-then-hibernate.service.d/asus-zephyrus-g14-nvidia.conf"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "powerd-drop-ins",
|
"id": "powerd-drop-ins",
|
||||||
@@ -118,14 +120,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",
|
||||||
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# nvidia-powerd (Dynamic Boost) was the first error in the chain that hung this model's GPU on a change\n# of power source, and asusd starts it on mains. It runs only when the kernel command line says\n# zephyrus.nvidia-powerd — an explicit opt-in, at boot.\n[Unit]\nConditionKernelCommandLine=zephyrus.nvidia-powerd\n"
|
"template": "templates/systemd/system/nvidia-powerd.service.d/asus-zephyrus-g14.conf"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "backlight-rule",
|
"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",
|
||||||
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The backlights are writable by the video group, so the brightness keys and the module's brightness tool\n# move the panel without root.\nACTION==\"add\", SUBSYSTEM==\"backlight\", RUN+=\"/usr/bin/chgrp video /sys/class/backlight/%k/brightness\", RUN+=\"/usr/bin/chmod g+w /sys/class/backlight/%k/brightness\"\n"
|
"template": "templates/udev/rules.d/90-backlight.rules"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "udev",
|
"id": "udev",
|
||||||
@@ -151,7 +153,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",
|
||||||
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# On low battery the machine suspends rather than powering off, at 7 % — s2idle still draws a little,\n# so it leaves headroom. A drop-in over the package's own UPower.conf, which stays the package's.\n[UPower]\nUsePercentageForPolicy=true\nPercentageLow=15.0\nPercentageCritical=10.0\nPercentageAction=7.0\nCriticalPowerAction=Suspend\nAllowRiskyCriticalPowerAction=true\n"
|
"template": "templates/UPower/UPower.conf.d/50-asus-zephyrus-g14.conf"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "upower",
|
"id": "upower",
|
||||||
@@ -174,7 +176,22 @@
|
|||||||
"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",
|
||||||
"content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The touchpad's settings, applied by X every time the device appears — at login and after every\n# resume, when the device is initialised again. This replaces the predecessor's sleep hook, which ran\n# xinput after a resume as a named person on a guessed display.\nSection \"InputClass\"\n Identifier \"asus-zephyrus-g14 touchpad\"\n MatchIsTouchpad \"on\"\n Option \"Tapping\" \"on\"\n Option \"NaturalScrolling\" \"true\"\n Option \"AccelSpeed\" \"0.15\"\nEndSection\n"
|
"template": "templates/X11/xorg.conf.d/30-asus-zephyrus-g14-touchpad.conf"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"collection": "shell:after-wake:normal",
|
||||||
|
"template": "templates/after-wake"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"collection": "contribution:node-hotkeys:trigger",
|
||||||
|
"template": "templates/hotkeys-triggers"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"collection": "contribution:node-display-session:config",
|
||||||
|
"name": "display-session",
|
||||||
|
"template": "templates/display-session"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"build": {
|
"build": {
|
||||||
@@ -194,31 +211,12 @@
|
|||||||
"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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.
|
||||||
|
#
|
||||||
|
# On low battery the machine suspends rather than powering off, at 7 % — s2idle still draws a little,
|
||||||
|
# so it leaves headroom. A drop-in over the package's own UPower.conf, which stays the package's.
|
||||||
|
[UPower]
|
||||||
|
UsePercentageForPolicy=true
|
||||||
|
PercentageLow=15.0
|
||||||
|
PercentageCritical=10.0
|
||||||
|
PercentageAction=7.0
|
||||||
|
CriticalPowerAction=Suspend
|
||||||
|
AllowRiskyCriticalPowerAction=true
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.
|
||||||
|
#
|
||||||
|
# The touchpad's settings, applied by X every time the device appears — at login and after every
|
||||||
|
# resume, when the device is initialised again. This replaces the predecessor's sleep hook, which ran
|
||||||
|
# xinput after a resume as a named person on a guessed display.
|
||||||
|
Section "InputClass"
|
||||||
|
Identifier "asus-zephyrus-g14 touchpad"
|
||||||
|
MatchIsTouchpad "on"
|
||||||
|
Option "Tapping" "on"
|
||||||
|
Option "NaturalScrolling" "true"
|
||||||
|
Option "AccelSpeed" "0.15"
|
||||||
|
EndSection
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# The touchpad's settings again after waking, in the operator's session: X applies the module's
|
||||||
|
# input class when the device appears, and this covers a wake that does not initialise it again.
|
||||||
|
# Runs as root from the power module; the reset itself runs as the session's owner.
|
||||||
|
sleep 2
|
||||||
|
owner=$(ps -o user= -C i3 | head -n 1)
|
||||||
|
[ -n "$owner" ] && runuser -u "$owner" -- /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-touchpad reset
|
||||||
|
true
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# The laptop's own lines in i3 (module asus-zephyrus-g14, novox/hq ADR 0208, ADR 0210). Owned by the
|
||||||
|
# mesh: replaced at every push. Once the controller places contributions to node-display-session
|
||||||
|
# (ADR 0210), these become the module's contribution instead of a file in i3's directory.
|
||||||
|
#
|
||||||
|
# The keys the firmware turns into ordinary key presses. The vendor keys that reach no X client are
|
||||||
|
# triggerhappy's (/etc/triggerhappy/triggers.d/asus-g14.conf): M4 and Fn+F4/F5 for media, Fn+F7/F8 for
|
||||||
|
# the panel, Fn+F10 for the touchpad. The keyboard backlight (Fn+F2/F3) is the firmware's and asusd's.
|
||||||
|
|
||||||
|
# Fn+F6, the screenshot key: the firmware sends Super+Shift+S. Released before it runs, because the
|
||||||
|
# screenshot grabs the pointer to select a region, which fails while the key is still held.
|
||||||
|
bindsym --release $mod+Shift+s exec --no-startup-id $XDG_CONFIG_HOME/i3/scripts/screenshot.sh
|
||||||
|
|
||||||
|
# Fn+F9, the display key: the firmware sends Super+P. Odd workspaces to the panel, even ones to the
|
||||||
|
# external output.
|
||||||
|
bindsym $mod+p exec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-display order
|
||||||
|
|
||||||
|
# The keyboard backlight's level, shown when it changes.
|
||||||
|
exec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-kbd-notify
|
||||||
|
# The laptop's own lines in i3 (module asus-zephyrus-g14, novox/hq ADR 0208, ADR 0210). Owned by the
|
||||||
|
# mesh: replaced at every push. Once the controller places contributions to node-display-session
|
||||||
|
# (ADR 0210), these become the module's contribution instead of a file in i3's directory.
|
||||||
|
#
|
||||||
|
# The model's panel and touchpad.
|
||||||
|
|
||||||
|
# The internal panel is the primary output, where the bars' tray goes. Which monitors are on and where
|
||||||
|
# is the display server's (autorandr, run at every session start).
|
||||||
|
exec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-display primary
|
||||||
|
|
||||||
|
# The touchpad's settings again, by hand. X applies them itself whenever the device appears.
|
||||||
|
bindsym $mod+Shift+x exec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-touchpad reset
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# The ROG Zephyrus G14's vendor keys, which reach no X client: media (the M-keys), panel brightness,
|
||||||
|
# and the touchpad key. Each runs this module's own script, as the operator's account.
|
||||||
|
KEY_PROG1 1 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media play-pause
|
||||||
|
KEY_PROG3 1 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media previous
|
||||||
|
KEY_PROG4 1 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media next
|
||||||
|
KEY_BRIGHTNESSDOWN 1 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight -
|
||||||
|
KEY_BRIGHTNESSDOWN 2 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight -
|
||||||
|
KEY_BRIGHTNESSUP 1 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight +
|
||||||
|
KEY_BRIGHTNESSUP 2 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight +
|
||||||
|
KEY_F21 1 /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-touchpad reset
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.
|
||||||
|
#
|
||||||
|
# The discrete GPU's driver options on the ROG Zephyrus G14 (GA403, RTX 40 series, hybrid graphics).
|
||||||
|
#
|
||||||
|
# NVreg_DynamicPowerManagement=0x00 turns runtime D3 off. With it on, a change of power source sends
|
||||||
|
# the driver an ACPI notification it fails to handle on this model ("RmHandleDNotifierEvent: Failed to
|
||||||
|
# handle ACPI D-Notifier event, status=0x62"), and the GPU stops making progress until the machine is
|
||||||
|
# powered off. Off costs a few idle watts in hybrid mode and keeps the machine up.
|
||||||
|
#
|
||||||
|
# NVreg_PreserveVideoMemoryAllocations=1 saves video memory across suspend, so what used the GPU still
|
||||||
|
# works after waking. It needs nvidia-suspend, -hibernate and -resume to run around a sleep, which this
|
||||||
|
# module's drop-ins on the sleep services ask for.
|
||||||
|
#
|
||||||
|
# A change here applies when the driver next loads: at the next boot.
|
||||||
|
options nvidia NVreg_PreserveVideoMemoryAllocations=1
|
||||||
|
options nvidia NVreg_DynamicPowerManagement=0x00
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.
|
||||||
|
#
|
||||||
|
# The ACPI video driver does not change the backlight itself on the brightness keys: on this model it
|
||||||
|
# moves the wrong one. The keys are triggerhappy's (see /etc/triggerhappy/triggers.d/asus-g14.conf).
|
||||||
|
# Applies when the module next loads: at the next boot.
|
||||||
|
options video brightness_switch_enabled=0
|
||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.
|
||||||
|
#
|
||||||
|
# nvidia-powerd (Dynamic Boost) was the first error in the chain that hung this model's GPU on a change
|
||||||
|
# of power source, and asusd starts it on mains. It runs only when the kernel command line says
|
||||||
|
# zephyrus.nvidia-powerd — an explicit opt-in, at boot.
|
||||||
|
[Unit]
|
||||||
|
ConditionKernelCommandLine=zephyrus.nvidia-powerd
|
||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.
|
||||||
|
#
|
||||||
|
# The NVIDIA driver's own sleep actions, asked for by the sleep itself rather than enabled as
|
||||||
|
# links: the mesh declares files and never makes links (novox/hq ADR 0012), and the host's service
|
||||||
|
# shape must not start these units by hand, which would put the GPU to sleep with the machine awake.
|
||||||
|
[Unit]
|
||||||
|
Wants=nvidia-hibernate.service nvidia-resume.service
|
||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.
|
||||||
|
#
|
||||||
|
# The NVIDIA driver's own sleep actions, asked for by the sleep itself rather than enabled as
|
||||||
|
# links: the mesh declares files and never makes links (novox/hq ADR 0012), and the host's service
|
||||||
|
# shape must not start these units by hand, which would put the GPU to sleep with the machine awake.
|
||||||
|
[Unit]
|
||||||
|
Wants=nvidia-suspend-then-hibernate.service nvidia-resume.service
|
||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.
|
||||||
|
#
|
||||||
|
# The NVIDIA driver's own sleep actions, asked for by the sleep itself rather than enabled as
|
||||||
|
# links: the mesh declares files and never makes links (novox/hq ADR 0012), and the host's service
|
||||||
|
# shape must not start these units by hand, which would put the GPU to sleep with the machine awake.
|
||||||
|
[Unit]
|
||||||
|
Wants=nvidia-suspend.service nvidia-resume.service
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.
|
||||||
|
#
|
||||||
|
# The backlights are writable by the video group, so the brightness keys and the module's brightness tool
|
||||||
|
# move the panel without root.
|
||||||
|
ACTION=="add", SUBSYSTEM=="backlight", RUN+="/usr/bin/chgrp video /sys/class/backlight/%k/brightness", RUN+="/usr/bin/chmod g+w /sys/class/backlight/%k/brightness"
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"password": "${secret:admin}",
|
||||||
|
"host": "${bound:route:name}"
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
DATABASE_HOST=${bound:postgres-database:at}
|
||||||
|
DATABASE_PORT=${bound:postgres-database:port}
|
||||||
|
DATABASE_NAME=${bound:postgres-database:as}
|
||||||
|
DATABASE_USER=${bound:postgres-database:as}
|
||||||
|
DATABASE_PASSWORD_FILE=/run/secrets/database
|
||||||
|
DISABLE_EMBEDDED_PSQL=true
|
||||||
|
BASEROW_PUBLIC_URL=https://${bound:route:name}
|
||||||
@@ -46,6 +46,26 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"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",
|
||||||
@@ -70,7 +90,7 @@
|
|||||||
"type": "file",
|
"type": "file",
|
||||||
"path": "${dir:state}/server.env",
|
"path": "${dir:state}/server.env",
|
||||||
"mode": "0600",
|
"mode": "0600",
|
||||||
"content": "DATABASE_HOST=${bound:postgres-database:at}\nDATABASE_PORT=${bound:postgres-database:port}\nDATABASE_NAME=${bound:postgres-database:as}\nDATABASE_USER=${bound:postgres-database:as}\nDATABASE_PASSWORD_FILE=/run/secrets/database\nDISABLE_EMBEDDED_PSQL=true\nBASEROW_PUBLIC_URL=https://${bound:route:name}\n"
|
"template": "files/server.env"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "net",
|
"id": "net",
|
||||||
@@ -102,8 +122,9 @@
|
|||||||
"type": "file",
|
"type": "file",
|
||||||
"path": "${dir:mesh-state}/config.json",
|
"path": "${dir:mesh-state}/config.json",
|
||||||
"mode": "0600",
|
"mode": "0600",
|
||||||
"content": "{\n \"password\": \"${secret:admin}\",\n \"host\": \"${bound:route:name}\"\n}\n",
|
"template": "files/config.json",
|
||||||
"merge": "json",
|
"merge": "json",
|
||||||
|
"takes-config": ["host", "email", "url"],
|
||||||
"protected": ["password"]
|
"protected": ["password"]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -123,6 +144,11 @@
|
|||||||
"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`, not trusted (§5).
|
`~/.config/betterbird/configuration.json`, trusted as `accounts` is; `tags` as `tags.json`, trusted too since issue 751 (the operator's decision), so a change to it is the operator's to approve.
|
||||||
**No value holds a password, a token or a cookie**: a key shaped like one is refused, and so is an address whose
|
**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,7 +7,8 @@ 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, and is not trusted.
|
// - ~/.config/betterbird/tags.json: tags, which names no server and sends no mail; trusted too since issue 751,
|
||||||
|
// so a change to it is the operator's to approve.
|
||||||
//
|
//
|
||||||
// An entry is keyed by a name of the operator's (lower-case letters and digits, as an account's), a tag by its
|
// 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"`
|
||||||
Settings map[string]any `json:"settings"`
|
Config map[string]any `json:"config"`
|
||||||
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,13 +114,21 @@ func TestItPlacesBetterbirdWhereTheCommunityPackageDidAndOnlyAfterItIsGone(t *te
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ADR 0329 §4–§6, ADR 0326, issue 586: every setting reaches the machine as a mergeable file of the account's that
|
// ADR 0329 §4–§6, ADR 0326, issue 586, ADR 0348: every value reaches the machine as a mergeable file of the
|
||||||
// names the keys it takes; four kinds and accounts and prefs are trusted, tags is not; no setting has a default in the
|
// account's that names the keys it takes, and every one is trusted, tags too since issue 751 (the operator's
|
||||||
// definition, since each is the operator's own value; and each file's default declares nothing.
|
// decision); each is an object whose default is the file's own, empty, never required, since a node without the
|
||||||
func TestTheSettingsAreMergeableFilesThatNameTheirKeysAndTagsAloneIsUntrusted(t *testing.T) {
|
// operator's setup still runs Betterbird; and each file's default declares nothing.
|
||||||
|
func TestTheValuesAreTrustedMergeableFilesThatNameTheirKeys(t *testing.T) {
|
||||||
m, _ := readManifest(t)
|
m, _ := readManifest(t)
|
||||||
if len(m.Settings) != 0 {
|
if _, said := m.Config["tags"]; !said || len(m.Config) != 7 {
|
||||||
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)
|
t.Fatalf("the seven values are declared under config: %v", m.Config)
|
||||||
|
}
|
||||||
|
for key, raw := range m.Config {
|
||||||
|
d, _ := raw.(map[string]any)
|
||||||
|
def, _ := d["default"].(map[string]any)
|
||||||
|
if d["type"] != "object" || d["trusted"] != true || d["required"] != nil || def == nil || len(def) != 0 {
|
||||||
|
t.Errorf("%s is a trusted object whose default is empty and which is not required: %v", key, d)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
files := map[string]struct {
|
files := map[string]struct {
|
||||||
path, mode string
|
path, mode string
|
||||||
@@ -130,7 +138,7 @@ func TestTheSettingsAreMergeableFilesThatNameTheirKeysAndTagsAloneIsUntrusted(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"}, false},
|
"tags": {tagsFile, "0644", []any{"tags"}, true},
|
||||||
}
|
}
|
||||||
merging := 0
|
merging := 0
|
||||||
for _, r := range m.Resources {
|
for _, r := range m.Resources {
|
||||||
@@ -144,27 +152,27 @@ func TestTheSettingsAreMergeableFilesThatNameTheirKeysAndTagsAloneIsUntrusted(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-settings"], want.takes) {
|
r["mode"] != want.mode || r["owner"] != "${machine:account}" || !reflect.DeepEqual(r["takes-config"], 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(r["content"].(string)), &content); err != nil || len(content) != len(want.takes) {
|
if err := json.Unmarshal([]byte(templateText(t, r)), &content); err != nil || len(content) != len(want.takes) {
|
||||||
t.Errorf("%s's default holds each key it takes, empty: %v %v", id, content, err)
|
t.Errorf("%s's default holds each key it takes, empty: %v %v", id, content, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
got, err := parseAccounts([]byte(resource(t, m, "accounts")["content"].(string)))
|
got, err := parseAccounts([]byte(templateText(t, resource(t, m, "accounts"))))
|
||||||
if err != nil || len(got) != 0 {
|
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(resource(t, m, "prefs")["content"].(string)))
|
prefs, err := parseDeclaredPrefs([]byte(templateText(t, resource(t, m, "prefs"))))
|
||||||
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(resource(t, m, "configuration")["content"].(string)), configurationFile, &c); err != nil {
|
if err := decodeInto([]byte(templateText(t, resource(t, m, "configuration"))), configurationFile, &c); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := decodeInto([]byte(resource(t, m, "tags")["content"].(string)), tagsFile, &c); err != nil {
|
if err := decodeInto([]byte(templateText(t, resource(t, m, "tags"))), tagsFile, &c); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -268,13 +276,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 := start["content"].(string)
|
content := templateText(t, start)
|
||||||
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 := launcher["content"].(string)
|
lc := templateText(t, launcher)
|
||||||
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") {
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The module's directory, where its manifest and its files/ are.
|
||||||
|
const moduleDir = "../.."
|
||||||
|
|
||||||
|
// templateText is the text a file resource places on the machine: its template's, in the module's files/
|
||||||
|
// (novox/hq ADR 0349 §2). A resource that names no template, or a template that is not there, fails the test.
|
||||||
|
func templateText(t *testing.T, r map[string]any) string {
|
||||||
|
t.Helper()
|
||||||
|
name, _ := r["template"].(string)
|
||||||
|
if name == "" {
|
||||||
|
t.Fatalf("resource %v names no template", r["id"])
|
||||||
|
}
|
||||||
|
raw, err := os.ReadFile(filepath.Join(moduleDir, filepath.FromSlash(name)))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resource %v names %s: %v", r["id"], name, err)
|
||||||
|
}
|
||||||
|
return string(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
// No file resource carries its text inline: each names its template in files/, and the build packs files/
|
||||||
|
// into the one archive the controller reads the templates back from (novox/hq ADR 0349 §2).
|
||||||
|
func TestEveryFileTakesItsTextFromATemplate(t *testing.T) {
|
||||||
|
raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var m struct {
|
||||||
|
Resources []map[string]any `json:"resources"`
|
||||||
|
Build struct {
|
||||||
|
Artifacts []map[string]any `json:"artifacts"`
|
||||||
|
} `json:"build"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &m); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, r := range m.Resources {
|
||||||
|
if _, inline := r["content"]; inline {
|
||||||
|
t.Errorf("resource %v keeps its text inline: keep it in files/ and name it as the template", r["id"])
|
||||||
|
}
|
||||||
|
if r["type"] == "file" {
|
||||||
|
templateText(t, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
packs := 0
|
||||||
|
for _, a := range m.Build.Artifacts {
|
||||||
|
if a["kind"] == "archive" && a["from"] == "files" {
|
||||||
|
packs++
|
||||||
|
if a["name"] != "files" {
|
||||||
|
t.Errorf("the templates archive is named %v, not files", a["name"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if packs != 1 {
|
||||||
|
t.Errorf("the build packs files/ into %d archives, not one", packs)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"accounts": {}
|
||||||
|
}
|
||||||
Executable
+10
@@ -0,0 +1,10 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Betterbird's start, written by the mesh (module betterbird, novox/hq ADR 0316). Replaced at every push.
|
||||||
|
#
|
||||||
|
# Before Betterbird starts, the module's tools place its user.js in the profile (the settings prefs, accounts, identities, calendars,
|
||||||
|
# address-books and tags) and write what Betterbird rewrites itself: the account lists and each declared account's filters. They do
|
||||||
|
# it only while no Betterbird of this account runs, because Betterbird rewrites its saved preferences when it quits; a
|
||||||
|
# second start while one runs only hands over to it. What the preparation could not do it says here, and
|
||||||
|
# betterbird_check says it too. Betterbird starts either way: its configuration never holds the mail back.
|
||||||
|
/usr/local/lib/mesh-betterbird/betterbird-tools prepare || echo "betterbird: the mesh's preparation did not complete; betterbird_check says why" >&2
|
||||||
|
exec /opt/betterbird/betterbird "$@"
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"identities": {},
|
||||||
|
"calendars": {},
|
||||||
|
"address-books": {},
|
||||||
|
"filters": {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
[Desktop Entry]
|
||||||
|
Version=1.0
|
||||||
|
Type=Application
|
||||||
|
Name=Betterbird
|
||||||
|
GenericName=Mail Client
|
||||||
|
Comment=Send and receive mail with Betterbird
|
||||||
|
Exec=/usr/local/bin/betterbird %u
|
||||||
|
Icon=/opt/betterbird/chrome/icons/default/default128.png
|
||||||
|
Terminal=false
|
||||||
|
Categories=Network;Email;
|
||||||
|
MimeType=message/rfc822;x-scheme-handler/mailto;application/x-xpinstall;application/x-extension-ics;text/calendar;text/vcard;text/x-vcard;x-scheme-handler/webcal;x-scheme-handler/webcals;x-scheme-handler/mid;
|
||||||
|
StartupNotify=true
|
||||||
|
StartupWMClass=eu.betterbird.Betterbird
|
||||||
|
Keywords=Email;E-mail;Calendar;Thunderbird;
|
||||||
|
Actions=ComposeMessage;OpenAddressBook;
|
||||||
|
# Written by the mesh (module betterbird, novox/hq ADR 0316 §3), in place of the community package's entry:
|
||||||
|
# it starts Betterbird through the module's start, and names the icon in the installation, with no link.
|
||||||
|
|
||||||
|
[Desktop Action ComposeMessage]
|
||||||
|
Name=Write new message
|
||||||
|
Exec=/usr/local/bin/betterbird -compose
|
||||||
|
|
||||||
|
[Desktop Action OpenAddressBook]
|
||||||
|
Name=Open address book
|
||||||
|
Exec=/usr/local/bin/betterbird -addressbook
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"prefs": {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"tags": {}
|
||||||
|
}
|
||||||
@@ -33,6 +33,50 @@
|
|||||||
"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",
|
||||||
@@ -48,10 +92,10 @@
|
|||||||
"owner": "${machine:account}",
|
"owner": "${machine:account}",
|
||||||
"mode": "0644",
|
"mode": "0644",
|
||||||
"merge": "json",
|
"merge": "json",
|
||||||
"takes-settings": [
|
"takes-config": [
|
||||||
"prefs"
|
"prefs"
|
||||||
],
|
],
|
||||||
"content": "{\n \"prefs\": {}\n}\n",
|
"template": "files/prefs.json",
|
||||||
"trusted": true
|
"trusted": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -61,10 +105,10 @@
|
|||||||
"owner": "${machine:account}",
|
"owner": "${machine:account}",
|
||||||
"mode": "0600",
|
"mode": "0600",
|
||||||
"merge": "json",
|
"merge": "json",
|
||||||
"takes-settings": [
|
"takes-config": [
|
||||||
"accounts"
|
"accounts"
|
||||||
],
|
],
|
||||||
"content": "{\n \"accounts\": {}\n}\n",
|
"template": "files/accounts.json",
|
||||||
"trusted": true
|
"trusted": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -74,13 +118,13 @@
|
|||||||
"owner": "${machine:account}",
|
"owner": "${machine:account}",
|
||||||
"mode": "0600",
|
"mode": "0600",
|
||||||
"merge": "json",
|
"merge": "json",
|
||||||
"takes-settings": [
|
"takes-config": [
|
||||||
"identities",
|
"identities",
|
||||||
"calendars",
|
"calendars",
|
||||||
"address-books",
|
"address-books",
|
||||||
"filters"
|
"filters"
|
||||||
],
|
],
|
||||||
"content": "{\n \"identities\": {},\n \"calendars\": {},\n \"address-books\": {},\n \"filters\": {}\n}\n",
|
"template": "files/configuration.json",
|
||||||
"trusted": true
|
"trusted": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -90,11 +134,11 @@
|
|||||||
"owner": "${machine:account}",
|
"owner": "${machine:account}",
|
||||||
"mode": "0644",
|
"mode": "0644",
|
||||||
"merge": "json",
|
"merge": "json",
|
||||||
"takes-settings": [
|
"takes-config": [
|
||||||
"tags"
|
"tags"
|
||||||
],
|
],
|
||||||
"content": "{\n \"tags\": {}\n}\n",
|
"template": "files/tags.json",
|
||||||
"trusted": false
|
"trusted": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "community-package",
|
"id": "community-package",
|
||||||
@@ -137,14 +181,14 @@
|
|||||||
"type": "file",
|
"type": "file",
|
||||||
"path": "/usr/local/bin/betterbird",
|
"path": "/usr/local/bin/betterbird",
|
||||||
"mode": "0755",
|
"mode": "0755",
|
||||||
"content": "#!/bin/sh\n# Betterbird's start, written by the mesh (module betterbird, novox/hq ADR 0316). Replaced at every push.\n#\n# Before Betterbird starts, the module's tools place its user.js in the profile (the settings prefs, accounts, identities, calendars,\n# address-books and tags) and write what Betterbird rewrites itself: the account lists and each declared account's filters. They do\n# it only while no Betterbird of this account runs, because Betterbird rewrites its saved preferences when it quits; a\n# second start while one runs only hands over to it. What the preparation could not do it says here, and\n# betterbird_check says it too. Betterbird starts either way: its configuration never holds the mail back.\n/usr/local/lib/mesh-betterbird/betterbird-tools prepare || echo \"betterbird: the mesh's preparation did not complete; betterbird_check says why\" >&2\nexec /opt/betterbird/betterbird \"$@\"\n"
|
"template": "files/betterbird"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "launcher",
|
"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",
|
||||||
"content": "[Desktop Entry]\nVersion=1.0\nType=Application\nName=Betterbird\nGenericName=Mail Client\nComment=Send and receive mail with Betterbird\nExec=/usr/local/bin/betterbird %u\nIcon=/opt/betterbird/chrome/icons/default/default128.png\nTerminal=false\nCategories=Network;Email;\nMimeType=message/rfc822;x-scheme-handler/mailto;application/x-xpinstall;application/x-extension-ics;text/calendar;text/vcard;text/x-vcard;x-scheme-handler/webcal;x-scheme-handler/webcals;x-scheme-handler/mid;\nStartupNotify=true\nStartupWMClass=eu.betterbird.Betterbird\nKeywords=Email;E-mail;Calendar;Thunderbird;\nActions=ComposeMessage;OpenAddressBook;\n# Written by the mesh (module betterbird, novox/hq ADR 0316 §3), in place of the community package's entry:\n# it starts Betterbird through the module's start, and names the icon in the installation, with no link.\n\n[Desktop Action ComposeMessage]\nName=Write new message\nExec=/usr/local/bin/betterbird -compose\n\n[Desktop Action OpenAddressBook]\nName=Open address book\nExec=/usr/local/bin/betterbird -addressbook\n"
|
"template": "files/eu.betterbird.Betterbird.desktop"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"build": {
|
"build": {
|
||||||
@@ -191,6 +235,11 @@
|
|||||||
"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 -o /mesh-builder ./cmd/mesh-builder
|
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -buildvcs=false -ldflags=-buildid= -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,
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
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,7 +75,10 @@ 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 {
|
||||||
content, _ := r["content"].(string)
|
if r["type"] != "file" {
|
||||||
|
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"])
|
||||||
@@ -106,7 +109,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, _ := r["content"].(string)
|
content := templateText(t, r)
|
||||||
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)
|
||||||
}
|
}
|
||||||
@@ -115,3 +118,57 @@ 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,6 +39,12 @@
|
|||||||
"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)"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -54,12 +60,17 @@
|
|||||||
"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",
|
||||||
"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"
|
"template": "files/build-agent.env"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "server",
|
"id": "server",
|
||||||
@@ -72,6 +83,7 @@
|
|||||||
"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": [
|
||||||
@@ -103,6 +115,11 @@
|
|||||||
"repository": "novox/mesh-controller",
|
"repository": "novox/mesh-controller",
|
||||||
"ref": "main"
|
"ref": "main"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "files",
|
||||||
|
"kind": "archive",
|
||||||
|
"from": "files"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"on": [
|
"on": [
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+58
@@ -0,0 +1,58 @@
|
|||||||
|
#!/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
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
[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",
|
||||||
"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"
|
"template": "files/anchor"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"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",
|
||||||
"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"
|
"template": "files/mesh-ca-trust.service"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "trust",
|
"id": "trust",
|
||||||
@@ -52,5 +52,14 @@
|
|||||||
"unit"
|
"unit"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"build": {
|
||||||
|
"artifacts": [
|
||||||
|
{
|
||||||
|
"name": "files",
|
||||||
|
"kind": "archive",
|
||||||
|
"from": "files"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,7 +43,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, 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, 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 |
|
||||||
| `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,6 +55,17 @@ 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
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -585,7 +585,25 @@ 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 {
|
||||||
|
|||||||
@@ -216,6 +216,9 @@ 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
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ 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.
|
||||||
@@ -139,6 +142,7 @@ 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,11 +19,12 @@ 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"`
|
||||||
Shell []struct {
|
// The zsh code is an item of the collection shell:zsh:<slot> (novox/hq ADR 0349 clause 13), its text the
|
||||||
For string `json:"for"`
|
// template file beside the manifest.
|
||||||
Slot string `json:"slot"`
|
Items []struct {
|
||||||
Code string `json:"code"`
|
Collection string `json:"collection"`
|
||||||
} `json:"shell"`
|
Template string `json:"template"`
|
||||||
|
} `json:"items"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func readManifestShell(t *testing.T) manifestShell {
|
func readManifestShell(t *testing.T) manifestShell {
|
||||||
@@ -54,9 +55,13 @@ 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 _, s := range m.Shell {
|
for _, it := range m.Items {
|
||||||
if s.For == "zsh" {
|
if strings.HasPrefix(it.Collection, "shell:zsh:") {
|
||||||
code = append(code, s.Code)
|
text, err := os.ReadFile(filepath.Join("..", "..", it.Template))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
code = append(code, string(text))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(code) != 1 {
|
if len(code) != 1 {
|
||||||
@@ -71,8 +76,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 r["content"] != "${machine:agent-account}\n" {
|
if text := templateText(t, r); text != "${machine:agent-account}\n" {
|
||||||
t.Fatalf("the account's file holds %q", r["content"])
|
t.Fatalf("the account's file holds %q", text)
|
||||||
}
|
}
|
||||||
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")
|
||||||
@@ -163,7 +168,7 @@ func TestClaudeAgentRunsTheRealClaudeUnderTheAlias(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
start, _ := resourceByID(t, readManifestShell(t), "start")["content"].(string)
|
start := templateText(t, resourceByID(t, readManifestShell(t), "start"))
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The module's directory, where its manifest and its files/ are.
|
||||||
|
const moduleDir = "../.."
|
||||||
|
|
||||||
|
// templateText is the text a file resource places on the machine: its template's, in the module's files/
|
||||||
|
// (novox/hq ADR 0349 §2). A resource that names no template, or a template that is not there, fails the test.
|
||||||
|
func templateText(t *testing.T, r map[string]any) string {
|
||||||
|
t.Helper()
|
||||||
|
name, _ := r["template"].(string)
|
||||||
|
if name == "" {
|
||||||
|
t.Fatalf("resource %v names no template", r["id"])
|
||||||
|
}
|
||||||
|
raw, err := os.ReadFile(filepath.Join(moduleDir, filepath.FromSlash(name)))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resource %v names %s: %v", r["id"], name, err)
|
||||||
|
}
|
||||||
|
return string(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
// No file resource carries its text inline: each names its template in files/, and the build packs files/
|
||||||
|
// into the one archive the controller reads the templates back from (novox/hq ADR 0349 §2).
|
||||||
|
func TestEveryFileTakesItsTextFromATemplate(t *testing.T) {
|
||||||
|
raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var m struct {
|
||||||
|
Resources []map[string]any `json:"resources"`
|
||||||
|
Build struct {
|
||||||
|
Artifacts []map[string]any `json:"artifacts"`
|
||||||
|
} `json:"build"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &m); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, r := range m.Resources {
|
||||||
|
if _, inline := r["content"]; inline {
|
||||||
|
t.Errorf("resource %v keeps its text inline: keep it in files/ and name it as the template", r["id"])
|
||||||
|
}
|
||||||
|
if r["type"] == "file" {
|
||||||
|
templateText(t, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
packs := 0
|
||||||
|
for _, a := range m.Build.Artifacts {
|
||||||
|
if a["kind"] == "archive" && a["from"] == "files" {
|
||||||
|
packs++
|
||||||
|
if a["name"] != "files" {
|
||||||
|
t.Errorf("the templates archive is named %v, not files", a["name"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if packs != 1 {
|
||||||
|
t.Errorf("the build packs files/ into %d archives, not one", packs)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
${machine:agent-account}
|
||||||
Executable
+13
@@ -0,0 +1,13 @@
|
|||||||
|
#!/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 "$@"
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"node": "${machine:name}",
|
||||||
|
"console": "http://127.0.0.1:${bound:mcp-endpoint:port}/mcp"
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"role": "",
|
||||||
|
"mcp_servers": {},
|
||||||
|
"managed_settings": {},
|
||||||
|
"instructions": {},
|
||||||
|
"output_styles": {},
|
||||||
|
"skills": {},
|
||||||
|
"commands": {},
|
||||||
|
"agents": {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# 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
|
||||||
@@ -1,16 +1,24 @@
|
|||||||
{
|
{
|
||||||
"module": "claude-code",
|
"module": "claude-code",
|
||||||
"version": "1",
|
"version": "1",
|
||||||
|
"moved": true,
|
||||||
"slug": "agent",
|
"slug": "agent",
|
||||||
"capabilities": [
|
"capabilities": [
|
||||||
"package-manager"
|
"package-manager"
|
||||||
],
|
],
|
||||||
"requires": [
|
"requires": [
|
||||||
"mcp-endpoint"
|
"mcp-endpoint",
|
||||||
|
{
|
||||||
|
"name": "gitea-read",
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"binds": {
|
"binds": {
|
||||||
"mcp-endpoint": "${dir:state}/mcp-endpoint.json"
|
"mcp-endpoint": "${dir:state}/mcp-endpoint.json"
|
||||||
},
|
},
|
||||||
|
"secrets": {
|
||||||
|
"gitea-read": "${dir:state}/gitea-read.secret"
|
||||||
|
},
|
||||||
"uses": [
|
"uses": [
|
||||||
"operator-channel"
|
"operator-channel"
|
||||||
],
|
],
|
||||||
@@ -85,6 +93,57 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"config": {
|
||||||
|
"role": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "What this machine is for, in a sentence, written into the instructions every Claude Code session on it reads; empty says nothing.",
|
||||||
|
"trusted": true,
|
||||||
|
"default": ""
|
||||||
|
},
|
||||||
|
"mcp_servers": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "The tool servers every session on this machine is given beside the mesh's own, by name, each as Claude Code's managed MCP configuration takes it; empty adds none.",
|
||||||
|
"trusted": true,
|
||||||
|
"default": {}
|
||||||
|
},
|
||||||
|
"managed_settings": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Keys of Claude Code's managed settings every session on this machine obeys, such as spinnerTipsEnabled, laid over the mesh's own; empty adds none. Falls back to agent-managed-settings, the mesh's or this node's.",
|
||||||
|
"trusted": true,
|
||||||
|
"predefined": "agent-managed-settings",
|
||||||
|
"default": {}
|
||||||
|
},
|
||||||
|
"instructions": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "The instructions every session on this machine reads, by name, each a text as one line or a list of its lines; empty adds none.",
|
||||||
|
"trusted": true,
|
||||||
|
"default": {}
|
||||||
|
},
|
||||||
|
"output_styles": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "The output styles offered to every session on this machine, by name, each a text as one line or a list of its lines; empty adds none.",
|
||||||
|
"trusted": true,
|
||||||
|
"default": {}
|
||||||
|
},
|
||||||
|
"skills": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "The skills offered to every session on this machine, by name, each its files by path, a text as one line or a list of its lines; a skill without its SKILL.md is left out; empty adds none.",
|
||||||
|
"trusted": true,
|
||||||
|
"default": {}
|
||||||
|
},
|
||||||
|
"commands": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "The slash commands offered to every session on this machine, by name, each a text as one line or a list of its lines; empty adds none.",
|
||||||
|
"trusted": true,
|
||||||
|
"default": {}
|
||||||
|
},
|
||||||
|
"agents": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "The agents offered to every session on this machine, by name, each a text as one line or a list of its lines; empty adds none.",
|
||||||
|
"trusted": true,
|
||||||
|
"default": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
"resources": [
|
"resources": [
|
||||||
{
|
{
|
||||||
"id": "package",
|
"id": "package",
|
||||||
@@ -102,14 +161,14 @@
|
|||||||
"type": "file",
|
"type": "file",
|
||||||
"path": "/usr/local/bin/claude-agent",
|
"path": "/usr/local/bin/claude-agent",
|
||||||
"mode": "0755",
|
"mode": "0755",
|
||||||
"content": "#!/bin/sh\n# The mesh's (module claude-code, novox/hq ADR 0266): start the agent as the account agents run as on this\n# machine. Written whole at every push: an edit here is overwritten.\nagent='${machine:agent-account}'\nif [ \"$(id -un)\" = \"$agent\" ]; then\n\texec claude \"$@\"\nfi\n# Another account, the operator's, becomes the agent's through its own sudo: the person is the authority. The\n# operator's override of the guard travels only when it is set in this shell, as it would reach claude.\nif [ -n \"$MESH_GUARD_OVERRIDE\" ]; then\n\texec sudo -iu \"$agent\" env MESH_GUARD_OVERRIDE=\"$MESH_GUARD_OVERRIDE\" claude \"$@\"\nfi\nexec sudo -iu \"$agent\" claude \"$@\"\n"
|
"template": "files/claude-agent"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "agent-account-name",
|
"id": "agent-account-name",
|
||||||
"type": "file",
|
"type": "file",
|
||||||
"path": "/etc/claude-code/agent-account",
|
"path": "/etc/claude-code/agent-account",
|
||||||
"mode": "0644",
|
"mode": "0644",
|
||||||
"content": "${machine:agent-account}\n"
|
"template": "files/agent-account"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "agent-account",
|
"id": "agent-account",
|
||||||
@@ -138,7 +197,7 @@
|
|||||||
"path": "${dir:state}/facts.json",
|
"path": "${dir:state}/facts.json",
|
||||||
"mode": "0600",
|
"mode": "0600",
|
||||||
"owner": "${machine:account}",
|
"owner": "${machine:account}",
|
||||||
"content": "{\n \"node\": \"${machine:name}\",\n \"console\": \"http://127.0.0.1:${bound:mcp-endpoint:port}/mcp\"\n}\n"
|
"template": "files/facts.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "settings",
|
"id": "settings",
|
||||||
@@ -146,15 +205,15 @@
|
|||||||
"path": "${dir:state}/settings.json",
|
"path": "${dir:state}/settings.json",
|
||||||
"mode": "0600",
|
"mode": "0600",
|
||||||
"owner": "${machine:account}",
|
"owner": "${machine:account}",
|
||||||
|
"takes-config": ["role", "mcp_servers", "managed_settings", "instructions", "output_styles", "skills", "commands", "agents"],
|
||||||
"merge": "json",
|
"merge": "json",
|
||||||
"content": "{\n \"role\": \"\",\n \"mcp_servers\": {},\n \"managed_settings\": {},\n \"instructions\": {},\n \"output_styles\": {},\n \"skills\": {},\n \"commands\": {},\n \"agents\": {}\n}\n"
|
"template": "files/settings.json"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"shell": [
|
"items": [
|
||||||
{
|
{
|
||||||
"for": "zsh",
|
"collection": "shell:zsh:normal",
|
||||||
"slot": "normal",
|
"template": "files/zsh-normal"
|
||||||
"code": "# The agent's session (module claude-code, novox/hq ADR 0266): where this machine names an account of the\n# agents' own, claude in an interactive zsh is claude-agent, which starts the session as that account. Where\n# agents run as the operator's account the file names that account, and nothing is added. claude-agent runs\n# the real claude: exec, sudo and its sh see no alias.\nif [[ -r /etc/claude-code/agent-account ]]; then\n\t() {\n\t\tlocal agent=$(</etc/claude-code/agent-account)\n\t\tif [[ -n $agent && $agent != $USERNAME ]]; then\n\t\t\talias claude=claude-agent\n\t\tfi\n\t}\nfi\n"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"build": {
|
"build": {
|
||||||
@@ -174,6 +233,11 @@
|
|||||||
"MESH_CLAUDE_CODE_FACTS": "${dir:state}/facts.json",
|
"MESH_CLAUDE_CODE_FACTS": "${dir:state}/facts.json",
|
||||||
"MESH_CLAUDE_CODE_SETTINGS": "${dir:state}/settings.json"
|
"MESH_CLAUDE_CODE_SETTINGS": "${dir:state}/settings.json"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "files",
|
||||||
|
"kind": "archive",
|
||||||
|
"from": "files"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The module's directory, where its manifest and its files/ are.
|
||||||
|
const moduleDir = "../.."
|
||||||
|
|
||||||
|
// templateText is the text a file resource places on the machine: its template's, in the module's files/
|
||||||
|
// (novox/hq ADR 0349 §2). A resource that names no template, or a template that is not there, fails the test.
|
||||||
|
func templateText(t *testing.T, r map[string]any) string {
|
||||||
|
t.Helper()
|
||||||
|
name, _ := r["template"].(string)
|
||||||
|
if name == "" {
|
||||||
|
t.Fatalf("resource %v names no template", r["id"])
|
||||||
|
}
|
||||||
|
raw, err := os.ReadFile(filepath.Join(moduleDir, filepath.FromSlash(name)))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resource %v names %s: %v", r["id"], name, err)
|
||||||
|
}
|
||||||
|
return string(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
// No file resource carries its text inline: each names its template in files/, and the build packs files/
|
||||||
|
// into the one archive the controller reads the templates back from (novox/hq ADR 0349 §2).
|
||||||
|
func TestEveryFileTakesItsTextFromATemplate(t *testing.T) {
|
||||||
|
raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var m struct {
|
||||||
|
Resources []map[string]any `json:"resources"`
|
||||||
|
Build struct {
|
||||||
|
Artifacts []map[string]any `json:"artifacts"`
|
||||||
|
} `json:"build"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &m); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, r := range m.Resources {
|
||||||
|
if _, inline := r["content"]; inline {
|
||||||
|
t.Errorf("resource %v keeps its text inline: keep it in files/ and name it as the template", r["id"])
|
||||||
|
}
|
||||||
|
if r["type"] == "file" {
|
||||||
|
templateText(t, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
packs := 0
|
||||||
|
for _, a := range m.Build.Artifacts {
|
||||||
|
if a["kind"] == "archive" && a["from"] == "files" {
|
||||||
|
packs++
|
||||||
|
if a["name"] != "files" {
|
||||||
|
t.Errorf("the templates archive is named %v, not files", a["name"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if packs != 1 {
|
||||||
|
t.Errorf("the build packs files/ into %d archives, not one", packs)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
postgresql://${bound:postgres-database:as}:${secret:postgres-database}@${bound:postgres-database:at}:${bound:postgres-database:port}/${bound:postgres-database:as}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"cadence_minutes": 240,
|
||||||
|
"floor_minutes": 60,
|
||||||
|
"failures_to_notify": 3,
|
||||||
|
"cooldown_hours": 24,
|
||||||
|
"refresh_warn_days": 3
|
||||||
|
}
|
||||||
@@ -76,6 +76,43 @@
|
|||||||
"reads": [
|
"reads": [
|
||||||
"claude-code.holdings"
|
"claude-code.holdings"
|
||||||
],
|
],
|
||||||
|
"config": {
|
||||||
|
"cadence_minutes": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "How old a licence's grant may grow before it is rotated, in minutes; 0 keeps 240.",
|
||||||
|
"minimum": 0,
|
||||||
|
"trusted": true,
|
||||||
|
"default": 240
|
||||||
|
},
|
||||||
|
"floor_minutes": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "How little time a grant may have left before it is refreshed in any case, in minutes; 0 keeps 60.",
|
||||||
|
"minimum": 0,
|
||||||
|
"trusted": true,
|
||||||
|
"default": 60
|
||||||
|
},
|
||||||
|
"failures_to_notify": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "How many refreshes of a licence fail in a row before the operator is told, a count; 0 keeps 3.",
|
||||||
|
"minimum": 0,
|
||||||
|
"trusted": true,
|
||||||
|
"default": 3
|
||||||
|
},
|
||||||
|
"cooldown_hours": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "How long after telling the operator about a licence nothing more is said about it, in hours; 0 keeps 24.",
|
||||||
|
"minimum": 0,
|
||||||
|
"trusted": true,
|
||||||
|
"default": 24
|
||||||
|
},
|
||||||
|
"refresh_warn_days": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "How long before a licence's refresh token itself expires the operator is warned, in days; 0 keeps 3.",
|
||||||
|
"minimum": 0,
|
||||||
|
"trusted": true,
|
||||||
|
"default": 3
|
||||||
|
}
|
||||||
|
},
|
||||||
"resources": [
|
"resources": [
|
||||||
{
|
{
|
||||||
"id": "state",
|
"id": "state",
|
||||||
@@ -88,15 +125,16 @@
|
|||||||
"type": "file",
|
"type": "file",
|
||||||
"path": "${dir:state}/database.url",
|
"path": "${dir:state}/database.url",
|
||||||
"mode": "0600",
|
"mode": "0600",
|
||||||
"content": "postgresql://${bound:postgres-database:as}:${secret:postgres-database}@${bound:postgres-database:at}:${bound:postgres-database:port}/${bound:postgres-database:as}\n"
|
"template": "files/database.url"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "settings",
|
"id": "settings",
|
||||||
"type": "file",
|
"type": "file",
|
||||||
"path": "${dir:state}/settings.json",
|
"path": "${dir:state}/settings.json",
|
||||||
"mode": "0600",
|
"mode": "0600",
|
||||||
|
"takes-config": ["cadence_minutes", "floor_minutes", "failures_to_notify", "cooldown_hours", "refresh_warn_days"],
|
||||||
"merge": "json",
|
"merge": "json",
|
||||||
"content": "{\n \"cadence_minutes\": 240,\n \"floor_minutes\": 60,\n \"failures_to_notify\": 3,\n \"cooldown_hours\": 24,\n \"refresh_warn_days\": 3\n}\n"
|
"template": "files/settings.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "prepare",
|
"id": "prepare",
|
||||||
@@ -134,6 +172,11 @@
|
|||||||
"MESH_LICENCE_KEY_FILE": "${dir:state}/grant.key",
|
"MESH_LICENCE_KEY_FILE": "${dir:state}/grant.key",
|
||||||
"MESH_LICENCE_SETTINGS": "${dir:state}/settings.json"
|
"MESH_LICENCE_SETTINGS": "${dir:state}/settings.json"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "files",
|
||||||
|
"kind": "archive",
|
||||||
|
"from": "files"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,15 @@ The clipboard manager as a module (novox/hq ADR 0208, research 026/04).
|
|||||||
- Claims the mesh's `node-clipboard` seat and serves its verbs `history` and `copy`. Requires
|
- Claims the mesh's `node-clipboard` seat and serves its verbs `history` and `copy`. Requires
|
||||||
`x11-display` on its own machine.
|
`x11-display` on its own machine.
|
||||||
- **Declares `rofi-greenclip` absent** (ADR 0180). This module replaces it.
|
- **Declares `rofi-greenclip` absent** (ADR 0180). This module replaces it.
|
||||||
- Starts `clipmenud` **once per session**, from the session's start (the `xinitrc` slot `normal`).
|
- Declares `clipmenud.service` as a **user unit of the operator's account**, enabled and running
|
||||||
It is not a user unit as well. Its packaged unit needs user-scoped units (mesh-host #72, not
|
(`scope: user`, ADR 0177; issue 743), so `$mod+.` shows the history again. It is the only starter: the
|
||||||
merged), and the session start alone is one starter.
|
session's `xinitrc` line is gone. A drop-in (`~/.config/systemd/user/clipmenud.service.d/mesh.conf`)
|
||||||
|
replaces `ExecStart` with the module's launcher, which finds the X display from its socket (the account's
|
||||||
|
service manager has no `DISPLAY`) and puts the text-only `xsel` first on the `PATH`; until the display is up
|
||||||
|
it waits (a quick exit would run into the start limit, which the drop-in also switches off, with `RestartSec=2s`). `DISPLAY` and `XAUTHORITY` come from the user manager when the session's xinitrc has handed them over (module xorg); with several X servers and no `DISPLAY` yet, it waits for them. The store is in the account's runtime directory (mode 0700),
|
||||||
|
so no other account reads it.
|
||||||
|
**After delivery the operator logs in again**: the running session keeps the `clipmenud` its xinitrc
|
||||||
|
started, and the new unit waits on clipmenud's session lock until that one ends.
|
||||||
- Binds `$mod+period` to `clipmenu`, as its own i3 drop-in (`50-clipmenu.conf`). clipmenu shows the
|
- Binds `$mod+period` to `clipmenu`, as its own i3 drop-in (`50-clipmenu.conf`). clipmenu shows the
|
||||||
history through `dmenu`, the seat command of `node-launcher`, so it looks like every other menu.
|
history through `dmenu`, the seat command of `node-launcher`, so it looks like every other menu.
|
||||||
- Its settings are environment contributions (ADR 0203), read by the daemon, the menu and the tools
|
- Its settings are environment contributions (ADR 0203), read by the daemon, the menu and the tools
|
||||||
@@ -23,11 +29,11 @@ The clipboard manager as a module (novox/hq ADR 0208, research 026/04).
|
|||||||
|
|
||||||
| tool | does |
|
| tool | does |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `node-clipboard.history` | the history, newest first, each entry once with its id, first line, time, size and text (cut) |
|
| `node-clipboard.history` | the history, newest first: count, time and length of each entry, and whether the daemon collects. **Never a text, a first line or an id** (issue 750); the text is read through clipmenu on the operator's own screen |
|
||||||
| `node-clipboard.copy` | put text on the clipboard; it enters the history like any copy |
|
| `node-clipboard.copy` | put text on the clipboard, then read it back; an error with the reason (no `DISPLAY`, `xsel` exited, other contents) when it does not hold the text. It enters the history like any copy |
|
||||||
| `clipmenu_paste` | what the clipboard holds now |
|
| `clipmenu_paste` | how many bytes of text the clipboard holds now, never the text |
|
||||||
| `clipmenu_clear` | forget the history; the daemon's locks stay |
|
| `clipmenu_clear` | forget the history; the daemon's locks stay |
|
||||||
| `clipmenu_delete` | forget one entry, by id or first line |
|
| `clipmenu_delete` | forget one entry, by its place in the history (1 is the newest) and the time the history gave it, refused if the history changed since; not by text or id |
|
||||||
|
|
||||||
The history is read from clipmenu's own store, in the account's runtime directory, under clipmenu's
|
The history is read from clipmenu's own store, in the account's runtime directory, under clipmenu's
|
||||||
own lock, so a copy arriving meanwhile is neither lost nor half-written. `copy` hands the text to an
|
own lock, so a copy arriving meanwhile is neither lost nor half-written. `copy` hands the text to an
|
||||||
|
|||||||
@@ -15,8 +15,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
mostCopy = 1 << 20
|
mostCopy = 1 << 20
|
||||||
mostPaste = 64 << 10
|
|
||||||
// majorVersion is clipmenu's store layout: <dir>/clipmenu.<major>.<user>/.
|
// majorVersion is clipmenu's store layout: <dir>/clipmenu.<major>.<user>/.
|
||||||
majorVersion = 6
|
majorVersion = 6
|
||||||
)
|
)
|
||||||
@@ -71,15 +70,15 @@ func cksum(data []byte) string {
|
|||||||
|
|
||||||
func entryID(line string) string { return cksum([]byte(line + "\n")) }
|
func entryID(line string) string { return cksum([]byte(line + "\n")) }
|
||||||
|
|
||||||
// Entry is one clip in the history.
|
// Entry is one clip in the history as a caller sees it: when and how long, never what. The text of a
|
||||||
|
// clip, its first line and its id (a checksum of that line) stay in clipmenu's store, readable on the
|
||||||
|
// operator's own screen through clipmenu (issue 750).
|
||||||
type Entry struct {
|
type Entry struct {
|
||||||
ID string `json:"id"`
|
At string `json:"at"`
|
||||||
Line string `json:"line"`
|
Bytes int `json:"bytes"`
|
||||||
At string `json:"at"`
|
id string
|
||||||
Bytes int `json:"bytes"`
|
line string
|
||||||
Text string `json:"text,omitempty"`
|
at int64
|
||||||
Truncated bool `json:"truncated,omitempty"`
|
|
||||||
at int64
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// HistoryResult is what node-clipboard.history answers.
|
// HistoryResult is what node-clipboard.history answers.
|
||||||
@@ -119,14 +118,15 @@ func readStore(dir string) ([]Entry, error) {
|
|||||||
}
|
}
|
||||||
out := make([]Entry, 0, len(latest))
|
out := make([]Entry, 0, len(latest))
|
||||||
for line, ns := range latest {
|
for line, ns := range latest {
|
||||||
out = append(out, Entry{ID: entryID(line), Line: line, at: ns, At: time.Unix(0, ns).Format(time.RFC3339)})
|
out = append(out, Entry{id: entryID(line), line: line, at: ns, At: time.Unix(0, ns).Format(time.RFC3339)})
|
||||||
}
|
}
|
||||||
sort.Slice(out, func(i, j int) bool { return out[i].at > out[j].at })
|
sort.Slice(out, func(i, j int) bool { return out[i].at > out[j].at })
|
||||||
return out, scan.Err()
|
return out, scan.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// History is the clipboard's history, newest first.
|
// History is the clipboard's history, newest first: how many entries, when each was copied and how
|
||||||
func History(limit, maxBytes int) (HistoryResult, error) {
|
// long each is, and whether the daemon is collecting. It never returns a copied text.
|
||||||
|
func History(limit int) (HistoryResult, error) {
|
||||||
dir, err := storeDir(findEnvironment())
|
dir, err := storeDir(findEnvironment())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return HistoryResult{}, err
|
return HistoryResult{}, err
|
||||||
@@ -140,15 +140,8 @@ func History(limit, maxBytes int) (HistoryResult, error) {
|
|||||||
if i == limit {
|
if i == limit {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
if info, err := os.Stat(filepath.Join(dir, e.ID)); err == nil {
|
if info, err := os.Stat(filepath.Join(dir, e.id)); err == nil {
|
||||||
e.Bytes = int(info.Size())
|
e.Bytes = int(info.Size())
|
||||||
if maxBytes > 0 {
|
|
||||||
raw, _ := os.ReadFile(filepath.Join(dir, e.ID))
|
|
||||||
if len(raw) > maxBytes {
|
|
||||||
raw, e.Truncated = raw[:maxBytes], true
|
|
||||||
}
|
|
||||||
e.Text = string(raw)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
out.Entries = append(out.Entries, e)
|
out.Entries = append(out.Entries, e)
|
||||||
}
|
}
|
||||||
@@ -159,6 +152,40 @@ func History(limit, maxBytes int) (HistoryResult, error) {
|
|||||||
type CopyResult struct {
|
type CopyResult struct {
|
||||||
Bytes int `json:"bytes"`
|
Bytes int `json:"bytes"`
|
||||||
Unit string `json:"unit"`
|
Unit string `json:"unit"`
|
||||||
|
// Verified says the clipboard was read back and held exactly the text.
|
||||||
|
Verified bool `json:"verified"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// readBackWait is the pause between reads of the clipboard while the owner starts; read-back tries 15 times.
|
||||||
|
var readBackWait = 200 * time.Millisecond
|
||||||
|
|
||||||
|
// readBack waits for the clipboard to hold text and answers why when it does not.
|
||||||
|
func readBack(s Session, unit, text string) error {
|
||||||
|
var last string
|
||||||
|
for i := 0; i < 15; i++ {
|
||||||
|
time.Sleep(readBackWait)
|
||||||
|
r, err := s.run(3*time.Second, "", "xsel", "--output", "--clipboard")
|
||||||
|
switch {
|
||||||
|
case err != nil:
|
||||||
|
last = err.Error()
|
||||||
|
case r.Code != 0:
|
||||||
|
last = "xsel: " + strings.TrimSpace(r.Stderr)
|
||||||
|
case r.Stdout == text:
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
last = fmt.Sprintf("it holds %d bytes of something else", len(r.Stdout))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
why := ""
|
||||||
|
if s.Display == "" {
|
||||||
|
why = "; the session has no DISPLAY, so the owner could not reach the screen"
|
||||||
|
}
|
||||||
|
if j, err := s.run(5*time.Second, "", "journalctl", "--user", "-u", unit+".service", "-n", "5", "--no-pager", "-o", "cat"); err == nil {
|
||||||
|
if tail := strings.TrimSpace(j.Stdout); tail != "" {
|
||||||
|
why += "; the owner said: " + tail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Errorf("the clipboard does not hold the text (%d bytes): %s%s", len(text), last, why)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Copy puts text on the clipboard. The clipboard is owned by a process until another copies, so the
|
// Copy puts text on the clipboard. The clipboard is owned by a process until another copies, so the
|
||||||
@@ -194,17 +221,20 @@ func Copy(text string) (CopyResult, error) {
|
|||||||
os.Remove(f.Name())
|
os.Remove(f.Name())
|
||||||
return CopyResult{}, err
|
return CopyResult{}, err
|
||||||
}
|
}
|
||||||
return CopyResult{Bytes: len(text), Unit: unit + ".service"}, nil
|
if err := readBack(s, unit, text); err != nil {
|
||||||
|
return CopyResult{}, err
|
||||||
|
}
|
||||||
|
return CopyResult{Bytes: len(text), Unit: unit + ".service", Verified: true}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// PasteResult is what clipmenu_paste answers.
|
// PasteResult is what clipmenu_paste answers: how much text the clipboard holds, never the text. The
|
||||||
|
// clipboard is the newest history entry, and a copied secret must not reach a caller (issue 750).
|
||||||
type PasteResult struct {
|
type PasteResult struct {
|
||||||
Text string `json:"text"`
|
Bytes int `json:"bytes"`
|
||||||
Bytes int `json:"bytes"`
|
Text bool `json:"holds_text"`
|
||||||
Truncated bool `json:"truncated,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Paste reads the clipboard now.
|
// Paste reads the clipboard now and says how long it is.
|
||||||
func Paste() (PasteResult, error) {
|
func Paste() (PasteResult, error) {
|
||||||
s, err := findSession()
|
s, err := findSession()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -217,11 +247,7 @@ func Paste() (PasteResult, error) {
|
|||||||
if r.Code != 0 {
|
if r.Code != 0 {
|
||||||
return PasteResult{}, fmt.Errorf("xsel: %s", strings.TrimSpace(r.Stderr))
|
return PasteResult{}, fmt.Errorf("xsel: %s", strings.TrimSpace(r.Stderr))
|
||||||
}
|
}
|
||||||
out := PasteResult{Text: r.Stdout, Bytes: len(r.Stdout), Truncated: r.Truncated}
|
return PasteResult{Bytes: len(r.Stdout), Text: len(r.Stdout) > 0}, nil
|
||||||
if len(out.Text) > mostPaste {
|
|
||||||
out.Text, out.Truncated = out.Text[:mostPaste], true
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ChangeResult is what clear and delete answer.
|
// ChangeResult is what clear and delete answer.
|
||||||
@@ -298,10 +324,12 @@ func Clear() (ChangeResult, error) {
|
|||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete forgets one entry, by id or by its first line.
|
// Delete forgets one entry, by its place in the history as node-clipboard.history lists it (1 is the
|
||||||
func Delete(id, line string) (ChangeResult, error) {
|
// newest) together with the time it reports, so a copy arriving since the listing cannot shift the
|
||||||
if (id == "") == (line == "") {
|
// place onto another entry. Not by its text or id: either would let a caller confirm a guess of a copied line.
|
||||||
return ChangeResult{}, errors.New("give the entry's id or its line, one of the two")
|
func Delete(place int, at string) (ChangeResult, error) {
|
||||||
|
if place < 1 {
|
||||||
|
return ChangeResult{}, errors.New("give the entry's place in the history, 1 for the newest")
|
||||||
}
|
}
|
||||||
dir, err := storeOf()
|
dir, err := storeOf()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -309,6 +337,17 @@ func Delete(id, line string) (ChangeResult, error) {
|
|||||||
}
|
}
|
||||||
var out ChangeResult
|
var out ChangeResult
|
||||||
err = withStoreLock(dir, func() error {
|
err = withStoreLock(dir, func() error {
|
||||||
|
entries, err := readStore(dir)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if place > len(entries) {
|
||||||
|
return fmt.Errorf("no entry %d: the clipboard history holds %d", place, len(entries))
|
||||||
|
}
|
||||||
|
if entries[place-1].At != at {
|
||||||
|
return fmt.Errorf("entry %d was copied at %s, not at %s: the history changed since it was listed; list it again", place, entries[place-1].At, at)
|
||||||
|
}
|
||||||
|
line := entries[place-1].line
|
||||||
raw, err := os.ReadFile(filepath.Join(dir, "line_cache"))
|
raw, err := os.ReadFile(filepath.Join(dir, "line_cache"))
|
||||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||||
return err
|
return err
|
||||||
@@ -319,7 +358,7 @@ func Delete(id, line string) (ChangeResult, error) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
_, text, _ := strings.Cut(l, " ")
|
_, text, _ := strings.Cut(l, " ")
|
||||||
if text == line || (id != "" && entryID(text) == id) {
|
if text == line {
|
||||||
out.Removed++
|
out.Removed++
|
||||||
_ = os.Remove(filepath.Join(dir, entryID(text)))
|
_ = os.Remove(filepath.Join(dir, entryID(text)))
|
||||||
continue
|
continue
|
||||||
@@ -327,7 +366,7 @@ func Delete(id, line string) (ChangeResult, error) {
|
|||||||
kept = append(kept, l)
|
kept = append(kept, l)
|
||||||
}
|
}
|
||||||
if out.Removed == 0 {
|
if out.Removed == 0 {
|
||||||
return fmt.Errorf("no entry %s%s in the clipboard history", id, line)
|
return fmt.Errorf("no entry %d in the clipboard history", place)
|
||||||
}
|
}
|
||||||
content := strings.Join(kept, "\n")
|
content := strings.Join(kept, "\n")
|
||||||
if content != "" {
|
if content != "" {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
@@ -51,46 +52,84 @@ func store(t *testing.T, clips map[string]string, order ...string) string {
|
|||||||
return dir
|
return dir
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTheHistoryIsNewestFirstOnceEachWithItsText(t *testing.T) {
|
func TestTheHistoryAnswersCountTimesAndLengthsNeverContent(t *testing.T) {
|
||||||
store(t, map[string]string{"first": "first", "second (2 lines)": "second\nline two"}, "first", "second (2 lines)", "first")
|
store(t, map[string]string{"first": "first", "sk-ant-api03-SECRETKEY (2 lines)": "sk-ant-api03-SECRETKEY\nline two"}, "first", "sk-ant-api03-SECRETKEY (2 lines)", "first")
|
||||||
fakeProcess(t, nobody, "clipmenud")
|
fakeProcess(t, nobody, "clipmenud")
|
||||||
h, err := History(10, 4096)
|
h, err := History(10)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if !h.Collecting || h.Total != 2 || h.Entries[0].Line != "first" || h.Entries[1].Text != "second\nline two" || h.Entries[1].Bytes != 15 {
|
if !h.Collecting || h.Total != 2 || len(h.Entries) != 2 || h.Entries[0].Bytes != 5 || h.Entries[1].Bytes != 31 || h.Entries[0].At == "" {
|
||||||
t.Fatalf("%+v", h)
|
t.Fatalf("%+v", h)
|
||||||
}
|
}
|
||||||
if h, _ := History(1, 3); len(h.Entries) != 1 || h.Entries[0].Text != "fir" || !h.Entries[0].Truncated {
|
raw, _ := json.Marshal(h)
|
||||||
t.Fatalf("limited and cut: %+v", h)
|
for _, leak := range []string{"first", "SECRETKEY", "line two"} {
|
||||||
|
if strings.Contains(string(raw), leak) {
|
||||||
|
t.Fatalf("the history answers %q: %s", leak, raw)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if h, _ := History(10, 0); h.Entries[0].Text != "" || h.Entries[0].Bytes != 5 {
|
if h, _ := History(1); len(h.Entries) != 1 {
|
||||||
t.Fatalf("without text: %+v", h)
|
t.Fatalf("limited: %+v", h)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAnEntryIsDeletedByIdOrLineWithItsText(t *testing.T) {
|
// at is the time History reports for the entry at place.
|
||||||
|
func at(t *testing.T, place int) string {
|
||||||
|
t.Helper()
|
||||||
|
h, err := History(100)
|
||||||
|
if err != nil || place > len(h.Entries) {
|
||||||
|
t.Fatalf("%+v, %v", h, err)
|
||||||
|
}
|
||||||
|
return h.Entries[place-1].At
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestADeleteWhoseListingIsStaleIsRefused(t *testing.T) {
|
||||||
|
dir := store(t, map[string]string{"a": "a", "b": "b"}, "a", "b")
|
||||||
|
listed := at(t, 1) // b
|
||||||
|
f, _ := os.OpenFile(filepath.Join(dir, "line_cache"), os.O_APPEND|os.O_WRONLY, 0o600)
|
||||||
|
f.WriteString("1800000000000000000 a\n") // a copy arrives: place 1 is now a
|
||||||
|
f.Close()
|
||||||
|
if _, err := Delete(1, listed); err == nil || !strings.Contains(err.Error(), "list it again") {
|
||||||
|
t.Fatalf("the wrong entry was deleted: %v", err)
|
||||||
|
}
|
||||||
|
if h, _ := History(10); h.Total != 2 {
|
||||||
|
t.Fatalf("%+v", h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnEntryIsDeletedByItsPlaceWithItsTextAndNeverByItsText(t *testing.T) {
|
||||||
dir := store(t, map[string]string{"a": "a", "b": "b", "c": "c"}, "a", "b", "c", "a")
|
dir := store(t, map[string]string{"a": "a", "b": "b", "c": "c"}, "a", "b", "c", "a")
|
||||||
r, err := Delete(entryID("a"), "")
|
// newest first: a, c, b
|
||||||
|
r, err := Delete(1, at(t, 1))
|
||||||
if err != nil || r.Removed != 2 || r.Remaining != 2 {
|
if err != nil || r.Removed != 2 || r.Remaining != 2 {
|
||||||
t.Fatalf("by id, both copies: %+v, %v", r, err)
|
t.Fatalf("by place, both copies: %+v, %v", r, err)
|
||||||
}
|
}
|
||||||
if _, err := os.Stat(filepath.Join(dir, entryID("a"))); !errors.Is(err, os.ErrNotExist) {
|
if _, err := os.Stat(filepath.Join(dir, entryID("a"))); !errors.Is(err, os.ErrNotExist) {
|
||||||
t.Fatal("the text stayed")
|
t.Fatal("the text stayed")
|
||||||
}
|
}
|
||||||
if r, err := Delete("", "b"); err != nil || r.Removed != 1 || r.Remaining != 1 {
|
if r, err := Delete(2, at(t, 2)); err != nil || r.Removed != 1 || r.Remaining != 1 {
|
||||||
t.Fatalf("by line: %+v, %v", r, err)
|
t.Fatalf("second place (b): %+v, %v", r, err)
|
||||||
}
|
}
|
||||||
if _, err := Delete("", "zzz"); err == nil {
|
if _, err := Delete(5, "x"); err == nil {
|
||||||
t.Fatal("a missing entry was reported deleted")
|
t.Fatal("a missing place was reported deleted")
|
||||||
}
|
}
|
||||||
if _, err := Delete("x", "y"); err == nil {
|
if _, err := Delete(0, "x"); err == nil {
|
||||||
t.Fatal("both an id and a line were accepted")
|
t.Fatal("place 0 was accepted")
|
||||||
}
|
}
|
||||||
cache, _ := os.ReadFile(filepath.Join(dir, "line_cache"))
|
cache, _ := os.ReadFile(filepath.Join(dir, "line_cache"))
|
||||||
if !strings.HasSuffix(string(cache), " c\n") || strings.Count(string(cache), "\n") != 1 {
|
if !strings.HasSuffix(string(cache), " c\n") || strings.Count(string(cache), "\n") != 1 {
|
||||||
t.Fatalf("line cache: %q", cache)
|
t.Fatalf("line cache: %q", cache)
|
||||||
}
|
}
|
||||||
|
for _, tool := range tools() {
|
||||||
|
if tool.Name == "clipmenu_delete" {
|
||||||
|
if _, ok := tool.Input["line"]; ok {
|
||||||
|
t.Fatal("delete still takes a line")
|
||||||
|
}
|
||||||
|
if _, ok := tool.Input["id"]; ok {
|
||||||
|
t.Fatal("delete still takes an id")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestClearForgetsEverythingButKeepsTheDaemonsLocks(t *testing.T) {
|
func TestClearForgetsEverythingButKeepsTheDaemonsLocks(t *testing.T) {
|
||||||
@@ -112,13 +151,21 @@ func TestClearForgetsEverythingButKeepsTheDaemonsLocks(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCopyHandsTheTextToAnOwnerUnderTheAccountsServiceManager(t *testing.T) {
|
func copyBed(t *testing.T, xsel string) string {
|
||||||
|
t.Helper()
|
||||||
store(t, nil)
|
store(t, nil)
|
||||||
fakeProcess(t, nobody, "i3", "DISPLAY=:1")
|
fakeProcess(t, nobody, "i3", "DISPLAY=:1")
|
||||||
bin := fakeBinaries(t, map[string]string{"systemctl": "true", "systemd-run": `echo "$*" > "$LOG"; for last; do :; done; cat "$last" > "$LOG.text"`})
|
bin := fakeBinaries(t, map[string]string{"systemctl": "true", "xsel": xsel,
|
||||||
|
"systemd-run": `echo "$*" > "$LOG"; for last; do :; done; cat "$last" > "$LOG.text"`})
|
||||||
t.Setenv("LOG", filepath.Join(bin, "log"))
|
t.Setenv("LOG", filepath.Join(bin, "log"))
|
||||||
|
readBackWait = 0
|
||||||
|
return bin
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCopyHandsTheTextToAnOwnerUnderTheAccountsServiceManagerAndReadsItBack(t *testing.T) {
|
||||||
|
bin := copyBed(t, `cat "$LOG.text"`)
|
||||||
r, err := Copy("secret-free text")
|
r, err := Copy("secret-free text")
|
||||||
if err != nil || r.Bytes != 16 || !strings.HasPrefix(r.Unit, "clipmenu-copy-") {
|
if err != nil || r.Bytes != 16 || !r.Verified || !strings.HasPrefix(r.Unit, "clipmenu-copy-") {
|
||||||
t.Fatalf("%+v, %v", r, err)
|
t.Fatalf("%+v, %v", r, err)
|
||||||
}
|
}
|
||||||
asked, _ := os.ReadFile(filepath.Join(bin, "log"))
|
asked, _ := os.ReadFile(filepath.Join(bin, "log"))
|
||||||
@@ -134,7 +181,23 @@ func TestCopyHandsTheTextToAnOwnerUnderTheAccountsServiceManager(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPasteReadsTheClipboardOrSaysThereIsNoSession(t *testing.T) {
|
func TestCopyFailsWhenTheClipboardHoldsSomethingElse(t *testing.T) {
|
||||||
|
copyBed(t, `printf 'old contents'`)
|
||||||
|
_, err := Copy("new text")
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "does not hold") || strings.Contains(err.Error(), "old contents") {
|
||||||
|
t.Fatalf("a copy that did not land was reported: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCopyFailsSayingWhyWhenTheOwnerCannotReachTheDisplay(t *testing.T) {
|
||||||
|
copyBed(t, `echo "xsel: Can't open display: (null)" >&2; exit 1`)
|
||||||
|
_, err := Copy("new text")
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "Can't open display") {
|
||||||
|
t.Fatalf("the reason was not given: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPasteSaysHowMuchTheClipboardHoldsNeverWhat(t *testing.T) {
|
||||||
fakeMachine(t)
|
fakeMachine(t)
|
||||||
if _, err := Paste(); !errors.Is(err, ErrNoSession) {
|
if _, err := Paste(); !errors.Is(err, ErrNoSession) {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -142,8 +205,9 @@ func TestPasteReadsTheClipboardOrSaysThereIsNoSession(t *testing.T) {
|
|||||||
fakeProcess(t, nobody, "i3", "DISPLAY=:1")
|
fakeProcess(t, nobody, "i3", "DISPLAY=:1")
|
||||||
fakeBinaries(t, map[string]string{"xsel": `[ "$*" = "--output --clipboard" ] && printf 'on the clipboard'`})
|
fakeBinaries(t, map[string]string{"xsel": `[ "$*" = "--output --clipboard" ] && printf 'on the clipboard'`})
|
||||||
p, err := Paste()
|
p, err := Paste()
|
||||||
if err != nil || p.Text != "on the clipboard" || p.Bytes != 16 {
|
raw, _ := json.Marshal(p)
|
||||||
t.Fatalf("%+v, %v", p, err)
|
if err != nil || p.Bytes != 16 || strings.Contains(string(raw), "on the clipboard") {
|
||||||
|
t.Fatalf("%s, %v", raw, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,7 +221,26 @@ func TestWithoutAStoreTheChangesSayWhy(t *testing.T) {
|
|||||||
if _, err := Clear(); err == nil || !strings.Contains(err.Error(), "clipmenud has not run") {
|
if _, err := Clear(); err == nil || !strings.Contains(err.Error(), "clipmenud has not run") {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if h, err := History(5, 0); err != nil || h.Total != 0 || h.Collecting {
|
if h, err := History(5); err != nil || h.Total != 0 || h.Collecting {
|
||||||
t.Fatalf("an empty history: %+v, %v", h, err)
|
t.Fatalf("an empty history: %+v, %v", h, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// clipmenud is a bash script: its process is named "bash" and only its command line names clipmenud
|
||||||
|
// (issue 860: collecting was false while the daemon ran).
|
||||||
|
func TestCollectingSeesClipmenudRunAsABashScript(t *testing.T) {
|
||||||
|
store(t, map[string]string{"first": "first"}, "first")
|
||||||
|
fakeProcess(t, nobody, "bash")
|
||||||
|
cmdline := filepath.Join(procRoot, strconv.Itoa(nobody), "cmdline")
|
||||||
|
if err := os.WriteFile(cmdline, []byte("bash\x00/usr/bin/clipmenud\x00"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if h, err := History(10); err != nil || !h.Collecting {
|
||||||
|
t.Fatalf("clipmenud runs as a bash script: %+v %v", h, err)
|
||||||
|
}
|
||||||
|
// another bash script is not the daemon
|
||||||
|
os.WriteFile(cmdline, []byte("bash\x00/usr/bin/other\x00"), 0o644)
|
||||||
|
if h, _ := History(10); h.Collecting {
|
||||||
|
t.Fatalf("a bash script that is not clipmenud counted: %+v", h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,28 +22,23 @@ func tools() []stdio.Tool {
|
|||||||
return []stdio.Tool{
|
return []stdio.Tool{
|
||||||
{
|
{
|
||||||
Name: "node-clipboard.history",
|
Name: "node-clipboard.history",
|
||||||
Description: "What the operator copied, newest first: each entry's id, its first line, when, its size " +
|
Description: "What the operator copied, newest first: how many entries, when each was copied and how long " +
|
||||||
"and its text (each cut at max_bytes). Whether the clipboard daemon is collecting.",
|
"it is, never what it says. Whether the clipboard daemon is collecting. The text is read on the operator's own screen, through clipmenu.",
|
||||||
Input: map[string]any{
|
Input: map[string]any{
|
||||||
"limit": map[string]any{"type": "integer", "description": "at most this many entries (default 20, at most 500)"},
|
"limit": map[string]any{"type": "integer", "description": "at most this many entries (default 20, at most 500)"},
|
||||||
"max_bytes": map[string]any{"type": "integer", "description": "cut each entry's text at this many bytes; 0 leaves the text out (default 4096, at most 65536)"},
|
|
||||||
},
|
},
|
||||||
Run: func(args map[string]any) (any, error) {
|
Run: func(args map[string]any) (any, error) {
|
||||||
limit, err := whole(args, "limit", 20, 1, 500)
|
limit, err := whole(args, "limit", 20, 1, 500)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
most, err := whole(args, "max_bytes", 4096, 0, 65536)
|
return History(limit)
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return History(limit, most)
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "node-clipboard.copy",
|
Name: "node-clipboard.copy",
|
||||||
Description: "Put text on the operator's clipboard, as if they had copied it; it enters the history " +
|
Description: "Put text on the operator's clipboard, as if they had copied it; it enters the history " +
|
||||||
"like any copy. Answers how many bytes.",
|
"like any copy. Reads the clipboard back and answers an error with the reason when it does not hold the text.",
|
||||||
Input: map[string]any{
|
Input: map[string]any{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": map[string]any{
|
"properties": map[string]any{
|
||||||
@@ -61,7 +56,7 @@ func tools() []stdio.Tool {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "clipmenu_paste",
|
Name: "clipmenu_paste",
|
||||||
Description: "What the operator's clipboard holds right now, as text (cut at 64 KiB, said in truncated).",
|
Description: "How many bytes of text the operator's clipboard holds right now, never the text itself.",
|
||||||
Run: func(map[string]any) (any, error) { return Paste() },
|
Run: func(map[string]any) (any, error) { return Paste() },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -71,19 +66,22 @@ func tools() []stdio.Tool {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "clipmenu_delete",
|
Name: "clipmenu_delete",
|
||||||
Description: "Forget one entry of the clipboard history, by its id as the history answers it, or by " +
|
Description: "Forget one entry of the clipboard history, by its place in node-clipboard.history's list " +
|
||||||
"its first line exactly.",
|
"(1 is the newest) and its time as the history gives it; refused when the two no longer match.",
|
||||||
Input: map[string]any{
|
Input: map[string]any{
|
||||||
"id": map[string]any{"type": "string", "description": "the entry's id"},
|
"place": map[string]any{"type": "integer", "description": "the entry's place in the history, 1 for the newest"},
|
||||||
"line": map[string]any{"type": "string", "description": "the entry's first line, exactly"},
|
"at": map[string]any{"type": "string", "description": "the entry's time, as the history reports it"},
|
||||||
},
|
},
|
||||||
Run: func(args map[string]any) (any, error) {
|
Run: func(args map[string]any) (any, error) {
|
||||||
id, err := text(args, "id", false)
|
place, err := whole(args, "place", 0, 0, 100000)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
line, _ := args["line"].(string)
|
at, err := text(args, "at", true)
|
||||||
return Delete(id, line)
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return Delete(place, at)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user