diff --git a/modules/bluetooth/README.md b/modules/bluetooth/README.md
new file mode 100644
index 0000000..08c6a71
--- /dev/null
+++ b/modules/bluetooth/README.md
@@ -0,0 +1,53 @@
+# bluetooth
+
+Bluetooth on the two workstations (novox/hq research 027/02, to-be 42 phase 2 step 9).
+
+## Owns
+
+| what | where |
+|---|---|
+| the Bluetooth stack and its daemon | package `bluez` |
+| `bluetoothctl`, which the tools speak through | package `bluez-utils` |
+| the daemon, running and enabled | `bluetooth.service` |
+
+All official. `/etc/bluetooth/main.conf` is the package's file, unchanged on both workstations
+(every setting commented out). The module states nothing in it, so it declares nothing there.
+
+## Improves
+
+- **The stack is declared, not a dependency of something else.** On both workstations `bluez` is
+ installed only as a dependency. Removing the applet that pulled it in would have left it an orphan
+ for the next clean-up to take, and Bluetooth with it.
+- **An owner for the daemon**, running and enabled on both today with nothing recording why.
+- **Headphones from the mesh.** `bluetooth_connect` and `bluetooth_devices` (with battery) answer from
+ any machine, without the applet.
+
+## Tools
+
+All answer JSON; `(r)` reads, `(a)` acts. They run as the operator account. bluez's bus policy lets
+the account act; if it ever refuses (`AccessDenied`), the act is repeated through `sudo -n`. An act
+whose output says it failed (`Failed to …`, `org.bluez.Error…`, `not available`) is an error, whatever
+bluetoothctl's exit status.
+
+| tool | what |
+|---|---|
+| `bluetooth_controller` (r) | address, name, powered, discoverable, pairable, discovering |
+| `bluetooth_power` (r/a) | read, or switch the controller on or off |
+| `bluetooth_devices` (r) | all, paired, connected or trusted devices: kind, paired, bonded, trusted, blocked, connected, battery where reported |
+| `bluetooth_scan` (r) | discover for 1 to 15 s (default 8); the unpaired devices found, strongest signal first |
+| `bluetooth_connect` / `bluetooth_disconnect` (a) | one device; connect waits up to 15 s |
+| `bluetooth_trust` (a) | trust, or untrust |
+| `bluetooth_pair` (a) | pair with an agent that confirms nothing (headphones, speakers), then trust. A device that shows a code is paired from the desktop |
+| `bluetooth_remove` (a) | forget a device |
+
+## What changes when it is assigned
+
+Nothing on disk on either workstation: both packages are installed, and the service is enabled and
+running. `bluez` becomes explicitly the mesh's.
+
+## Leaves as found
+
+- The paired devices and their keys under `/var/lib/bluetooth` (bluez's state).
+- `blueman` on both workstations, and its applet, which the window manager's configuration starts.
+ That line is the `i3` module's to keep or drop.
+- `bluez-obex` and the AUR terminal client `bluetuith-bin` (with its `-debug`) on the laptop.
diff --git a/modules/bluetooth/cmd/bluetooth-tools/bluetooth.go b/modules/bluetooth/cmd/bluetooth-tools/bluetooth.go
new file mode 100644
index 0000000..3022882
--- /dev/null
+++ b/modules/bluetooth/cmd/bluetooth-tools/bluetooth.go
@@ -0,0 +1,281 @@
+package main
+
+import (
+ "fmt"
+ "regexp"
+ "sort"
+ "strconv"
+ "strings"
+ "time"
+)
+
+var macAddress = regexp.MustCompile(`^[0-9A-Fa-f]{2}(:[0-9A-Fa-f]{2}){5}$`)
+
+func addressOf(args map[string]any) (string, error) {
+ a, err := text(args, "address")
+ if err != nil {
+ return "", err
+ }
+ if !macAddress.MatchString(a) {
+ return "", fmt.Errorf("%q is not a Bluetooth address (six hex pairs separated by colons)", a)
+ }
+ return strings.ToUpper(a), nil
+}
+
+var ansi = regexp.MustCompile(`\x1b\[[0-9;]*[A-Za-z]|\x01|\x02`)
+
+// btFailed are the words bluetoothctl uses for an act that did not happen, whatever its exit status.
+var btFailed = regexp.MustCompile(`(?m)(Failed to \w+|not available|org\.bluez\.Error\.\w+|No default controller available)`)
+
+// bt runs bluetoothctl once, non-interactively, as the account; bluez's bus policy lets the
+// account act, and if it refuses, the act is run through sudo -n.
+func bt(timeout time.Duration, args ...string) (string, error) {
+ c := Cmd{Name: "bluetoothctl", Args: args, Timeout: timeout}
+ r := run(c)
+ if strings.Contains(r.Stdout+r.Stderr, "AccessDenied") || strings.Contains(r.Stdout+r.Stderr, "Not authorized") {
+ c.Root = true
+ r = run(c)
+ }
+ out := ansi.ReplaceAllString(r.Stdout+"\n"+r.Stderr, "")
+ if r.Error != "" {
+ return out, failure(c, r)
+ }
+ if strings.Contains(out, "No default controller available") {
+ return out, fmt.Errorf("this machine has no Bluetooth controller bluez can use: none is present, it is blocked (rfkill), or bluetooth.service is not running")
+ }
+ if m := btFailed.FindString(out); m != "" || r.Status != 0 {
+ said := strings.TrimSpace(out)
+ if said == "" {
+ said = fmt.Sprintf("exit status %d", r.Status)
+ }
+ return out, fmt.Errorf("bluetoothctl %s: %s", strings.Join(args, " "), tail(said, 1000))
+ }
+ return out, nil
+}
+
+// fields reads bluetoothctl's "\tKey: value" lines; a key seen twice keeps its first value.
+func fields(s string) map[string]string {
+ out := map[string]string{}
+ for _, l := range strings.Split(s, "\n") {
+ if !strings.HasPrefix(l, "\t") {
+ continue
+ }
+ k, v, ok := strings.Cut(strings.TrimSpace(l), ":")
+ if !ok {
+ continue
+ }
+ if _, seen := out[k]; !seen {
+ out[k] = strings.TrimSpace(v)
+ }
+ }
+ return out
+}
+
+func yes(v string) bool { return v == "yes" }
+
+// ControllerAnswer is what bluetooth_controller answers.
+type ControllerAnswer struct {
+ Address string `json:"address"`
+ Name string `json:"name"`
+ Alias string `json:"alias"`
+ Powered bool `json:"powered"`
+ PowerState string `json:"power_state,omitempty"`
+ Discoverable bool `json:"discoverable"`
+ Pairable bool `json:"pairable"`
+ Discovering bool `json:"discovering"`
+}
+
+// Controller answers the default controller.
+func Controller() (ControllerAnswer, error) {
+ out, err := bt(CallTimeout, "show")
+ if err != nil {
+ return ControllerAnswer{}, err
+ }
+ c := ControllerAnswer{}
+ for _, l := range strings.Split(out, "\n") {
+ if f := strings.Fields(l); len(f) >= 2 && f[0] == "Controller" {
+ c.Address = f[1]
+ break
+ }
+ }
+ if c.Address == "" {
+ return ControllerAnswer{}, fmt.Errorf("bluetoothctl show answered no controller: %s", tail(strings.TrimSpace(out), 500))
+ }
+ f := fields(out)
+ c.Name, c.Alias, c.PowerState = f["Name"], f["Alias"], f["PowerState"]
+ c.Powered, c.Discoverable, c.Pairable, c.Discovering = yes(f["Powered"]), yes(f["Discoverable"]), yes(f["Pairable"]), yes(f["Discovering"])
+ return c, nil
+}
+
+// Power switches the controller on or off.
+func Power(on bool) (map[string]any, error) {
+ word := "off"
+ if on {
+ word = "on"
+ }
+ if _, err := bt(CallTimeout, "power", word); err != nil {
+ return nil, err
+ }
+ c, err := Controller()
+ if err != nil {
+ return nil, err
+ }
+ return map[string]any{"powered": c.Powered, "asked": word}, nil
+}
+
+// Device is one device bluez knows.
+type Device struct {
+ Address string `json:"address"`
+ Name string `json:"name"`
+ Icon string `json:"kind,omitempty"`
+ Paired bool `json:"paired"`
+ Bonded bool `json:"bonded"`
+ Trusted bool `json:"trusted"`
+ Blocked bool `json:"blocked"`
+ Connected bool `json:"connected"`
+ Battery *int `json:"battery_percent,omitempty"`
+ RSSI *int `json:"rssi,omitempty"`
+}
+
+var inParens = regexp.MustCompile(`\((-?[0-9]+)\)`)
+
+// number reads "0x50 (80)" or "-62" as a number.
+func number(v string) *int {
+ if m := inParens.FindStringSubmatch(v); m != nil {
+ v = m[1]
+ }
+ n, err := strconv.Atoi(strings.TrimSpace(v))
+ if err != nil {
+ return nil
+ }
+ return &n
+}
+
+// deviceInfo asks bluez for one device.
+func deviceInfo(address string) (Device, error) {
+ out, err := bt(CallTimeout, "info", address)
+ if err != nil {
+ return Device{}, err
+ }
+ f := fields(out)
+ d := Device{Address: address, Name: f["Name"], Icon: f["Icon"], Paired: yes(f["Paired"]), Bonded: yes(f["Bonded"]),
+ Trusted: yes(f["Trusted"]), Blocked: yes(f["Blocked"]), Connected: yes(f["Connected"])}
+ if d.Name == "" {
+ d.Name = f["Alias"]
+ }
+ if v, ok := f["Battery Percentage"]; ok {
+ d.Battery = number(v)
+ }
+ if v, ok := f["RSSI"]; ok {
+ d.RSSI = number(v)
+ }
+ return d, nil
+}
+
+// listed reads "Device
" lines.
+func listed(out string) []string {
+ seen := map[string]bool{}
+ addrs := []string{}
+ for _, l := range strings.Split(out, "\n") {
+ f := strings.Fields(strings.TrimSpace(l))
+ if len(f) >= 2 && f[0] == "Device" && macAddress.MatchString(f[1]) && !seen[f[1]] {
+ seen[f[1]] = true
+ addrs = append(addrs, f[1])
+ }
+ }
+ return addrs
+}
+
+// Devices answers the devices bluez knows, with each one's state.
+func Devices(which string) (map[string]any, error) {
+ if err := oneOf("which", which, "all", "paired", "connected", "trusted"); err != nil {
+ return nil, err
+ }
+ args := []string{"devices"}
+ if which != "all" {
+ args = append(args, strings.ToUpper(which[:1])+which[1:])
+ }
+ out, err := bt(CallTimeout, args...)
+ if err != nil {
+ return nil, err
+ }
+ devices := []Device{}
+ for _, a := range listed(out) {
+ d, err := deviceInfo(a)
+ if err != nil {
+ return nil, err
+ }
+ devices = append(devices, d)
+ }
+ sort.SliceStable(devices, func(i, k int) bool {
+ if devices[i].Connected != devices[k].Connected {
+ return devices[i].Connected
+ }
+ return devices[i].Name < devices[k].Name
+ })
+ return map[string]any{"which": which, "count": len(devices), "devices": devices}, nil
+}
+
+// Scan discovers for a while and answers the devices found that are not paired.
+func Scan(seconds int) (map[string]any, error) {
+ // bluetoothctl's own --timeout ends the scan; the command's bound is a little longer.
+ limit := time.Duration(seconds+4) * time.Second
+ if _, err := bt(limit, "--timeout", strconv.Itoa(seconds), "scan", "on"); err != nil {
+ return nil, err
+ }
+ out, err := bt(CallTimeout, "devices")
+ if err != nil {
+ return nil, err
+ }
+ found := []Device{}
+ for _, a := range listed(out) {
+ // A device seen a moment ago may have gone out of reach: it is skipped, not a failure.
+ d, err := deviceInfo(a)
+ if err != nil {
+ continue
+ }
+ if !d.Paired {
+ found = append(found, d)
+ }
+ }
+ sort.SliceStable(found, func(i, k int) bool {
+ ri, rk := -1000, -1000
+ if found[i].RSSI != nil {
+ ri = *found[i].RSSI
+ }
+ if found[k].RSSI != nil {
+ rk = *found[k].RSSI
+ }
+ return ri > rk
+ })
+ return map[string]any{"seconds": seconds, "count": len(found), "found": found}, nil
+}
+
+// Act runs one act on a device and answers the device's state afterwards.
+func Act(verb, address string) (map[string]any, error) {
+ limit := CallTimeout
+ if verb == "connect" {
+ // A connect waits for the device; bluetoothctl's own timeout ends it first.
+ if _, err := bt(limit, "--timeout", "15", verb, address); err != nil {
+ return nil, err
+ }
+ } else if _, err := bt(limit, verb, address); err != nil {
+ return nil, err
+ }
+ if verb == "remove" {
+ return map[string]any{"act": verb, "address": address, "removed": true}, nil
+ }
+ d, err := deviceInfo(address)
+ if err != nil {
+ return nil, err
+ }
+ return map[string]any{"act": verb, "device": d}, nil
+}
+
+// Pair pairs a device with an agent that confirms nothing, then trusts it.
+func Pair(address string) (map[string]any, error) {
+ if _, err := bt(CallTimeout, "--agent", "NoInputNoOutput", "--timeout", "15", "pair", address); err != nil {
+ return nil, err
+ }
+ return Act("trust", address)
+}
diff --git a/modules/bluetooth/cmd/bluetooth-tools/bluetooth_test.go b/modules/bluetooth/cmd/bluetooth-tools/bluetooth_test.go
new file mode 100644
index 0000000..117c5e5
--- /dev/null
+++ b/modules/bluetooth/cmd/bluetooth-tools/bluetooth_test.go
@@ -0,0 +1,163 @@
+package main
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestTheManifestIsTheStackItsToolsAndTheDaemon(t *testing.T) {
+ m := readManifest(t)
+ holdsTheBundle(t, m, "bluetooth")
+ if got := strings.Join(m.packages(), ","); got != "bluez,bluez-utils" {
+ t.Errorf("packages %s: the applet and the TUI are the operator's", got)
+ }
+ s := m.services()["bluetooth.service"]
+ if s == nil || s["state"] != "running" || s["boot"] != "enabled" {
+ t.Errorf("%v", s)
+ }
+ if len(m.Resources) != 3 {
+ t.Errorf("no configuration file: /etc/bluetooth/main.conf is the package's, unchanged on both workstations: %v", m.Resources)
+ }
+}
+
+const show = "Controller 4C:82:A9:97:01:8E (public)\n\tName: g14\n\tAlias: g14\n\tPowered: yes\n\tPowerState: on\n\tDiscoverable: no\n\tPairable: yes\n\tUUID: Headset (00001108-0000-1000-8000-00805f9b34fb)\n\tDiscovering: no\n"
+
+func headphones(connected bool) string {
+ c := "no"
+ extra := ""
+ if connected {
+ c, extra = "yes", "\tBattery Percentage: 0x50 (80)\n"
+ }
+ return "Device 80:99:E7:C2:29:DA (public)\n\tName: WH-1000XM4\n\tAlias: WH-1000XM4\n\tIcon: audio-headset\n\tPaired: yes\n\tBonded: yes\n\tTrusted: yes\n\tBlocked: no\n\tConnected: " + c + "\n" + extra + "\tUUID: Headset (00001108-0000-1000-8000-00805f9b34fb)\n"
+}
+
+func TestControllerReadsShow(t *testing.T) {
+ using(t, func(string, Cmd) Result { return ok("\x1b[0;94m" + show) })
+ c, err := Controller()
+ if err != nil || c.Address != "4C:82:A9:97:01:8E" || c.Name != "g14" || !c.Powered || c.Discoverable || !c.Pairable {
+ t.Fatalf("%+v %v", c, err)
+ }
+ using(t, func(string, Cmd) Result { return ok("No default controller available\n") })
+ if _, err := Controller(); err == nil || !strings.Contains(err.Error(), "no Bluetooth controller") {
+ t.Fatalf("%v", err)
+ }
+}
+
+func TestDevicesAskEachOneAndReportBatteryWhereGiven(t *testing.T) {
+ f := using(t, func(line string, c Cmd) Result {
+ switch line {
+ case "bluetoothctl devices Paired":
+ return ok("Device 80:99:E7:C2:29:DA WH-1000XM4\nDevice 2C:41:A1:E4:EC:86 Earmuffs\n")
+ case "bluetoothctl info 80:99:E7:C2:29:DA":
+ return ok(headphones(true))
+ }
+ return ok("Device 2C:41:A1:E4:EC:86 (public)\n\tName: Earmuffs\n\tPaired: yes\n\tConnected: no\n")
+ })
+ got, err := Devices("paired")
+ devices := got["devices"].([]Device)
+ if err != nil || got["count"] != 2 || !devices[0].Connected || devices[0].Battery == nil || *devices[0].Battery != 80 || devices[1].Battery != nil {
+ t.Fatalf("%+v %v", got, err)
+ }
+ if f.lines()[0] != "bluetoothctl devices Paired" {
+ t.Errorf("%v", f.lines())
+ }
+ if _, err := Devices("nearby"); err == nil {
+ t.Error("an unknown which")
+ }
+}
+
+func TestAnActThatFailsIsAnErrorWhateverTheExitStatus(t *testing.T) {
+ using(t, func(line string, c Cmd) Result {
+ return ok("Attempting to connect to 80:99:E7:C2:29:DA\nFailed to connect: org.bluez.Error.Failed br-connection-page-timeout\n")
+ })
+ if _, err := Act("connect", "80:99:E7:C2:29:DA"); err == nil || !strings.Contains(err.Error(), "page-timeout") {
+ t.Fatalf("%v", err)
+ }
+ using(t, func(string, Cmd) Result { return Result{Status: 1, Stdout: "Device 00:11:22:33:44:55 not available\n"} })
+ if _, err := Act("trust", "00:11:22:33:44:55"); err == nil || !strings.Contains(err.Error(), "not available") {
+ t.Fatalf("%v", err)
+ }
+}
+
+func TestConnectWaitsWithBluetoothctlsOwnTimeoutAndAnswersTheState(t *testing.T) {
+ f := using(t, func(line string, c Cmd) Result {
+ if strings.Contains(line, "connect") {
+ return ok("Attempting to connect\n[CHG] Device Connected: yes\nConnection successful\n")
+ }
+ return ok(headphones(true))
+ })
+ got, err := Act("connect", "80:99:E7:C2:29:DA")
+ if err != nil || !got["device"].(Device).Connected {
+ t.Fatalf("%v %v", got, err)
+ }
+ if f.lines()[0] != "bluetoothctl --timeout 15 connect 80:99:E7:C2:29:DA" || f.asked[0].Timeout != CallTimeout {
+ t.Errorf("%v", f.lines())
+ }
+}
+
+func TestAnActBluezRefusesTheAccountIsRetriedThroughSudo(t *testing.T) {
+ f := using(t, func(line string, c Cmd) Result {
+ if strings.HasPrefix(line, "sudo") {
+ return ok("Changing power off succeeded\n" + show)
+ }
+ return ok("Failed to set power off: org.freedesktop.DBus.Error.AccessDenied\n")
+ })
+ if _, err := Power(false); err != nil {
+ t.Fatal(err)
+ }
+ if l := f.lines(); l[0] != "bluetoothctl power off" || l[1] != "sudo -n bluetoothctl power off" {
+ t.Errorf("%v", l)
+ }
+}
+
+func TestScanIsBoundedAndAnswersUnpairedDevicesStrongestFirst(t *testing.T) {
+ f := using(t, func(line string, c Cmd) Result {
+ switch {
+ case strings.Contains(line, "scan on"):
+ return ok("Discovery started\n[NEW] Device AA:BB:CC:DD:EE:01 Speaker\n")
+ case line == "bluetoothctl devices":
+ return ok("Device 80:99:E7:C2:29:DA WH-1000XM4\nDevice AA:BB:CC:DD:EE:01 Speaker\nDevice AA:BB:CC:DD:EE:02 Phone\nDevice AA:BB:CC:DD:EE:03 Gone\n")
+ case strings.HasSuffix(line, "EE:01"):
+ return ok("Device AA:BB:CC:DD:EE:01\n\tName: Speaker\n\tPaired: no\n\tRSSI: 0xffffffc4 (-60)\n")
+ case strings.HasSuffix(line, "EE:02"):
+ return ok("Device AA:BB:CC:DD:EE:02\n\tName: Phone\n\tPaired: no\n\tRSSI: -40\n")
+ case strings.HasSuffix(line, "EE:03"):
+ return Result{Status: 1, Stdout: "Device AA:BB:CC:DD:EE:03 not available\n"}
+ }
+ return ok(headphones(false))
+ })
+ got, err := Scan(8)
+ found := got["found"].([]Device)
+ if err != nil || len(found) != 2 || found[0].Name != "Phone" || *found[1].RSSI != -60 {
+ t.Fatalf("%+v %v", got, err)
+ }
+ if f.lines()[0] != "bluetoothctl --timeout 8 scan on" || f.asked[0].Timeout.Seconds() != 12 {
+ t.Errorf("%v %v", f.lines()[0], f.asked[0].Timeout)
+ }
+}
+
+func TestPairUsesAnAgentThatConfirmsNothingAndThenTrusts(t *testing.T) {
+ f := using(t, func(line string, c Cmd) Result {
+ if strings.Contains(line, " pair ") {
+ return ok("Pairing successful\n")
+ }
+ return ok(headphones(false))
+ })
+ if _, err := Pair("80:99:e7:c2:29:da"); err != nil {
+ t.Fatal(err)
+ }
+ if l := f.lines(); l[0] != "bluetoothctl --agent NoInputNoOutput --timeout 15 pair 80:99:e7:c2:29:da" || l[1] != "bluetoothctl trust 80:99:e7:c2:29:da" {
+ t.Errorf("%v", l)
+ }
+}
+
+func TestAnAddressIsSixHexPairs(t *testing.T) {
+ for _, bad := range []string{"", "80:99:E7:C2:29", "80:99:E7:C2:29:DA; rm", "--help", "GG:99:E7:C2:29:DA"} {
+ if _, err := addressOf(map[string]any{"address": bad}); err == nil {
+ t.Errorf("%q accepted", bad)
+ }
+ }
+ if a, err := addressOf(map[string]any{"address": "80:99:e7:c2:29:da"}); err != nil || a != "80:99:E7:C2:29:DA" {
+ t.Errorf("%s %v", a, err)
+ }
+}
diff --git a/modules/bluetooth/cmd/bluetooth-tools/kit.go b/modules/bluetooth/cmd/bluetooth-tools/kit.go
new file mode 100644
index 0000000..adc5aac
--- /dev/null
+++ b/modules/bluetooth/cmd/bluetooth-tools/kit.go
@@ -0,0 +1,352 @@
+package main
+
+// kit.go is the same file in each of the workstations' tool bundles (fonts, docker-compose, snapd,
+// flatpak, cups, bluetooth, xclip, dmenu): how a tool runs a command, escalates, bounds what it
+// keeps, and names a failure. A module is built from its own directory, so the file is copied rather
+// than shared; a change to one copy is made to all eight.
+//
+// The rules it holds (novox/hq research 026/05, to-be 38 WP4):
+// - the node's tool runtime runs as the operator account, not root (ADR 0175 §4); a command that
+// needs root goes through `sudo -n`, never a prompt, and a refusal is named as such;
+// - one command gets 20 s, below the runtime's 30 s call limit, and is ended with everything it
+// started when it takes longer;
+// - each stream is kept to 256 KiB, and the answer says when it was cut;
+// - a failure is an error with what went wrong in it, never an empty answer.
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "os/exec"
+ "strings"
+ "syscall"
+ "time"
+)
+
+// Bounds every command is held to.
+const (
+ CallTimeout = 20 * time.Second
+ MostOutput = 256 << 10
+)
+
+// Cmd is one command a tool runs.
+type Cmd struct {
+ Name string
+ Args []string
+ // Stdin is written to the command's standard input when not empty.
+ Stdin string
+ // Env is added to this process's own environment.
+ Env []string
+ // Root says the command needs root: it is run through `sudo -n` when this process is not root.
+ Root bool
+ // Timeout replaces CallTimeout; only a background job (jobs.go) asks for longer.
+ Timeout time.Duration
+ // Detached is for a program that forks a child which outlives it, as xclip does to keep the
+ // selection: its streams go to files, because a pipe the child inherits would hold the call open
+ // until the child exits.
+ Detached bool
+}
+
+// Result is what a command did.
+type Result struct {
+ Stdout string `json:"stdout"`
+ Stderr string `json:"stderr"`
+ Status int `json:"status"`
+ // Error is why it did not run to an answer: "not-found" when the program is not there,
+ // "timeout" when it was ended for taking too long, else the spawn error.
+ Error string `json:"error,omitempty"`
+ Truncated bool `json:"truncated,omitempty"`
+}
+
+// Runner runs a command. Tests replace it; nothing else does.
+type Runner func(Cmd) Result
+
+var (
+ run Runner = execRun
+ euid = os.Geteuid
+)
+
+// argv is the command as it is run: through sudo without a prompt when it needs root and this
+// process is not root.
+func argv(c Cmd) (string, []string) {
+ if c.Root && euid() != 0 {
+ return "sudo", append([]string{"-n", c.Name}, c.Args...)
+ }
+ return c.Name, c.Args
+}
+
+// bounded keeps the first MostOutput bytes written to it and notes that more came.
+type bounded struct {
+ b bytes.Buffer
+ cut bool
+}
+
+func (w *bounded) Write(p []byte) (int, error) {
+ room := MostOutput - w.b.Len()
+ if room <= 0 {
+ w.cut = w.cut || len(p) > 0
+ return len(p), nil
+ }
+ if len(p) > room {
+ w.b.Write(p[:room])
+ w.cut = true
+ return len(p), nil
+ }
+ return w.b.Write(p)
+}
+
+func execRun(c Cmd) Result {
+ timeout := c.Timeout
+ if timeout <= 0 {
+ timeout = CallTimeout
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), timeout)
+ defer cancel()
+ name, args := argv(c)
+ cmd := exec.CommandContext(ctx, name, args...)
+ cmd.Env = append(append(os.Environ(), "LC_ALL=C"), c.Env...)
+ if !c.Detached {
+ // Its own process group, so that ending it on a timeout ends what it started too.
+ cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
+ cmd.Cancel = func() error {
+ if cmd.Process != nil {
+ _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
+ }
+ return nil
+ }
+ }
+ cmd.WaitDelay = 2 * time.Second
+ if c.Stdin != "" {
+ cmd.Stdin = strings.NewReader(c.Stdin)
+ }
+ var out, errs bounded
+ var outFile, errFile *os.File
+ if c.Detached {
+ var err error
+ if outFile, err = os.CreateTemp("", "mesh-tool-out-*"); err != nil {
+ return Result{Status: 127, Error: err.Error()}
+ }
+ defer os.Remove(outFile.Name())
+ defer outFile.Close()
+ if errFile, err = os.CreateTemp("", "mesh-tool-err-*"); err != nil {
+ return Result{Status: 127, Error: err.Error()}
+ }
+ defer os.Remove(errFile.Name())
+ defer errFile.Close()
+ cmd.Stdout, cmd.Stderr = outFile, errFile
+ } else {
+ cmd.Stdout, cmd.Stderr = &out, &errs
+ }
+ err := cmd.Run()
+ if c.Detached {
+ for _, f := range []struct {
+ file *os.File
+ into *bounded
+ }{{outFile, &out}, {errFile, &errs}} {
+ if _, e := f.file.Seek(0, io.SeekStart); e == nil {
+ _, _ = io.Copy(f.into, f.file)
+ }
+ }
+ }
+ r := Result{Stdout: out.b.String(), Stderr: errs.b.String(), Truncated: out.cut || errs.cut}
+ var exit *exec.ExitError
+ switch {
+ case err == nil:
+ case ctx.Err() == context.DeadlineExceeded:
+ r.Status, r.Error = 124, "timeout"
+ case errors.Is(err, exec.ErrNotFound) || errors.Is(err, os.ErrNotExist):
+ r.Status, r.Error = 127, "not-found"
+ case errors.As(err, &exit):
+ r.Status = exit.ExitCode()
+ default:
+ r.Status, r.Error = 127, err.Error()
+ }
+ return r
+}
+
+// call runs a command and answers its result, or an error naming what went wrong.
+func call(c Cmd) (Result, error) {
+ r := run(c)
+ if r.Status == 0 && r.Error == "" {
+ return r, nil
+ }
+ return r, failure(c, r)
+}
+
+// failure names how a command failed: not installed, refused escalation, too slow, or its exit
+// status with the end of what it said.
+func failure(c Cmd, r Result) error {
+ program, _ := argv(c)
+ switch {
+ case r.Error == "not-found" && program == "sudo":
+ return fmt.Errorf("%s needs root, and sudo is not installed here for the runtime's account to escalate with", c.Name)
+ case r.Error == "not-found":
+ if hint, ok := providedBy[c.Name]; ok {
+ return fmt.Errorf("%s is not installed on this machine (%s)", c.Name, hint)
+ }
+ return fmt.Errorf("%s is not installed on this machine", c.Name)
+ case r.Error == "timeout":
+ limit := c.Timeout
+ if limit <= 0 {
+ limit = CallTimeout
+ }
+ return fmt.Errorf("%s gave no answer within %s and was ended", c.Name, limit)
+ case r.Error != "":
+ return fmt.Errorf("%s did not run: %s", c.Name, r.Error)
+ case program == "sudo" && strings.Contains(r.Stderr, "command not found"):
+ if hint, ok := providedBy[c.Name]; ok {
+ return fmt.Errorf("%s is not installed on this machine (%s)", c.Name, hint)
+ }
+ return fmt.Errorf("%s is not installed on this machine", c.Name)
+ case program == "sudo" && strings.HasPrefix(strings.TrimSpace(r.Stderr), "sudo:"):
+ return fmt.Errorf("%s needs root, and sudo -n refused the runtime's account: %s (the escalation is the sudo module's to declare)",
+ c.Name, firstLine(r.Stderr))
+ }
+ said := tail(strings.TrimSpace(r.Stderr), 2000)
+ if said == "" {
+ said = tail(strings.TrimSpace(r.Stdout), 2000)
+ }
+ if said == "" {
+ said = "and said nothing"
+ }
+ return fmt.Errorf("%s %s exited %d: %s", c.Name, strings.Join(c.Args, " "), r.Status, said)
+}
+
+func firstLine(s string) string {
+ s = strings.TrimSpace(s)
+ if i := strings.IndexByte(s, '\n'); i >= 0 {
+ return s[:i]
+ }
+ return s
+}
+
+func tail(s string, n int) string {
+ if len(s) <= n {
+ return s
+ }
+ return "…" + s[len(s)-n:]
+}
+
+// lines are a command's output lines, blank ones dropped.
+func lines(s string) []string {
+ out := []string{}
+ for _, l := range strings.Split(s, "\n") {
+ if strings.TrimSpace(l) != "" {
+ out = append(out, strings.TrimRight(l, "\r"))
+ }
+ }
+ return out
+}
+
+// Arguments, read the way a tool's JSON arguments arrive.
+
+func text(args map[string]any, key string) (string, error) {
+ v, ok := args[key]
+ if !ok || v == nil {
+ return "", fmt.Errorf("%s is required", key)
+ }
+ s, ok := v.(string)
+ if !ok {
+ return "", fmt.Errorf("%s must be a string", key)
+ }
+ if strings.TrimSpace(s) == "" {
+ return "", fmt.Errorf("%s must not be empty", key)
+ }
+ return s, nil
+}
+
+func optText(args map[string]any, key, def string) (string, error) {
+ v, ok := args[key]
+ if !ok || v == nil {
+ return def, nil
+ }
+ s, ok := v.(string)
+ if !ok {
+ return "", fmt.Errorf("%s must be a string", key)
+ }
+ if strings.TrimSpace(s) == "" {
+ return def, nil
+ }
+ return s, nil
+}
+
+// optWhole reads a whole number, defaulted, refused below least and held to most.
+func optWhole(args map[string]any, key string, def, least, most int) (int, error) {
+ v, ok := args[key]
+ if !ok || v == nil {
+ return def, nil
+ }
+ f, ok := v.(float64)
+ if !ok {
+ if i, isInt := v.(int); isInt {
+ f = float64(i)
+ } else {
+ return 0, fmt.Errorf("%s must be a number", key)
+ }
+ }
+ if f != float64(int(f)) {
+ return 0, fmt.Errorf("%s must be a whole number", key)
+ }
+ n := int(f)
+ if n < least {
+ return 0, fmt.Errorf("%s must be at least %d", key, least)
+ }
+ if n > most {
+ n = most
+ }
+ return n, nil
+}
+
+func optFlag(args map[string]any, key string, def bool) (bool, error) {
+ v, ok := args[key]
+ if !ok || v == nil {
+ return def, nil
+ }
+ b, ok := v.(bool)
+ if !ok {
+ return false, fmt.Errorf("%s must be true or false", key)
+ }
+ return b, nil
+}
+
+func optList(args map[string]any, key string) ([]string, error) {
+ v, ok := args[key]
+ if !ok || v == nil {
+ return nil, nil
+ }
+ items, ok := v.([]any)
+ if !ok {
+ return nil, fmt.Errorf("%s must be a list of strings", key)
+ }
+ out := make([]string, 0, len(items))
+ for _, it := range items {
+ s, ok := it.(string)
+ if !ok || strings.TrimSpace(s) == "" {
+ return nil, fmt.Errorf("%s must be a list of non-empty strings", key)
+ }
+ out = append(out, s)
+ }
+ return out, nil
+}
+
+// oneOf refuses a value outside a closed set.
+func oneOf(key, value string, allowed ...string) error {
+ for _, a := range allowed {
+ if value == a {
+ return nil
+ }
+ }
+ return fmt.Errorf("%s must be one of %s, not %q", key, strings.Join(allowed, ", "), value)
+}
+
+// plainName refuses a name that could be read as an option or carries a path or a space: package,
+// snap, application and printer names never do.
+func plainName(key, value string) error {
+ if strings.HasPrefix(value, "-") || strings.ContainsAny(value, " \t\n/\\") {
+ return fmt.Errorf("%s %q is not a plain name", key, value)
+ }
+ return nil
+}
diff --git a/modules/bluetooth/cmd/bluetooth-tools/kit_test.go b/modules/bluetooth/cmd/bluetooth-tools/kit_test.go
new file mode 100644
index 0000000..c5d3557
--- /dev/null
+++ b/modules/bluetooth/cmd/bluetooth-tools/kit_test.go
@@ -0,0 +1,147 @@
+package main
+
+// Tests of kit.go, the same in each workstation module.
+
+import (
+ "strings"
+ "testing"
+ "time"
+)
+
+// fake records the commands asked and answers each from a function of the command line.
+type fake struct {
+ asked []Cmd
+ answer func(line string, c Cmd) Result
+}
+
+func (f *fake) runner() Runner {
+ return func(c Cmd) Result {
+ f.asked = append(f.asked, c)
+ name, args := argv(c)
+ line := strings.TrimSpace(name + " " + strings.Join(args, " "))
+ if f.answer == nil {
+ return Result{}
+ }
+ return f.answer(line, c)
+ }
+}
+
+func (f *fake) lines() []string {
+ out := []string{}
+ for _, c := range f.asked {
+ name, args := argv(c)
+ out = append(out, strings.TrimSpace(name+" "+strings.Join(args, " ")))
+ }
+ return out
+}
+
+// using installs a fake runner and a non-root uid for one test.
+func using(t *testing.T, answer func(line string, c Cmd) Result) *fake {
+ t.Helper()
+ f := &fake{answer: answer}
+ wasRun, wasUID := run, euid
+ run, euid = f.runner(), func() int { return 1000 }
+ t.Cleanup(func() { run, euid = wasRun, wasUID })
+ return f
+}
+
+func ok(stdout string) Result { return Result{Stdout: stdout} }
+
+func TestKitAnActThatNeedsRootGoesThroughSudoWithoutAPromptUnlessAlreadyRoot(t *testing.T) {
+ was := euid
+ defer func() { euid = was }()
+ euid = func() int { return 1000 }
+ if name, args := argv(Cmd{Name: "x", Args: []string{"a"}, Root: true}); name != "sudo" || strings.Join(args, " ") != "-n x a" {
+ t.Fatalf("not root: %s %v", name, args)
+ }
+ if name, _ := argv(Cmd{Name: "x"}); name != "x" {
+ t.Fatalf("a read is run as the account: %s", name)
+ }
+ euid = func() int { return 0 }
+ if name, _ := argv(Cmd{Name: "x", Root: true}); name != "x" {
+ t.Fatalf("as root no sudo: %s", name)
+ }
+}
+
+func TestKitAFailureIsNamedByHowItFailed(t *testing.T) {
+ was := euid
+ defer func() { euid = was }()
+ euid = func() int { return 1000 }
+ cases := []struct {
+ c Cmd
+ r Result
+ want string
+ }{
+ {Cmd{Name: "nothere"}, Result{Status: 127, Error: "not-found"}, "not installed"},
+ {Cmd{Name: "x", Root: true}, Result{Status: 127, Error: "not-found"}, "sudo is not installed"},
+ {Cmd{Name: "x", Root: true}, Result{Status: 1, Stderr: "sudo: a password is required\n"}, "sudo -n refused"},
+ {Cmd{Name: "x", Root: true}, Result{Status: 1, Stderr: "sudo: x: command not found\n"}, "x is not installed"},
+ {Cmd{Name: "x"}, Result{Status: 124, Error: "timeout"}, "within 20s"},
+ {Cmd{Name: "x", Args: []string{"y"}}, Result{Status: 3, Stderr: "boom\n"}, "x y exited 3: boom"},
+ {Cmd{Name: "x"}, Result{Status: 3}, "said nothing"},
+ }
+ for _, k := range cases {
+ err := failure(k.c, k.r)
+ if err == nil || !strings.Contains(err.Error(), k.want) {
+ t.Errorf("%+v: %v, want %q", k.r, err, k.want)
+ }
+ }
+}
+
+func TestKitOutputIsBoundedAndSaysSo(t *testing.T) {
+ var w bounded
+ big := strings.Repeat("a", MostOutput+10)
+ n, _ := w.Write([]byte(big))
+ if n != len(big) || w.b.Len() != MostOutput || !w.cut {
+ t.Fatalf("kept %d of %d, cut %v", w.b.Len(), len(big), w.cut)
+ }
+}
+
+func TestKitTheRealRunnerRunsEndsAndReportsAMissingProgram(t *testing.T) {
+ r := execRun(Cmd{Name: "sh", Args: []string{"-c", "echo out; echo err >&2; exit 3"}})
+ if r.Status != 3 || strings.TrimSpace(r.Stdout) != "out" || strings.TrimSpace(r.Stderr) != "err" {
+ t.Fatalf("%+v", r)
+ }
+ r = execRun(Cmd{Name: "sh", Args: []string{"-c", "sleep 5 & sleep 5"}, Timeout: 200 * time.Millisecond})
+ if r.Error != "timeout" {
+ t.Fatalf("a slow command: %+v", r)
+ }
+ r = execRun(Cmd{Name: "no-such-program-anywhere"})
+ if r.Error != "not-found" {
+ t.Fatalf("a missing program: %+v", r)
+ }
+ r = execRun(Cmd{Name: "cat", Stdin: "given"})
+ if r.Stdout != "given" {
+ t.Fatalf("stdin: %+v", r)
+ }
+ start := time.Now()
+ r = execRun(Cmd{Name: "sh", Args: []string{"-c", "echo kept; (sleep 3 &) ; exit 0"}, Detached: true})
+ if r.Status != 0 || strings.TrimSpace(r.Stdout) != "kept" || time.Since(start) > 2*time.Second {
+ t.Fatalf("a detached command returns when it exits, not when its child does: %+v after %s", r, time.Since(start))
+ }
+}
+
+func TestKitArgumentsAreReadStrictly(t *testing.T) {
+ args := map[string]any{"s": "x", "n": float64(5), "f": 1.5, "b": true, "l": []any{"a", "b"}}
+ if _, err := text(args, "missing"); err == nil {
+ t.Error("a missing required string")
+ }
+ if n, _ := optWhole(args, "n", 1, 1, 3); n != 3 {
+ t.Errorf("held to most: %d", n)
+ }
+ if _, err := optWhole(args, "n", 1, 6, 9); err == nil {
+ t.Error("below least")
+ }
+ if _, err := optWhole(args, "f", 1, 0, 9); err == nil {
+ t.Error("a fraction")
+ }
+ if l, _ := optList(args, "l"); len(l) != 2 {
+ t.Errorf("list: %v", l)
+ }
+ if b, _ := optFlag(args, "b", false); !b {
+ t.Error("flag")
+ }
+ if err := plainName("name", "--all"); err == nil {
+ t.Error("an option as a name")
+ }
+}
diff --git a/modules/bluetooth/cmd/bluetooth-tools/main.go b/modules/bluetooth/cmd/bluetooth-tools/main.go
new file mode 100644
index 0000000..f063aaf
--- /dev/null
+++ b/modules/bluetooth/cmd/bluetooth-tools/main.go
@@ -0,0 +1,135 @@
+// The bluetooth module's tools (novox/hq research 027/02, 026/05): the controller, the devices with
+// their state and battery, scanning, and pairing, connecting, trusting and forgetting a device. A Go
+// bundle the node's runtime launches over stdio (ADR 0188, ADR 0193); it runs as the operator
+// account, and speaks to bluez through bluetoothctl.
+package main
+
+import (
+ "fmt"
+ "os"
+
+ stdio "git.novox.be/novox/mesh-sdk/go"
+)
+
+var providedBy = map[string]string{
+ "bluetoothctl": "the bluez-utils package, which this module installs",
+}
+
+func main() {
+ if err := stdio.Serve("", tools()); err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+}
+
+var addressArg = map[string]any{"type": "string", "description": "the device's address, such as 80:99:E7:C2:29:DA, as bluetooth_devices answers it"}
+
+func withAddress(f func(string) (any, error)) func(map[string]any) (any, error) {
+ return func(args map[string]any) (any, error) {
+ a, err := addressOf(args)
+ if err != nil {
+ return nil, err
+ }
+ return f(a)
+ }
+}
+
+func tools() []stdio.Tool {
+ return []stdio.Tool{
+ {
+ Name: "bluetooth_controller",
+ Description: "The machine's Bluetooth controller: address, name, powered, discoverable, pairable, discovering. (r)",
+ Input: map[string]any{},
+ Run: func(map[string]any) (any, error) { return Controller() },
+ },
+ {
+ Name: "bluetooth_power",
+ Description: "Whether the controller is powered; with on, switch it on or off. (r/a)",
+ Input: map[string]any{"on": map[string]any{"type": "boolean", "description": "power the controller on (true) or off (false)"}},
+ Run: func(args map[string]any) (any, error) {
+ if _, given := args["on"]; !given {
+ c, err := Controller()
+ if err != nil {
+ return nil, err
+ }
+ return map[string]any{"powered": c.Powered}, nil
+ }
+ on, err := optFlag(args, "on", true)
+ if err != nil {
+ return nil, err
+ }
+ return Power(on)
+ },
+ },
+ {
+ Name: "bluetooth_devices",
+ Description: "The devices bluez knows: every one, or only the paired, connected or trusted. Each with its " +
+ "name, kind, paired, bonded, trusted, blocked, connected, and its battery where the device reports it. (r)",
+ Input: map[string]any{"which": map[string]any{"type": "string", "enum": []string{"all", "paired", "connected", "trusted"}, "description": "which devices (default all)"}},
+ Run: func(args map[string]any) (any, error) {
+ which, err := optText(args, "which", "all")
+ if err != nil {
+ return nil, err
+ }
+ return Devices(which)
+ },
+ },
+ {
+ Name: "bluetooth_scan",
+ Description: "Discover devices nearby for a few seconds (default 8, at most 15), and answer the ones not " +
+ "paired, with their signal strength. (r)",
+ Input: map[string]any{"seconds": map[string]any{"type": "integer", "description": "how long to scan (default 8, at most 15)"}},
+ Run: func(args map[string]any) (any, error) {
+ s, err := optWhole(args, "seconds", 8, 1, 15)
+ if err != nil {
+ return nil, err
+ }
+ return Scan(s)
+ },
+ },
+ {
+ Name: "bluetooth_connect",
+ Description: "Connect a paired device, such as headphones. Answers its state afterwards. (a)",
+ Input: map[string]any{"address": addressArg},
+ Run: withAddress(func(a string) (any, error) { return Act("connect", a) }),
+ },
+ {
+ Name: "bluetooth_disconnect",
+ Description: "Disconnect a device. (a)",
+ Input: map[string]any{"address": addressArg},
+ Run: withAddress(func(a string) (any, error) { return Act("disconnect", a) }),
+ },
+ {
+ Name: "bluetooth_trust",
+ Description: "Trust a device, so it may connect by itself; or with trusted false, stop trusting it. (a)",
+ Input: map[string]any{"address": addressArg, "trusted": map[string]any{"type": "boolean", "description": "trust (default) or untrust"}},
+ Run: func(args map[string]any) (any, error) {
+ a, err := addressOf(args)
+ if err != nil {
+ return nil, err
+ }
+ trusted, err := optFlag(args, "trusted", true)
+ if err != nil {
+ return nil, err
+ }
+ if trusted {
+ return Act("trust", a)
+ }
+ return Act("untrust", a)
+ },
+ },
+ {
+ Name: "bluetooth_pair",
+ Description: "Pair a device found by a scan, and trust it. Works for a device that needs no code to be " +
+ "confirmed, such as headphones; one that shows a code is paired from the desktop. (a)",
+ Input: map[string]any{"address": addressArg},
+ Run: withAddress(func(a string) (any, error) { return Pair(a) }),
+ },
+ {
+ Name: "bluetooth_remove",
+ Description: "Forget a device: unpair it and drop what bluez knows of it. (a)",
+ Input: map[string]any{"address": addressArg},
+ Run: withAddress(func(a string) (any, error) { return Act("remove", a) }),
+ },
+ }
+}
diff --git a/modules/bluetooth/cmd/bluetooth-tools/manifest_kit_test.go b/modules/bluetooth/cmd/bluetooth-tools/manifest_kit_test.go
new file mode 100644
index 0000000..3e675b4
--- /dev/null
+++ b/modules/bluetooth/cmd/bluetooth-tools/manifest_kit_test.go
@@ -0,0 +1,107 @@
+package main
+
+// manifest_kit_test.go is the same file in each workstation module: it reads the module's
+// definition so the module's own tests can hold it to what it says.
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+ "testing"
+)
+
+type manifest struct {
+ Module string `json:"module"`
+ Capabilities []string `json:"capabilities"`
+ Claims []any `json:"claims"`
+ Seats []any `json:"seats"`
+ Tools []string `json:"tools"`
+ Resources []map[string]any `json:"resources"`
+ Build struct {
+ Artifacts []map[string]any `json:"artifacts"`
+ } `json:"build"`
+}
+
+func readManifest(t *testing.T) manifest {
+ t.Helper()
+ raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ var m manifest
+ if err := json.Unmarshal(raw, &m); err != nil {
+ t.Fatalf("module.json: %v", err)
+ }
+ return m
+}
+
+func (m manifest) resource(id string) map[string]any {
+ for _, r := range m.Resources {
+ if r["id"] == id {
+ return r
+ }
+ }
+ return nil
+}
+
+// packages are the packages the module installs, sorted.
+func (m manifest) packages() []string {
+ out := []string{}
+ for _, r := range m.Resources {
+ if r["type"] == "package" && r["absent"] != true {
+ out = append(out, r["package"].(string))
+ }
+ }
+ sort.Strings(out)
+ return out
+}
+
+// services are the units the module declares, by unit name.
+func (m manifest) services() map[string]map[string]any {
+ out := map[string]map[string]any{}
+ for _, r := range m.Resources {
+ if r["type"] == "service" {
+ out[r["unit"].(string)] = r
+ }
+ }
+ return out
+}
+
+// holdsTheBundle holds the manifest to the Go bundle this directory builds: every tool registered
+// is listed and nothing else, each named _…, and the artifact builds this command.
+func holdsTheBundle(t *testing.T, m manifest, prefix string) {
+ t.Helper()
+ registered := []string{}
+ for _, tool := range tools() {
+ registered = append(registered, tool.Name)
+ if !strings.HasPrefix(tool.Name, prefix+"_") {
+ t.Errorf("tool %s is not named %s_…", tool.Name, prefix)
+ }
+ if tool.Description == "" || tool.Run == nil || tool.Input == nil {
+ t.Errorf("tool %s is not described, runnable and given an input schema", tool.Name)
+ }
+ }
+ if strings.Join(registered, ",") != strings.Join(m.Tools, ",") {
+ t.Errorf("registered %v, listed %v", registered, m.Tools)
+ }
+ if len(m.Build.Artifacts) != 1 {
+ t.Fatalf("one artifact, got %d", len(m.Build.Artifacts))
+ }
+ cwd, _ := os.Getwd()
+ binary := filepath.Base(cwd)
+ a := m.Build.Artifacts[0]
+ want := map[string]any{"kind": "bundle", "language": "go", "system": "arch", "from": "cmd/" + binary, "binary": binary}
+ for k, v := range want {
+ if a[k] != v {
+ t.Errorf("artifact %s = %v, want %v", k, a[k], v)
+ }
+ }
+ if loads, _ := a["loads"].([]any); len(loads) != 1 || loads[0] != binary {
+ t.Errorf("artifact loads %v, want [%s]", a["loads"], binary)
+ }
+ if m.Claims != nil || m.Seats != nil {
+ t.Errorf("claims %v, seats %v: this module holds no seat", m.Claims, m.Seats)
+ }
+}
diff --git a/modules/bluetooth/go.mod b/modules/bluetooth/go.mod
new file mode 100644
index 0000000..790596e
--- /dev/null
+++ b/modules/bluetooth/go.mod
@@ -0,0 +1,5 @@
+module bluetooth
+
+go 1.22
+
+require git.novox.be/novox/mesh-sdk/go v0.1.6
diff --git a/modules/bluetooth/go.sum b/modules/bluetooth/go.sum
new file mode 100644
index 0000000..0dd6061
--- /dev/null
+++ b/modules/bluetooth/go.sum
@@ -0,0 +1,2 @@
+git.novox.be/novox/mesh-sdk/go v0.1.6 h1:9qzdYONYbJdWcu6sxQcq9v1LI0JxcfkiKYkMUzJSkVQ=
+git.novox.be/novox/mesh-sdk/go v0.1.6/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
diff --git a/modules/bluetooth/module.json b/modules/bluetooth/module.json
new file mode 100644
index 0000000..dfaabf1
--- /dev/null
+++ b/modules/bluetooth/module.json
@@ -0,0 +1,53 @@
+{
+ "module": "bluetooth",
+ "version": "1",
+ "capabilities": [
+ "package-manager",
+ "service-manager"
+ ],
+ "tools": [
+ "bluetooth_controller",
+ "bluetooth_power",
+ "bluetooth_devices",
+ "bluetooth_scan",
+ "bluetooth_connect",
+ "bluetooth_disconnect",
+ "bluetooth_trust",
+ "bluetooth_pair",
+ "bluetooth_remove"
+ ],
+ "resources": [
+ {
+ "id": "stack",
+ "type": "package",
+ "package": "bluez"
+ },
+ {
+ "id": "utilities",
+ "type": "package",
+ "package": "bluez-utils"
+ },
+ {
+ "id": "daemon",
+ "type": "service",
+ "unit": "bluetooth.service",
+ "state": "running",
+ "boot": "enabled"
+ }
+ ],
+ "build": {
+ "artifacts": [
+ {
+ "name": "tools",
+ "kind": "bundle",
+ "language": "go",
+ "system": "arch",
+ "from": "cmd/bluetooth-tools",
+ "binary": "bluetooth-tools",
+ "loads": [
+ "bluetooth-tools"
+ ]
+ }
+ ]
+ }
+}
diff --git a/modules/cups/README.md b/modules/cups/README.md
new file mode 100644
index 0000000..6141bbd
--- /dev/null
+++ b/modules/cups/README.md
@@ -0,0 +1,84 @@
+# cups
+
+Printing on the two workstations (novox/hq research 027/02: "`cups` with the printer's driver"; to-be 42
+phase 2 step 9).
+
+## Owns
+
+| what | where |
+|---|---|
+| the print scheduler | package `cups` |
+| driverless printing: the filters that turn a document into what an IPP Everywhere printer takes | package `cups-filters` |
+| the scheduler, started on demand and at boot | `cups.socket` and `cups.service`, running and enabled |
+
+All from the official repositories. The queues (`/etc/cups/printers.conf`, the PPDs CUPS generates)
+and the default printer are CUPS's own state, set through its tools. They are found (ADR 0182), and
+the module declares none of them.
+
+## The printer's driver: none needed for the Brother
+
+Research 027 asked for "`cups` with the printer's driver". Measured on 2026-10-04:
+
+| workstation | queue | device | prints through | driver package installed |
+|---|---|---|---|---|
+| laptop | `Brother` (MFC-L8690CDW) | `ipp://` on the LAN | **IPP Everywhere, driverless** | `brother-mfc-l8690cdw` (AUR), unused |
+| desktop | `Brother_MFC_Novox` (default) | `ipp://` on the LAN | **IPP Everywhere, driverless** | `brother-mfc-l8390cdw` and its `-debug` (AUR), unused |
+| desktop | `Kanjuro` (Canon PIXMA MG4200) | `cnijnet:` | the vendor's PPD and filter | `cnijfilter-mg4200` 3.80 (AUR) |
+
+**Both Brother queues already print without a vendor driver.** CUPS's own IPP Everywhere support,
+with `cups-filters`, is the whole driver. The vendor packages installed beside them serve no queue,
+and the laptop's pulls in 32-bit glibc from multilib for a filter nothing runs. So the module declares
+no driver, and the Brother needs nothing outside the official repositories.
+
+**The Canon is the exception, and it is blocked.** Its driver is a user-repository package from 2012,
+with its own network backend. Like snapd, it waits for the mesh's package repository (research 027
+question 1, option P2). Until then it stays as found on the desktop. If the printer answers IPP (check
+with `cups_drivers` on a fresh queue, or `driverless` from `cups-filters`), a driverless queue replaces
+it and the question goes away.
+
+## Improves
+
+- **An owner for the scheduler.** It runs on both workstations today, enabled by nothing the mesh records.
+- **No driver package that nothing uses.** The README's one-off step below removes them, once.
+- **Supplies and state from anywhere.** `cups_printers` answers each queue's toner levels and flags a
+ low one. It also answers why a queue stopped, without opening the printer's page.
+- **The laptop has no default printer**, so a print without a named printer fails there.
+ `cups_default` sets one; it is the operator's choice, not declared.
+
+## Tools
+
+All answer JSON; `(r)` reads, `(a)` acts. They run as the operator account. CUPS lets any account
+print and cancel its own jobs. Setting the default, resuming a printer, and cancelling another
+account's job are kept for its administrators, so those go through `sudo -n`; a cancel tries the
+account first.
+
+| tool | what |
+|---|---|
+| `cups_printers` (r) | every queue: state, enabled, accepting, default, device, make and model, driverless or not, state reasons, supply levels (low flagged) |
+| `cups_queue` (r) | jobs waiting or printing, or the completed ones, newest first, bounded |
+| `cups_cancel` (a) | one job, or every job on a printer |
+| `cups_print` (a) | a file on this machine to a printer or the default, with copies and IPP options; answers the job id |
+| `cups_default` (r/a) | read the default, or set it |
+| `cups_resume` (a) | enable a stopped printer and make it accept jobs |
+| `cups_drivers` (r) | how each queue prints, the packages that bring filters and backends (foreign ones flagged), findings, and the driver models CUPS offers, filtered |
+
+## What changes when it is assigned
+
+Nothing on disk on either workstation: both have `cups` (explicit) and `cups-filters` (as its
+dependency), with `cups.socket` and `cups.service` enabled and running. The packages become the
+mesh's; `cups-filters` is now declared explicitly.
+
+## The one-off step for the operator (ADR 0182)
+
+The mesh removes nothing it did not install. Once the Brother queues are confirmed printing (they do
+today), remove the unused vendor drivers, once:
+
+- **laptop:** `brother-mfc-l8690cdw`, then whatever `pacman -Qdtq` shows it alone pulled in
+ (`lib32-glibc`).
+- **desktop:** `brother-mfc-l8390cdw` and `brother-mfc-l8390cdw-debug`. Keep `cnijfilter-mg4200` while
+ the Canon queue is used.
+
+## Leaves as found
+
+The queues and their PPDs, the default printer, `cups.path` (enabled by the package's preset),
+`system-config-printer` on the desktop, and `cnijfilter-mg4200`.
diff --git a/modules/cups/cmd/cups-tools/cups.go b/modules/cups/cmd/cups-tools/cups.go
new file mode 100644
index 0000000..659516d
--- /dev/null
+++ b/modules/cups/cmd/cups-tools/cups.go
@@ -0,0 +1,560 @@
+package main
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "regexp"
+ "sort"
+ "strconv"
+ "strings"
+)
+
+var queueName = regexp.MustCompile(`^[A-Za-z0-9_.@-]{1,127}$`)
+
+func checkPrinter(p string) error {
+ if !queueName.MatchString(p) || strings.HasPrefix(p, "-") {
+ return fmt.Errorf("%q is not a printer queue's name", p)
+ }
+ return nil
+}
+
+var optionName = regexp.MustCompile(`^[a-z][a-z0-9-]{0,63}$`)
+var optionValue = regexp.MustCompile(`^[A-Za-z0-9._:-]{1,128}$`)
+
+// optionsOf reads the print options object.
+func optionsOf(args map[string]any) (map[string]string, error) {
+ v, ok := args["options"]
+ if !ok || v == nil {
+ return nil, nil
+ }
+ m, ok := v.(map[string]any)
+ if !ok {
+ return nil, fmt.Errorf("options must be an object of names to values")
+ }
+ out := map[string]string{}
+ for k, val := range m {
+ s, ok := val.(string)
+ if !ok || !optionName.MatchString(k) || !optionValue.MatchString(s) {
+ return nil, fmt.Errorf("option %s=%v is not an IPP option name and a plain value", k, val)
+ }
+ out[k] = s
+ }
+ return out, nil
+}
+
+// lpoptionsOf reads lpoptions' answer: name=value pairs, a value quoted with ' or with backslash escapes.
+func lpoptionsOf(s string) map[string]string {
+ out := map[string]string{}
+ s = strings.TrimSpace(s)
+ i := 0
+ for i < len(s) {
+ for i < len(s) && s[i] == ' ' {
+ i++
+ }
+ start := i
+ for i < len(s) && s[i] != '=' && s[i] != ' ' {
+ i++
+ }
+ key := s[start:i]
+ if i >= len(s) || s[i] != '=' {
+ if key != "" {
+ out[key] = ""
+ }
+ continue
+ }
+ i++
+ var b strings.Builder
+ for i < len(s) && s[i] != ' ' {
+ switch s[i] {
+ case '\'':
+ i++
+ for i < len(s) && s[i] != '\'' {
+ if s[i] == '\\' && i+1 < len(s) {
+ i++
+ }
+ b.WriteByte(s[i])
+ i++
+ }
+ i++
+ case '\\':
+ if i+1 < len(s) {
+ b.WriteByte(s[i+1])
+ }
+ i += 2
+ default:
+ b.WriteByte(s[i])
+ i++
+ }
+ }
+ out[key] = b.String()
+ }
+ return out
+}
+
+// Marker is one supply the printer reports.
+type Marker struct {
+ Name string `json:"name"`
+ Type string `json:"type,omitempty"`
+ Level int `json:"level_percent"`
+ Low bool `json:"low"`
+}
+
+// Printer is one queue.
+type Printer struct {
+ Name string `json:"name"`
+ State string `json:"state"`
+ Enabled bool `json:"enabled"`
+ Accepting bool `json:"accepting"`
+ Default bool `json:"default"`
+ URI string `json:"uri"`
+ MakeModel string `json:"make_and_model"`
+ Driverless bool `json:"driverless"`
+ Shared bool `json:"shared"`
+ Reasons []string `json:"reasons"`
+ Markers []Marker `json:"markers"`
+ Since string `json:"since,omitempty"`
+ Message string `json:"message,omitempty"`
+}
+
+// PrintersAnswer is what cups_printers answers.
+type PrintersAnswer struct {
+ Scheduler string `json:"scheduler"`
+ Default string `json:"default,omitempty"`
+ Printers []Printer `json:"printers"`
+}
+
+func lpstat(args ...string) (string, error) {
+ r, err := call(Cmd{Name: "lpstat", Args: args})
+ if err != nil {
+ if strings.Contains(r.Stderr, "Scheduler is not running") || strings.Contains(r.Stderr, "Connection refused") {
+ return "", fmt.Errorf("the print scheduler is not running on this machine: %s", firstLine(r.Stderr))
+ }
+ // lpstat answers "No destinations added." with a non-zero status: that is no printers.
+ if strings.Contains(r.Stderr, "No destinations added") {
+ return "", nil
+ }
+ return "", err
+ }
+ return r.Stdout, nil
+}
+
+func defaultPrinter() (string, error) {
+ out, err := lpstat("-d")
+ if err != nil {
+ return "", err
+ }
+ if _, after, ok := strings.Cut(out, "system default destination: "); ok {
+ return strings.TrimSpace(firstLine(after)), nil
+ }
+ return "", nil
+}
+
+// Printers answers every queue with its state, device, model and supplies.
+func Printers() (PrintersAnswer, error) {
+ out := PrintersAnswer{Printers: []Printer{}}
+ sched, err := lpstat("-r")
+ if err != nil {
+ return out, err
+ }
+ out.Scheduler = strings.TrimSpace(sched)
+ if out.Default, err = defaultPrinter(); err != nil {
+ return out, err
+ }
+ ps, err := lpstat("-p")
+ if err != nil {
+ return out, err
+ }
+ var cur *Printer
+ for _, l := range strings.Split(ps, "\n") {
+ if strings.HasPrefix(l, "printer ") {
+ f := strings.Fields(l)
+ if len(f) < 3 {
+ continue
+ }
+ out.Printers = append(out.Printers, Printer{Name: f[1], Reasons: []string{}, Markers: []Marker{}})
+ cur = &out.Printers[len(out.Printers)-1]
+ rest := strings.Join(f[2:], " ")
+ switch {
+ case strings.HasPrefix(rest, "is idle"):
+ cur.State = "idle"
+ case strings.HasPrefix(rest, "now printing"):
+ cur.State = "printing"
+ default:
+ cur.State = "stopped"
+ }
+ // "disabled since …" (stopped), or "enabled since …" after the state.
+ cur.Enabled = !strings.HasPrefix(rest, "disabled") && !strings.Contains(rest, "disabled since")
+ if _, since, found := strings.Cut(rest, " since "); found {
+ cur.Since = strings.TrimSuffix(strings.TrimSpace(since), " -")
+ }
+ continue
+ }
+ if cur != nil && strings.HasPrefix(l, "\t") && strings.TrimSpace(l) != "" {
+ cur.Message = strings.TrimSpace(cur.Message + " " + strings.TrimSpace(l))
+ }
+ }
+ acc, err := lpstat("-a")
+ if err != nil {
+ return out, err
+ }
+ accepting := map[string]bool{}
+ for _, l := range lines(acc) {
+ if f := strings.Fields(l); len(f) >= 2 && f[1] == "accepting" {
+ accepting[f[0]] = true
+ }
+ }
+ for i := range out.Printers {
+ p := &out.Printers[i]
+ p.Accepting = accepting[p.Name]
+ p.Default = p.Name == out.Default
+ r, err := call(Cmd{Name: "lpoptions", Args: []string{"-p", p.Name}})
+ if err != nil {
+ return out, err
+ }
+ o := lpoptionsOf(r.Stdout)
+ p.URI = o["device-uri"]
+ p.MakeModel = o["printer-make-and-model"]
+ p.Driverless = driverless(p.MakeModel, p.URI)
+ p.Shared = o["printer-is-shared"] == "true"
+ for _, reason := range strings.Split(o["printer-state-reasons"], ",") {
+ if reason = strings.TrimSpace(reason); reason != "" && reason != "none" {
+ p.Reasons = append(p.Reasons, reason)
+ }
+ }
+ p.Markers = markersOf(o)
+ }
+ return out, nil
+}
+
+// driverless says a queue prints without a vendor driver: CUPS's own IPP Everywhere model, or a
+// driverless URI.
+func driverless(model, uri string) bool {
+ m := strings.ToLower(model)
+ return strings.Contains(m, "ipp everywhere") || strings.Contains(m, "driverless") || strings.HasPrefix(uri, "implicitclass:") ||
+ strings.HasPrefix(uri, "ipp://") && strings.Contains(m, "everywhere")
+}
+
+func markersOf(o map[string]string) []Marker {
+ split := func(k string) []string {
+ if o[k] == "" {
+ return nil
+ }
+ return strings.Split(o[k], ",")
+ }
+ names, levels, lows, types := split("marker-names"), split("marker-levels"), split("marker-low-levels"), split("marker-types")
+ out := []Marker{}
+ for i, n := range names {
+ if i >= len(levels) {
+ break
+ }
+ level, err := strconv.Atoi(strings.TrimSpace(levels[i]))
+ if err != nil {
+ continue
+ }
+ m := Marker{Name: strings.TrimSpace(n), Level: level}
+ if i < len(types) {
+ m.Type = strings.TrimSpace(types[i])
+ }
+ if i < len(lows) {
+ if low, err := strconv.Atoi(strings.TrimSpace(lows[i])); err == nil && level >= 0 && level <= low {
+ m.Low = true
+ }
+ }
+ out = append(out, m)
+ }
+ return out
+}
+
+// Job is one print job.
+type Job struct {
+ ID string `json:"id"`
+ Printer string `json:"printer"`
+ User string `json:"user"`
+ Bytes int64 `json:"bytes"`
+ Submitted string `json:"submitted"`
+}
+
+// Queue answers the jobs waiting, or the finished ones.
+func Queue(printer string, completed bool, limit int) (map[string]any, error) {
+ args := []string{}
+ if completed {
+ args = append(args, "-W", "completed")
+ }
+ args = append(args, "-o")
+ if printer != "" {
+ if err := checkPrinter(printer); err != nil {
+ return nil, err
+ }
+ args = append(args, printer)
+ }
+ out, err := lpstat(args...)
+ if err != nil {
+ return nil, err
+ }
+ jobs := []Job{}
+ for _, l := range lines(out) {
+ f := strings.Fields(l)
+ if len(f) < 4 {
+ continue
+ }
+ i := strings.LastIndex(f[0], "-")
+ if i <= 0 {
+ continue
+ }
+ size, _ := strconv.ParseInt(f[2], 10, 64)
+ jobs = append(jobs, Job{ID: f[0], Printer: f[0][:i], User: f[1], Bytes: size, Submitted: strings.Join(f[3:], " ")})
+ }
+ sort.SliceStable(jobs, func(a, b int) bool { return jobNumber(jobs[a].ID) > jobNumber(jobs[b].ID) })
+ total := len(jobs)
+ if len(jobs) > limit {
+ jobs = jobs[:limit]
+ }
+ return map[string]any{"count": total, "jobs": jobs, "completed": completed}, nil
+}
+
+func jobNumber(id string) int {
+ n, _ := strconv.Atoi(id[strings.LastIndex(id, "-")+1:])
+ return n
+}
+
+var jobID = regexp.MustCompile(`^([A-Za-z0-9_.@-]+-)?[0-9]+$`)
+
+// refused says CUPS kept an act for its administrators.
+func refused(r Result) bool {
+ s := r.Stderr + r.Stdout
+ return strings.Contains(s, "Forbidden") || strings.Contains(s, "not-authorized") || strings.Contains(s, "Not authorized") || strings.Contains(s, "not allowed")
+}
+
+// asAccountThenRoot runs an act as the account, and through sudo -n when CUPS refuses the account.
+func asAccountThenRoot(c Cmd) (Result, bool, error) {
+ r := run(c)
+ if r.Status == 0 && r.Error == "" {
+ return r, false, nil
+ }
+ if !refused(r) {
+ return r, false, failure(c, r)
+ }
+ c.Root = true
+ r, err := call(c)
+ return r, true, err
+}
+
+// Cancel cancels one job, or every job on a printer.
+func Cancel(job, printer string, all bool) (map[string]any, error) {
+ var c Cmd
+ switch {
+ case all:
+ if printer == "" {
+ return nil, fmt.Errorf("all needs printer: cancelling every job on every printer is not offered")
+ }
+ if err := checkPrinter(printer); err != nil {
+ return nil, err
+ }
+ c = Cmd{Name: "cancel", Args: []string{"-a", printer}}
+ case job != "":
+ if !jobID.MatchString(job) {
+ return nil, fmt.Errorf("%q is not a job id", job)
+ }
+ c = Cmd{Name: "cancel", Args: []string{job}}
+ default:
+ return nil, fmt.Errorf("give job, or printer with all")
+ }
+ _, escalated, err := asAccountThenRoot(c)
+ if err != nil {
+ return nil, err
+ }
+ return map[string]any{"cancelled": strings.Join(c.Args, " "), "as_root": escalated}, nil
+}
+
+var requestID = regexp.MustCompile(`request id is (\S+)`)
+
+// statFile tells a regular file. Tests replace it.
+var statFile = func(p string) error {
+ info, err := os.Stat(p)
+ if err != nil {
+ return err
+ }
+ if !info.Mode().IsRegular() {
+ return fmt.Errorf("%s is not a regular file", p)
+ }
+ return nil
+}
+
+// Print sends a file to a printer.
+func Print(file, printer string, copies int, opts map[string]string, title string) (map[string]any, error) {
+ if !filepath.IsAbs(file) {
+ return nil, fmt.Errorf("file must be an absolute path, not %q", file)
+ }
+ if err := statFile(file); err != nil {
+ return nil, fmt.Errorf("cannot print %s: %v", file, err)
+ }
+ args := []string{}
+ if printer != "" {
+ if err := checkPrinter(printer); err != nil {
+ return nil, err
+ }
+ args = append(args, "-d", printer)
+ }
+ args = append(args, "-n", strconv.Itoa(copies))
+ names := make([]string, 0, len(opts))
+ for k := range opts {
+ names = append(names, k)
+ }
+ sort.Strings(names)
+ for _, k := range names {
+ args = append(args, "-o", k+"="+opts[k])
+ }
+ if title == "" {
+ title = filepath.Base(file)
+ }
+ args = append(args, "-t", title, "--", file)
+ r, err := call(Cmd{Name: "lp", Args: args})
+ if err != nil {
+ if strings.Contains(r.Stderr, "No default destination") {
+ return nil, fmt.Errorf("no printer given and this machine has no default printer: name one, or set one with cups_default")
+ }
+ return nil, err
+ }
+ m := requestID.FindStringSubmatch(r.Stdout)
+ if m == nil {
+ return nil, fmt.Errorf("lp answered no job id: %s", strings.TrimSpace(r.Stdout+r.Stderr))
+ }
+ return map[string]any{"job": m[1], "file": file, "copies": copies, "follow": "cups_queue"}, nil
+}
+
+// Default answers the default printer, or sets it.
+func Default(printer string) (map[string]any, error) {
+ if printer == "" {
+ d, err := defaultPrinter()
+ if err != nil {
+ return nil, err
+ }
+ return map[string]any{"default": d}, nil
+ }
+ if err := checkPrinter(printer); err != nil {
+ return nil, err
+ }
+ was, err := defaultPrinter()
+ if err != nil {
+ return nil, err
+ }
+ if _, err := call(Cmd{Name: "lpadmin", Args: []string{"-d", printer}, Root: true}); err != nil {
+ return nil, err
+ }
+ return map[string]any{"default": printer, "was": was}, nil
+}
+
+// Resume enables a printer and makes it accept jobs.
+func Resume(printer string) (map[string]any, error) {
+ if err := checkPrinter(printer); err != nil {
+ return nil, err
+ }
+ for _, c := range []string{"cupsenable", "cupsaccept"} {
+ if _, err := call(Cmd{Name: c, Args: []string{printer}, Root: true}); err != nil {
+ return nil, err
+ }
+ }
+ return map[string]any{"printer": printer, "enabled": true, "accepting": true}, nil
+}
+
+// DriverPackage is a package that brings filters or backends.
+type DriverPackage struct {
+ Package string `json:"package"`
+ Version string `json:"version"`
+ Foreign bool `json:"foreign"`
+}
+
+// Model is one driver model CUPS offers.
+type Model struct {
+ PPD string `json:"ppd"`
+ Description string `json:"description"`
+}
+
+// QueueDriver is how one queue prints.
+type QueueDriver struct {
+ Printer string `json:"printer"`
+ MakeModel string `json:"make_and_model"`
+ Driverless bool `json:"driverless"`
+ URI string `json:"uri"`
+}
+
+// DriversAnswer is what cups_drivers answers.
+type DriversAnswer struct {
+ Queues []QueueDriver `json:"queues"`
+ Packages []DriverPackage `json:"driver_packages"`
+ Models []Model `json:"models"`
+ Matched int `json:"models_matched"`
+ Findings []string `json:"findings"`
+}
+
+// driverDirs are where drivers put what CUPS runs.
+var driverDirs = []string{"/usr/lib/cups/filter", "/usr/lib/cups/backend"}
+
+// basePackages bring CUPS's own filters and backends, not a printer's driver.
+var basePackages = map[string]bool{"cups": true, "cups-filters": true, "libcups": true, "ghostscript": true, "cups-pdf": false}
+
+// Drivers answers how each queue prints and which packages bring drivers.
+func Drivers(match string, limit int) (DriversAnswer, error) {
+ out := DriversAnswer{Queues: []QueueDriver{}, Packages: []DriverPackage{}, Models: []Model{}, Findings: []string{}}
+ ps, err := Printers()
+ if err != nil {
+ return out, err
+ }
+ for _, p := range ps.Printers {
+ out.Queues = append(out.Queues, QueueDriver{Printer: p.Name, MakeModel: p.MakeModel, Driverless: p.Driverless, URI: p.URI})
+ }
+ r := run(Cmd{Name: "pacman", Args: append([]string{"-Qo"}, driverDirs...)})
+ if r.Error != "" {
+ return out, failure(Cmd{Name: "pacman", Args: []string{"-Qo"}}, r)
+ }
+ seen := map[string]bool{}
+ for _, l := range lines(r.Stdout) {
+ if _, after, ok := strings.Cut(l, " is owned by "); ok {
+ f := strings.Fields(after)
+ if len(f) >= 2 && !seen[f[0]] && !basePackages[f[0]] {
+ seen[f[0]] = true
+ out.Packages = append(out.Packages, DriverPackage{Package: f[0], Version: f[1]})
+ }
+ }
+ }
+ if len(out.Packages) > 0 {
+ r := run(Cmd{Name: "pacman", Args: []string{"-Qqm"}})
+ foreign := map[string]bool{}
+ for _, l := range lines(r.Stdout) {
+ foreign[strings.TrimSpace(l)] = true
+ }
+ for i := range out.Packages {
+ out.Packages[i].Foreign = foreign[out.Packages[i].Package]
+ }
+ }
+ sort.Slice(out.Packages, func(i, k int) bool { return out.Packages[i].Package < out.Packages[k].Package })
+ m, err := call(Cmd{Name: "lpinfo", Args: []string{"-m"}})
+ if err != nil {
+ return out, err
+ }
+ for _, l := range lines(m.Stdout) {
+ ppd, desc, _ := strings.Cut(l, " ")
+ if match != "" && !strings.Contains(strings.ToLower(desc), strings.ToLower(match)) && !strings.Contains(strings.ToLower(ppd), strings.ToLower(match)) {
+ continue
+ }
+ out.Matched++
+ if len(out.Models) < limit {
+ out.Models = append(out.Models, Model{PPD: ppd, Description: desc})
+ }
+ }
+ // Which driver packages no queue prints through.
+ allDriverless := len(out.Queues) > 0
+ for _, q := range out.Queues {
+ allDriverless = allDriverless && q.Driverless
+ }
+ for _, p := range out.Packages {
+ if p.Foreign {
+ out.Findings = append(out.Findings, "driver package "+p.Package+" is not from the official repositories")
+ }
+ if allDriverless {
+ out.Findings = append(out.Findings, "every queue prints driverless, so no queue uses the driver package "+p.Package)
+ }
+ }
+ return out, nil
+}
diff --git a/modules/cups/cmd/cups-tools/cups_test.go b/modules/cups/cmd/cups-tools/cups_test.go
new file mode 100644
index 0000000..d58561a
--- /dev/null
+++ b/modules/cups/cmd/cups-tools/cups_test.go
@@ -0,0 +1,234 @@
+package main
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestTheManifestIsTheSchedulerAndDriverlessPrintingAndNoVendorDriver(t *testing.T) {
+ m := readManifest(t)
+ holdsTheBundle(t, m, "cups")
+ if got := strings.Join(m.packages(), ","); got != "cups,cups-filters" {
+ t.Errorf("packages %s: a vendor driver is from outside the official repositories, and no queue measured needs one but the desktop's Canon", got)
+ }
+ s := m.services()
+ for _, u := range []string{"cups.socket", "cups.service"} {
+ if s[u] == nil || s[u]["state"] != "running" || s[u]["boot"] != "enabled" {
+ t.Errorf("%s: %v", u, s[u])
+ }
+ }
+ for _, r := range m.Resources {
+ if r["type"] == "file" {
+ t.Errorf("the queues and cupsd's files are CUPS's own: %v", r["id"])
+ }
+ }
+}
+
+// The desktop's two queues on 2026-10-04.
+func theDesktop(t *testing.T, more func(line string, c Cmd) (Result, bool)) *fake {
+ return using(t, func(line string, c Cmd) Result {
+ if more != nil {
+ if r, handled := more(line, c); handled {
+ return r
+ }
+ }
+ switch line {
+ case "lpstat -r":
+ return ok("scheduler is running\n")
+ case "lpstat -d":
+ return ok("system default destination: Brother_MFC_Novox\n")
+ case "lpstat -p":
+ return ok("printer Brother_MFC_Novox is idle. enabled since Mon Jun 29 21:34:30 2026\n" +
+ "printer Kanjuro disabled since Sun Jun 9 11:16:03 2024 -\n\tPaused\n")
+ case "lpstat -a":
+ return ok("Brother_MFC_Novox accepting requests since Mon Jun 29 21:34:30 2026\nKanjuro not accepting requests since Sun Jun 9 11:16:03 2024 -\n\tRejecting Jobs\n")
+ case "lpoptions -p Brother_MFC_Novox":
+ return ok(`copies=1 device-uri=ipp://192.0.2.171/ipp/port1 finishings=3 marker-levels=70,100,8,100 marker-low-levels=10,10,10,10 marker-names='Black\ Toner\ Cartridge,Cyan\ Toner\ Cartridge,Magenta\ Toner\ Cartridge,Yellow\ Toner\ Cartridge' marker-types=toner,toner,toner,toner printer-is-shared=false printer-make-and-model='Printer - IPP Everywhere' printer-state-reasons=none`)
+ case "lpoptions -p Kanjuro":
+ return ok(`device-uri=cnijnet:/18-0C-AC-B0-62-83 printer-is-shared=false printer-make-and-model='Canon MG4200 series Ver.3.80' printer-state-reasons=paused`)
+ }
+ return Result{Status: 9, Stderr: "unexpected " + line}
+ })
+}
+
+func TestPrintersReadsStateDeviceModelAndSupplies(t *testing.T) {
+ theDesktop(t, nil)
+ got, err := Printers()
+ if err != nil || len(got.Printers) != 2 || got.Default != "Brother_MFC_Novox" || got.Scheduler != "scheduler is running" {
+ t.Fatalf("%+v %v", got, err)
+ }
+ b, k := got.Printers[0], got.Printers[1]
+ if b.State != "idle" || !b.Enabled || !b.Accepting || !b.Default || !b.Driverless || b.URI != "ipp://192.0.2.171/ipp/port1" || len(b.Reasons) != 0 {
+ t.Errorf("%+v", b)
+ }
+ if len(b.Markers) != 4 || b.Markers[0].Name != "Black Toner Cartridge" || b.Markers[2].Level != 8 || !b.Markers[2].Low || b.Markers[0].Low {
+ t.Errorf("markers %+v", b.Markers)
+ }
+ if k.State != "stopped" || k.Enabled || k.Accepting || k.Driverless || strings.Join(k.Reasons, ",") != "paused" || k.Message != "Paused" {
+ t.Errorf("%+v", k)
+ }
+}
+
+func TestPrintersWithoutAQueueOrAScheduler(t *testing.T) {
+ using(t, func(line string, c Cmd) Result {
+ if line == "lpstat -r" {
+ return ok("scheduler is running\n")
+ }
+ return Result{Status: 1, Stderr: "lpstat: No destinations added.\n"}
+ })
+ got, err := Printers()
+ if err != nil || len(got.Printers) != 0 {
+ t.Fatalf("no queue is an empty answer, not a failure: %+v %v", got, err)
+ }
+ using(t, func(string, Cmd) Result { return Result{Status: 1, Stderr: "lpstat: Scheduler is not running.\n"} })
+ if _, err := Printers(); err == nil || !strings.Contains(err.Error(), "scheduler is not running") {
+ t.Fatalf("%v", err)
+ }
+}
+
+func TestLpoptionsQuotingIsRead(t *testing.T) {
+ o := lpoptionsOf(`a=1 b='x y' c=p\ q d e=`)
+ if o["a"] != "1" || o["b"] != "x y" || o["c"] != "p q" || o["d"] != "" || o["e"] != "" {
+ t.Errorf("%v", o)
+ }
+}
+
+func TestQueueReadsJobsNewestFirstAndBounded(t *testing.T) {
+ f := using(t, func(string, Cmd) Result {
+ return ok("Brother-6 jochen 1024 Mon Jul 8 12:15:34 2024\nBrother-8 jochen 2048 Mon Jul 8 12:19:56 2024\nBrother-7 other 1024 Mon Jul 8 12:15:23 2024\n")
+ })
+ got, err := Queue("Brother", true, 2)
+ jobs := got["jobs"].([]Job)
+ if err != nil || got["count"] != 3 || len(jobs) != 2 || jobs[0].ID != "Brother-8" || jobs[0].Printer != "Brother" || jobs[0].Bytes != 2048 || jobs[0].Submitted != "Mon Jul 8 12:19:56 2024" {
+ t.Fatalf("%+v %v", got, err)
+ }
+ if f.lines()[0] != "lpstat -W completed -o Brother" {
+ t.Errorf("%v", f.lines())
+ }
+ if _, err := Queue("-h", false, 5); err == nil {
+ t.Error("an option as a printer")
+ }
+}
+
+func TestCancelTriesTheAccountThenRootWhenCUPSRefusesIt(t *testing.T) {
+ f := using(t, func(line string, c Cmd) Result {
+ if !strings.HasPrefix(line, "sudo") {
+ return Result{Status: 1, Stderr: "cancel: Forbidden\n"}
+ }
+ return ok("")
+ })
+ got, err := Cancel("Brother-12", "", false)
+ if err != nil || got["as_root"] != true {
+ t.Fatalf("%v %v", got, err)
+ }
+ if strings.Join(f.lines(), "|") != "cancel Brother-12|sudo -n cancel Brother-12" {
+ t.Errorf("%v", f.lines())
+ }
+ f = using(t, func(string, Cmd) Result { return ok("") })
+ if got, err := Cancel("", "Brother", true); err != nil || got["as_root"] != false || f.lines()[0] != "cancel -a Brother" {
+ t.Fatalf("%v %v %v", got, err, f.lines())
+ }
+ using(t, func(string, Cmd) Result { return Result{Status: 1, Stderr: "cancel: Unknown job 99\n"} })
+ if _, err := Cancel("99", "", false); err == nil || !strings.Contains(err.Error(), "Unknown job") {
+ t.Errorf("a failure that is not a refusal is not retried as root: %v", err)
+ }
+ for _, bad := range [][3]string{{"", "", ""}, {"12; rm", "", ""}, {"", "", "all"}} {
+ if _, err := Cancel(bad[0], bad[1], bad[2] == "all"); err == nil {
+ t.Errorf("%v accepted", bad)
+ }
+ }
+}
+
+func TestPrintChecksTheFileAndOptionsAndAnswersTheJob(t *testing.T) {
+ was := statFile
+ defer func() { statFile = was }()
+ statFile = func(p string) error {
+ if p == "/home/op/doc.pdf" {
+ return nil
+ }
+ return errString("no such file")
+ }
+ f := using(t, func(string, Cmd) Result { return ok("request id is Brother-13 (1 file(s))\n") })
+ got, err := Print("/home/op/doc.pdf", "Brother", 2, map[string]string{"sides": "two-sided-long-edge", "media": "A4"}, "")
+ if err != nil || got["job"] != "Brother-13" {
+ t.Fatalf("%v %v", got, err)
+ }
+ if l := f.lines()[0]; l != "lp -d Brother -n 2 -o media=A4 -o sides=two-sided-long-edge -t doc.pdf -- /home/op/doc.pdf" {
+ t.Errorf("%s", l)
+ }
+ if _, err := Print("doc.pdf", "", 1, nil, ""); err == nil {
+ t.Error("a relative file")
+ }
+ if _, err := Print("/home/op/missing.pdf", "", 1, nil, ""); err == nil {
+ t.Error("a missing file")
+ }
+ if _, err := optionsOf(map[string]any{"options": map[string]any{"sides": "x y"}}); err == nil {
+ t.Error("an option value with a space")
+ }
+ if _, err := optionsOf(map[string]any{"options": map[string]any{"-o": "x"}}); err == nil {
+ t.Error("an option name that is an option")
+ }
+ using(t, func(string, Cmd) Result { return Result{Status: 1, Stderr: "lp: Error - No default destination."} })
+ if _, err := Print("/home/op/doc.pdf", "", 1, nil, ""); err == nil || !strings.Contains(err.Error(), "no default printer") {
+ t.Errorf("%v", err)
+ }
+}
+
+type errString string
+
+func (e errString) Error() string { return string(e) }
+
+func TestDefaultReadsAndSetsThroughSudo(t *testing.T) {
+ f := theDesktop(t, func(line string, c Cmd) (Result, bool) {
+ if strings.HasPrefix(line, "sudo -n lpadmin") {
+ return ok(""), true
+ }
+ return Result{}, false
+ })
+ got, err := Default("")
+ if err != nil || got["default"] != "Brother_MFC_Novox" {
+ t.Fatalf("%v %v", got, err)
+ }
+ got, err = Default("Kanjuro")
+ if err != nil || got["default"] != "Kanjuro" || got["was"] != "Brother_MFC_Novox" {
+ t.Fatalf("%v %v", got, err)
+ }
+ if l := f.lines(); l[len(l)-1] != "sudo -n lpadmin -d Kanjuro" {
+ t.Errorf("%v", l)
+ }
+}
+
+func TestResumeEnablesAndAcceptsThroughSudo(t *testing.T) {
+ f := using(t, func(string, Cmd) Result { return ok("") })
+ if _, err := Resume("Kanjuro"); err != nil {
+ t.Fatal(err)
+ }
+ if strings.Join(f.lines(), "|") != "sudo -n cupsenable Kanjuro|sudo -n cupsaccept Kanjuro" {
+ t.Errorf("%v", f.lines())
+ }
+}
+
+func TestDriversNamesForeignDriverPackagesAndOnesNoQueueUses(t *testing.T) {
+ theDesktop(t, func(line string, c Cmd) (Result, bool) {
+ switch {
+ case strings.HasPrefix(line, "pacman -Qo"):
+ return ok("/usr/lib/cups/filter/ is owned by brother-mfc-l8390cdw 3.5.1-2\n/usr/lib/cups/filter/ is owned by cups 2:2.4.19-1\n/usr/lib/cups/filter/ is owned by cups-filters 2.0.1-2\n/usr/lib/cups/backend/ is owned by cnijfilter-mg4200 3.80-6\n/usr/lib/cups/backend/ is owned by cups 2:2.4.19-1\n"), true
+ case line == "pacman -Qqm":
+ return ok("brother-mfc-l8390cdw\ncnijfilter-mg4200\nsnapd\n"), true
+ case line == "lpinfo -m":
+ return ok("drv:///sample.drv/dymo.ppd DYMO Label Printer\ncanonmg4200.ppd Canon MG4200 series Ver.3.80\neverywhere IPP Everywhere\n"), true
+ }
+ return Result{}, false
+ })
+ got, err := Drivers("canon", 10)
+ if err != nil || len(got.Queues) != 2 || len(got.Packages) != 2 || got.Matched != 1 || got.Models[0].PPD != "canonmg4200.ppd" {
+ t.Fatalf("%+v %v", got, err)
+ }
+ if !got.Packages[0].Foreign || got.Packages[0].Package != "brother-mfc-l8390cdw" {
+ t.Errorf("%+v", got.Packages)
+ }
+ all := strings.Join(got.Findings, ";")
+ if !strings.Contains(all, "cnijfilter-mg4200 is not from the official") || strings.Contains(all, "every queue prints driverless") {
+ t.Errorf("the Canon queue uses its driver, so not every queue is driverless: %s", all)
+ }
+}
diff --git a/modules/cups/cmd/cups-tools/kit.go b/modules/cups/cmd/cups-tools/kit.go
new file mode 100644
index 0000000..adc5aac
--- /dev/null
+++ b/modules/cups/cmd/cups-tools/kit.go
@@ -0,0 +1,352 @@
+package main
+
+// kit.go is the same file in each of the workstations' tool bundles (fonts, docker-compose, snapd,
+// flatpak, cups, bluetooth, xclip, dmenu): how a tool runs a command, escalates, bounds what it
+// keeps, and names a failure. A module is built from its own directory, so the file is copied rather
+// than shared; a change to one copy is made to all eight.
+//
+// The rules it holds (novox/hq research 026/05, to-be 38 WP4):
+// - the node's tool runtime runs as the operator account, not root (ADR 0175 §4); a command that
+// needs root goes through `sudo -n`, never a prompt, and a refusal is named as such;
+// - one command gets 20 s, below the runtime's 30 s call limit, and is ended with everything it
+// started when it takes longer;
+// - each stream is kept to 256 KiB, and the answer says when it was cut;
+// - a failure is an error with what went wrong in it, never an empty answer.
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "os/exec"
+ "strings"
+ "syscall"
+ "time"
+)
+
+// Bounds every command is held to.
+const (
+ CallTimeout = 20 * time.Second
+ MostOutput = 256 << 10
+)
+
+// Cmd is one command a tool runs.
+type Cmd struct {
+ Name string
+ Args []string
+ // Stdin is written to the command's standard input when not empty.
+ Stdin string
+ // Env is added to this process's own environment.
+ Env []string
+ // Root says the command needs root: it is run through `sudo -n` when this process is not root.
+ Root bool
+ // Timeout replaces CallTimeout; only a background job (jobs.go) asks for longer.
+ Timeout time.Duration
+ // Detached is for a program that forks a child which outlives it, as xclip does to keep the
+ // selection: its streams go to files, because a pipe the child inherits would hold the call open
+ // until the child exits.
+ Detached bool
+}
+
+// Result is what a command did.
+type Result struct {
+ Stdout string `json:"stdout"`
+ Stderr string `json:"stderr"`
+ Status int `json:"status"`
+ // Error is why it did not run to an answer: "not-found" when the program is not there,
+ // "timeout" when it was ended for taking too long, else the spawn error.
+ Error string `json:"error,omitempty"`
+ Truncated bool `json:"truncated,omitempty"`
+}
+
+// Runner runs a command. Tests replace it; nothing else does.
+type Runner func(Cmd) Result
+
+var (
+ run Runner = execRun
+ euid = os.Geteuid
+)
+
+// argv is the command as it is run: through sudo without a prompt when it needs root and this
+// process is not root.
+func argv(c Cmd) (string, []string) {
+ if c.Root && euid() != 0 {
+ return "sudo", append([]string{"-n", c.Name}, c.Args...)
+ }
+ return c.Name, c.Args
+}
+
+// bounded keeps the first MostOutput bytes written to it and notes that more came.
+type bounded struct {
+ b bytes.Buffer
+ cut bool
+}
+
+func (w *bounded) Write(p []byte) (int, error) {
+ room := MostOutput - w.b.Len()
+ if room <= 0 {
+ w.cut = w.cut || len(p) > 0
+ return len(p), nil
+ }
+ if len(p) > room {
+ w.b.Write(p[:room])
+ w.cut = true
+ return len(p), nil
+ }
+ return w.b.Write(p)
+}
+
+func execRun(c Cmd) Result {
+ timeout := c.Timeout
+ if timeout <= 0 {
+ timeout = CallTimeout
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), timeout)
+ defer cancel()
+ name, args := argv(c)
+ cmd := exec.CommandContext(ctx, name, args...)
+ cmd.Env = append(append(os.Environ(), "LC_ALL=C"), c.Env...)
+ if !c.Detached {
+ // Its own process group, so that ending it on a timeout ends what it started too.
+ cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
+ cmd.Cancel = func() error {
+ if cmd.Process != nil {
+ _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
+ }
+ return nil
+ }
+ }
+ cmd.WaitDelay = 2 * time.Second
+ if c.Stdin != "" {
+ cmd.Stdin = strings.NewReader(c.Stdin)
+ }
+ var out, errs bounded
+ var outFile, errFile *os.File
+ if c.Detached {
+ var err error
+ if outFile, err = os.CreateTemp("", "mesh-tool-out-*"); err != nil {
+ return Result{Status: 127, Error: err.Error()}
+ }
+ defer os.Remove(outFile.Name())
+ defer outFile.Close()
+ if errFile, err = os.CreateTemp("", "mesh-tool-err-*"); err != nil {
+ return Result{Status: 127, Error: err.Error()}
+ }
+ defer os.Remove(errFile.Name())
+ defer errFile.Close()
+ cmd.Stdout, cmd.Stderr = outFile, errFile
+ } else {
+ cmd.Stdout, cmd.Stderr = &out, &errs
+ }
+ err := cmd.Run()
+ if c.Detached {
+ for _, f := range []struct {
+ file *os.File
+ into *bounded
+ }{{outFile, &out}, {errFile, &errs}} {
+ if _, e := f.file.Seek(0, io.SeekStart); e == nil {
+ _, _ = io.Copy(f.into, f.file)
+ }
+ }
+ }
+ r := Result{Stdout: out.b.String(), Stderr: errs.b.String(), Truncated: out.cut || errs.cut}
+ var exit *exec.ExitError
+ switch {
+ case err == nil:
+ case ctx.Err() == context.DeadlineExceeded:
+ r.Status, r.Error = 124, "timeout"
+ case errors.Is(err, exec.ErrNotFound) || errors.Is(err, os.ErrNotExist):
+ r.Status, r.Error = 127, "not-found"
+ case errors.As(err, &exit):
+ r.Status = exit.ExitCode()
+ default:
+ r.Status, r.Error = 127, err.Error()
+ }
+ return r
+}
+
+// call runs a command and answers its result, or an error naming what went wrong.
+func call(c Cmd) (Result, error) {
+ r := run(c)
+ if r.Status == 0 && r.Error == "" {
+ return r, nil
+ }
+ return r, failure(c, r)
+}
+
+// failure names how a command failed: not installed, refused escalation, too slow, or its exit
+// status with the end of what it said.
+func failure(c Cmd, r Result) error {
+ program, _ := argv(c)
+ switch {
+ case r.Error == "not-found" && program == "sudo":
+ return fmt.Errorf("%s needs root, and sudo is not installed here for the runtime's account to escalate with", c.Name)
+ case r.Error == "not-found":
+ if hint, ok := providedBy[c.Name]; ok {
+ return fmt.Errorf("%s is not installed on this machine (%s)", c.Name, hint)
+ }
+ return fmt.Errorf("%s is not installed on this machine", c.Name)
+ case r.Error == "timeout":
+ limit := c.Timeout
+ if limit <= 0 {
+ limit = CallTimeout
+ }
+ return fmt.Errorf("%s gave no answer within %s and was ended", c.Name, limit)
+ case r.Error != "":
+ return fmt.Errorf("%s did not run: %s", c.Name, r.Error)
+ case program == "sudo" && strings.Contains(r.Stderr, "command not found"):
+ if hint, ok := providedBy[c.Name]; ok {
+ return fmt.Errorf("%s is not installed on this machine (%s)", c.Name, hint)
+ }
+ return fmt.Errorf("%s is not installed on this machine", c.Name)
+ case program == "sudo" && strings.HasPrefix(strings.TrimSpace(r.Stderr), "sudo:"):
+ return fmt.Errorf("%s needs root, and sudo -n refused the runtime's account: %s (the escalation is the sudo module's to declare)",
+ c.Name, firstLine(r.Stderr))
+ }
+ said := tail(strings.TrimSpace(r.Stderr), 2000)
+ if said == "" {
+ said = tail(strings.TrimSpace(r.Stdout), 2000)
+ }
+ if said == "" {
+ said = "and said nothing"
+ }
+ return fmt.Errorf("%s %s exited %d: %s", c.Name, strings.Join(c.Args, " "), r.Status, said)
+}
+
+func firstLine(s string) string {
+ s = strings.TrimSpace(s)
+ if i := strings.IndexByte(s, '\n'); i >= 0 {
+ return s[:i]
+ }
+ return s
+}
+
+func tail(s string, n int) string {
+ if len(s) <= n {
+ return s
+ }
+ return "…" + s[len(s)-n:]
+}
+
+// lines are a command's output lines, blank ones dropped.
+func lines(s string) []string {
+ out := []string{}
+ for _, l := range strings.Split(s, "\n") {
+ if strings.TrimSpace(l) != "" {
+ out = append(out, strings.TrimRight(l, "\r"))
+ }
+ }
+ return out
+}
+
+// Arguments, read the way a tool's JSON arguments arrive.
+
+func text(args map[string]any, key string) (string, error) {
+ v, ok := args[key]
+ if !ok || v == nil {
+ return "", fmt.Errorf("%s is required", key)
+ }
+ s, ok := v.(string)
+ if !ok {
+ return "", fmt.Errorf("%s must be a string", key)
+ }
+ if strings.TrimSpace(s) == "" {
+ return "", fmt.Errorf("%s must not be empty", key)
+ }
+ return s, nil
+}
+
+func optText(args map[string]any, key, def string) (string, error) {
+ v, ok := args[key]
+ if !ok || v == nil {
+ return def, nil
+ }
+ s, ok := v.(string)
+ if !ok {
+ return "", fmt.Errorf("%s must be a string", key)
+ }
+ if strings.TrimSpace(s) == "" {
+ return def, nil
+ }
+ return s, nil
+}
+
+// optWhole reads a whole number, defaulted, refused below least and held to most.
+func optWhole(args map[string]any, key string, def, least, most int) (int, error) {
+ v, ok := args[key]
+ if !ok || v == nil {
+ return def, nil
+ }
+ f, ok := v.(float64)
+ if !ok {
+ if i, isInt := v.(int); isInt {
+ f = float64(i)
+ } else {
+ return 0, fmt.Errorf("%s must be a number", key)
+ }
+ }
+ if f != float64(int(f)) {
+ return 0, fmt.Errorf("%s must be a whole number", key)
+ }
+ n := int(f)
+ if n < least {
+ return 0, fmt.Errorf("%s must be at least %d", key, least)
+ }
+ if n > most {
+ n = most
+ }
+ return n, nil
+}
+
+func optFlag(args map[string]any, key string, def bool) (bool, error) {
+ v, ok := args[key]
+ if !ok || v == nil {
+ return def, nil
+ }
+ b, ok := v.(bool)
+ if !ok {
+ return false, fmt.Errorf("%s must be true or false", key)
+ }
+ return b, nil
+}
+
+func optList(args map[string]any, key string) ([]string, error) {
+ v, ok := args[key]
+ if !ok || v == nil {
+ return nil, nil
+ }
+ items, ok := v.([]any)
+ if !ok {
+ return nil, fmt.Errorf("%s must be a list of strings", key)
+ }
+ out := make([]string, 0, len(items))
+ for _, it := range items {
+ s, ok := it.(string)
+ if !ok || strings.TrimSpace(s) == "" {
+ return nil, fmt.Errorf("%s must be a list of non-empty strings", key)
+ }
+ out = append(out, s)
+ }
+ return out, nil
+}
+
+// oneOf refuses a value outside a closed set.
+func oneOf(key, value string, allowed ...string) error {
+ for _, a := range allowed {
+ if value == a {
+ return nil
+ }
+ }
+ return fmt.Errorf("%s must be one of %s, not %q", key, strings.Join(allowed, ", "), value)
+}
+
+// plainName refuses a name that could be read as an option or carries a path or a space: package,
+// snap, application and printer names never do.
+func plainName(key, value string) error {
+ if strings.HasPrefix(value, "-") || strings.ContainsAny(value, " \t\n/\\") {
+ return fmt.Errorf("%s %q is not a plain name", key, value)
+ }
+ return nil
+}
diff --git a/modules/cups/cmd/cups-tools/kit_test.go b/modules/cups/cmd/cups-tools/kit_test.go
new file mode 100644
index 0000000..c5d3557
--- /dev/null
+++ b/modules/cups/cmd/cups-tools/kit_test.go
@@ -0,0 +1,147 @@
+package main
+
+// Tests of kit.go, the same in each workstation module.
+
+import (
+ "strings"
+ "testing"
+ "time"
+)
+
+// fake records the commands asked and answers each from a function of the command line.
+type fake struct {
+ asked []Cmd
+ answer func(line string, c Cmd) Result
+}
+
+func (f *fake) runner() Runner {
+ return func(c Cmd) Result {
+ f.asked = append(f.asked, c)
+ name, args := argv(c)
+ line := strings.TrimSpace(name + " " + strings.Join(args, " "))
+ if f.answer == nil {
+ return Result{}
+ }
+ return f.answer(line, c)
+ }
+}
+
+func (f *fake) lines() []string {
+ out := []string{}
+ for _, c := range f.asked {
+ name, args := argv(c)
+ out = append(out, strings.TrimSpace(name+" "+strings.Join(args, " ")))
+ }
+ return out
+}
+
+// using installs a fake runner and a non-root uid for one test.
+func using(t *testing.T, answer func(line string, c Cmd) Result) *fake {
+ t.Helper()
+ f := &fake{answer: answer}
+ wasRun, wasUID := run, euid
+ run, euid = f.runner(), func() int { return 1000 }
+ t.Cleanup(func() { run, euid = wasRun, wasUID })
+ return f
+}
+
+func ok(stdout string) Result { return Result{Stdout: stdout} }
+
+func TestKitAnActThatNeedsRootGoesThroughSudoWithoutAPromptUnlessAlreadyRoot(t *testing.T) {
+ was := euid
+ defer func() { euid = was }()
+ euid = func() int { return 1000 }
+ if name, args := argv(Cmd{Name: "x", Args: []string{"a"}, Root: true}); name != "sudo" || strings.Join(args, " ") != "-n x a" {
+ t.Fatalf("not root: %s %v", name, args)
+ }
+ if name, _ := argv(Cmd{Name: "x"}); name != "x" {
+ t.Fatalf("a read is run as the account: %s", name)
+ }
+ euid = func() int { return 0 }
+ if name, _ := argv(Cmd{Name: "x", Root: true}); name != "x" {
+ t.Fatalf("as root no sudo: %s", name)
+ }
+}
+
+func TestKitAFailureIsNamedByHowItFailed(t *testing.T) {
+ was := euid
+ defer func() { euid = was }()
+ euid = func() int { return 1000 }
+ cases := []struct {
+ c Cmd
+ r Result
+ want string
+ }{
+ {Cmd{Name: "nothere"}, Result{Status: 127, Error: "not-found"}, "not installed"},
+ {Cmd{Name: "x", Root: true}, Result{Status: 127, Error: "not-found"}, "sudo is not installed"},
+ {Cmd{Name: "x", Root: true}, Result{Status: 1, Stderr: "sudo: a password is required\n"}, "sudo -n refused"},
+ {Cmd{Name: "x", Root: true}, Result{Status: 1, Stderr: "sudo: x: command not found\n"}, "x is not installed"},
+ {Cmd{Name: "x"}, Result{Status: 124, Error: "timeout"}, "within 20s"},
+ {Cmd{Name: "x", Args: []string{"y"}}, Result{Status: 3, Stderr: "boom\n"}, "x y exited 3: boom"},
+ {Cmd{Name: "x"}, Result{Status: 3}, "said nothing"},
+ }
+ for _, k := range cases {
+ err := failure(k.c, k.r)
+ if err == nil || !strings.Contains(err.Error(), k.want) {
+ t.Errorf("%+v: %v, want %q", k.r, err, k.want)
+ }
+ }
+}
+
+func TestKitOutputIsBoundedAndSaysSo(t *testing.T) {
+ var w bounded
+ big := strings.Repeat("a", MostOutput+10)
+ n, _ := w.Write([]byte(big))
+ if n != len(big) || w.b.Len() != MostOutput || !w.cut {
+ t.Fatalf("kept %d of %d, cut %v", w.b.Len(), len(big), w.cut)
+ }
+}
+
+func TestKitTheRealRunnerRunsEndsAndReportsAMissingProgram(t *testing.T) {
+ r := execRun(Cmd{Name: "sh", Args: []string{"-c", "echo out; echo err >&2; exit 3"}})
+ if r.Status != 3 || strings.TrimSpace(r.Stdout) != "out" || strings.TrimSpace(r.Stderr) != "err" {
+ t.Fatalf("%+v", r)
+ }
+ r = execRun(Cmd{Name: "sh", Args: []string{"-c", "sleep 5 & sleep 5"}, Timeout: 200 * time.Millisecond})
+ if r.Error != "timeout" {
+ t.Fatalf("a slow command: %+v", r)
+ }
+ r = execRun(Cmd{Name: "no-such-program-anywhere"})
+ if r.Error != "not-found" {
+ t.Fatalf("a missing program: %+v", r)
+ }
+ r = execRun(Cmd{Name: "cat", Stdin: "given"})
+ if r.Stdout != "given" {
+ t.Fatalf("stdin: %+v", r)
+ }
+ start := time.Now()
+ r = execRun(Cmd{Name: "sh", Args: []string{"-c", "echo kept; (sleep 3 &) ; exit 0"}, Detached: true})
+ if r.Status != 0 || strings.TrimSpace(r.Stdout) != "kept" || time.Since(start) > 2*time.Second {
+ t.Fatalf("a detached command returns when it exits, not when its child does: %+v after %s", r, time.Since(start))
+ }
+}
+
+func TestKitArgumentsAreReadStrictly(t *testing.T) {
+ args := map[string]any{"s": "x", "n": float64(5), "f": 1.5, "b": true, "l": []any{"a", "b"}}
+ if _, err := text(args, "missing"); err == nil {
+ t.Error("a missing required string")
+ }
+ if n, _ := optWhole(args, "n", 1, 1, 3); n != 3 {
+ t.Errorf("held to most: %d", n)
+ }
+ if _, err := optWhole(args, "n", 1, 6, 9); err == nil {
+ t.Error("below least")
+ }
+ if _, err := optWhole(args, "f", 1, 0, 9); err == nil {
+ t.Error("a fraction")
+ }
+ if l, _ := optList(args, "l"); len(l) != 2 {
+ t.Errorf("list: %v", l)
+ }
+ if b, _ := optFlag(args, "b", false); !b {
+ t.Error("flag")
+ }
+ if err := plainName("name", "--all"); err == nil {
+ t.Error("an option as a name")
+ }
+}
diff --git a/modules/cups/cmd/cups-tools/main.go b/modules/cups/cmd/cups-tools/main.go
new file mode 100644
index 0000000..93cd848
--- /dev/null
+++ b/modules/cups/cmd/cups-tools/main.go
@@ -0,0 +1,177 @@
+// The cups module's tools (novox/hq research 027/02, 026/05): the printers, their state, supplies and
+// driver, the queue, and printing, cancelling and choosing the default. A Go bundle the node's runtime
+// launches over stdio (ADR 0188, ADR 0193); it runs as the operator account. An act CUPS keeps for
+// its administrators goes through `sudo -n`.
+package main
+
+import (
+ "fmt"
+ "os"
+
+ stdio "git.novox.be/novox/mesh-sdk/go"
+)
+
+var providedBy = map[string]string{
+ "lpstat": "the cups package, which this module installs",
+ "lpoptions": "the cups package, which this module installs",
+ "lp": "the cups package, which this module installs",
+ "cancel": "the cups package, which this module installs",
+ "lpadmin": "the cups package, which this module installs",
+ "lpinfo": "the cups package, which this module installs",
+ "cupsenable": "the cups package, which this module installs",
+ "cupsaccept": "the cups package, which this module installs",
+ "pacman": "this is not an Arch machine",
+}
+
+func main() {
+ if err := stdio.Serve("", tools()); err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+}
+
+var printerArg = map[string]any{"type": "string", "description": "the printer's queue name, as cups_printers answers it"}
+
+func tools() []stdio.Tool {
+ return []stdio.Tool{
+ {
+ Name: "cups_printers",
+ Description: "Every printer queue: state, whether it is enabled and accepting jobs, the default, its device " +
+ "address, make and model, whether it prints driverless (IPP Everywhere), the reasons for its state, and " +
+ "supply levels where the printer reports them. (r)",
+ Input: map[string]any{},
+ Run: func(map[string]any) (any, error) { return Printers() },
+ },
+ {
+ Name: "cups_queue",
+ Description: "The jobs waiting or printing, on every printer or one; or, with completed, the finished ones. " +
+ "Each with its id, printer, owner, size and when it was submitted. (r)",
+ Input: map[string]any{
+ "printer": printerArg,
+ "completed": map[string]any{"type": "boolean", "description": "the finished jobs instead"},
+ "limit": map[string]any{"type": "integer", "description": "at most this many, newest first (default 50, at most 500)"},
+ },
+ Run: func(args map[string]any) (any, error) {
+ p, err := optText(args, "printer", "")
+ if err != nil {
+ return nil, err
+ }
+ done, err := optFlag(args, "completed", false)
+ if err != nil {
+ return nil, err
+ }
+ limit, err := optWhole(args, "limit", 50, 1, 500)
+ if err != nil {
+ return nil, err
+ }
+ return Queue(p, done, limit)
+ },
+ },
+ {
+ Name: "cups_cancel",
+ Description: "Cancel one job by its id (\"Brother-12\" or 12), or every job on a printer with all. Another " +
+ "account's job is cancelled through sudo -n. (a)",
+ Input: map[string]any{
+ "job": map[string]any{"type": "string", "description": "the job id"},
+ "printer": printerArg,
+ "all": map[string]any{"type": "boolean", "description": "every job on printer"},
+ },
+ Run: func(args map[string]any) (any, error) {
+ job, err := optText(args, "job", "")
+ if err != nil {
+ return nil, err
+ }
+ p, err := optText(args, "printer", "")
+ if err != nil {
+ return nil, err
+ }
+ all, err := optFlag(args, "all", false)
+ if err != nil {
+ return nil, err
+ }
+ return Cancel(job, p, all)
+ },
+ },
+ {
+ Name: "cups_print",
+ Description: "Print a file on this machine, to a printer or the default, with copies and IPP options such as " +
+ "sides=two-sided-long-edge or media=A4. Answers the job id. (a)",
+ Input: map[string]any{
+ "file": map[string]any{"type": "string", "description": "the file's absolute path on this machine"},
+ "printer": printerArg,
+ "copies": map[string]any{"type": "integer", "description": "copies (default 1, at most 99)"},
+ "options": map[string]any{"type": "object", "additionalProperties": map[string]any{"type": "string"}, "description": "IPP options, name to value"},
+ "title": map[string]any{"type": "string", "description": "the job's title (default the file's name)"},
+ },
+ Run: func(args map[string]any) (any, error) {
+ file, err := text(args, "file")
+ if err != nil {
+ return nil, err
+ }
+ p, err := optText(args, "printer", "")
+ if err != nil {
+ return nil, err
+ }
+ copies, err := optWhole(args, "copies", 1, 1, 99)
+ if err != nil {
+ return nil, err
+ }
+ opts, err := optionsOf(args)
+ if err != nil {
+ return nil, err
+ }
+ title, err := optText(args, "title", "")
+ if err != nil {
+ return nil, err
+ }
+ return Print(file, p, copies, opts, title)
+ },
+ },
+ {
+ Name: "cups_default",
+ Description: "The machine's default printer; with printer, make that printer the default (through sudo -n). " +
+ "The default is CUPS's own setting, kept as the operator chose it: the mesh does not declare it. (r/a)",
+ Input: map[string]any{"printer": printerArg},
+ Run: func(args map[string]any) (any, error) {
+ p, err := optText(args, "printer", "")
+ if err != nil {
+ return nil, err
+ }
+ return Default(p)
+ },
+ },
+ {
+ Name: "cups_resume",
+ Description: "Enable a printer and make it accept jobs again, after CUPS stopped it on an error. Through sudo -n. (a)",
+ Input: map[string]any{"printer": printerArg},
+ Run: func(args map[string]any) (any, error) {
+ p, err := text(args, "printer")
+ if err != nil {
+ return nil, err
+ }
+ return Resume(p)
+ },
+ },
+ {
+ Name: "cups_drivers",
+ Description: "What each printer prints through (driverless or a driver's PPD), the packages that bring drivers " +
+ "and backends, which of them are from outside the official repositories, and the driver models CUPS " +
+ "offers, filtered by match. (r)",
+ Input: map[string]any{
+ "match": map[string]any{"type": "string", "description": "only models whose description contains this, any case"},
+ "limit": map[string]any{"type": "integer", "description": "at most this many models (default 50, at most 1000)"},
+ },
+ Run: func(args map[string]any) (any, error) {
+ match, err := optText(args, "match", "")
+ if err != nil {
+ return nil, err
+ }
+ limit, err := optWhole(args, "limit", 50, 0, 1000)
+ if err != nil {
+ return nil, err
+ }
+ return Drivers(match, limit)
+ },
+ },
+ }
+}
diff --git a/modules/cups/cmd/cups-tools/manifest_kit_test.go b/modules/cups/cmd/cups-tools/manifest_kit_test.go
new file mode 100644
index 0000000..3e675b4
--- /dev/null
+++ b/modules/cups/cmd/cups-tools/manifest_kit_test.go
@@ -0,0 +1,107 @@
+package main
+
+// manifest_kit_test.go is the same file in each workstation module: it reads the module's
+// definition so the module's own tests can hold it to what it says.
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+ "testing"
+)
+
+type manifest struct {
+ Module string `json:"module"`
+ Capabilities []string `json:"capabilities"`
+ Claims []any `json:"claims"`
+ Seats []any `json:"seats"`
+ Tools []string `json:"tools"`
+ Resources []map[string]any `json:"resources"`
+ Build struct {
+ Artifacts []map[string]any `json:"artifacts"`
+ } `json:"build"`
+}
+
+func readManifest(t *testing.T) manifest {
+ t.Helper()
+ raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ var m manifest
+ if err := json.Unmarshal(raw, &m); err != nil {
+ t.Fatalf("module.json: %v", err)
+ }
+ return m
+}
+
+func (m manifest) resource(id string) map[string]any {
+ for _, r := range m.Resources {
+ if r["id"] == id {
+ return r
+ }
+ }
+ return nil
+}
+
+// packages are the packages the module installs, sorted.
+func (m manifest) packages() []string {
+ out := []string{}
+ for _, r := range m.Resources {
+ if r["type"] == "package" && r["absent"] != true {
+ out = append(out, r["package"].(string))
+ }
+ }
+ sort.Strings(out)
+ return out
+}
+
+// services are the units the module declares, by unit name.
+func (m manifest) services() map[string]map[string]any {
+ out := map[string]map[string]any{}
+ for _, r := range m.Resources {
+ if r["type"] == "service" {
+ out[r["unit"].(string)] = r
+ }
+ }
+ return out
+}
+
+// holdsTheBundle holds the manifest to the Go bundle this directory builds: every tool registered
+// is listed and nothing else, each named _…, and the artifact builds this command.
+func holdsTheBundle(t *testing.T, m manifest, prefix string) {
+ t.Helper()
+ registered := []string{}
+ for _, tool := range tools() {
+ registered = append(registered, tool.Name)
+ if !strings.HasPrefix(tool.Name, prefix+"_") {
+ t.Errorf("tool %s is not named %s_…", tool.Name, prefix)
+ }
+ if tool.Description == "" || tool.Run == nil || tool.Input == nil {
+ t.Errorf("tool %s is not described, runnable and given an input schema", tool.Name)
+ }
+ }
+ if strings.Join(registered, ",") != strings.Join(m.Tools, ",") {
+ t.Errorf("registered %v, listed %v", registered, m.Tools)
+ }
+ if len(m.Build.Artifacts) != 1 {
+ t.Fatalf("one artifact, got %d", len(m.Build.Artifacts))
+ }
+ cwd, _ := os.Getwd()
+ binary := filepath.Base(cwd)
+ a := m.Build.Artifacts[0]
+ want := map[string]any{"kind": "bundle", "language": "go", "system": "arch", "from": "cmd/" + binary, "binary": binary}
+ for k, v := range want {
+ if a[k] != v {
+ t.Errorf("artifact %s = %v, want %v", k, a[k], v)
+ }
+ }
+ if loads, _ := a["loads"].([]any); len(loads) != 1 || loads[0] != binary {
+ t.Errorf("artifact loads %v, want [%s]", a["loads"], binary)
+ }
+ if m.Claims != nil || m.Seats != nil {
+ t.Errorf("claims %v, seats %v: this module holds no seat", m.Claims, m.Seats)
+ }
+}
diff --git a/modules/cups/go.mod b/modules/cups/go.mod
new file mode 100644
index 0000000..56eba25
--- /dev/null
+++ b/modules/cups/go.mod
@@ -0,0 +1,5 @@
+module cups
+
+go 1.22
+
+require git.novox.be/novox/mesh-sdk/go v0.1.6
diff --git a/modules/cups/go.sum b/modules/cups/go.sum
new file mode 100644
index 0000000..0dd6061
--- /dev/null
+++ b/modules/cups/go.sum
@@ -0,0 +1,2 @@
+git.novox.be/novox/mesh-sdk/go v0.1.6 h1:9qzdYONYbJdWcu6sxQcq9v1LI0JxcfkiKYkMUzJSkVQ=
+git.novox.be/novox/mesh-sdk/go v0.1.6/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
diff --git a/modules/cups/module.json b/modules/cups/module.json
new file mode 100644
index 0000000..01a4822
--- /dev/null
+++ b/modules/cups/module.json
@@ -0,0 +1,58 @@
+{
+ "module": "cups",
+ "version": "1",
+ "capabilities": [
+ "package-manager",
+ "service-manager"
+ ],
+ "tools": [
+ "cups_printers",
+ "cups_queue",
+ "cups_cancel",
+ "cups_print",
+ "cups_default",
+ "cups_resume",
+ "cups_drivers"
+ ],
+ "resources": [
+ {
+ "id": "package",
+ "type": "package",
+ "package": "cups"
+ },
+ {
+ "id": "driverless",
+ "type": "package",
+ "package": "cups-filters"
+ },
+ {
+ "id": "socket",
+ "type": "service",
+ "unit": "cups.socket",
+ "state": "running",
+ "boot": "enabled"
+ },
+ {
+ "id": "scheduler",
+ "type": "service",
+ "unit": "cups.service",
+ "state": "running",
+ "boot": "enabled"
+ }
+ ],
+ "build": {
+ "artifacts": [
+ {
+ "name": "tools",
+ "kind": "bundle",
+ "language": "go",
+ "system": "arch",
+ "from": "cmd/cups-tools",
+ "binary": "cups-tools",
+ "loads": [
+ "cups-tools"
+ ]
+ }
+ ]
+ }
+}
diff --git a/modules/dmenu/README.md b/modules/dmenu/README.md
new file mode 100644
index 0000000..00e2e11
--- /dev/null
+++ b/modules/dmenu/README.md
@@ -0,0 +1,56 @@
+# dmenu
+
+The suckless menu, as a module (novox/hq research 026/04: "`dmenu` is a module of its own (official
+repositories), able to hold the same seat on a machine that wants it"; to-be 42 phase 2 step 7).
+
+## Owns
+
+| what | where |
+|---|---|
+| `dmenu`, `dmenu_run`, `dmenu_path`, `stest` | package `dmenu` (official repositories) |
+
+## Improves: two broken calls work by existing
+
+Research 026/04 measured "plain `dmenu` in two places" in the operator's configuration, with dmenu
+installed on neither workstation, so both failed. Measured again on 2026-10-04, the two are one line on
+each workstation, in the notifier's configuration:
+
+- `~/.config/dunst/dunstrc`: `dmenu = /usr/bin/dmenu -p dunst:`
+
+It is the menu dunst opens to pick a notification's action or link. Every other menu in the
+operator's scripts calls `rofi -dmenu`. Once this module installs the package, `/usr/bin/dmenu` exists
+and that menu opens. The `dunst` module may later point the line at the launcher seat's command
+instead (below). The line is that module's to own, so this module does not touch it.
+
+## The seat it would hold: not claimed yet
+
+Research 026/04 gives `node-launcher` to `rofi`, with a dmenu-compatible command as part of its
+protocol, and lets `dmenu` hold the same seat on a machine that wants it. **The seat is not in the
+controller's seat table yet**, and a claim on an unknown seat is refused. So this module claims
+nothing. Its tool `dmenu_menu` is shaped like the seat's `menu` verb (research 026/05): show a list,
+answer the chosen line. When the seat is recorded, the claim is one line here, serving `menu`.
+
+## Tools
+
+All answer JSON. `(r)` reads; `(d)` acts in the operator's session.
+
+| tool | what |
+|---|---|
+| `dmenu_menu` (d) | show up to 1000 choices, with a prompt, as one line or a vertical list, case-insensitive by default. Answers the chosen line and its index; a typed line that is not a choice (`typed`); `cancelled` when dismissed; `timed_out` when not answered in time (default 20 s, at most 25, below the runtime's 30 s call limit) |
+| `dmenu_session` (r) | the session the menu would appear in and how it was found, or why there is none; dmenu's version |
+
+**Reaching the session** works as the `xclip` module's README describes: the runtime runs as the
+account with no session words, and `session.go` finds the account's display and cookie from its own
+processes. With no session, the tool says so and shows nothing. A keyboard held by another program
+(a locked screen, an open menu) is answered as such.
+
+The menu draws in fontconfig's `monospace` at dmenu's default size, which the `fonts` module makes
+JetBrains Mono Nerd Font.
+
+## What changes when it is assigned
+
+On both workstations, the `dmenu` package is installed, which neither has today. Nothing else.
+
+## Leaves as found
+
+The notifier's configuration, and every `rofi -dmenu` call in the operator's scripts.
diff --git a/modules/dmenu/cmd/dmenu-tools/dmenu.go b/modules/dmenu/cmd/dmenu-tools/dmenu.go
new file mode 100644
index 0000000..4105d1e
--- /dev/null
+++ b/modules/dmenu/cmd/dmenu-tools/dmenu.go
@@ -0,0 +1,112 @@
+package main
+
+import (
+ "fmt"
+ "strconv"
+ "strings"
+ "time"
+)
+
+// MostWait is the longest a menu stays open: below the runtime's 30 s call limit, so an unanswered
+// menu is answered as such rather than as a call the runtime gave up on.
+const MostWait = 25
+
+// MostChoices bounds the list.
+const MostChoices = 1000
+
+// Ask is one menu.
+type Ask struct {
+ Choices []string
+ Prompt string
+ Lines int
+ CaseInsensitive bool
+ Timeout int
+}
+
+// MenuAnswer is what dmenu_menu answers.
+type MenuAnswer struct {
+ Chosen string `json:"chosen,omitempty"`
+ Index int `json:"index"`
+ Typed bool `json:"typed"`
+ Cancelled bool `json:"cancelled"`
+ TimedOut bool `json:"timed_out"`
+ Session Session `json:"session"`
+}
+
+// Menu shows the choices and answers the one taken. dmenu prints the selected (or typed) line and
+// exits 0; it exits 1 with nothing printed when dismissed.
+func Menu(a Ask) (MenuAnswer, error) {
+ if len(a.Choices) == 0 {
+ return MenuAnswer{}, fmt.Errorf("choices is required: at least one line")
+ }
+ if len(a.Choices) > MostChoices {
+ return MenuAnswer{}, fmt.Errorf("%d choices; at most %d are shown", len(a.Choices), MostChoices)
+ }
+ for _, c := range a.Choices {
+ if strings.ContainsAny(c, "\n\r") {
+ return MenuAnswer{}, fmt.Errorf("a choice holds a line break: %q", c)
+ }
+ }
+ if strings.ContainsAny(a.Prompt, "\n\r") {
+ return MenuAnswer{}, fmt.Errorf("the prompt holds a line break")
+ }
+ s, err := findSession()
+ if err != nil {
+ return MenuAnswer{}, err
+ }
+ args := []string{}
+ if a.CaseInsensitive {
+ args = append(args, "-i")
+ }
+ if a.Lines > 0 {
+ args = append(args, "-l", strconv.Itoa(a.Lines))
+ }
+ if a.Prompt != "" {
+ args = append(args, "-p", a.Prompt)
+ }
+ c := Cmd{Name: "dmenu", Args: args, Stdin: strings.Join(a.Choices, "\n") + "\n", Env: s.Env(), Timeout: time.Duration(a.Timeout) * time.Second}
+ r := run(c)
+ out := MenuAnswer{Index: -1, Session: s}
+ switch {
+ case r.Error == "timeout":
+ out.TimedOut = true
+ return out, nil
+ case r.Error != "":
+ return MenuAnswer{}, failure(c, r)
+ case strings.Contains(r.Stderr, "cannot open display"):
+ return MenuAnswer{}, fmt.Errorf("the X session at %s (found by %s) refused the connection", s.Display, s.FoundBy)
+ case strings.Contains(r.Stderr, "cannot grab keyboard"):
+ return MenuAnswer{}, fmt.Errorf("dmenu could not take the keyboard: another program holds it (a locked screen, an open menu)")
+ case r.Status == 1 && strings.TrimSpace(r.Stdout) == "":
+ out.Cancelled = true
+ return out, nil
+ case r.Status != 0:
+ return MenuAnswer{}, failure(c, r)
+ }
+ out.Chosen = strings.TrimRight(r.Stdout, "\r\n")
+ for i, ch := range a.Choices {
+ if ch == out.Chosen {
+ out.Index = i
+ break
+ }
+ }
+ out.Typed = out.Index < 0
+ return out, nil
+}
+
+// SessionCheck answers the session the menu would appear in, and dmenu's version.
+func SessionCheck() (map[string]any, error) {
+ out := map[string]any{}
+ if r := run(Cmd{Name: "dmenu", Args: []string{"-v"}}); r.Error == "" {
+ out["dmenu"] = strings.TrimSpace(r.Stdout + r.Stderr)
+ } else {
+ out["dmenu"] = failure(Cmd{Name: "dmenu"}, r).Error()
+ }
+ s, err := findSession()
+ if err != nil {
+ out["found"], out["why"] = false, err.Error()
+ return out, nil
+ }
+ out["found"], out["session"] = true, s
+ return out, nil
+}
diff --git a/modules/dmenu/cmd/dmenu-tools/dmenu_test.go b/modules/dmenu/cmd/dmenu-tools/dmenu_test.go
new file mode 100644
index 0000000..0e0e584
--- /dev/null
+++ b/modules/dmenu/cmd/dmenu-tools/dmenu_test.go
@@ -0,0 +1,108 @@
+package main
+
+import (
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestTheManifestIsThePackageAndClaimsNoSeatYet(t *testing.T) {
+ m := readManifest(t)
+ // holdsTheBundle also holds it to no claim: node-launcher is not in the controller's seat table
+ // yet (README).
+ holdsTheBundle(t, m, "dmenu")
+ if got := strings.Join(m.packages(), ","); got != "dmenu" || len(m.Resources) != 1 {
+ t.Errorf("packages %s, resources %v", got, m.Resources)
+ }
+}
+
+func aSession(t *testing.T) {
+ t.Helper()
+ _, sockets := aMachine(t, map[string]string{"DISPLAY": ":1", "XAUTHORITY": "/home/op/.Xauthority"})
+ aSocket(t, sockets, "X1")
+}
+
+func TestMenuShowsTheChoicesInTheSessionAndAnswersTheOneTaken(t *testing.T) {
+ aSession(t)
+ f := using(t, func(string, Cmd) Result { return ok("Lock\n") })
+ got, err := Menu(Ask{Choices: []string{"Shutdown", "Lock"}, Prompt: "power:", Lines: 5, CaseInsensitive: true, Timeout: 20})
+ if err != nil || got.Chosen != "Lock" || got.Index != 1 || got.Typed || got.Cancelled || got.TimedOut {
+ t.Fatalf("%+v %v", got, err)
+ }
+ c := f.asked[0]
+ if f.lines()[0] != "dmenu -i -l 5 -p power:" || c.Stdin != "Shutdown\nLock\n" || c.Timeout != 20*time.Second {
+ t.Errorf("%v %+v", f.lines(), c)
+ }
+ if strings.Join(c.Env, " ") != "DISPLAY=:1 XAUTHORITY=/home/op/.Xauthority" {
+ t.Errorf("env %v", c.Env)
+ }
+}
+
+func TestMenuTellsTypedDismissedAndUnansweredApart(t *testing.T) {
+ aSession(t)
+ answer := ok("something else\n")
+ using(t, func(string, Cmd) Result { return answer })
+ got, err := Menu(Ask{Choices: []string{"a"}, Timeout: 5})
+ if err != nil || !got.Typed || got.Index != -1 || got.Chosen != "something else" {
+ t.Errorf("typed: %+v %v", got, err)
+ }
+ answer = Result{Status: 1}
+ got, err = Menu(Ask{Choices: []string{"a"}, Timeout: 5})
+ if err != nil || !got.Cancelled || got.Chosen != "" {
+ t.Errorf("dismissed: %+v %v", got, err)
+ }
+ answer = Result{Status: 124, Error: "timeout"}
+ got, err = Menu(Ask{Choices: []string{"a"}, Timeout: 5})
+ if err != nil || !got.TimedOut {
+ t.Errorf("unanswered: %+v %v", got, err)
+ }
+ answer = Result{Status: 1, Stderr: "cannot grab keyboard\n"}
+ if _, err := Menu(Ask{Choices: []string{"a"}, Timeout: 5}); err == nil || !strings.Contains(err.Error(), "keyboard") {
+ t.Errorf("a held keyboard: %v", err)
+ }
+ answer = Result{Status: 1, Stderr: "cannot open display\n"}
+ if _, err := Menu(Ask{Choices: []string{"a"}, Timeout: 5}); err == nil || !strings.Contains(err.Error(), "refused") {
+ t.Errorf("a refusing display: %v", err)
+ }
+}
+
+func TestMenuRefusesWhatCannotBeShownAndRunsNothingWithoutASession(t *testing.T) {
+ aSession(t)
+ f := using(t, func(string, Cmd) Result { return ok("") })
+ for _, bad := range []Ask{{}, {Choices: []string{"a\nb"}}, {Choices: []string{"a"}, Prompt: "x\ny"}, {Choices: make([]string, MostChoices+1)}} {
+ if _, err := Menu(bad); err == nil {
+ t.Errorf("%+v accepted", bad)
+ }
+ }
+ aMachine(t, map[string]string{})
+ if _, err := Menu(Ask{Choices: []string{"a"}, Timeout: 5}); err == nil || !strings.Contains(err.Error(), "no graphical session") {
+ t.Errorf("%v", err)
+ }
+ if len(f.asked) != 0 {
+ t.Errorf("ran %v", f.lines())
+ }
+}
+
+func TestTheWaitIsBelowTheRuntimesCallLimit(t *testing.T) {
+ if MostWait >= 30 {
+ t.Fatalf("a menu may stay open %d s; the runtime gives a call 30", MostWait)
+ }
+ n, _ := optWhole(map[string]any{"timeout_seconds": float64(600)}, "timeout_seconds", 20, 1, MostWait)
+ if n != MostWait {
+ t.Errorf("%d", n)
+ }
+}
+
+func TestSessionCheckSaysTheVersionAndTheSession(t *testing.T) {
+ aSession(t)
+ using(t, func(string, Cmd) Result { return ok("dmenu-5.4\n") })
+ got, err := SessionCheck()
+ if err != nil || got["dmenu"] != "dmenu-5.4" || got["found"] != true {
+ t.Fatalf("%v %v", got, err)
+ }
+ using(t, func(string, Cmd) Result { return Result{Status: 127, Error: "not-found"} })
+ got, _ = SessionCheck()
+ if !strings.Contains(got["dmenu"].(string), "not installed") {
+ t.Errorf("%v", got)
+ }
+}
diff --git a/modules/dmenu/cmd/dmenu-tools/kit.go b/modules/dmenu/cmd/dmenu-tools/kit.go
new file mode 100644
index 0000000..adc5aac
--- /dev/null
+++ b/modules/dmenu/cmd/dmenu-tools/kit.go
@@ -0,0 +1,352 @@
+package main
+
+// kit.go is the same file in each of the workstations' tool bundles (fonts, docker-compose, snapd,
+// flatpak, cups, bluetooth, xclip, dmenu): how a tool runs a command, escalates, bounds what it
+// keeps, and names a failure. A module is built from its own directory, so the file is copied rather
+// than shared; a change to one copy is made to all eight.
+//
+// The rules it holds (novox/hq research 026/05, to-be 38 WP4):
+// - the node's tool runtime runs as the operator account, not root (ADR 0175 §4); a command that
+// needs root goes through `sudo -n`, never a prompt, and a refusal is named as such;
+// - one command gets 20 s, below the runtime's 30 s call limit, and is ended with everything it
+// started when it takes longer;
+// - each stream is kept to 256 KiB, and the answer says when it was cut;
+// - a failure is an error with what went wrong in it, never an empty answer.
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "os/exec"
+ "strings"
+ "syscall"
+ "time"
+)
+
+// Bounds every command is held to.
+const (
+ CallTimeout = 20 * time.Second
+ MostOutput = 256 << 10
+)
+
+// Cmd is one command a tool runs.
+type Cmd struct {
+ Name string
+ Args []string
+ // Stdin is written to the command's standard input when not empty.
+ Stdin string
+ // Env is added to this process's own environment.
+ Env []string
+ // Root says the command needs root: it is run through `sudo -n` when this process is not root.
+ Root bool
+ // Timeout replaces CallTimeout; only a background job (jobs.go) asks for longer.
+ Timeout time.Duration
+ // Detached is for a program that forks a child which outlives it, as xclip does to keep the
+ // selection: its streams go to files, because a pipe the child inherits would hold the call open
+ // until the child exits.
+ Detached bool
+}
+
+// Result is what a command did.
+type Result struct {
+ Stdout string `json:"stdout"`
+ Stderr string `json:"stderr"`
+ Status int `json:"status"`
+ // Error is why it did not run to an answer: "not-found" when the program is not there,
+ // "timeout" when it was ended for taking too long, else the spawn error.
+ Error string `json:"error,omitempty"`
+ Truncated bool `json:"truncated,omitempty"`
+}
+
+// Runner runs a command. Tests replace it; nothing else does.
+type Runner func(Cmd) Result
+
+var (
+ run Runner = execRun
+ euid = os.Geteuid
+)
+
+// argv is the command as it is run: through sudo without a prompt when it needs root and this
+// process is not root.
+func argv(c Cmd) (string, []string) {
+ if c.Root && euid() != 0 {
+ return "sudo", append([]string{"-n", c.Name}, c.Args...)
+ }
+ return c.Name, c.Args
+}
+
+// bounded keeps the first MostOutput bytes written to it and notes that more came.
+type bounded struct {
+ b bytes.Buffer
+ cut bool
+}
+
+func (w *bounded) Write(p []byte) (int, error) {
+ room := MostOutput - w.b.Len()
+ if room <= 0 {
+ w.cut = w.cut || len(p) > 0
+ return len(p), nil
+ }
+ if len(p) > room {
+ w.b.Write(p[:room])
+ w.cut = true
+ return len(p), nil
+ }
+ return w.b.Write(p)
+}
+
+func execRun(c Cmd) Result {
+ timeout := c.Timeout
+ if timeout <= 0 {
+ timeout = CallTimeout
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), timeout)
+ defer cancel()
+ name, args := argv(c)
+ cmd := exec.CommandContext(ctx, name, args...)
+ cmd.Env = append(append(os.Environ(), "LC_ALL=C"), c.Env...)
+ if !c.Detached {
+ // Its own process group, so that ending it on a timeout ends what it started too.
+ cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
+ cmd.Cancel = func() error {
+ if cmd.Process != nil {
+ _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
+ }
+ return nil
+ }
+ }
+ cmd.WaitDelay = 2 * time.Second
+ if c.Stdin != "" {
+ cmd.Stdin = strings.NewReader(c.Stdin)
+ }
+ var out, errs bounded
+ var outFile, errFile *os.File
+ if c.Detached {
+ var err error
+ if outFile, err = os.CreateTemp("", "mesh-tool-out-*"); err != nil {
+ return Result{Status: 127, Error: err.Error()}
+ }
+ defer os.Remove(outFile.Name())
+ defer outFile.Close()
+ if errFile, err = os.CreateTemp("", "mesh-tool-err-*"); err != nil {
+ return Result{Status: 127, Error: err.Error()}
+ }
+ defer os.Remove(errFile.Name())
+ defer errFile.Close()
+ cmd.Stdout, cmd.Stderr = outFile, errFile
+ } else {
+ cmd.Stdout, cmd.Stderr = &out, &errs
+ }
+ err := cmd.Run()
+ if c.Detached {
+ for _, f := range []struct {
+ file *os.File
+ into *bounded
+ }{{outFile, &out}, {errFile, &errs}} {
+ if _, e := f.file.Seek(0, io.SeekStart); e == nil {
+ _, _ = io.Copy(f.into, f.file)
+ }
+ }
+ }
+ r := Result{Stdout: out.b.String(), Stderr: errs.b.String(), Truncated: out.cut || errs.cut}
+ var exit *exec.ExitError
+ switch {
+ case err == nil:
+ case ctx.Err() == context.DeadlineExceeded:
+ r.Status, r.Error = 124, "timeout"
+ case errors.Is(err, exec.ErrNotFound) || errors.Is(err, os.ErrNotExist):
+ r.Status, r.Error = 127, "not-found"
+ case errors.As(err, &exit):
+ r.Status = exit.ExitCode()
+ default:
+ r.Status, r.Error = 127, err.Error()
+ }
+ return r
+}
+
+// call runs a command and answers its result, or an error naming what went wrong.
+func call(c Cmd) (Result, error) {
+ r := run(c)
+ if r.Status == 0 && r.Error == "" {
+ return r, nil
+ }
+ return r, failure(c, r)
+}
+
+// failure names how a command failed: not installed, refused escalation, too slow, or its exit
+// status with the end of what it said.
+func failure(c Cmd, r Result) error {
+ program, _ := argv(c)
+ switch {
+ case r.Error == "not-found" && program == "sudo":
+ return fmt.Errorf("%s needs root, and sudo is not installed here for the runtime's account to escalate with", c.Name)
+ case r.Error == "not-found":
+ if hint, ok := providedBy[c.Name]; ok {
+ return fmt.Errorf("%s is not installed on this machine (%s)", c.Name, hint)
+ }
+ return fmt.Errorf("%s is not installed on this machine", c.Name)
+ case r.Error == "timeout":
+ limit := c.Timeout
+ if limit <= 0 {
+ limit = CallTimeout
+ }
+ return fmt.Errorf("%s gave no answer within %s and was ended", c.Name, limit)
+ case r.Error != "":
+ return fmt.Errorf("%s did not run: %s", c.Name, r.Error)
+ case program == "sudo" && strings.Contains(r.Stderr, "command not found"):
+ if hint, ok := providedBy[c.Name]; ok {
+ return fmt.Errorf("%s is not installed on this machine (%s)", c.Name, hint)
+ }
+ return fmt.Errorf("%s is not installed on this machine", c.Name)
+ case program == "sudo" && strings.HasPrefix(strings.TrimSpace(r.Stderr), "sudo:"):
+ return fmt.Errorf("%s needs root, and sudo -n refused the runtime's account: %s (the escalation is the sudo module's to declare)",
+ c.Name, firstLine(r.Stderr))
+ }
+ said := tail(strings.TrimSpace(r.Stderr), 2000)
+ if said == "" {
+ said = tail(strings.TrimSpace(r.Stdout), 2000)
+ }
+ if said == "" {
+ said = "and said nothing"
+ }
+ return fmt.Errorf("%s %s exited %d: %s", c.Name, strings.Join(c.Args, " "), r.Status, said)
+}
+
+func firstLine(s string) string {
+ s = strings.TrimSpace(s)
+ if i := strings.IndexByte(s, '\n'); i >= 0 {
+ return s[:i]
+ }
+ return s
+}
+
+func tail(s string, n int) string {
+ if len(s) <= n {
+ return s
+ }
+ return "…" + s[len(s)-n:]
+}
+
+// lines are a command's output lines, blank ones dropped.
+func lines(s string) []string {
+ out := []string{}
+ for _, l := range strings.Split(s, "\n") {
+ if strings.TrimSpace(l) != "" {
+ out = append(out, strings.TrimRight(l, "\r"))
+ }
+ }
+ return out
+}
+
+// Arguments, read the way a tool's JSON arguments arrive.
+
+func text(args map[string]any, key string) (string, error) {
+ v, ok := args[key]
+ if !ok || v == nil {
+ return "", fmt.Errorf("%s is required", key)
+ }
+ s, ok := v.(string)
+ if !ok {
+ return "", fmt.Errorf("%s must be a string", key)
+ }
+ if strings.TrimSpace(s) == "" {
+ return "", fmt.Errorf("%s must not be empty", key)
+ }
+ return s, nil
+}
+
+func optText(args map[string]any, key, def string) (string, error) {
+ v, ok := args[key]
+ if !ok || v == nil {
+ return def, nil
+ }
+ s, ok := v.(string)
+ if !ok {
+ return "", fmt.Errorf("%s must be a string", key)
+ }
+ if strings.TrimSpace(s) == "" {
+ return def, nil
+ }
+ return s, nil
+}
+
+// optWhole reads a whole number, defaulted, refused below least and held to most.
+func optWhole(args map[string]any, key string, def, least, most int) (int, error) {
+ v, ok := args[key]
+ if !ok || v == nil {
+ return def, nil
+ }
+ f, ok := v.(float64)
+ if !ok {
+ if i, isInt := v.(int); isInt {
+ f = float64(i)
+ } else {
+ return 0, fmt.Errorf("%s must be a number", key)
+ }
+ }
+ if f != float64(int(f)) {
+ return 0, fmt.Errorf("%s must be a whole number", key)
+ }
+ n := int(f)
+ if n < least {
+ return 0, fmt.Errorf("%s must be at least %d", key, least)
+ }
+ if n > most {
+ n = most
+ }
+ return n, nil
+}
+
+func optFlag(args map[string]any, key string, def bool) (bool, error) {
+ v, ok := args[key]
+ if !ok || v == nil {
+ return def, nil
+ }
+ b, ok := v.(bool)
+ if !ok {
+ return false, fmt.Errorf("%s must be true or false", key)
+ }
+ return b, nil
+}
+
+func optList(args map[string]any, key string) ([]string, error) {
+ v, ok := args[key]
+ if !ok || v == nil {
+ return nil, nil
+ }
+ items, ok := v.([]any)
+ if !ok {
+ return nil, fmt.Errorf("%s must be a list of strings", key)
+ }
+ out := make([]string, 0, len(items))
+ for _, it := range items {
+ s, ok := it.(string)
+ if !ok || strings.TrimSpace(s) == "" {
+ return nil, fmt.Errorf("%s must be a list of non-empty strings", key)
+ }
+ out = append(out, s)
+ }
+ return out, nil
+}
+
+// oneOf refuses a value outside a closed set.
+func oneOf(key, value string, allowed ...string) error {
+ for _, a := range allowed {
+ if value == a {
+ return nil
+ }
+ }
+ return fmt.Errorf("%s must be one of %s, not %q", key, strings.Join(allowed, ", "), value)
+}
+
+// plainName refuses a name that could be read as an option or carries a path or a space: package,
+// snap, application and printer names never do.
+func plainName(key, value string) error {
+ if strings.HasPrefix(value, "-") || strings.ContainsAny(value, " \t\n/\\") {
+ return fmt.Errorf("%s %q is not a plain name", key, value)
+ }
+ return nil
+}
diff --git a/modules/dmenu/cmd/dmenu-tools/kit_test.go b/modules/dmenu/cmd/dmenu-tools/kit_test.go
new file mode 100644
index 0000000..c5d3557
--- /dev/null
+++ b/modules/dmenu/cmd/dmenu-tools/kit_test.go
@@ -0,0 +1,147 @@
+package main
+
+// Tests of kit.go, the same in each workstation module.
+
+import (
+ "strings"
+ "testing"
+ "time"
+)
+
+// fake records the commands asked and answers each from a function of the command line.
+type fake struct {
+ asked []Cmd
+ answer func(line string, c Cmd) Result
+}
+
+func (f *fake) runner() Runner {
+ return func(c Cmd) Result {
+ f.asked = append(f.asked, c)
+ name, args := argv(c)
+ line := strings.TrimSpace(name + " " + strings.Join(args, " "))
+ if f.answer == nil {
+ return Result{}
+ }
+ return f.answer(line, c)
+ }
+}
+
+func (f *fake) lines() []string {
+ out := []string{}
+ for _, c := range f.asked {
+ name, args := argv(c)
+ out = append(out, strings.TrimSpace(name+" "+strings.Join(args, " ")))
+ }
+ return out
+}
+
+// using installs a fake runner and a non-root uid for one test.
+func using(t *testing.T, answer func(line string, c Cmd) Result) *fake {
+ t.Helper()
+ f := &fake{answer: answer}
+ wasRun, wasUID := run, euid
+ run, euid = f.runner(), func() int { return 1000 }
+ t.Cleanup(func() { run, euid = wasRun, wasUID })
+ return f
+}
+
+func ok(stdout string) Result { return Result{Stdout: stdout} }
+
+func TestKitAnActThatNeedsRootGoesThroughSudoWithoutAPromptUnlessAlreadyRoot(t *testing.T) {
+ was := euid
+ defer func() { euid = was }()
+ euid = func() int { return 1000 }
+ if name, args := argv(Cmd{Name: "x", Args: []string{"a"}, Root: true}); name != "sudo" || strings.Join(args, " ") != "-n x a" {
+ t.Fatalf("not root: %s %v", name, args)
+ }
+ if name, _ := argv(Cmd{Name: "x"}); name != "x" {
+ t.Fatalf("a read is run as the account: %s", name)
+ }
+ euid = func() int { return 0 }
+ if name, _ := argv(Cmd{Name: "x", Root: true}); name != "x" {
+ t.Fatalf("as root no sudo: %s", name)
+ }
+}
+
+func TestKitAFailureIsNamedByHowItFailed(t *testing.T) {
+ was := euid
+ defer func() { euid = was }()
+ euid = func() int { return 1000 }
+ cases := []struct {
+ c Cmd
+ r Result
+ want string
+ }{
+ {Cmd{Name: "nothere"}, Result{Status: 127, Error: "not-found"}, "not installed"},
+ {Cmd{Name: "x", Root: true}, Result{Status: 127, Error: "not-found"}, "sudo is not installed"},
+ {Cmd{Name: "x", Root: true}, Result{Status: 1, Stderr: "sudo: a password is required\n"}, "sudo -n refused"},
+ {Cmd{Name: "x", Root: true}, Result{Status: 1, Stderr: "sudo: x: command not found\n"}, "x is not installed"},
+ {Cmd{Name: "x"}, Result{Status: 124, Error: "timeout"}, "within 20s"},
+ {Cmd{Name: "x", Args: []string{"y"}}, Result{Status: 3, Stderr: "boom\n"}, "x y exited 3: boom"},
+ {Cmd{Name: "x"}, Result{Status: 3}, "said nothing"},
+ }
+ for _, k := range cases {
+ err := failure(k.c, k.r)
+ if err == nil || !strings.Contains(err.Error(), k.want) {
+ t.Errorf("%+v: %v, want %q", k.r, err, k.want)
+ }
+ }
+}
+
+func TestKitOutputIsBoundedAndSaysSo(t *testing.T) {
+ var w bounded
+ big := strings.Repeat("a", MostOutput+10)
+ n, _ := w.Write([]byte(big))
+ if n != len(big) || w.b.Len() != MostOutput || !w.cut {
+ t.Fatalf("kept %d of %d, cut %v", w.b.Len(), len(big), w.cut)
+ }
+}
+
+func TestKitTheRealRunnerRunsEndsAndReportsAMissingProgram(t *testing.T) {
+ r := execRun(Cmd{Name: "sh", Args: []string{"-c", "echo out; echo err >&2; exit 3"}})
+ if r.Status != 3 || strings.TrimSpace(r.Stdout) != "out" || strings.TrimSpace(r.Stderr) != "err" {
+ t.Fatalf("%+v", r)
+ }
+ r = execRun(Cmd{Name: "sh", Args: []string{"-c", "sleep 5 & sleep 5"}, Timeout: 200 * time.Millisecond})
+ if r.Error != "timeout" {
+ t.Fatalf("a slow command: %+v", r)
+ }
+ r = execRun(Cmd{Name: "no-such-program-anywhere"})
+ if r.Error != "not-found" {
+ t.Fatalf("a missing program: %+v", r)
+ }
+ r = execRun(Cmd{Name: "cat", Stdin: "given"})
+ if r.Stdout != "given" {
+ t.Fatalf("stdin: %+v", r)
+ }
+ start := time.Now()
+ r = execRun(Cmd{Name: "sh", Args: []string{"-c", "echo kept; (sleep 3 &) ; exit 0"}, Detached: true})
+ if r.Status != 0 || strings.TrimSpace(r.Stdout) != "kept" || time.Since(start) > 2*time.Second {
+ t.Fatalf("a detached command returns when it exits, not when its child does: %+v after %s", r, time.Since(start))
+ }
+}
+
+func TestKitArgumentsAreReadStrictly(t *testing.T) {
+ args := map[string]any{"s": "x", "n": float64(5), "f": 1.5, "b": true, "l": []any{"a", "b"}}
+ if _, err := text(args, "missing"); err == nil {
+ t.Error("a missing required string")
+ }
+ if n, _ := optWhole(args, "n", 1, 1, 3); n != 3 {
+ t.Errorf("held to most: %d", n)
+ }
+ if _, err := optWhole(args, "n", 1, 6, 9); err == nil {
+ t.Error("below least")
+ }
+ if _, err := optWhole(args, "f", 1, 0, 9); err == nil {
+ t.Error("a fraction")
+ }
+ if l, _ := optList(args, "l"); len(l) != 2 {
+ t.Errorf("list: %v", l)
+ }
+ if b, _ := optFlag(args, "b", false); !b {
+ t.Error("flag")
+ }
+ if err := plainName("name", "--all"); err == nil {
+ t.Error("an option as a name")
+ }
+}
diff --git a/modules/dmenu/cmd/dmenu-tools/main.go b/modules/dmenu/cmd/dmenu-tools/main.go
new file mode 100644
index 0000000..d98a193
--- /dev/null
+++ b/modules/dmenu/cmd/dmenu-tools/main.go
@@ -0,0 +1,70 @@
+// The dmenu module's tool (novox/hq research 026/04, 026/05): show the operator a menu of choices in
+// the graphical session and answer the one chosen — the dmenu-compatible command as a tool. A Go
+// bundle the node's runtime launches over stdio (ADR 0188, ADR 0193). It runs as the operator account
+// and reaches the account's X session as session.go finds it; with no session, it says so.
+package main
+
+import (
+ "fmt"
+ "os"
+
+ stdio "git.novox.be/novox/mesh-sdk/go"
+)
+
+var providedBy = map[string]string{
+ "dmenu": "the dmenu package, which this module installs",
+}
+
+func main() {
+ if err := stdio.Serve("", tools()); err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+}
+
+func tools() []stdio.Tool {
+ return []stdio.Tool{
+ {
+ Name: "dmenu_menu",
+ Description: "Show the operator a menu of choices on their screen and answer the line chosen, its index, or " +
+ "that the menu was dismissed or not answered in time (default 20 s, at most 25). The operator may also " +
+ "type a line that is not a choice. Needs the operator's graphical session. (d)",
+ Input: map[string]any{
+ "choices": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "the lines to choose from, at most 1000, none with a line break"},
+ "prompt": map[string]any{"type": "string", "description": "a prompt shown left of the input"},
+ "lines": map[string]any{"type": "integer", "description": "show the choices as a vertical list of this many lines (default 0: one horizontal line; at most 40)"},
+ "case_insensitive": map[string]any{"type": "boolean", "description": "match what is typed regardless of case (default true)"},
+ "timeout_seconds": map[string]any{"type": "integer", "description": "close the menu unanswered after this long (default 20, at most 25)"},
+ },
+ Run: func(args map[string]any) (any, error) {
+ choices, err := optList(args, "choices")
+ if err != nil {
+ return nil, err
+ }
+ prompt, err := optText(args, "prompt", "")
+ if err != nil {
+ return nil, err
+ }
+ n, err := optWhole(args, "lines", 0, 0, 40)
+ if err != nil {
+ return nil, err
+ }
+ ci, err := optFlag(args, "case_insensitive", true)
+ if err != nil {
+ return nil, err
+ }
+ timeout, err := optWhole(args, "timeout_seconds", 20, 1, MostWait)
+ if err != nil {
+ return nil, err
+ }
+ return Menu(Ask{Choices: choices, Prompt: prompt, Lines: n, CaseInsensitive: ci, Timeout: timeout})
+ },
+ },
+ {
+ Name: "dmenu_session",
+ Description: "Which X session the menu would appear in and how it was found, or why there is none; and dmenu's version. (r)",
+ Input: map[string]any{},
+ Run: func(map[string]any) (any, error) { return SessionCheck() },
+ },
+ }
+}
diff --git a/modules/dmenu/cmd/dmenu-tools/manifest_kit_test.go b/modules/dmenu/cmd/dmenu-tools/manifest_kit_test.go
new file mode 100644
index 0000000..3e675b4
--- /dev/null
+++ b/modules/dmenu/cmd/dmenu-tools/manifest_kit_test.go
@@ -0,0 +1,107 @@
+package main
+
+// manifest_kit_test.go is the same file in each workstation module: it reads the module's
+// definition so the module's own tests can hold it to what it says.
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+ "testing"
+)
+
+type manifest struct {
+ Module string `json:"module"`
+ Capabilities []string `json:"capabilities"`
+ Claims []any `json:"claims"`
+ Seats []any `json:"seats"`
+ Tools []string `json:"tools"`
+ Resources []map[string]any `json:"resources"`
+ Build struct {
+ Artifacts []map[string]any `json:"artifacts"`
+ } `json:"build"`
+}
+
+func readManifest(t *testing.T) manifest {
+ t.Helper()
+ raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ var m manifest
+ if err := json.Unmarshal(raw, &m); err != nil {
+ t.Fatalf("module.json: %v", err)
+ }
+ return m
+}
+
+func (m manifest) resource(id string) map[string]any {
+ for _, r := range m.Resources {
+ if r["id"] == id {
+ return r
+ }
+ }
+ return nil
+}
+
+// packages are the packages the module installs, sorted.
+func (m manifest) packages() []string {
+ out := []string{}
+ for _, r := range m.Resources {
+ if r["type"] == "package" && r["absent"] != true {
+ out = append(out, r["package"].(string))
+ }
+ }
+ sort.Strings(out)
+ return out
+}
+
+// services are the units the module declares, by unit name.
+func (m manifest) services() map[string]map[string]any {
+ out := map[string]map[string]any{}
+ for _, r := range m.Resources {
+ if r["type"] == "service" {
+ out[r["unit"].(string)] = r
+ }
+ }
+ return out
+}
+
+// holdsTheBundle holds the manifest to the Go bundle this directory builds: every tool registered
+// is listed and nothing else, each named _…, and the artifact builds this command.
+func holdsTheBundle(t *testing.T, m manifest, prefix string) {
+ t.Helper()
+ registered := []string{}
+ for _, tool := range tools() {
+ registered = append(registered, tool.Name)
+ if !strings.HasPrefix(tool.Name, prefix+"_") {
+ t.Errorf("tool %s is not named %s_…", tool.Name, prefix)
+ }
+ if tool.Description == "" || tool.Run == nil || tool.Input == nil {
+ t.Errorf("tool %s is not described, runnable and given an input schema", tool.Name)
+ }
+ }
+ if strings.Join(registered, ",") != strings.Join(m.Tools, ",") {
+ t.Errorf("registered %v, listed %v", registered, m.Tools)
+ }
+ if len(m.Build.Artifacts) != 1 {
+ t.Fatalf("one artifact, got %d", len(m.Build.Artifacts))
+ }
+ cwd, _ := os.Getwd()
+ binary := filepath.Base(cwd)
+ a := m.Build.Artifacts[0]
+ want := map[string]any{"kind": "bundle", "language": "go", "system": "arch", "from": "cmd/" + binary, "binary": binary}
+ for k, v := range want {
+ if a[k] != v {
+ t.Errorf("artifact %s = %v, want %v", k, a[k], v)
+ }
+ }
+ if loads, _ := a["loads"].([]any); len(loads) != 1 || loads[0] != binary {
+ t.Errorf("artifact loads %v, want [%s]", a["loads"], binary)
+ }
+ if m.Claims != nil || m.Seats != nil {
+ t.Errorf("claims %v, seats %v: this module holds no seat", m.Claims, m.Seats)
+ }
+}
diff --git a/modules/dmenu/cmd/dmenu-tools/session.go b/modules/dmenu/cmd/dmenu-tools/session.go
new file mode 100644
index 0000000..43ba119
--- /dev/null
+++ b/modules/dmenu/cmd/dmenu-tools/session.go
@@ -0,0 +1,177 @@
+package main
+
+// session.go is the same file in the bundles whose tools act in the operator's graphical session
+// (xclip, dmenu): how a process the node's tool runtime launched reaches that session.
+//
+// The runtime is a system service running as the operator account (novox/hq ADR 0175 §4), in the
+// machine's own mount namespace, and is given no session words: no DISPLAY, no XAUTHORITY. An X
+// server accepts a client that names its display and presents the cookie in the authority file, and
+// both are the account's: the display's socket is in /tmp/.X11-unix, and the cookie file is
+// readable by the account. So the session is found, not configured:
+//
+// 1. the process's own DISPLAY, when the runtime happens to have one;
+// 2. else the DISPLAY and XAUTHORITY of the account's own running processes, read from
+// /proc//environ (the window manager's, by preference), whose socket exists;
+// 3. else the only X socket there is, with the authority file in the account's home.
+//
+// When none is found the tool says that no graphical session of the account is running, and does
+// nothing.
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "sort"
+ "strconv"
+ "strings"
+ "syscall"
+)
+
+// Session is the operator's X session as a tool reaches it.
+type Session struct {
+ Display string `json:"display"`
+ XAuthority string `json:"xauthority,omitempty"`
+ // FoundBy says how: "environment", "process ()" or "socket".
+ FoundBy string `json:"found_by"`
+}
+
+// Env is what a command needs to reach the session.
+func (s Session) Env() []string {
+ env := []string{"DISPLAY=" + s.Display}
+ if s.XAuthority != "" {
+ env = append(env, "XAUTHORITY="+s.XAuthority)
+ }
+ return env
+}
+
+// Where the session is looked for. Tests point these at a tree of their own.
+var (
+ procRoot = "/proc"
+ x11Sockets = "/tmp/.X11-unix"
+ getenv = os.Getenv
+ myUID = os.Getuid
+)
+
+// sessionWMs are the programs whose environment is the session's own, preferred over any other
+// process's (a terminal's child may carry a stale or forwarded DISPLAY).
+var sessionWMs = map[string]bool{"i3": true, "sway": true, "xinit": true, "i3bar": true, "picom": true, "dunst": true}
+
+func accountHome() string {
+ if h := strings.TrimSpace(getenv("MESH_OPERATOR_HOME")); h != "" {
+ return h
+ }
+ if h := strings.TrimSpace(getenv("HOME")); h != "" {
+ return h
+ }
+ h, _ := os.UserHomeDir()
+ return h
+}
+
+// socketOf is the local socket of a display such as ":1" or ":1.0", or "" for a remote one.
+func socketOf(display string) string {
+ if !strings.HasPrefix(display, ":") {
+ return ""
+ }
+ n := strings.TrimPrefix(display, ":")
+ if i := strings.IndexByte(n, '.'); i >= 0 {
+ n = n[:i]
+ }
+ if _, err := strconv.Atoi(n); err != nil {
+ return ""
+ }
+ return filepath.Join(x11Sockets, "X"+n)
+}
+
+func exists(p string) bool {
+ _, err := os.Stat(p)
+ return err == nil
+}
+
+// findSession answers the account's X session, or an error saying there is none.
+func findSession() (Session, error) {
+ if d := strings.TrimSpace(getenv("DISPLAY")); d != "" {
+ if s := socketOf(d); s == "" || exists(s) {
+ return Session{Display: d, XAuthority: getenv("XAUTHORITY"), FoundBy: "environment"}, nil
+ }
+ }
+ type seen struct {
+ Session
+ wm bool
+ count int
+ }
+ found := map[string]*seen{}
+ entries, _ := os.ReadDir(procRoot)
+ for _, e := range entries {
+ pid, err := strconv.Atoi(e.Name())
+ if err != nil || !e.IsDir() {
+ continue
+ }
+ dir := filepath.Join(procRoot, e.Name())
+ info, err := os.Stat(dir)
+ if err != nil {
+ continue
+ }
+ if st, ok := info.Sys().(*syscall.Stat_t); !ok || int(st.Uid) != myUID() {
+ continue
+ }
+ raw, err := os.ReadFile(filepath.Join(dir, "environ"))
+ if err != nil {
+ continue
+ }
+ var display, auth string
+ for _, kv := range strings.Split(string(raw), "\x00") {
+ switch {
+ case strings.HasPrefix(kv, "DISPLAY="):
+ display = strings.TrimPrefix(kv, "DISPLAY=")
+ case strings.HasPrefix(kv, "XAUTHORITY="):
+ auth = strings.TrimPrefix(kv, "XAUTHORITY=")
+ }
+ }
+ if display == "" {
+ continue
+ }
+ if s := socketOf(display); s == "" || !exists(s) {
+ continue
+ }
+ comm, _ := os.ReadFile(filepath.Join(dir, "comm"))
+ name := strings.TrimSpace(string(comm))
+ key := display + "\x00" + auth
+ if found[key] == nil {
+ found[key] = &seen{Session: Session{Display: display, XAuthority: auth, FoundBy: fmt.Sprintf("process %d (%s)", pid, name)}}
+ }
+ f := found[key]
+ f.count++
+ if sessionWMs[name] && !f.wm {
+ f.wm = true
+ f.FoundBy = fmt.Sprintf("process %d (%s)", pid, name)
+ }
+ }
+ if len(found) > 0 {
+ all := make([]*seen, 0, len(found))
+ for _, f := range found {
+ all = append(all, f)
+ }
+ sort.Slice(all, func(i, k int) bool {
+ if all[i].wm != all[k].wm {
+ return all[i].wm
+ }
+ if all[i].count != all[k].count {
+ return all[i].count > all[k].count
+ }
+ return all[i].Display < all[k].Display
+ })
+ return all[0].Session, nil
+ }
+ sockets, _ := filepath.Glob(filepath.Join(x11Sockets, "X*"))
+ if len(sockets) == 1 {
+ s := Session{Display: ":" + strings.TrimPrefix(filepath.Base(sockets[0]), "X"), FoundBy: "socket"}
+ if a := filepath.Join(accountHome(), ".Xauthority"); exists(a) {
+ s.XAuthority = a
+ }
+ return s, nil
+ }
+ if len(sockets) > 1 {
+ return Session{}, fmt.Errorf("no process of this account names its X display, and there are %d X sockets in %s: which one is the operator's session cannot be told", len(sockets), x11Sockets)
+ }
+ return Session{}, fmt.Errorf("no graphical session of this account is running on this machine: no process of the account has DISPLAY set, and there is no X socket in %s. A desktop tool acts only while the operator is logged in to the graphical session", x11Sockets)
+}
diff --git a/modules/dmenu/cmd/dmenu-tools/session_test.go b/modules/dmenu/cmd/dmenu-tools/session_test.go
new file mode 100644
index 0000000..707e754
--- /dev/null
+++ b/modules/dmenu/cmd/dmenu-tools/session_test.go
@@ -0,0 +1,94 @@
+package main
+
+import (
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "testing"
+)
+
+// aMachine gives findSession a /proc and an X socket directory of the test's own.
+func aMachine(t *testing.T, env map[string]string) (proc, sockets string) {
+ t.Helper()
+ root := t.TempDir()
+ proc, sockets = filepath.Join(root, "proc"), filepath.Join(root, "x11")
+ for _, d := range []string{proc, sockets} {
+ if err := os.MkdirAll(d, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ }
+ wasProc, wasX, wasEnv := procRoot, x11Sockets, getenv
+ procRoot, x11Sockets = proc, sockets
+ getenv = func(k string) string { return env[k] }
+ t.Cleanup(func() { procRoot, x11Sockets, getenv = wasProc, wasX, wasEnv })
+ return proc, sockets
+}
+
+func aProcess(t *testing.T, proc string, pid int, comm string, env ...string) {
+ t.Helper()
+ dir := filepath.Join(proc, strconv.Itoa(pid))
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ _ = os.WriteFile(filepath.Join(dir, "comm"), []byte(comm+"\n"), 0o644)
+ _ = os.WriteFile(filepath.Join(dir, "environ"), []byte(strings.Join(env, "\x00")+"\x00"), 0o644)
+}
+
+func aSocket(t *testing.T, dir, name string) {
+ t.Helper()
+ if err := os.WriteFile(filepath.Join(dir, name), nil, 0o644); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestSessionTheWindowManagersDisplayAndCookieAreTheSessions(t *testing.T) {
+ proc, sockets := aMachine(t, map[string]string{"MESH_OPERATOR_HOME": "/home/op"})
+ aSocket(t, sockets, "X1")
+ aProcess(t, proc, 3, "bash", "DISPLAY=:9", "XAUTHORITY=/stale")
+ aProcess(t, proc, 4, "kitty", "DISPLAY=:1", "XAUTHORITY=/home/op/.Xauthority")
+ aProcess(t, proc, 5, "i3", "DISPLAY=:1.0", "XAUTHORITY=/home/op/.Xauthority")
+ aProcess(t, proc, 6, "sshd", "PATH=/bin")
+ s, err := findSession()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if s.Display != ":1.0" || s.XAuthority != "/home/op/.Xauthority" || !strings.Contains(s.FoundBy, "i3") {
+ t.Fatalf("%+v: a display without a socket (:9) is skipped, and the window manager's is preferred", s)
+ }
+ if got := strings.Join(s.Env(), " "); got != "DISPLAY=:1.0 XAUTHORITY=/home/op/.Xauthority" {
+ t.Fatalf("env %s", got)
+ }
+}
+
+func TestSessionTheOnlySocketWithTheHomesCookieIsTheFallback(t *testing.T) {
+ home := t.TempDir()
+ _ = os.WriteFile(filepath.Join(home, ".Xauthority"), []byte("c"), 0o600)
+ _, sockets := aMachine(t, map[string]string{"MESH_OPERATOR_HOME": home})
+ aSocket(t, sockets, "X0")
+ s, err := findSession()
+ if err != nil || s.Display != ":0" || s.XAuthority != filepath.Join(home, ".Xauthority") || s.FoundBy != "socket" {
+ t.Fatalf("%+v %v", s, err)
+ }
+}
+
+func TestSessionNoSessionIsSaidNotGuessed(t *testing.T) {
+ _, sockets := aMachine(t, map[string]string{})
+ if _, err := findSession(); err == nil || !strings.Contains(err.Error(), "no graphical session") {
+ t.Fatalf("none: %v", err)
+ }
+ aSocket(t, sockets, "X0")
+ aSocket(t, sockets, "X1")
+ if _, err := findSession(); err == nil || !strings.Contains(err.Error(), "2 X sockets") {
+ t.Fatalf("two: %v", err)
+ }
+}
+
+func TestSessionTheProcessesOwnDisplayComesFirst(t *testing.T) {
+ _, sockets := aMachine(t, map[string]string{"DISPLAY": ":2", "XAUTHORITY": "/a"})
+ aSocket(t, sockets, "X2")
+ s, err := findSession()
+ if err != nil || s.Display != ":2" || s.FoundBy != "environment" {
+ t.Fatalf("%+v %v", s, err)
+ }
+}
diff --git a/modules/dmenu/go.mod b/modules/dmenu/go.mod
new file mode 100644
index 0000000..8cf0001
--- /dev/null
+++ b/modules/dmenu/go.mod
@@ -0,0 +1,5 @@
+module dmenu
+
+go 1.22
+
+require git.novox.be/novox/mesh-sdk/go v0.1.6
diff --git a/modules/dmenu/go.sum b/modules/dmenu/go.sum
new file mode 100644
index 0000000..0dd6061
--- /dev/null
+++ b/modules/dmenu/go.sum
@@ -0,0 +1,2 @@
+git.novox.be/novox/mesh-sdk/go v0.1.6 h1:9qzdYONYbJdWcu6sxQcq9v1LI0JxcfkiKYkMUzJSkVQ=
+git.novox.be/novox/mesh-sdk/go v0.1.6/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
diff --git a/modules/dmenu/module.json b/modules/dmenu/module.json
new file mode 100644
index 0000000..aeeb3ce
--- /dev/null
+++ b/modules/dmenu/module.json
@@ -0,0 +1,33 @@
+{
+ "module": "dmenu",
+ "version": "1",
+ "capabilities": [
+ "package-manager"
+ ],
+ "tools": [
+ "dmenu_menu",
+ "dmenu_session"
+ ],
+ "resources": [
+ {
+ "id": "package",
+ "type": "package",
+ "package": "dmenu"
+ }
+ ],
+ "build": {
+ "artifacts": [
+ {
+ "name": "tools",
+ "kind": "bundle",
+ "language": "go",
+ "system": "arch",
+ "from": "cmd/dmenu-tools",
+ "binary": "dmenu-tools",
+ "loads": [
+ "dmenu-tools"
+ ]
+ }
+ ]
+ }
+}
diff --git a/modules/docker-compose/README.md b/modules/docker-compose/README.md
new file mode 100644
index 0000000..e712833
--- /dev/null
+++ b/modules/docker-compose/README.md
@@ -0,0 +1,65 @@
+# docker-compose
+
+Compose, for development work on the two workstations (novox/hq research 027/02: "the distribution's
+package and nothing else", assigned only to the workstations; to-be 42 phase 2 step 9). The servers
+run nothing through compose.
+
+## Owns
+
+| what | where |
+|---|---|
+| compose, as the container runtime's plugin (`docker compose`) and as `docker-compose` | package `docker-compose` |
+
+Nothing else. The runtime, its configuration, buildx and the `docker` group are the `docker` module's
+(to-be 42 phase 1 step 8). This module needs that runtime on the machine. Until the `docker` module
+holds `node-container-runtime` there, nothing in the mesh says so, and the tools answer that the
+runtime is missing or unreachable rather than an empty list.
+
+## Improves
+
+- **An owner for a package both workstations already carry.** On both, `docker-compose` 5.5.0 is
+ installed explicitly by hand, as the plugin in `/usr/lib/docker/cli-plugins`. Assigning the module
+ changes nothing on disk; from then on the package is the mesh's, upgraded with the machine and
+ given back when the module goes.
+- **The projects become visible from the mesh** without a shell on the machine: which are running,
+ where their files are, their containers, logs and rendered configuration.
+- **No account-level copy of the plugin** exists on either workstation (`~/.docker/cli-plugins` is
+ empty), so there is no second compose to remove.
+
+## Tools
+
+All answer JSON; `(r)` reads, `(a)` acts. They run as the operator account, which reaches the runtime
+through the `docker` group. Nothing goes through `sudo`.
+
+A project is named by `dir`, its absolute directory, or by `project`, its name. A directory docker
+already knows a project for is that project, with the files it was started from, overrides included.
+Any other directory must hold a compose file.
+
+| tool | what |
+|---|---|
+| `docker_compose_projects` (r) | every project docker knows, running or stopped: status, working directory (from the containers' labels), compose files, services, containers running of total |
+| `docker_compose_ps` (r) | one project's containers: service, state, health, exit code, image, published ports |
+| `docker_compose_logs` (r) | the last lines per service (default 200, at most 5000), optionally `since`; cut at 256 KiB |
+| `docker_compose_config` (r) | the rendered configuration. Values of environment variables, build arguments and labels whose names suggest a secret, and inline secret or config content, are replaced with `[redacted]`, and the answer counts them |
+| `docker_compose_up` (a) | `up --detach`, with the pull policy (default `missing`) and optionally `--build`, for all or some services |
+| `docker_compose_down` (a) | `down`: containers and networks. **Volumes are kept**: no tool here removes data |
+| `docker_compose_restart` (a) | restart all or some services |
+| `docker_compose_pull` (a) | pull images without starting anything |
+| `docker_compose_job` (r) | a long act's state: running or finished, exit status, the end of its output; without an id, every act this process knows |
+
+**Acts are jobs.** An `up` that pulls or builds takes minutes, and the runtime gives a call 30 s. Each
+act runs inside the tool's process for up to 15 minutes, and is waited on for 18 s. A finished act is
+answered with its output, and a failed one as an error. One still running is answered with its job id,
+which `docker_compose_job` follows. A job ends if the runtime restarts the bundle.
+
+## Leaves as found
+
+The projects themselves are the operator's work, under the operator's directories. Measured on
+2026-10-04:
+
+- **laptop:** `anton-lavinmq` and `anton-traefik` running, `anton-redis` stopped.
+- **desktop:** `anton-lavinmq`, `lavinmq` (from `/services/lavinmq`, a predecessor's directory) and
+ `registry` running.
+
+Whether the desktop's `lavinmq` and `registry` should still run is the operator's call;
+`docker_compose_down` with their directory stops them.
diff --git a/modules/docker-compose/cmd/docker-compose-tools/compose.go b/modules/docker-compose/cmd/docker-compose-tools/compose.go
new file mode 100644
index 0000000..566b367
--- /dev/null
+++ b/modules/docker-compose/cmd/docker-compose-tools/compose.go
@@ -0,0 +1,455 @@
+package main
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "regexp"
+ "sort"
+ "strings"
+)
+
+// Project is one compose project as docker knows it.
+type Project struct {
+ Name string `json:"name"`
+ Status string `json:"status,omitempty"`
+ WorkingDir string `json:"working_dir,omitempty"`
+ ConfigFiles []string `json:"config_files"`
+ Services []string `json:"services"`
+ Running int `json:"running"`
+ Containers int `json:"containers"`
+}
+
+// ProjectsAnswer is what docker_compose_projects answers.
+type ProjectsAnswer struct {
+ Count int `json:"count"`
+ Projects []Project `json:"projects"`
+}
+
+// composeFiles are the names compose looks for in a directory, in its order.
+var composeFiles = []string{"compose.yaml", "compose.yml", "docker-compose.yaml", "docker-compose.yml"}
+
+// statDir says whether a path is a directory. Tests replace it.
+var statDir = func(p string) bool {
+ info, err := os.Stat(p)
+ return err == nil && info.IsDir()
+}
+
+// statFile says whether a path is a regular file. Tests replace it.
+var statFile = func(p string) bool {
+ info, err := os.Stat(p)
+ return err == nil && info.Mode().IsRegular()
+}
+
+// docker runs one docker command and names a daemon the account cannot reach as such.
+func docker(args ...string) (Result, error) {
+ r, err := call(Cmd{Name: "docker", Args: args})
+ if err != nil && strings.Contains(r.Stderr, "permission denied") && strings.Contains(r.Stderr, "docker.sock") {
+ return r, fmt.Errorf("the account cannot reach the container runtime's socket (permission denied): it is not in the docker group, or has not logged in since it was added. The docker module owns the group's members")
+ }
+ if err != nil && strings.Contains(r.Stderr, "Cannot connect to the Docker daemon") {
+ return r, fmt.Errorf("the container runtime is not running on this machine: %s", firstLine(r.Stderr))
+ }
+ return r, err
+}
+
+// Projects merges what compose lists with what the containers' labels say.
+func Projects() (ProjectsAnswer, error) {
+ r, err := docker("compose", "ls", "--all", "--format", "json")
+ if err != nil {
+ return ProjectsAnswer{}, err
+ }
+ var listed []struct {
+ Name string `json:"Name"`
+ Status string `json:"Status"`
+ ConfigFiles string `json:"ConfigFiles"`
+ }
+ if s := strings.TrimSpace(r.Stdout); s != "" {
+ if err := json.Unmarshal([]byte(s), &listed); err != nil {
+ return ProjectsAnswer{}, fmt.Errorf("docker compose ls answered what is not JSON: %v", err)
+ }
+ }
+ by := map[string]*Project{}
+ get := func(name string) *Project {
+ if by[name] == nil {
+ by[name] = &Project{Name: name, ConfigFiles: []string{}, Services: []string{}}
+ }
+ return by[name]
+ }
+ for _, l := range listed {
+ p := get(l.Name)
+ p.Status = l.Status
+ p.ConfigFiles = splitFiles(l.ConfigFiles)
+ }
+ r, err = docker("ps", "-a", "--filter", "label=com.docker.compose.project", "--format",
+ `{{.Label "com.docker.compose.project"}}`+"\t"+`{{.Label "com.docker.compose.project.working_dir"}}`+"\t"+
+ `{{.Label "com.docker.compose.project.config_files"}}`+"\t"+`{{.Label "com.docker.compose.service"}}`+"\t{{.State}}")
+ if err != nil {
+ return ProjectsAnswer{}, err
+ }
+ services := map[string]map[string]bool{}
+ for _, l := range lines(r.Stdout) {
+ f := strings.Split(l, "\t")
+ if len(f) < 5 || f[0] == "" {
+ continue
+ }
+ p := get(f[0])
+ if p.WorkingDir == "" {
+ p.WorkingDir = f[1]
+ }
+ if len(p.ConfigFiles) == 0 {
+ p.ConfigFiles = splitFiles(f[2])
+ }
+ if services[f[0]] == nil {
+ services[f[0]] = map[string]bool{}
+ }
+ if f[3] != "" {
+ services[f[0]][f[3]] = true
+ }
+ p.Containers++
+ if f[4] == "running" {
+ p.Running++
+ }
+ }
+ out := ProjectsAnswer{Projects: []Project{}}
+ for name, p := range by {
+ for s := range services[name] {
+ p.Services = append(p.Services, s)
+ }
+ sort.Strings(p.Services)
+ if p.WorkingDir == "" && len(p.ConfigFiles) > 0 {
+ p.WorkingDir = filepath.Dir(p.ConfigFiles[0])
+ }
+ out.Projects = append(out.Projects, *p)
+ }
+ sort.Slice(out.Projects, func(i, k int) bool { return out.Projects[i].Name < out.Projects[k].Name })
+ out.Count = len(out.Projects)
+ return out, nil
+}
+
+func splitFiles(s string) []string {
+ out := []string{}
+ for _, f := range strings.Split(s, ",") {
+ if f = strings.TrimSpace(f); f != "" {
+ out = append(out, f)
+ }
+ }
+ return out
+}
+
+// Target is the project a tool acts on, and how compose is told which it is.
+type Target struct {
+ Project string `json:"project,omitempty"`
+ Dir string `json:"dir,omitempty"`
+ Files []string `json:"files,omitempty"`
+}
+
+// args are compose's own options naming the target.
+func (t Target) args() []string {
+ out := []string{"compose"}
+ if t.Dir != "" {
+ out = append(out, "--project-directory", t.Dir)
+ }
+ for _, f := range t.Files {
+ out = append(out, "-f", f)
+ }
+ if t.Project != "" {
+ out = append(out, "-p", t.Project)
+ }
+ return out
+}
+
+var projectName = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`)
+
+// targetOf reads dir or project. A directory docker already knows a project for is that project,
+// with the files it was started from; otherwise it must hold a compose file. needFiles says the
+// tool reads the files (config, up, pull), so a project known only by its containers is not enough.
+func targetOf(args map[string]any, needFiles bool) (Target, error) {
+ dir, err := optText(args, "dir", "")
+ if err != nil {
+ return Target{}, err
+ }
+ name, err := optText(args, "project", "")
+ if err != nil {
+ return Target{}, err
+ }
+ if dir == "" && name == "" {
+ return Target{}, fmt.Errorf("give dir, the project's directory, or project, its name")
+ }
+ if dir != "" {
+ if !filepath.IsAbs(dir) {
+ return Target{}, fmt.Errorf("dir must be an absolute path, not %q", dir)
+ }
+ dir = filepath.Clean(dir)
+ if !statDir(dir) {
+ return Target{}, fmt.Errorf("%s is not a directory on this machine", dir)
+ }
+ }
+ if name != "" && !projectName.MatchString(name) {
+ return Target{}, fmt.Errorf("%q is not a compose project name", name)
+ }
+ known, err := Projects()
+ if err != nil {
+ return Target{}, err
+ }
+ for _, p := range known.Projects {
+ if (dir != "" && p.WorkingDir == dir) || (dir == "" && p.Name == name) {
+ if name != "" && p.Name != name {
+ continue
+ }
+ t := Target{Project: p.Name, Dir: p.WorkingDir}
+ present := len(p.ConfigFiles) > 0
+ for _, f := range p.ConfigFiles {
+ present = present && statFile(f)
+ }
+ if present {
+ t.Files = p.ConfigFiles
+ } else if needFiles && !hasComposeFile(t.Dir) {
+ return Target{}, fmt.Errorf("project %s was started from %s, which is no longer there", p.Name, strings.Join(p.ConfigFiles, ", "))
+ }
+ return t, nil
+ }
+ }
+ if dir == "" {
+ return Target{}, fmt.Errorf("no compose project named %s is known to docker here: give dir, its directory", name)
+ }
+ if !hasComposeFile(dir) {
+ return Target{}, fmt.Errorf("%s holds no compose file (%s)", dir, strings.Join(composeFiles, ", "))
+ }
+ return Target{Dir: dir, Project: name}, nil
+}
+
+func hasComposeFile(dir string) bool {
+ for _, f := range composeFiles {
+ if statFile(filepath.Join(dir, f)) {
+ return true
+ }
+ }
+ return false
+}
+
+// Container is one of a project's containers.
+type Container struct {
+ Name string `json:"name"`
+ Service string `json:"service"`
+ State string `json:"state"`
+ Status string `json:"status"`
+ Health string `json:"health,omitempty"`
+ ExitCode int `json:"exit_code"`
+ Image string `json:"image"`
+ Ports []string `json:"ports"`
+}
+
+// PsAnswer is what docker_compose_ps answers.
+type PsAnswer struct {
+ Target Target `json:"target"`
+ Containers []Container `json:"containers"`
+}
+
+// jsonObjects reads compose's JSON output, which is one array or one object per line by version.
+func jsonObjects(s string, into any) error {
+ s = strings.TrimSpace(s)
+ if s == "" {
+ s = "[]"
+ }
+ if !strings.HasPrefix(s, "[") {
+ s = "[" + strings.Join(lines(s), ",") + "]"
+ }
+ return json.Unmarshal([]byte(s), into)
+}
+
+// Ps answers a project's containers.
+func Ps(t Target) (PsAnswer, error) {
+ r, err := docker(append(t.args(), "ps", "-a", "--format", "json")...)
+ if err != nil {
+ return PsAnswer{}, err
+ }
+ var raw []struct {
+ Name string `json:"Name"`
+ Service string `json:"Service"`
+ State string `json:"State"`
+ Status string `json:"Status"`
+ Health string `json:"Health"`
+ ExitCode int `json:"ExitCode"`
+ Image string `json:"Image"`
+ Publishers []struct {
+ URL string `json:"URL"`
+ TargetPort int `json:"TargetPort"`
+ PublishedPort int `json:"PublishedPort"`
+ Protocol string `json:"Protocol"`
+ } `json:"Publishers"`
+ }
+ if err := jsonObjects(r.Stdout, &raw); err != nil {
+ return PsAnswer{}, fmt.Errorf("docker compose ps answered what is not JSON: %v", err)
+ }
+ out := PsAnswer{Target: t, Containers: []Container{}}
+ for _, c := range raw {
+ ports := []string{}
+ for _, p := range c.Publishers {
+ if p.PublishedPort == 0 {
+ continue
+ }
+ ports = append(ports, fmt.Sprintf("%s:%d->%d/%s", p.URL, p.PublishedPort, p.TargetPort, p.Protocol))
+ }
+ out.Containers = append(out.Containers, Container{Name: c.Name, Service: c.Service, State: c.State, Status: c.Status,
+ Health: c.Health, ExitCode: c.ExitCode, Image: c.Image, Ports: ports})
+ }
+ return out, nil
+}
+
+// LogsAnswer is what docker_compose_logs answers.
+type LogsAnswer struct {
+ Target Target `json:"target"`
+ Lines []string `json:"lines"`
+ Truncated bool `json:"truncated,omitempty"`
+}
+
+var since = regexp.MustCompile(`^[0-9A-Za-z:.+-]+$`)
+
+// Logs answers a project's last lines.
+func Logs(t Target, services []string, n int, from string) (LogsAnswer, error) {
+ args := append(t.args(), "logs", "--no-color", "--timestamps", "--tail", fmt.Sprint(n))
+ if from != "" {
+ if !since.MatchString(from) {
+ return LogsAnswer{}, fmt.Errorf("since %q is neither a duration nor a timestamp", from)
+ }
+ args = append(args, "--since", from)
+ }
+ for _, s := range services {
+ if err := plainName("service", s); err != nil {
+ return LogsAnswer{}, err
+ }
+ }
+ r, err := docker(append(args, services...)...)
+ if err != nil {
+ return LogsAnswer{}, err
+ }
+ // compose writes the containers' output on its stdout, and its own complaints on stderr.
+ return LogsAnswer{Target: t, Lines: lines(r.Stdout), Truncated: r.Truncated}, nil
+}
+
+// ConfigAnswer is what docker_compose_config answers.
+type ConfigAnswer struct {
+ Target Target `json:"target"`
+ Services []string `json:"services"`
+ Redacted int `json:"redacted"`
+ Rendered map[string]any `json:"rendered"`
+}
+
+// secretish is a name whose value is not shown.
+var secretish = regexp.MustCompile(`(?i)(pass|secret|token|key|credential|auth|private|cert|cookie|session|salt|dsn|api)`)
+
+// redact replaces the values of secret-looking names in the maps compose renders.
+func redact(v any, count *int) {
+ switch x := v.(type) {
+ case map[string]any:
+ for k, child := range x {
+ switch k {
+ case "environment", "args", "labels", "build_args":
+ if m, ok := child.(map[string]any); ok {
+ for name, val := range m {
+ if val != nil && secretish.MatchString(name) {
+ m[name] = "[redacted]"
+ *count++
+ }
+ }
+ continue
+ }
+ case "content":
+ // An inline config or secret: its content is the secret itself.
+ if _, ok := child.(string); ok {
+ x[k] = "[redacted]"
+ *count++
+ continue
+ }
+ }
+ redact(child, count)
+ }
+ case []any:
+ for _, child := range x {
+ redact(child, count)
+ }
+ }
+}
+
+// Config answers the rendered configuration.
+func Config(t Target) (ConfigAnswer, error) {
+ r, err := docker(append(t.args(), "config", "--format", "json")...)
+ if err != nil {
+ return ConfigAnswer{}, err
+ }
+ var rendered map[string]any
+ if err := json.Unmarshal([]byte(r.Stdout), &rendered); err != nil {
+ return ConfigAnswer{}, fmt.Errorf("docker compose config answered what is not JSON: %v", err)
+ }
+ out := ConfigAnswer{Target: t, Services: []string{}, Rendered: rendered}
+ if s, ok := rendered["services"].(map[string]any); ok {
+ for name := range s {
+ out.Services = append(out.Services, name)
+ }
+ sort.Strings(out.Services)
+ }
+ redact(rendered, &out.Redacted)
+ return out, nil
+}
+
+// ActAnswer is what an act answers: the target and the job that carries it.
+type ActAnswer struct {
+ Act string `json:"act"`
+ Target Target `json:"target"`
+ Job Job `json:"job"`
+}
+
+func act(name string, t Target, extra ...string) (ActAnswer, error) {
+ j, err := actAsJob(Cmd{Name: "docker", Args: append(append(t.args(), name), extra...)})
+ if err != nil {
+ return ActAnswer{}, err
+ }
+ return ActAnswer{Act: name, Target: t, Job: j}, nil
+}
+
+func checkServices(services []string) error {
+ for _, s := range services {
+ if err := plainName("service", s); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// Up brings a project up, detached.
+func Up(t Target, services []string, build bool, pull string) (ActAnswer, error) {
+ if err := oneOf("pull", pull, "missing", "always", "never"); err != nil {
+ return ActAnswer{}, err
+ }
+ if err := checkServices(services); err != nil {
+ return ActAnswer{}, err
+ }
+ extra := []string{"--detach", "--pull", pull}
+ if build {
+ extra = append(extra, "--build")
+ }
+ return act("up", t, append(extra, services...)...)
+}
+
+// Down stops and removes a project's containers and networks, keeping its volumes.
+func Down(t Target) (ActAnswer, error) {
+ return act("down", t)
+}
+
+// Restart restarts a project's containers.
+func Restart(t Target, services []string) (ActAnswer, error) {
+ if err := checkServices(services); err != nil {
+ return ActAnswer{}, err
+ }
+ return act("restart", t, services...)
+}
+
+// Pull pulls a project's images.
+func Pull(t Target, services []string) (ActAnswer, error) {
+ if err := checkServices(services); err != nil {
+ return ActAnswer{}, err
+ }
+ return act("pull", t, services...)
+}
diff --git a/modules/docker-compose/cmd/docker-compose-tools/compose_test.go b/modules/docker-compose/cmd/docker-compose-tools/compose_test.go
new file mode 100644
index 0000000..750939a
--- /dev/null
+++ b/modules/docker-compose/cmd/docker-compose-tools/compose_test.go
@@ -0,0 +1,222 @@
+package main
+
+import (
+ "encoding/json"
+ "strings"
+ "testing"
+)
+
+func TestTheManifestIsComposesPackageAndNothingElse(t *testing.T) {
+ m := readManifest(t)
+ holdsTheBundle(t, m, "docker_compose")
+ if got := strings.Join(m.packages(), ","); got != "docker-compose" {
+ t.Errorf("packages %s: buildx and the runtime are the docker module's", got)
+ }
+ if len(m.Resources) != 1 {
+ t.Errorf("one resource, the package: %v", m.Resources)
+ }
+}
+
+const lsJSON = `[{"Name":"anton-lavinmq","Status":"running(1)","ConfigFiles":"/home/op/hub/lavinmq/docker-compose.yml"},{"Name":"old","Status":"exited(2)","ConfigFiles":"/srv/old/compose.yaml,/srv/old/compose.override.yaml"}]`
+
+const psLabels = "anton-lavinmq\t/home/op/hub/lavinmq\t/home/op/hub/lavinmq/docker-compose.yml\tlavinmq\trunning\n" +
+ "old\t/srv/old\t/srv/old/compose.yaml,/srv/old/compose.override.yaml\tweb\texited\n" +
+ "old\t/srv/old\t/srv/old/compose.yaml,/srv/old/compose.override.yaml\tdb\texited\n"
+
+// aDocker answers compose ls and the labelled ps, and hands every other line to rest.
+func aDocker(t *testing.T, rest func(line string) Result) *fake {
+ return using(t, func(line string, c Cmd) Result {
+ switch {
+ case strings.HasPrefix(line, "docker compose ls"):
+ return ok(lsJSON)
+ case strings.HasPrefix(line, "docker ps -a --filter label=com.docker.compose.project"):
+ return ok(psLabels)
+ }
+ if rest != nil {
+ return rest(line)
+ }
+ return ok("")
+ })
+}
+
+func onDisk(t *testing.T, dirs, files []string) {
+ t.Helper()
+ wasD, wasF := statDir, statFile
+ in := func(set []string) func(string) bool {
+ return func(p string) bool {
+ for _, s := range set {
+ if s == p {
+ return true
+ }
+ }
+ return false
+ }
+ }
+ statDir, statFile = in(dirs), in(files)
+ t.Cleanup(func() { statDir, statFile = wasD, wasF })
+}
+
+func TestProjectsMergesComposesListWithTheContainersLabels(t *testing.T) {
+ aDocker(t, nil)
+ got, err := Projects()
+ if err != nil || got.Count != 2 {
+ t.Fatalf("%+v %v", got, err)
+ }
+ p := got.Projects[0]
+ if p.Name != "anton-lavinmq" || p.WorkingDir != "/home/op/hub/lavinmq" || p.Running != 1 || p.Containers != 1 || p.Status != "running(1)" {
+ t.Errorf("%+v", p)
+ }
+ o := got.Projects[1]
+ if strings.Join(o.Services, ",") != "db,web" || len(o.ConfigFiles) != 2 || o.Running != 0 || o.Containers != 2 {
+ t.Errorf("%+v", o)
+ }
+}
+
+func TestProjectsNamesADaemonTheAccountCannotReach(t *testing.T) {
+ using(t, func(string, Cmd) Result {
+ return Result{Status: 1, Stderr: "permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock"}
+ })
+ if _, err := Projects(); err == nil || !strings.Contains(err.Error(), "docker group") {
+ t.Fatalf("%v", err)
+ }
+}
+
+func TestATargetIsAKnownProjectWithItsFilesOrADirectoryHoldingAComposeFile(t *testing.T) {
+ aDocker(t, nil)
+ onDisk(t, []string{"/home/op/hub/lavinmq", "/srv/new", "/srv/empty", "/srv/old"},
+ []string{"/home/op/hub/lavinmq/docker-compose.yml", "/srv/new/compose.yaml"})
+ got, err := targetOf(map[string]any{"dir": "/home/op/hub/lavinmq/"}, true)
+ if err != nil || got.Project != "anton-lavinmq" || len(got.Files) != 1 {
+ t.Fatalf("a known directory: %+v %v", got, err)
+ }
+ if a := strings.Join(got.args(), " "); a != "compose --project-directory /home/op/hub/lavinmq -f /home/op/hub/lavinmq/docker-compose.yml -p anton-lavinmq" {
+ t.Errorf("args %s", a)
+ }
+ got, err = targetOf(map[string]any{"dir": "/srv/new"}, true)
+ if err != nil || got.Project != "" || got.Dir != "/srv/new" {
+ t.Fatalf("a new directory: %+v %v", got, err)
+ }
+ got, err = targetOf(map[string]any{"project": "old"}, false)
+ if err != nil || got.Dir != "/srv/old" || got.Files != nil {
+ t.Fatalf("a project whose files are gone, for an act that needs none: %+v %v", got, err)
+ }
+ if _, err := targetOf(map[string]any{"project": "old"}, true); err == nil || !strings.Contains(err.Error(), "no longer there") {
+ t.Errorf("its files are needed: %v", err)
+ }
+ for _, bad := range []map[string]any{{}, {"dir": "rel/path"}, {"dir": "/nope"}, {"dir": "/srv/empty"}, {"project": "Bad Name"}, {"project": "unknown"}} {
+ if _, err := targetOf(bad, false); err == nil {
+ t.Errorf("%v was accepted", bad)
+ }
+ }
+}
+
+func TestPsReadsEitherJSONShapeAndKeepsOnlyPublishedPorts(t *testing.T) {
+ aDocker(t, func(line string) Result {
+ return ok(`{"Name":"a-web-1","Service":"web","State":"running","Status":"Up 2 hours","Health":"healthy","ExitCode":0,"Image":"nginx","Publishers":[{"URL":"0.0.0.0","TargetPort":80,"PublishedPort":8080,"Protocol":"tcp"},{"URL":"","TargetPort":443,"PublishedPort":0,"Protocol":"tcp"}]}
+{"Name":"a-db-1","Service":"db","State":"exited","Status":"Exited (1)","ExitCode":1,"Image":"postgres","Publishers":[]}`)
+ })
+ got, err := Ps(Target{Project: "a"})
+ if err != nil || len(got.Containers) != 2 {
+ t.Fatalf("%+v %v", got, err)
+ }
+ if c := got.Containers[0]; strings.Join(c.Ports, ",") != "0.0.0.0:8080->80/tcp" || c.Health != "healthy" {
+ t.Errorf("%+v", c)
+ }
+ var arr []map[string]any
+ if err := jsonObjects(`[{"Name":"x"}]`, &arr); err != nil || len(arr) != 1 {
+ t.Errorf("an array: %v %v", arr, err)
+ }
+}
+
+func TestLogsAreBoundedAndRefuseAnOptionInAName(t *testing.T) {
+ f := aDocker(t, func(line string) Result { return Result{Stdout: "web-1 | a\nweb-1 | b\n", Truncated: true} })
+ got, err := Logs(Target{Project: "a"}, []string{"web"}, 50, "10m")
+ if err != nil || len(got.Lines) != 2 || !got.Truncated {
+ t.Fatalf("%+v %v", got, err)
+ }
+ if l := f.lines()[0]; l != "docker compose -p a logs --no-color --timestamps --tail 50 --since 10m web" {
+ t.Errorf("%s", l)
+ }
+ if _, err := Logs(Target{Project: "a"}, []string{"--follow"}, 5, ""); err == nil {
+ t.Error("an option as a service")
+ }
+ if _, err := Logs(Target{Project: "a"}, nil, 5, "1h; rm"); err == nil {
+ t.Error("a since that is neither")
+ }
+}
+
+func TestConfigRedactsSecretLookingValuesAndInlineContent(t *testing.T) {
+ aDocker(t, func(string) Result {
+ return ok(`{"name":"a","services":{"web":{"image":"nginx","environment":{"DB_PASSWORD":"hunter2","PORT":"80","API_TOKEN":"t"},"build":{"args":{"NPM_TOKEN":"n","NODE_ENV":"production"}}}},"secrets":{"s":{"content":"raw"}}}`)
+ })
+ got, err := Config(Target{Dir: "/srv/a"})
+ if err != nil || got.Redacted != 4 || strings.Join(got.Services, ",") != "web" {
+ t.Fatalf("%+v %v", got, err)
+ }
+ raw, _ := json.Marshal(got.Rendered)
+ for _, secret := range []string{"hunter2", `"t"`, `"n"`, "raw"} {
+ if strings.Contains(string(raw), secret) {
+ t.Errorf("%s shown: %s", secret, raw)
+ }
+ }
+ for _, kept := range []string{`"PORT":"80"`, `"NODE_ENV":"production"`, `"image":"nginx"`} {
+ if !strings.Contains(string(raw), kept) {
+ t.Errorf("%s hidden: %s", kept, raw)
+ }
+ }
+}
+
+func TestConfigAnInvalidFileIsComposesError(t *testing.T) {
+ aDocker(t, func(string) Result {
+ return Result{Status: 15, Stderr: "services.web Additional property foo is not allowed"}
+ })
+ if _, err := Config(Target{Dir: "/srv/a"}); err == nil || !strings.Contains(err.Error(), "Additional property") {
+ t.Fatalf("%v", err)
+ }
+}
+
+func TestActsAreJobsAsTheAccountKeepVolumesAndCheckTheirArguments(t *testing.T) {
+ f := aDocker(t, nil)
+ tg := Target{Dir: "/srv/a", Project: "a"}
+ if got, err := Up(tg, []string{"web"}, true, "always"); err != nil || got.Job.Running || got.Act != "up" {
+ t.Fatalf("%+v %v", got, err)
+ }
+ if _, err := Down(tg); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := Restart(tg, nil); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := Pull(tg, nil); err != nil {
+ t.Fatal(err)
+ }
+ want := []string{
+ "docker compose --project-directory /srv/a -p a up --detach --pull always --build web",
+ "docker compose --project-directory /srv/a -p a down",
+ "docker compose --project-directory /srv/a -p a restart",
+ "docker compose --project-directory /srv/a -p a pull",
+ }
+ if got := strings.Join(f.lines(), "\n"); got != strings.Join(want, "\n") {
+ t.Errorf("asked\n%s\nwant\n%s", got, strings.Join(want, "\n"))
+ }
+ for _, l := range f.lines() {
+ if strings.Contains(l, "sudo") || strings.Contains(l, "-v") || strings.Contains(l, "--volumes") {
+ t.Errorf("%s", l)
+ }
+ }
+ if _, err := Up(tg, nil, false, "sometimes"); err == nil {
+ t.Error("an unknown pull policy")
+ }
+ if _, err := Restart(tg, []string{"-t"}); err == nil {
+ t.Error("an option as a service")
+ }
+}
+
+func TestAFailedActIsAnError(t *testing.T) {
+ aDocker(t, func(string) Result {
+ return Result{Status: 1, Stderr: "Error response from daemon: port is already allocated"}
+ })
+ if _, err := Up(Target{Dir: "/srv/a"}, nil, false, "missing"); err == nil || !strings.Contains(err.Error(), "already allocated") {
+ t.Fatalf("%v", err)
+ }
+}
diff --git a/modules/docker-compose/cmd/docker-compose-tools/jobs.go b/modules/docker-compose/cmd/docker-compose-tools/jobs.go
new file mode 100644
index 0000000..3885479
--- /dev/null
+++ b/modules/docker-compose/cmd/docker-compose-tools/jobs.go
@@ -0,0 +1,151 @@
+package main
+
+// jobs.go is the same file in the bundles whose acts can outlast one call (flatpak, docker-compose):
+// an install or an `up` that pulls images takes minutes, and the runtime gives a call 30 s. Such an
+// act is started as a job inside this process, waited on for a while, and answered either finished
+// or with the job's id for the module's `_job` tool to follow. A job ends with this process: if the
+// runtime restarts the bundle, a running job is cut off, and its id is then unknown.
+
+import (
+ "fmt"
+ "sort"
+ "strings"
+ "sync"
+ "time"
+)
+
+// JobLimit is the longest a job may run; JobWait how long an act waits before answering a job id.
+const (
+ JobLimit = 15 * time.Minute
+ JobWait = 18 * time.Second
+ keptJobs = 50
+)
+
+// Job is one long act, as its tool answers it.
+type Job struct {
+ ID string `json:"job"`
+ Command string `json:"command"`
+ Started time.Time `json:"started"`
+ Finished *time.Time `json:"finished,omitempty"`
+ Running bool `json:"running"`
+ Status *int `json:"status,omitempty"`
+ Error string `json:"error,omitempty"`
+ Output string `json:"output,omitempty"`
+ Truncated bool `json:"truncated,omitempty"`
+ done chan struct{}
+}
+
+type jobBook struct {
+ mu sync.Mutex
+ seq int
+ jobs map[string]*Job
+}
+
+var jobs = &jobBook{jobs: map[string]*Job{}}
+
+// startJob runs c in the background, held to JobLimit.
+func startJob(c Cmd) *Job {
+ c.Timeout = JobLimit
+ name, args := argv(c)
+ jobs.mu.Lock()
+ jobs.seq++
+ j := &Job{ID: fmt.Sprintf("%d-%d", time.Now().Unix(), jobs.seq), Command: strings.TrimSpace(name + " " + strings.Join(args, " ")),
+ Started: time.Now().UTC(), Running: true, done: make(chan struct{})}
+ jobs.jobs[j.ID] = j
+ jobs.forgetOldest()
+ jobs.mu.Unlock()
+ go func() {
+ r := run(c)
+ var err error
+ if r.Status != 0 || r.Error != "" {
+ err = failure(c, r)
+ }
+ jobs.mu.Lock()
+ now := time.Now().UTC()
+ j.Finished, j.Running = &now, false
+ status := r.Status
+ j.Status = &status
+ if err != nil {
+ j.Error = err.Error()
+ }
+ j.Output = tail(strings.TrimSpace(r.Stdout+"\n"+r.Stderr), 16<<10)
+ j.Truncated = r.Truncated || len(r.Stdout)+len(r.Stderr) > 16<<10
+ jobs.mu.Unlock()
+ close(j.done)
+ }()
+ return j
+}
+
+// forgetOldest keeps the book bounded; finished jobs go first. Called with the lock held.
+func (b *jobBook) forgetOldest() {
+ if len(b.jobs) <= keptJobs {
+ return
+ }
+ all := make([]*Job, 0, len(b.jobs))
+ for _, j := range b.jobs {
+ all = append(all, j)
+ }
+ sort.Slice(all, func(i, k int) bool { return all[i].Started.Before(all[k].Started) })
+ for _, j := range all {
+ if len(b.jobs) <= keptJobs {
+ return
+ }
+ if !j.Running {
+ delete(b.jobs, j.ID)
+ }
+ }
+}
+
+// awaitJob waits up to d for a job to finish and answers a copy of it as it then stands.
+func awaitJob(j *Job, d time.Duration) Job {
+ select {
+ case <-j.done:
+ case <-time.After(d):
+ }
+ return snapshot(j)
+}
+
+func snapshot(j *Job) Job {
+ jobs.mu.Lock()
+ defer jobs.mu.Unlock()
+ c := *j
+ c.done = nil
+ return c
+}
+
+// jobByID answers a job by its id, or says it is not known to this process.
+func jobByID(id string) (Job, error) {
+ jobs.mu.Lock()
+ j, ok := jobs.jobs[id]
+ jobs.mu.Unlock()
+ if !ok {
+ return Job{}, fmt.Errorf("no job %s in this process: it was never started here, was forgotten after %d newer ones, or the bundle has restarted since", id, keptJobs)
+ }
+ return snapshot(j), nil
+}
+
+// listJobs answers every job this process knows, newest first.
+func listJobs() []Job {
+ jobs.mu.Lock()
+ all := make([]*Job, 0, len(jobs.jobs))
+ for _, j := range jobs.jobs {
+ all = append(all, j)
+ }
+ jobs.mu.Unlock()
+ sort.Slice(all, func(i, k int) bool { return all[i].Started.After(all[k].Started) })
+ out := make([]Job, 0, len(all))
+ for _, j := range all {
+ out = append(out, snapshot(j))
+ }
+ return out
+}
+
+// actAsJob starts c and answers the job once it finishes or JobWait passes, whichever is first.
+// A finished job that failed is answered as an error, so a failed act is never read as success.
+func actAsJob(c Cmd) (Job, error) {
+ j := awaitJob(startJob(c), JobWait)
+ if !j.Running && j.Error != "" {
+ return j, fmt.Errorf("%s (job %s)", j.Error, j.ID)
+ }
+ return j, nil
+}
diff --git a/modules/docker-compose/cmd/docker-compose-tools/jobs_test.go b/modules/docker-compose/cmd/docker-compose-tools/jobs_test.go
new file mode 100644
index 0000000..46e1725
--- /dev/null
+++ b/modules/docker-compose/cmd/docker-compose-tools/jobs_test.go
@@ -0,0 +1,51 @@
+package main
+
+import (
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestJobsAFastActIsAnsweredFinishedAndAFailedOneAsAnError(t *testing.T) {
+ using(t, func(line string, c Cmd) Result {
+ if c.Timeout != JobLimit {
+ t.Errorf("a job is held to JobLimit, not %s", c.Timeout)
+ }
+ if strings.Contains(line, "bad") {
+ return Result{Status: 2, Stderr: "it broke"}
+ }
+ return ok("done")
+ })
+ j, err := actAsJob(Cmd{Name: "good"})
+ if err != nil || j.Running || j.Status == nil || *j.Status != 0 || j.Output != "done" {
+ t.Fatalf("%+v %v", j, err)
+ }
+ if _, err := actAsJob(Cmd{Name: "bad"}); err == nil || !strings.Contains(err.Error(), "it broke") {
+ t.Fatalf("a failed job: %v", err)
+ }
+ got, err := jobByID(j.ID)
+ if err != nil || got.ID != j.ID {
+ t.Fatalf("by id: %+v %v", got, err)
+ }
+ if _, err := jobByID("nope"); err == nil {
+ t.Fatal("an unknown job")
+ }
+ if len(listJobs()) < 2 {
+ t.Fatal("listed")
+ }
+}
+
+func TestJobsASlowActIsAnsweredRunningWithItsID(t *testing.T) {
+ release := make(chan struct{})
+ using(t, func(line string, c Cmd) Result { <-release; return ok("") })
+ j := awaitJob(startJob(Cmd{Name: "slow"}), 50*time.Millisecond)
+ if !j.Running || j.ID == "" {
+ t.Fatalf("%+v", j)
+ }
+ close(release)
+ time.Sleep(50 * time.Millisecond)
+ got, _ := jobByID(j.ID)
+ if got.Running {
+ t.Fatalf("finished afterwards: %+v", got)
+ }
+}
diff --git a/modules/docker-compose/cmd/docker-compose-tools/kit.go b/modules/docker-compose/cmd/docker-compose-tools/kit.go
new file mode 100644
index 0000000..adc5aac
--- /dev/null
+++ b/modules/docker-compose/cmd/docker-compose-tools/kit.go
@@ -0,0 +1,352 @@
+package main
+
+// kit.go is the same file in each of the workstations' tool bundles (fonts, docker-compose, snapd,
+// flatpak, cups, bluetooth, xclip, dmenu): how a tool runs a command, escalates, bounds what it
+// keeps, and names a failure. A module is built from its own directory, so the file is copied rather
+// than shared; a change to one copy is made to all eight.
+//
+// The rules it holds (novox/hq research 026/05, to-be 38 WP4):
+// - the node's tool runtime runs as the operator account, not root (ADR 0175 §4); a command that
+// needs root goes through `sudo -n`, never a prompt, and a refusal is named as such;
+// - one command gets 20 s, below the runtime's 30 s call limit, and is ended with everything it
+// started when it takes longer;
+// - each stream is kept to 256 KiB, and the answer says when it was cut;
+// - a failure is an error with what went wrong in it, never an empty answer.
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "os/exec"
+ "strings"
+ "syscall"
+ "time"
+)
+
+// Bounds every command is held to.
+const (
+ CallTimeout = 20 * time.Second
+ MostOutput = 256 << 10
+)
+
+// Cmd is one command a tool runs.
+type Cmd struct {
+ Name string
+ Args []string
+ // Stdin is written to the command's standard input when not empty.
+ Stdin string
+ // Env is added to this process's own environment.
+ Env []string
+ // Root says the command needs root: it is run through `sudo -n` when this process is not root.
+ Root bool
+ // Timeout replaces CallTimeout; only a background job (jobs.go) asks for longer.
+ Timeout time.Duration
+ // Detached is for a program that forks a child which outlives it, as xclip does to keep the
+ // selection: its streams go to files, because a pipe the child inherits would hold the call open
+ // until the child exits.
+ Detached bool
+}
+
+// Result is what a command did.
+type Result struct {
+ Stdout string `json:"stdout"`
+ Stderr string `json:"stderr"`
+ Status int `json:"status"`
+ // Error is why it did not run to an answer: "not-found" when the program is not there,
+ // "timeout" when it was ended for taking too long, else the spawn error.
+ Error string `json:"error,omitempty"`
+ Truncated bool `json:"truncated,omitempty"`
+}
+
+// Runner runs a command. Tests replace it; nothing else does.
+type Runner func(Cmd) Result
+
+var (
+ run Runner = execRun
+ euid = os.Geteuid
+)
+
+// argv is the command as it is run: through sudo without a prompt when it needs root and this
+// process is not root.
+func argv(c Cmd) (string, []string) {
+ if c.Root && euid() != 0 {
+ return "sudo", append([]string{"-n", c.Name}, c.Args...)
+ }
+ return c.Name, c.Args
+}
+
+// bounded keeps the first MostOutput bytes written to it and notes that more came.
+type bounded struct {
+ b bytes.Buffer
+ cut bool
+}
+
+func (w *bounded) Write(p []byte) (int, error) {
+ room := MostOutput - w.b.Len()
+ if room <= 0 {
+ w.cut = w.cut || len(p) > 0
+ return len(p), nil
+ }
+ if len(p) > room {
+ w.b.Write(p[:room])
+ w.cut = true
+ return len(p), nil
+ }
+ return w.b.Write(p)
+}
+
+func execRun(c Cmd) Result {
+ timeout := c.Timeout
+ if timeout <= 0 {
+ timeout = CallTimeout
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), timeout)
+ defer cancel()
+ name, args := argv(c)
+ cmd := exec.CommandContext(ctx, name, args...)
+ cmd.Env = append(append(os.Environ(), "LC_ALL=C"), c.Env...)
+ if !c.Detached {
+ // Its own process group, so that ending it on a timeout ends what it started too.
+ cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
+ cmd.Cancel = func() error {
+ if cmd.Process != nil {
+ _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
+ }
+ return nil
+ }
+ }
+ cmd.WaitDelay = 2 * time.Second
+ if c.Stdin != "" {
+ cmd.Stdin = strings.NewReader(c.Stdin)
+ }
+ var out, errs bounded
+ var outFile, errFile *os.File
+ if c.Detached {
+ var err error
+ if outFile, err = os.CreateTemp("", "mesh-tool-out-*"); err != nil {
+ return Result{Status: 127, Error: err.Error()}
+ }
+ defer os.Remove(outFile.Name())
+ defer outFile.Close()
+ if errFile, err = os.CreateTemp("", "mesh-tool-err-*"); err != nil {
+ return Result{Status: 127, Error: err.Error()}
+ }
+ defer os.Remove(errFile.Name())
+ defer errFile.Close()
+ cmd.Stdout, cmd.Stderr = outFile, errFile
+ } else {
+ cmd.Stdout, cmd.Stderr = &out, &errs
+ }
+ err := cmd.Run()
+ if c.Detached {
+ for _, f := range []struct {
+ file *os.File
+ into *bounded
+ }{{outFile, &out}, {errFile, &errs}} {
+ if _, e := f.file.Seek(0, io.SeekStart); e == nil {
+ _, _ = io.Copy(f.into, f.file)
+ }
+ }
+ }
+ r := Result{Stdout: out.b.String(), Stderr: errs.b.String(), Truncated: out.cut || errs.cut}
+ var exit *exec.ExitError
+ switch {
+ case err == nil:
+ case ctx.Err() == context.DeadlineExceeded:
+ r.Status, r.Error = 124, "timeout"
+ case errors.Is(err, exec.ErrNotFound) || errors.Is(err, os.ErrNotExist):
+ r.Status, r.Error = 127, "not-found"
+ case errors.As(err, &exit):
+ r.Status = exit.ExitCode()
+ default:
+ r.Status, r.Error = 127, err.Error()
+ }
+ return r
+}
+
+// call runs a command and answers its result, or an error naming what went wrong.
+func call(c Cmd) (Result, error) {
+ r := run(c)
+ if r.Status == 0 && r.Error == "" {
+ return r, nil
+ }
+ return r, failure(c, r)
+}
+
+// failure names how a command failed: not installed, refused escalation, too slow, or its exit
+// status with the end of what it said.
+func failure(c Cmd, r Result) error {
+ program, _ := argv(c)
+ switch {
+ case r.Error == "not-found" && program == "sudo":
+ return fmt.Errorf("%s needs root, and sudo is not installed here for the runtime's account to escalate with", c.Name)
+ case r.Error == "not-found":
+ if hint, ok := providedBy[c.Name]; ok {
+ return fmt.Errorf("%s is not installed on this machine (%s)", c.Name, hint)
+ }
+ return fmt.Errorf("%s is not installed on this machine", c.Name)
+ case r.Error == "timeout":
+ limit := c.Timeout
+ if limit <= 0 {
+ limit = CallTimeout
+ }
+ return fmt.Errorf("%s gave no answer within %s and was ended", c.Name, limit)
+ case r.Error != "":
+ return fmt.Errorf("%s did not run: %s", c.Name, r.Error)
+ case program == "sudo" && strings.Contains(r.Stderr, "command not found"):
+ if hint, ok := providedBy[c.Name]; ok {
+ return fmt.Errorf("%s is not installed on this machine (%s)", c.Name, hint)
+ }
+ return fmt.Errorf("%s is not installed on this machine", c.Name)
+ case program == "sudo" && strings.HasPrefix(strings.TrimSpace(r.Stderr), "sudo:"):
+ return fmt.Errorf("%s needs root, and sudo -n refused the runtime's account: %s (the escalation is the sudo module's to declare)",
+ c.Name, firstLine(r.Stderr))
+ }
+ said := tail(strings.TrimSpace(r.Stderr), 2000)
+ if said == "" {
+ said = tail(strings.TrimSpace(r.Stdout), 2000)
+ }
+ if said == "" {
+ said = "and said nothing"
+ }
+ return fmt.Errorf("%s %s exited %d: %s", c.Name, strings.Join(c.Args, " "), r.Status, said)
+}
+
+func firstLine(s string) string {
+ s = strings.TrimSpace(s)
+ if i := strings.IndexByte(s, '\n'); i >= 0 {
+ return s[:i]
+ }
+ return s
+}
+
+func tail(s string, n int) string {
+ if len(s) <= n {
+ return s
+ }
+ return "…" + s[len(s)-n:]
+}
+
+// lines are a command's output lines, blank ones dropped.
+func lines(s string) []string {
+ out := []string{}
+ for _, l := range strings.Split(s, "\n") {
+ if strings.TrimSpace(l) != "" {
+ out = append(out, strings.TrimRight(l, "\r"))
+ }
+ }
+ return out
+}
+
+// Arguments, read the way a tool's JSON arguments arrive.
+
+func text(args map[string]any, key string) (string, error) {
+ v, ok := args[key]
+ if !ok || v == nil {
+ return "", fmt.Errorf("%s is required", key)
+ }
+ s, ok := v.(string)
+ if !ok {
+ return "", fmt.Errorf("%s must be a string", key)
+ }
+ if strings.TrimSpace(s) == "" {
+ return "", fmt.Errorf("%s must not be empty", key)
+ }
+ return s, nil
+}
+
+func optText(args map[string]any, key, def string) (string, error) {
+ v, ok := args[key]
+ if !ok || v == nil {
+ return def, nil
+ }
+ s, ok := v.(string)
+ if !ok {
+ return "", fmt.Errorf("%s must be a string", key)
+ }
+ if strings.TrimSpace(s) == "" {
+ return def, nil
+ }
+ return s, nil
+}
+
+// optWhole reads a whole number, defaulted, refused below least and held to most.
+func optWhole(args map[string]any, key string, def, least, most int) (int, error) {
+ v, ok := args[key]
+ if !ok || v == nil {
+ return def, nil
+ }
+ f, ok := v.(float64)
+ if !ok {
+ if i, isInt := v.(int); isInt {
+ f = float64(i)
+ } else {
+ return 0, fmt.Errorf("%s must be a number", key)
+ }
+ }
+ if f != float64(int(f)) {
+ return 0, fmt.Errorf("%s must be a whole number", key)
+ }
+ n := int(f)
+ if n < least {
+ return 0, fmt.Errorf("%s must be at least %d", key, least)
+ }
+ if n > most {
+ n = most
+ }
+ return n, nil
+}
+
+func optFlag(args map[string]any, key string, def bool) (bool, error) {
+ v, ok := args[key]
+ if !ok || v == nil {
+ return def, nil
+ }
+ b, ok := v.(bool)
+ if !ok {
+ return false, fmt.Errorf("%s must be true or false", key)
+ }
+ return b, nil
+}
+
+func optList(args map[string]any, key string) ([]string, error) {
+ v, ok := args[key]
+ if !ok || v == nil {
+ return nil, nil
+ }
+ items, ok := v.([]any)
+ if !ok {
+ return nil, fmt.Errorf("%s must be a list of strings", key)
+ }
+ out := make([]string, 0, len(items))
+ for _, it := range items {
+ s, ok := it.(string)
+ if !ok || strings.TrimSpace(s) == "" {
+ return nil, fmt.Errorf("%s must be a list of non-empty strings", key)
+ }
+ out = append(out, s)
+ }
+ return out, nil
+}
+
+// oneOf refuses a value outside a closed set.
+func oneOf(key, value string, allowed ...string) error {
+ for _, a := range allowed {
+ if value == a {
+ return nil
+ }
+ }
+ return fmt.Errorf("%s must be one of %s, not %q", key, strings.Join(allowed, ", "), value)
+}
+
+// plainName refuses a name that could be read as an option or carries a path or a space: package,
+// snap, application and printer names never do.
+func plainName(key, value string) error {
+ if strings.HasPrefix(value, "-") || strings.ContainsAny(value, " \t\n/\\") {
+ return fmt.Errorf("%s %q is not a plain name", key, value)
+ }
+ return nil
+}
diff --git a/modules/docker-compose/cmd/docker-compose-tools/kit_test.go b/modules/docker-compose/cmd/docker-compose-tools/kit_test.go
new file mode 100644
index 0000000..c5d3557
--- /dev/null
+++ b/modules/docker-compose/cmd/docker-compose-tools/kit_test.go
@@ -0,0 +1,147 @@
+package main
+
+// Tests of kit.go, the same in each workstation module.
+
+import (
+ "strings"
+ "testing"
+ "time"
+)
+
+// fake records the commands asked and answers each from a function of the command line.
+type fake struct {
+ asked []Cmd
+ answer func(line string, c Cmd) Result
+}
+
+func (f *fake) runner() Runner {
+ return func(c Cmd) Result {
+ f.asked = append(f.asked, c)
+ name, args := argv(c)
+ line := strings.TrimSpace(name + " " + strings.Join(args, " "))
+ if f.answer == nil {
+ return Result{}
+ }
+ return f.answer(line, c)
+ }
+}
+
+func (f *fake) lines() []string {
+ out := []string{}
+ for _, c := range f.asked {
+ name, args := argv(c)
+ out = append(out, strings.TrimSpace(name+" "+strings.Join(args, " ")))
+ }
+ return out
+}
+
+// using installs a fake runner and a non-root uid for one test.
+func using(t *testing.T, answer func(line string, c Cmd) Result) *fake {
+ t.Helper()
+ f := &fake{answer: answer}
+ wasRun, wasUID := run, euid
+ run, euid = f.runner(), func() int { return 1000 }
+ t.Cleanup(func() { run, euid = wasRun, wasUID })
+ return f
+}
+
+func ok(stdout string) Result { return Result{Stdout: stdout} }
+
+func TestKitAnActThatNeedsRootGoesThroughSudoWithoutAPromptUnlessAlreadyRoot(t *testing.T) {
+ was := euid
+ defer func() { euid = was }()
+ euid = func() int { return 1000 }
+ if name, args := argv(Cmd{Name: "x", Args: []string{"a"}, Root: true}); name != "sudo" || strings.Join(args, " ") != "-n x a" {
+ t.Fatalf("not root: %s %v", name, args)
+ }
+ if name, _ := argv(Cmd{Name: "x"}); name != "x" {
+ t.Fatalf("a read is run as the account: %s", name)
+ }
+ euid = func() int { return 0 }
+ if name, _ := argv(Cmd{Name: "x", Root: true}); name != "x" {
+ t.Fatalf("as root no sudo: %s", name)
+ }
+}
+
+func TestKitAFailureIsNamedByHowItFailed(t *testing.T) {
+ was := euid
+ defer func() { euid = was }()
+ euid = func() int { return 1000 }
+ cases := []struct {
+ c Cmd
+ r Result
+ want string
+ }{
+ {Cmd{Name: "nothere"}, Result{Status: 127, Error: "not-found"}, "not installed"},
+ {Cmd{Name: "x", Root: true}, Result{Status: 127, Error: "not-found"}, "sudo is not installed"},
+ {Cmd{Name: "x", Root: true}, Result{Status: 1, Stderr: "sudo: a password is required\n"}, "sudo -n refused"},
+ {Cmd{Name: "x", Root: true}, Result{Status: 1, Stderr: "sudo: x: command not found\n"}, "x is not installed"},
+ {Cmd{Name: "x"}, Result{Status: 124, Error: "timeout"}, "within 20s"},
+ {Cmd{Name: "x", Args: []string{"y"}}, Result{Status: 3, Stderr: "boom\n"}, "x y exited 3: boom"},
+ {Cmd{Name: "x"}, Result{Status: 3}, "said nothing"},
+ }
+ for _, k := range cases {
+ err := failure(k.c, k.r)
+ if err == nil || !strings.Contains(err.Error(), k.want) {
+ t.Errorf("%+v: %v, want %q", k.r, err, k.want)
+ }
+ }
+}
+
+func TestKitOutputIsBoundedAndSaysSo(t *testing.T) {
+ var w bounded
+ big := strings.Repeat("a", MostOutput+10)
+ n, _ := w.Write([]byte(big))
+ if n != len(big) || w.b.Len() != MostOutput || !w.cut {
+ t.Fatalf("kept %d of %d, cut %v", w.b.Len(), len(big), w.cut)
+ }
+}
+
+func TestKitTheRealRunnerRunsEndsAndReportsAMissingProgram(t *testing.T) {
+ r := execRun(Cmd{Name: "sh", Args: []string{"-c", "echo out; echo err >&2; exit 3"}})
+ if r.Status != 3 || strings.TrimSpace(r.Stdout) != "out" || strings.TrimSpace(r.Stderr) != "err" {
+ t.Fatalf("%+v", r)
+ }
+ r = execRun(Cmd{Name: "sh", Args: []string{"-c", "sleep 5 & sleep 5"}, Timeout: 200 * time.Millisecond})
+ if r.Error != "timeout" {
+ t.Fatalf("a slow command: %+v", r)
+ }
+ r = execRun(Cmd{Name: "no-such-program-anywhere"})
+ if r.Error != "not-found" {
+ t.Fatalf("a missing program: %+v", r)
+ }
+ r = execRun(Cmd{Name: "cat", Stdin: "given"})
+ if r.Stdout != "given" {
+ t.Fatalf("stdin: %+v", r)
+ }
+ start := time.Now()
+ r = execRun(Cmd{Name: "sh", Args: []string{"-c", "echo kept; (sleep 3 &) ; exit 0"}, Detached: true})
+ if r.Status != 0 || strings.TrimSpace(r.Stdout) != "kept" || time.Since(start) > 2*time.Second {
+ t.Fatalf("a detached command returns when it exits, not when its child does: %+v after %s", r, time.Since(start))
+ }
+}
+
+func TestKitArgumentsAreReadStrictly(t *testing.T) {
+ args := map[string]any{"s": "x", "n": float64(5), "f": 1.5, "b": true, "l": []any{"a", "b"}}
+ if _, err := text(args, "missing"); err == nil {
+ t.Error("a missing required string")
+ }
+ if n, _ := optWhole(args, "n", 1, 1, 3); n != 3 {
+ t.Errorf("held to most: %d", n)
+ }
+ if _, err := optWhole(args, "n", 1, 6, 9); err == nil {
+ t.Error("below least")
+ }
+ if _, err := optWhole(args, "f", 1, 0, 9); err == nil {
+ t.Error("a fraction")
+ }
+ if l, _ := optList(args, "l"); len(l) != 2 {
+ t.Errorf("list: %v", l)
+ }
+ if b, _ := optFlag(args, "b", false); !b {
+ t.Error("flag")
+ }
+ if err := plainName("name", "--all"); err == nil {
+ t.Error("an option as a name")
+ }
+}
diff --git a/modules/docker-compose/cmd/docker-compose-tools/main.go b/modules/docker-compose/cmd/docker-compose-tools/main.go
new file mode 100644
index 0000000..d283798
--- /dev/null
+++ b/modules/docker-compose/cmd/docker-compose-tools/main.go
@@ -0,0 +1,199 @@
+// The docker-compose module's tools (novox/hq research 027/02, 026/05): the compose projects on this
+// machine, their containers, logs and rendered configuration, and bringing one up, down or round
+// again by its directory. A Go bundle the node's runtime launches over stdio (ADR 0188, ADR 0193); it
+// runs as the operator account, which reaches the container runtime through the docker group.
+package main
+
+import (
+ "fmt"
+ "os"
+
+ stdio "git.novox.be/novox/mesh-sdk/go"
+)
+
+var providedBy = map[string]string{
+ "docker": "the docker module installs the container runtime; this module adds compose to it",
+}
+
+func main() {
+ if err := stdio.Serve("", tools()); err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+}
+
+// where is the argument schema every tool that acts on one project takes.
+var where = map[string]any{
+ "dir": map[string]any{"type": "string", "description": "the project's directory, absolute: where its compose file is"},
+ "project": map[string]any{"type": "string", "description": "the project's name, for a project docker already knows (instead of dir)"},
+}
+
+func with(extra map[string]any) map[string]any {
+ out := map[string]any{}
+ for k, v := range where {
+ out[k] = v
+ }
+ for k, v := range extra {
+ out[k] = v
+ }
+ return out
+}
+
+var servicesArg = map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "only these services (default all)"}
+
+func tools() []stdio.Tool {
+ return []stdio.Tool{
+ {
+ Name: "docker_compose_projects",
+ Description: "The compose projects docker knows on this machine, running or stopped: name, status, working " +
+ "directory, compose files, services, and containers running of total. (r)",
+ Input: map[string]any{},
+ Run: func(map[string]any) (any, error) { return Projects() },
+ },
+ {
+ Name: "docker_compose_ps",
+ Description: "One project's containers: service, state, health, exit code, image and published ports. (r)",
+ Input: with(nil),
+ Run: func(args map[string]any) (any, error) {
+ t, err := targetOf(args, false)
+ if err != nil {
+ return nil, err
+ }
+ return Ps(t)
+ },
+ },
+ {
+ Name: "docker_compose_logs",
+ Description: "One project's logs, the last lines of each service (default 200, at most 5000), optionally since a " +
+ "time (\"10m\", \"2026-10-04T12:00:00\"). Cut at 256 KiB. (r)",
+ Input: with(map[string]any{
+ "services": servicesArg,
+ "tail": map[string]any{"type": "integer", "description": "lines per service (default 200, at most 5000)"},
+ "since": map[string]any{"type": "string", "description": "only lines since this: a duration such as 10m or a timestamp"},
+ }),
+ Run: func(args map[string]any) (any, error) {
+ t, err := targetOf(args, false)
+ if err != nil {
+ return nil, err
+ }
+ services, err := optList(args, "services")
+ if err != nil {
+ return nil, err
+ }
+ n, err := optWhole(args, "tail", 200, 1, 5000)
+ if err != nil {
+ return nil, err
+ }
+ since, err := optText(args, "since", "")
+ if err != nil {
+ return nil, err
+ }
+ return Logs(t, services, n, since)
+ },
+ },
+ {
+ Name: "docker_compose_config",
+ Description: "A project's configuration as compose renders it: files merged, variables filled. Values of " +
+ "environment variables, build arguments and labels whose names suggest a secret are replaced with " +
+ "[redacted]. An invalid file is answered as the error compose gives. (r)",
+ Input: with(nil),
+ Run: func(args map[string]any) (any, error) {
+ t, err := targetOf(args, true)
+ if err != nil {
+ return nil, err
+ }
+ return Config(t)
+ },
+ },
+ {
+ Name: "docker_compose_up",
+ Description: "Bring a project up, detached: create and start its containers, building or pulling what is " +
+ "missing. Answers when finished, or after 18 s with a job to follow with docker_compose_job. (a)",
+ Input: with(map[string]any{
+ "services": servicesArg,
+ "build": map[string]any{"type": "boolean", "description": "build images before starting (--build)"},
+ "pull": map[string]any{"type": "string", "enum": []string{"missing", "always", "never"}, "description": "pull policy (default missing)"},
+ }),
+ Run: func(args map[string]any) (any, error) {
+ t, err := targetOf(args, true)
+ if err != nil {
+ return nil, err
+ }
+ services, err := optList(args, "services")
+ if err != nil {
+ return nil, err
+ }
+ build, err := optFlag(args, "build", false)
+ if err != nil {
+ return nil, err
+ }
+ pull, err := optText(args, "pull", "missing")
+ if err != nil {
+ return nil, err
+ }
+ return Up(t, services, build, pull)
+ },
+ },
+ {
+ Name: "docker_compose_down",
+ Description: "Stop and remove a project's containers and networks. Its volumes are kept: removing data is not " +
+ "this tool's. Answers when finished, or with a job to follow. (a)",
+ Input: with(nil),
+ Run: func(args map[string]any) (any, error) {
+ t, err := targetOf(args, false)
+ if err != nil {
+ return nil, err
+ }
+ return Down(t)
+ },
+ },
+ {
+ Name: "docker_compose_restart",
+ Description: "Restart a project's containers, or some of its services. Answers when finished, or with a job to follow. (a)",
+ Input: with(map[string]any{"services": servicesArg}),
+ Run: func(args map[string]any) (any, error) {
+ t, err := targetOf(args, false)
+ if err != nil {
+ return nil, err
+ }
+ services, err := optList(args, "services")
+ if err != nil {
+ return nil, err
+ }
+ return Restart(t, services)
+ },
+ },
+ {
+ Name: "docker_compose_pull",
+ Description: "Pull a project's images, or some services', without starting anything. Answers when finished, or with a job to follow. (a)",
+ Input: with(map[string]any{"services": servicesArg}),
+ Run: func(args map[string]any) (any, error) {
+ t, err := targetOf(args, true)
+ if err != nil {
+ return nil, err
+ }
+ services, err := optList(args, "services")
+ if err != nil {
+ return nil, err
+ }
+ return Pull(t, services)
+ },
+ },
+ {
+ Name: "docker_compose_job",
+ Description: "A long act this module started (up, down, restart, pull): running or finished, its exit status " +
+ "and the end of its output. Without job, every act this process knows, newest first. (r)",
+ Input: map[string]any{"job": map[string]any{"type": "string", "description": "the job id an act answered"}},
+ Run: func(args map[string]any) (any, error) {
+ id, err := optText(args, "job", "")
+ if err != nil {
+ return nil, err
+ }
+ if id == "" {
+ return map[string]any{"jobs": listJobs()}, nil
+ }
+ return jobByID(id)
+ },
+ },
+ }
+}
diff --git a/modules/docker-compose/cmd/docker-compose-tools/manifest_kit_test.go b/modules/docker-compose/cmd/docker-compose-tools/manifest_kit_test.go
new file mode 100644
index 0000000..3e675b4
--- /dev/null
+++ b/modules/docker-compose/cmd/docker-compose-tools/manifest_kit_test.go
@@ -0,0 +1,107 @@
+package main
+
+// manifest_kit_test.go is the same file in each workstation module: it reads the module's
+// definition so the module's own tests can hold it to what it says.
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+ "testing"
+)
+
+type manifest struct {
+ Module string `json:"module"`
+ Capabilities []string `json:"capabilities"`
+ Claims []any `json:"claims"`
+ Seats []any `json:"seats"`
+ Tools []string `json:"tools"`
+ Resources []map[string]any `json:"resources"`
+ Build struct {
+ Artifacts []map[string]any `json:"artifacts"`
+ } `json:"build"`
+}
+
+func readManifest(t *testing.T) manifest {
+ t.Helper()
+ raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ var m manifest
+ if err := json.Unmarshal(raw, &m); err != nil {
+ t.Fatalf("module.json: %v", err)
+ }
+ return m
+}
+
+func (m manifest) resource(id string) map[string]any {
+ for _, r := range m.Resources {
+ if r["id"] == id {
+ return r
+ }
+ }
+ return nil
+}
+
+// packages are the packages the module installs, sorted.
+func (m manifest) packages() []string {
+ out := []string{}
+ for _, r := range m.Resources {
+ if r["type"] == "package" && r["absent"] != true {
+ out = append(out, r["package"].(string))
+ }
+ }
+ sort.Strings(out)
+ return out
+}
+
+// services are the units the module declares, by unit name.
+func (m manifest) services() map[string]map[string]any {
+ out := map[string]map[string]any{}
+ for _, r := range m.Resources {
+ if r["type"] == "service" {
+ out[r["unit"].(string)] = r
+ }
+ }
+ return out
+}
+
+// holdsTheBundle holds the manifest to the Go bundle this directory builds: every tool registered
+// is listed and nothing else, each named _…, and the artifact builds this command.
+func holdsTheBundle(t *testing.T, m manifest, prefix string) {
+ t.Helper()
+ registered := []string{}
+ for _, tool := range tools() {
+ registered = append(registered, tool.Name)
+ if !strings.HasPrefix(tool.Name, prefix+"_") {
+ t.Errorf("tool %s is not named %s_…", tool.Name, prefix)
+ }
+ if tool.Description == "" || tool.Run == nil || tool.Input == nil {
+ t.Errorf("tool %s is not described, runnable and given an input schema", tool.Name)
+ }
+ }
+ if strings.Join(registered, ",") != strings.Join(m.Tools, ",") {
+ t.Errorf("registered %v, listed %v", registered, m.Tools)
+ }
+ if len(m.Build.Artifacts) != 1 {
+ t.Fatalf("one artifact, got %d", len(m.Build.Artifacts))
+ }
+ cwd, _ := os.Getwd()
+ binary := filepath.Base(cwd)
+ a := m.Build.Artifacts[0]
+ want := map[string]any{"kind": "bundle", "language": "go", "system": "arch", "from": "cmd/" + binary, "binary": binary}
+ for k, v := range want {
+ if a[k] != v {
+ t.Errorf("artifact %s = %v, want %v", k, a[k], v)
+ }
+ }
+ if loads, _ := a["loads"].([]any); len(loads) != 1 || loads[0] != binary {
+ t.Errorf("artifact loads %v, want [%s]", a["loads"], binary)
+ }
+ if m.Claims != nil || m.Seats != nil {
+ t.Errorf("claims %v, seats %v: this module holds no seat", m.Claims, m.Seats)
+ }
+}
diff --git a/modules/docker-compose/go.mod b/modules/docker-compose/go.mod
new file mode 100644
index 0000000..f6fd588
--- /dev/null
+++ b/modules/docker-compose/go.mod
@@ -0,0 +1,5 @@
+module docker-compose
+
+go 1.22
+
+require git.novox.be/novox/mesh-sdk/go v0.1.6
diff --git a/modules/docker-compose/go.sum b/modules/docker-compose/go.sum
new file mode 100644
index 0000000..0dd6061
--- /dev/null
+++ b/modules/docker-compose/go.sum
@@ -0,0 +1,2 @@
+git.novox.be/novox/mesh-sdk/go v0.1.6 h1:9qzdYONYbJdWcu6sxQcq9v1LI0JxcfkiKYkMUzJSkVQ=
+git.novox.be/novox/mesh-sdk/go v0.1.6/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
diff --git a/modules/docker-compose/module.json b/modules/docker-compose/module.json
new file mode 100644
index 0000000..2f576b4
--- /dev/null
+++ b/modules/docker-compose/module.json
@@ -0,0 +1,40 @@
+{
+ "module": "docker-compose",
+ "version": "1",
+ "capabilities": [
+ "package-manager"
+ ],
+ "tools": [
+ "docker_compose_projects",
+ "docker_compose_ps",
+ "docker_compose_logs",
+ "docker_compose_config",
+ "docker_compose_up",
+ "docker_compose_down",
+ "docker_compose_restart",
+ "docker_compose_pull",
+ "docker_compose_job"
+ ],
+ "resources": [
+ {
+ "id": "package",
+ "type": "package",
+ "package": "docker-compose"
+ }
+ ],
+ "build": {
+ "artifacts": [
+ {
+ "name": "tools",
+ "kind": "bundle",
+ "language": "go",
+ "system": "arch",
+ "from": "cmd/docker-compose-tools",
+ "binary": "docker-compose-tools",
+ "loads": [
+ "docker-compose-tools"
+ ]
+ }
+ ]
+ }
+}
diff --git a/modules/flatpak/README.md b/modules/flatpak/README.md
new file mode 100644
index 0000000..6b4d64f
--- /dev/null
+++ b/modules/flatpak/README.md
@@ -0,0 +1,80 @@
+# flatpak
+
+Flatpak applications on the two workstations (novox/hq research 027/02: "`snapd` and `flatpak` are
+modules, on the two workstations only"; to-be 42 phase 2 step 9).
+
+## Owns
+
+| what | where |
+|---|---|
+| flatpak | package `flatpak` (official repositories) |
+
+**The Flathub remote comes with the package.** The package ships `flathub.flatpakrepo` in
+`/usr/share/flatpak/remotes.d`, and flatpak adds every file there to the system installation as a
+remote. The module therefore declares no remote file of its own. `flatpak_remotes` checks that the
+system installation has Flathub, and says so when it does not.
+
+The installed applications and runtimes, and their data, are the operator's: found (ADR 0182).
+
+## Improves
+
+- **The laptop gains flatpak**, which only the desktop had, with Flathub.
+- **Unused runtimes become visible and removable.** On the desktop on 2026-10-04, `/var/lib/flatpak`
+ held 5.1 GB for three applications. They are Nextcloud on KDE 6.7, Warehouse on GNOME 46, and Plex on
+ Freedesktop 23.08. Beside them sat the whole Freedesktop 22.08 platform, both of its GL extensions,
+ and older codec and style extensions that nothing uses. `flatpak_unused` lists them read-only;
+ `flatpak_remove_unused` lets flatpak remove them.
+- **A duplicate remote is named.** The desktop has Flathub configured twice, once in the system
+ installation and once in the account's, which has nothing installed from it. `flatpak_remotes`
+ reports it.
+
+## Tools
+
+All answer JSON; `(r)` reads, `(a)` acts. Reads run as the operator account. An act on the system
+installation (the default) goes through `sudo -n`; one on the account's own installation does not.
+
+| tool | what |
+|---|---|
+| `flatpak_list` (r) | applications: id, name, version, branch, origin, installation, size in bytes |
+| `flatpak_runtimes` (r) | runtimes and extensions, the same way |
+| `flatpak_remotes` (r) | both installations' remotes, how many refs come from each, findings |
+| `flatpak_updates` (r) | refs with a newer commit on their remote (needs the network) |
+| `flatpak_unused` (r) | runtimes nothing needs, computed without changing anything (below), with the space each takes |
+| `flatpak_disk_usage` (r) | each installation's directory on disk, and the 50 largest refs |
+| `flatpak_install` (a) | install from a remote (default `flathub`) into the system or the account installation |
+| `flatpak_remove` (a) | uninstall, keeping the application's data unless `delete_data` |
+| `flatpak_update` (a) | update one ref, or everything in an installation |
+| `flatpak_remove_unused` (a) | `uninstall --unused`: flatpak decides |
+| `flatpak_job` (r) | a long act's state and the end of its output |
+
+**Unused, computed.** `flatpak uninstall --unused` has no dry run: it asks, and answering *yes* removes.
+So the read tool works it out from what flatpak already says. A runtime is in use when one of these
+holds:
+
+- an installed application names it as its runtime or SDK;
+- it fills an extension point declared in the metadata of something in use, matched by id
+ (subdirectories included) and accepted version;
+- it is pinned.
+
+The SDK is counted as used to err on the safe side. flatpak's own `--unused` may also remove an
+application's SDK.
+
+**Acts are jobs.** An install or update downloads hundreds of megabytes. Each act runs inside the
+tool's process for up to 15 minutes, and is waited on for 18 s. A finished act is answered, and a
+failed one as an error. One still running is answered with a job id for `flatpak_job`.
+
+## What changes when it is assigned
+
+- **laptop:** `flatpak` is installed, with Flathub as the system remote. Nothing else.
+- **desktop:** nothing on disk; the package, installed by hand, becomes the mesh's.
+
+## The one-off step for the operator (ADR 0182)
+
+On the desktop, the account's own Flathub remote duplicates the system's and has nothing installed
+from it. If it is not wanted: `flatpak remote-delete --user flathub`, once. The mesh did not make it,
+so it does not remove it.
+
+## Leaves as found
+
+The applications and runtimes, the account installation under `~/.local/share/flatpak`, application
+data under `~/.var/app`, and any remote other than the package's Flathub.
diff --git a/modules/flatpak/cmd/flatpak-tools/flatpak.go b/modules/flatpak/cmd/flatpak-tools/flatpak.go
new file mode 100644
index 0000000..1fdf87c
--- /dev/null
+++ b/modules/flatpak/cmd/flatpak-tools/flatpak.go
@@ -0,0 +1,501 @@
+package main
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "regexp"
+ "sort"
+ "strconv"
+ "strings"
+)
+
+// refs are an application or runtime id, optionally with arch and branch: org.gimp.GIMP,
+// org.freedesktop.Platform/x86_64/23.08, app/org.gimp.GIMP/x86_64/stable.
+var refs = regexp.MustCompile(`^((app|runtime)/)?[A-Za-z][A-Za-z0-9_-]*(\.[A-Za-z0-9_-]+)+(/[A-Za-z0-9_]*(/[A-Za-z0-9._-]*)?)?$`)
+
+func checkRef(r string) error {
+ if !refs.MatchString(r) {
+ return fmt.Errorf("%q is not a flatpak id or ref", r)
+ }
+ return nil
+}
+
+var remoteName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`)
+
+// Ref is one installed application or runtime.
+type Ref struct {
+ ID string `json:"id"`
+ Name string `json:"name,omitempty"`
+ Version string `json:"version,omitempty"`
+ Branch string `json:"branch"`
+ Arch string `json:"arch"`
+ Ref string `json:"ref"`
+ Origin string `json:"origin"`
+ Installation string `json:"installation"`
+ Size string `json:"size"`
+ Bytes int64 `json:"bytes"`
+}
+
+// sizeBytes reads flatpak's human size ("275.8 MB", with a no-break space): decimal units.
+func sizeBytes(s string) int64 {
+ f := strings.Fields(strings.ReplaceAll(s, " ", " "))
+ if len(f) == 0 {
+ return 0
+ }
+ n, err := strconv.ParseFloat(f[0], 64)
+ if err != nil {
+ return 0
+ }
+ unit := map[string]float64{"bytes": 1, "byte": 1, "B": 1, "kB": 1e3, "KB": 1e3, "MB": 1e6, "GB": 1e9, "TB": 1e12}
+ if len(f) > 1 {
+ if m, ok := unit[f[1]]; ok {
+ n *= m
+ }
+ }
+ return int64(n)
+}
+
+func installFlag(inst string) []string {
+ if inst == "" {
+ return nil
+ }
+ return []string{"--" + inst}
+}
+
+// listRefs runs flatpak list for apps or runtimes.
+func listRefs(kind, inst string) ([]Ref, error) {
+ args := append([]string{"list", "--" + kind, "--columns=application,name,version,branch,arch,origin,installation,size,ref"}, installFlag(inst)...)
+ r, err := call(Cmd{Name: "flatpak", Args: args})
+ if err != nil {
+ return nil, err
+ }
+ out := []Ref{}
+ for _, l := range lines(r.Stdout) {
+ f := strings.Split(l, "\t")
+ if len(f) < 9 {
+ continue
+ }
+ size := strings.ReplaceAll(f[7], " ", " ")
+ out = append(out, Ref{ID: f[0], Name: f[1], Version: f[2], Branch: f[3], Arch: f[4], Origin: f[5], Installation: f[6], Size: size, Bytes: sizeBytes(size), Ref: f[8]})
+ }
+ return out, nil
+}
+
+// ListAnswer is what flatpak_list and flatpak_runtimes answer.
+type ListAnswer struct {
+ Count int `json:"count"`
+ Bytes int64 `json:"bytes"`
+ Refs []Ref `json:"refs"`
+}
+
+// List answers the applications or runtimes.
+func List(kind, inst string) (ListAnswer, error) {
+ got, err := listRefs(kind, inst)
+ if err != nil {
+ return ListAnswer{}, err
+ }
+ out := ListAnswer{Count: len(got), Refs: got}
+ for _, r := range got {
+ out.Bytes += r.Bytes
+ }
+ return out, nil
+}
+
+// Remote is one remote of one installation.
+type Remote struct {
+ Name string `json:"name"`
+ URL string `json:"url"`
+ Installation string `json:"installation"`
+ Priority string `json:"priority"`
+ Disabled bool `json:"disabled"`
+ Installed int `json:"installed_from"`
+}
+
+// RemotesAnswer is what flatpak_remotes answers.
+type RemotesAnswer struct {
+ Remotes []Remote `json:"remotes"`
+ Flathub bool `json:"flathub_system"`
+ Findings []string `json:"findings"`
+}
+
+// Remotes answers both installations' remotes and what is wrong with them.
+func Remotes() (RemotesAnswer, error) {
+ r, err := call(Cmd{Name: "flatpak", Args: []string{"remotes", "--show-disabled", "--columns=name,url,options,priority"}})
+ if err != nil {
+ return RemotesAnswer{}, err
+ }
+ all, err := listRefs("app", "")
+ if err != nil {
+ return RemotesAnswer{}, err
+ }
+ rt, err := listRefs("runtime", "")
+ if err != nil {
+ return RemotesAnswer{}, err
+ }
+ from := map[string]int{}
+ for _, x := range append(all, rt...) {
+ from[x.Origin+"\x00"+x.Installation]++
+ }
+ out := RemotesAnswer{Remotes: []Remote{}, Findings: []string{}}
+ byURL := map[string][]string{}
+ for _, l := range lines(r.Stdout) {
+ f := strings.Split(l, "\t")
+ if len(f) < 4 {
+ continue
+ }
+ inst := "system"
+ disabled := false
+ for _, o := range strings.Split(f[2], ",") {
+ switch strings.TrimSpace(o) {
+ case "user":
+ inst = "user"
+ case "disabled":
+ disabled = true
+ }
+ if strings.HasPrefix(strings.TrimSpace(o), "system") {
+ inst = strings.TrimSpace(o)
+ }
+ }
+ rem := Remote{Name: f[0], URL: f[1], Installation: inst, Priority: f[3], Disabled: disabled, Installed: from[f[0]+"\x00"+inst]}
+ out.Remotes = append(out.Remotes, rem)
+ byURL[strings.TrimRight(rem.URL, "/")] = append(byURL[strings.TrimRight(rem.URL, "/")], rem.Name+" ("+inst+")")
+ if inst == "system" && rem.Name == "flathub" && !disabled {
+ out.Flathub = true
+ }
+ if rem.Installed == 0 {
+ out.Findings = append(out.Findings, fmt.Sprintf("remote %s in the %s installation has nothing installed from it", rem.Name, inst))
+ }
+ }
+ if !out.Flathub {
+ out.Findings = append(out.Findings, "the system installation has no enabled flathub remote: the flatpak package ships one in /usr/share/flatpak/remotes.d, so it was removed or disabled by hand")
+ }
+ for url, names := range byURL {
+ if len(names) > 1 {
+ sort.Strings(names)
+ out.Findings = append(out.Findings, fmt.Sprintf("%s is configured %d times: %s", url, len(names), strings.Join(names, ", ")))
+ }
+ }
+ sort.Strings(out.Findings)
+ return out, nil
+}
+
+// Updates answers what an update would change.
+func Updates(inst string) (map[string]any, error) {
+ args := append([]string{"remote-ls", "--updates", "--columns=application,branch,version,origin,ref"}, installFlag(inst)...)
+ r, err := call(Cmd{Name: "flatpak", Args: args})
+ if err != nil {
+ return nil, err
+ }
+ out := []map[string]string{}
+ for _, l := range lines(r.Stdout) {
+ f := strings.Split(l, "\t")
+ if len(f) >= 5 {
+ out = append(out, map[string]string{"id": f[0], "branch": f[1], "version": f[2], "origin": f[3], "ref": f[4]})
+ }
+ }
+ return map[string]any{"count": len(out), "updates": out}, nil
+}
+
+// Extension is one extension point a ref's metadata declares.
+type Extension struct {
+ ID string
+ Versions []string
+ Subdirs bool
+}
+
+// extensionsOf reads [Extension …] groups from a ref's metadata, with the versions each accepts:
+// versions, else version, else the branch of the ref declaring it.
+func extensionsOf(metadata, branch string) []Extension {
+ out := []Extension{}
+ var cur *Extension
+ flush := func() {
+ if cur != nil {
+ if len(cur.Versions) == 0 {
+ cur.Versions = []string{branch}
+ }
+ out = append(out, *cur)
+ }
+ cur = nil
+ }
+ var version, versions string
+ for _, l := range strings.Split(metadata, "\n") {
+ l = strings.TrimSpace(l)
+ if strings.HasPrefix(l, "[") {
+ if cur != nil {
+ cur.Versions = pickVersions(versions, version)
+ }
+ flush()
+ version, versions = "", ""
+ if strings.HasPrefix(l, "[Extension ") && strings.HasSuffix(l, "]") {
+ cur = &Extension{ID: strings.TrimSuffix(strings.TrimPrefix(l, "[Extension "), "]")}
+ }
+ continue
+ }
+ if cur == nil {
+ continue
+ }
+ k, v, found := strings.Cut(l, "=")
+ if !found {
+ continue
+ }
+ switch strings.TrimSpace(k) {
+ case "version":
+ version = strings.TrimSpace(v)
+ case "versions":
+ versions = strings.TrimSpace(v)
+ case "subdirectories":
+ cur.Subdirs = strings.TrimSpace(v) == "true"
+ }
+ }
+ if cur != nil {
+ cur.Versions = pickVersions(versions, version)
+ }
+ flush()
+ return out
+}
+
+func pickVersions(versions, version string) []string {
+ if versions != "" {
+ out := []string{}
+ for _, v := range strings.Split(versions, ";") {
+ if v = strings.TrimSpace(v); v != "" {
+ out = append(out, v)
+ }
+ }
+ return out
+ }
+ if version != "" {
+ return []string{version}
+ }
+ return nil
+}
+
+// fills says whether a runtime fills an extension point.
+func (e Extension) fills(r Ref) bool {
+ if !(r.ID == e.ID || (e.Subdirs && strings.HasPrefix(r.ID, e.ID+"."))) {
+ return false
+ }
+ for _, v := range e.Versions {
+ if v == r.Branch {
+ return true
+ }
+ }
+ return false
+}
+
+// UnusedAnswer is what flatpak_unused answers.
+type UnusedAnswer struct {
+ Count int `json:"count"`
+ Bytes int64 `json:"bytes"`
+ Unused []Ref `json:"unused"`
+ Pinned []string `json:"pinned"`
+ Note string `json:"note"`
+}
+
+func info(inst string, extra ...string) (string, error) {
+ r, err := call(Cmd{Name: "flatpak", Args: append(append([]string{"info"}, installFlag(inst)...), extra...)})
+ return strings.TrimSpace(r.Stdout), err
+}
+
+// Unused computes what no installed application needs.
+func Unused() (UnusedAnswer, error) {
+ apps, err := listRefs("app", "")
+ if err != nil {
+ return UnusedAnswer{}, err
+ }
+ runtimes, err := listRefs("runtime", "")
+ if err != nil {
+ return UnusedAnswer{}, err
+ }
+ key := func(inst, ref string) string {
+ return inst + "\x00" + strings.TrimPrefix(strings.TrimPrefix(ref, "runtime/"), "app/")
+ }
+ installed := map[string]Ref{}
+ for _, r := range runtimes {
+ installed[key(r.Installation, r.Ref)] = r
+ }
+ used := map[string]bool{}
+ type item struct {
+ inst, ref, branch string
+ }
+ queue := []item{}
+ // A user installation's application may use a system runtime; a system one only system runtimes.
+ mark := func(inst, ref string) {
+ for _, where := range []string{inst, "system"} {
+ k := key(where, ref)
+ if r, ok := installed[k]; ok && !used[k] {
+ used[k] = true
+ queue = append(queue, item{where, r.Ref, r.Branch})
+ return
+ }
+ }
+ }
+ for _, a := range apps {
+ for _, flag := range []string{"--show-runtime", "--show-sdk"} {
+ ref, err := info(a.Installation, flag, a.Ref)
+ if err != nil {
+ return UnusedAnswer{}, err
+ }
+ if ref != "" {
+ mark(a.Installation, ref)
+ }
+ }
+ queue = append(queue, item{a.Installation, a.Ref, a.Branch})
+ }
+ pinned := []string{}
+ for _, inst := range []string{"system", "user"} {
+ r := run(Cmd{Name: "flatpak", Args: []string{"pin", "--" + inst}})
+ if r.Status != 0 || r.Error != "" {
+ continue
+ }
+ for _, l := range lines(r.Stdout) {
+ p := strings.TrimSpace(l)
+ if strings.HasPrefix(p, "runtime/") {
+ pinned = append(pinned, p)
+ mark(inst, p)
+ }
+ }
+ }
+ for len(queue) > 0 {
+ it := queue[0]
+ queue = queue[1:]
+ meta, err := info(it.inst, "--show-metadata", it.ref)
+ if err != nil {
+ return UnusedAnswer{}, err
+ }
+ for _, e := range extensionsOf(meta, it.branch) {
+ for k, r := range installed {
+ if !used[k] && e.fills(r) && (r.Installation == it.inst || r.Installation == "system") {
+ used[k] = true
+ queue = append(queue, item{r.Installation, r.Ref, r.Branch})
+ }
+ }
+ }
+ }
+ out := UnusedAnswer{Unused: []Ref{}, Pinned: pinned,
+ Note: "Computed without changing anything. flatpak_remove_unused lets flatpak decide, which may differ in detail (a locale or debug extension listed here by nothing)."}
+ for k, r := range installed {
+ if !used[k] {
+ out.Unused = append(out.Unused, r)
+ out.Bytes += r.Bytes
+ }
+ }
+ sort.Slice(out.Unused, func(i, k int) bool { return out.Unused[i].Ref < out.Unused[k].Ref })
+ out.Count = len(out.Unused)
+ return out, nil
+}
+
+// Where each installation lives.
+var installDirs = map[string]string{"system": "/var/lib/flatpak", "user": ".local/share/flatpak"}
+
+// DiskAnswer is what flatpak_disk_usage answers.
+type DiskAnswer struct {
+ Installations map[string]int64 `json:"installation_bytes"`
+ Largest []Ref `json:"largest"`
+ Note string `json:"note,omitempty"`
+}
+
+// DiskUsage measures each installation's directory and lists the largest refs.
+func DiskUsage() (DiskAnswer, error) {
+ out := DiskAnswer{Installations: map[string]int64{}, Largest: []Ref{}}
+ for inst, dir := range installDirs {
+ if !filepath.IsAbs(dir) {
+ dir = filepath.Join(accountHome(), dir)
+ }
+ // du exits 1 when a file is unreadable and still prints the total of what it could read.
+ r := run(Cmd{Name: "du", Args: []string{"-sb", dir}})
+ if r.Error != "" {
+ return DiskAnswer{}, failure(Cmd{Name: "du", Args: []string{"-sb", dir}}, r)
+ }
+ f := strings.Fields(r.Stdout)
+ if len(f) == 0 {
+ if strings.Contains(r.Stderr, "No such file") {
+ out.Installations[inst] = 0
+ continue
+ }
+ return DiskAnswer{}, failure(Cmd{Name: "du", Args: []string{"-sb", dir}}, r)
+ }
+ n, _ := strconv.ParseInt(f[0], 10, 64)
+ out.Installations[inst] = n
+ if r.Status != 0 {
+ out.Note = "some files were unreadable to the account, so a total is a lower bound"
+ }
+ }
+ apps, err := listRefs("app", "")
+ if err != nil {
+ return DiskAnswer{}, err
+ }
+ rts, err := listRefs("runtime", "")
+ if err != nil {
+ return DiskAnswer{}, err
+ }
+ all := append(apps, rts...)
+ sort.Slice(all, func(i, k int) bool { return all[i].Bytes > all[k].Bytes })
+ if len(all) > 50 {
+ all = all[:50]
+ }
+ out.Largest = all
+ return out, nil
+}
+
+func accountHome() string {
+ return homeFrom(getenv)
+}
+
+// ActAnswer is what an act answers.
+type ActAnswer struct {
+ Act string `json:"act"`
+ Ref string `json:"ref,omitempty"`
+ Installation string `json:"installation"`
+ Job Job `json:"job"`
+}
+
+// act runs flatpak as a job; the system installation needs root, the account's does not.
+func act(verb, ref, inst string, args ...string) (ActAnswer, error) {
+ c := Cmd{Name: "flatpak", Args: append([]string{verb, "--" + inst, "--noninteractive", "-y"}, args...), Root: inst == "system"}
+ j, err := actAsJob(c)
+ if err != nil {
+ return ActAnswer{}, err
+ }
+ return ActAnswer{Act: verb, Ref: ref, Installation: inst, Job: j}, nil
+}
+
+// Install installs a ref from a remote.
+func Install(ref, remote, inst string) (ActAnswer, error) {
+ if !remoteName.MatchString(remote) {
+ return ActAnswer{}, fmt.Errorf("%q is not a remote's name", remote)
+ }
+ return act("install", ref, inst, remote, ref)
+}
+
+// Remove uninstalls a ref.
+func Remove(ref, inst string, deleteData bool) (ActAnswer, error) {
+ if deleteData {
+ return act("uninstall", ref, inst, "--delete-data", ref)
+ }
+ return act("uninstall", ref, inst, ref)
+}
+
+// Update updates one ref, or everything.
+func Update(ref, inst string) (ActAnswer, error) {
+ if ref == "" {
+ return act("update", "", inst)
+ }
+ return act("update", ref, inst, ref)
+}
+
+// RemoveUnused lets flatpak uninstall what it finds unused.
+func RemoveUnused(inst string) (ActAnswer, error) {
+ return act("uninstall", "", inst, "--unused")
+}
+
+// getenv and homeFrom read the account's home the way the runtime gives it.
+var getenv = func(k string) string { return strings.TrimSpace(os.Getenv(k)) }
+
+func homeFrom(env func(string) string) string {
+ if h := env("MESH_OPERATOR_HOME"); h != "" {
+ return h
+ }
+ return env("HOME")
+}
diff --git a/modules/flatpak/cmd/flatpak-tools/flatpak_test.go b/modules/flatpak/cmd/flatpak-tools/flatpak_test.go
new file mode 100644
index 0000000..22e7bf2
--- /dev/null
+++ b/modules/flatpak/cmd/flatpak-tools/flatpak_test.go
@@ -0,0 +1,249 @@
+package main
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestTheManifestIsFlatpaksPackageAndFlathubComesWithIt(t *testing.T) {
+ m := readManifest(t)
+ holdsTheBundle(t, m, "flatpak")
+ if got := strings.Join(m.packages(), ","); got != "flatpak" {
+ t.Errorf("packages %s", got)
+ }
+ // The package ships flathub in /usr/share/flatpak/remotes.d: the module declares no remote file.
+ if len(m.Resources) != 1 {
+ t.Errorf("one resource: %v", m.Resources)
+ }
+}
+
+const nb = " "
+
+// The desktop's installation on 2026-10-04, as flatpak list prints it.
+var apps = "com.nextcloud.desktopclient.nextcloud\tNextcloud Desktop\t3.14\tstable\tx86_64\tflathub\tsystem\t275.8" + nb + "MB\tcom.nextcloud.desktopclient.nextcloud/x86_64/stable\n" +
+ "tv.plex.PlexDesktop\tPlex\t1.9\tstable\tx86_64\tflathub\tsystem\t368.0" + nb + "MB\ttv.plex.PlexDesktop/x86_64/stable\n"
+
+var runtimes = strings.Join([]string{
+ "org.freedesktop.Platform\tFreedesktop Platform\t22.08.1\t22.08\tx86_64\tflathub\tsystem\t576.7" + nb + "MB\torg.freedesktop.Platform/x86_64/22.08",
+ "org.freedesktop.Platform\tFreedesktop Platform\t23.08.1\t23.08\tx86_64\tflathub\tsystem\t598.3" + nb + "MB\torg.freedesktop.Platform/x86_64/23.08",
+ "org.freedesktop.Platform.GL.default\tMesa\t\t22.08\tx86_64\tflathub\tsystem\t442.1" + nb + "MB\torg.freedesktop.Platform.GL.default/x86_64/22.08",
+ "org.freedesktop.Platform.GL.default\tMesa\t\t23.08\tx86_64\tflathub\tsystem\t533.8" + nb + "MB\torg.freedesktop.Platform.GL.default/x86_64/23.08",
+ "org.freedesktop.Platform.GL.default\tMesa\t\t23.08-extra\tx86_64\tflathub\tsystem\t533.8" + nb + "MB\torg.freedesktop.Platform.GL.default/x86_64/23.08-extra",
+ "org.freedesktop.Platform.openh264\topenh264\t\t2.2.0\tx86_64\tflathub\tsystem\t790.0" + nb + "kB\torg.freedesktop.Platform.openh264/x86_64/2.2.0",
+ "org.freedesktop.Sdk\tSdk\t\t23.08\tx86_64\tflathub\tsystem\t1.6" + nb + "GB\torg.freedesktop.Sdk/x86_64/23.08",
+ "org.kde.Platform\tKDE\t\t6.7\tx86_64\tflathub\tsystem\t931.4" + nb + "MB\torg.kde.Platform/x86_64/6.7",
+ "org.kde.KStyle.Adwaita\tAdwaita\t\t5.15-21.08\tx86_64\tflathub\tsystem\t16.3" + nb + "MB\torg.kde.KStyle.Adwaita/x86_64/5.15-21.08",
+ "org.kde.KStyle.Adwaita\tAdwaita\t\t6.7\tx86_64\tflathub\tsystem\t20.6" + nb + "MB\torg.kde.KStyle.Adwaita/x86_64/6.7",
+}, "\n") + "\n"
+
+const platformMeta = `[Runtime]
+name=org.freedesktop.Platform
+
+[Extension org.freedesktop.Platform.GL]
+versions = 23.08;23.08-extra;1.4
+version = 1.4
+directory = lib/x86_64-linux-gnu/GL
+subdirectories = true
+
+[Extension org.freedesktop.Platform.openh264]
+directory = lib/openh264
+version = 2.2.0
+
+[Extension org.freedesktop.Platform.Timezones]
+directory = share/zoneinfo
+`
+
+const kdeMeta = `[Runtime]
+name=org.kde.Platform
+
+[Extension org.kde.KStyle]
+directory = lib/plugins/styles
+subdirectories = true
+version = 6.7
+
+[Extension org.freedesktop.Platform.GL]
+versions = 23.08;1.4
+subdirectories = true
+`
+
+func theDesktop(t *testing.T) *fake {
+ return using(t, func(line string, c Cmd) Result {
+ switch {
+ case strings.HasPrefix(line, "flatpak list --app"):
+ return ok(apps)
+ case strings.HasPrefix(line, "flatpak list --runtime"):
+ return ok(runtimes)
+ case strings.Contains(line, "--show-runtime com.nextcloud"):
+ return ok("org.kde.Platform/x86_64/6.7\n")
+ case strings.Contains(line, "--show-runtime tv.plex"):
+ return ok("org.freedesktop.Platform/x86_64/23.08\n")
+ case strings.Contains(line, "--show-sdk tv.plex"):
+ return ok("org.freedesktop.Sdk/x86_64/23.08\n")
+ case strings.Contains(line, "--show-sdk"):
+ return ok("org.kde.Sdk/x86_64/6.7\n")
+ case strings.Contains(line, "--show-metadata org.freedesktop.Platform/x86_64/23.08"):
+ return ok(platformMeta)
+ case strings.Contains(line, "--show-metadata org.kde.Platform"):
+ return ok(kdeMeta)
+ case strings.Contains(line, "--show-metadata"):
+ return ok("[Application]\nname=x\n")
+ case strings.HasPrefix(line, "flatpak pin"):
+ return ok("")
+ case strings.HasPrefix(line, "flatpak remotes"):
+ return ok("flathub\thttps://dl.flathub.org/repo/\tsystem\t1\nflathub\thttps://dl.flathub.org/repo/\tuser\t1\n")
+ }
+ return Result{Status: 9, Stderr: "unexpected " + line}
+ })
+}
+
+func TestUnusedIsWhatNoApplicationNeedsDirectlyOrThroughAnExtensionPoint(t *testing.T) {
+ f := theDesktop(t)
+ got, err := Unused()
+ if err != nil {
+ t.Fatal(err)
+ }
+ names := []string{}
+ for _, r := range got.Unused {
+ names = append(names, r.Ref)
+ }
+ want := "org.freedesktop.Platform.GL.default/x86_64/22.08,org.freedesktop.Platform/x86_64/22.08,org.kde.KStyle.Adwaita/x86_64/5.15-21.08"
+ if strings.Join(names, ",") != want {
+ t.Errorf("unused\n%s\nwant\n%s", strings.Join(names, ","), want)
+ }
+ if got.Bytes != 576700000+442100000+16300000 {
+ t.Errorf("bytes %d", got.Bytes)
+ }
+ for _, l := range f.lines() {
+ if strings.Contains(l, "uninstall") || strings.HasPrefix(l, "sudo") {
+ t.Errorf("unused only reads: %s", l)
+ }
+ }
+}
+
+func TestAnExtensionPointAcceptsItsVersionsOrTheDeclaringBranch(t *testing.T) {
+ ext := extensionsOf(platformMeta, "23.08")
+ if len(ext) != 3 {
+ t.Fatalf("%+v", ext)
+ }
+ gl, h264, tz := ext[0], ext[1], ext[2]
+ if !gl.Subdirs || strings.Join(gl.Versions, ";") != "23.08;23.08-extra;1.4" {
+ t.Errorf("versions win over version: %+v", gl)
+ }
+ if h264.Subdirs || strings.Join(h264.Versions, ";") != "2.2.0" {
+ t.Errorf("%+v", h264)
+ }
+ if strings.Join(tz.Versions, ";") != "23.08" {
+ t.Errorf("no version is the declaring branch: %+v", tz)
+ }
+ if !gl.fills(Ref{ID: "org.freedesktop.Platform.GL.default", Branch: "23.08-extra"}) || gl.fills(Ref{ID: "org.freedesktop.Platform.GL.default", Branch: "22.08"}) {
+ t.Error("a subdirectory extension, by branch")
+ }
+ if h264.fills(Ref{ID: "org.freedesktop.Platform.openh264.x", Branch: "2.2.0"}) {
+ t.Error("without subdirectories only the id itself")
+ }
+}
+
+func TestRemotesFindTheDuplicateAndTheEmptyOne(t *testing.T) {
+ theDesktop(t)
+ got, err := Remotes()
+ if err != nil || len(got.Remotes) != 2 || !got.Flathub {
+ t.Fatalf("%+v %v", got, err)
+ }
+ if got.Remotes[0].Installed != 12 || got.Remotes[1].Installed != 0 {
+ t.Errorf("%+v", got.Remotes)
+ }
+ all := strings.Join(got.Findings, ";")
+ if !strings.Contains(all, "flathub in the user installation has nothing installed") || !strings.Contains(all, "configured 2 times") {
+ t.Errorf("%s", all)
+ }
+}
+
+func TestListReadsSizesAsBytes(t *testing.T) {
+ theDesktop(t)
+ got, err := List("app", "")
+ if err != nil || got.Count != 2 || got.Refs[0].Bytes != 275800000 || got.Refs[0].Size != "275.8 MB" || got.Bytes != 643800000 {
+ t.Fatalf("%+v %v", got, err)
+ }
+ for in, want := range map[string]int64{"1.6" + nb + "GB": 1600000000, "790.0 kB": 790000, "12 bytes": 12, "": 0} {
+ if b := sizeBytes(in); b != want {
+ t.Errorf("%q: %d", in, b)
+ }
+ }
+}
+
+func TestActsOnTheSystemInstallationEscalateAndTheAccountsDoNot(t *testing.T) {
+ f := using(t, func(string, Cmd) Result { return ok("done") })
+ if got, err := Install("org.gimp.GIMP", "flathub", "system"); err != nil || got.Job.Running {
+ t.Fatalf("%+v %v", got, err)
+ }
+ if _, err := Install("org.gimp.GIMP", "flathub", "user"); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := Remove("org.gimp.GIMP", "system", true); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := Update("", "user"); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := RemoveUnused("system"); err != nil {
+ t.Fatal(err)
+ }
+ want := []string{
+ "sudo -n flatpak install --system --noninteractive -y flathub org.gimp.GIMP",
+ "flatpak install --user --noninteractive -y flathub org.gimp.GIMP",
+ "sudo -n flatpak uninstall --system --noninteractive -y --delete-data org.gimp.GIMP",
+ "flatpak update --user --noninteractive -y",
+ "sudo -n flatpak uninstall --system --noninteractive -y --unused",
+ }
+ if got := strings.Join(f.lines(), "\n"); got != strings.Join(want, "\n") {
+ t.Errorf("asked\n%s", got)
+ }
+ if _, err := Install("org.gimp.GIMP", "--from=x", "system"); err == nil {
+ t.Error("an option as a remote")
+ }
+ for _, bad := range []string{"--assumeyes", "gimp", "org.gimp.GIMP; rm", "org/../x"} {
+ if checkRef(bad) == nil {
+ t.Errorf("%q accepted as a ref", bad)
+ }
+ }
+ for _, good := range []string{"org.gimp.GIMP", "org.freedesktop.Platform/x86_64/23.08", "app/org.gimp.GIMP/x86_64/stable", "org.freedesktop.Platform.GL.default//23.08-extra"} {
+ if err := checkRef(good); err != nil {
+ t.Errorf("%v", err)
+ }
+ }
+}
+
+func TestAFailedInstallIsAnErrorAndAMissingFlatpakIsSaid(t *testing.T) {
+ using(t, func(string, Cmd) Result {
+ return Result{Status: 1, Stderr: "error: Nothing matches org.nope.Nope in remote flathub"}
+ })
+ if _, err := Install("org.nope.Nope", "flathub", "user"); err == nil || !strings.Contains(err.Error(), "Nothing matches") {
+ t.Fatalf("%v", err)
+ }
+ using(t, func(string, Cmd) Result { return Result{Status: 127, Error: "not-found"} })
+ if _, err := List("app", ""); err == nil || !strings.Contains(err.Error(), "not installed") {
+ t.Fatalf("%v", err)
+ }
+}
+
+func TestDiskUsageReadsEachInstallationAndToleratesAnUnreadableFile(t *testing.T) {
+ t.Setenv("MESH_OPERATOR_HOME", "/home/op")
+ using(t, func(line string, c Cmd) Result {
+ switch {
+ case line == "du -sb /var/lib/flatpak":
+ return Result{Status: 1, Stdout: "5094728394\t/var/lib/flatpak\n", Stderr: "du: cannot read directory 'x': Permission denied"}
+ case line == "du -sb /home/op/.local/share/flatpak":
+ return Result{Status: 1, Stderr: "du: cannot access '/home/op/.local/share/flatpak': No such file or directory"}
+ case strings.Contains(line, "--app"):
+ return ok(apps)
+ }
+ return ok(runtimes)
+ })
+ got, err := DiskUsage()
+ if err != nil || got.Installations["system"] != 5094728394 || got.Installations["user"] != 0 || got.Note == "" {
+ t.Fatalf("%+v %v", got, err)
+ }
+ if got.Largest[0].ID != "org.freedesktop.Sdk" {
+ t.Errorf("largest first: %+v", got.Largest[0])
+ }
+}
diff --git a/modules/flatpak/cmd/flatpak-tools/jobs.go b/modules/flatpak/cmd/flatpak-tools/jobs.go
new file mode 100644
index 0000000..3885479
--- /dev/null
+++ b/modules/flatpak/cmd/flatpak-tools/jobs.go
@@ -0,0 +1,151 @@
+package main
+
+// jobs.go is the same file in the bundles whose acts can outlast one call (flatpak, docker-compose):
+// an install or an `up` that pulls images takes minutes, and the runtime gives a call 30 s. Such an
+// act is started as a job inside this process, waited on for a while, and answered either finished
+// or with the job's id for the module's `_job` tool to follow. A job ends with this process: if the
+// runtime restarts the bundle, a running job is cut off, and its id is then unknown.
+
+import (
+ "fmt"
+ "sort"
+ "strings"
+ "sync"
+ "time"
+)
+
+// JobLimit is the longest a job may run; JobWait how long an act waits before answering a job id.
+const (
+ JobLimit = 15 * time.Minute
+ JobWait = 18 * time.Second
+ keptJobs = 50
+)
+
+// Job is one long act, as its tool answers it.
+type Job struct {
+ ID string `json:"job"`
+ Command string `json:"command"`
+ Started time.Time `json:"started"`
+ Finished *time.Time `json:"finished,omitempty"`
+ Running bool `json:"running"`
+ Status *int `json:"status,omitempty"`
+ Error string `json:"error,omitempty"`
+ Output string `json:"output,omitempty"`
+ Truncated bool `json:"truncated,omitempty"`
+ done chan struct{}
+}
+
+type jobBook struct {
+ mu sync.Mutex
+ seq int
+ jobs map[string]*Job
+}
+
+var jobs = &jobBook{jobs: map[string]*Job{}}
+
+// startJob runs c in the background, held to JobLimit.
+func startJob(c Cmd) *Job {
+ c.Timeout = JobLimit
+ name, args := argv(c)
+ jobs.mu.Lock()
+ jobs.seq++
+ j := &Job{ID: fmt.Sprintf("%d-%d", time.Now().Unix(), jobs.seq), Command: strings.TrimSpace(name + " " + strings.Join(args, " ")),
+ Started: time.Now().UTC(), Running: true, done: make(chan struct{})}
+ jobs.jobs[j.ID] = j
+ jobs.forgetOldest()
+ jobs.mu.Unlock()
+ go func() {
+ r := run(c)
+ var err error
+ if r.Status != 0 || r.Error != "" {
+ err = failure(c, r)
+ }
+ jobs.mu.Lock()
+ now := time.Now().UTC()
+ j.Finished, j.Running = &now, false
+ status := r.Status
+ j.Status = &status
+ if err != nil {
+ j.Error = err.Error()
+ }
+ j.Output = tail(strings.TrimSpace(r.Stdout+"\n"+r.Stderr), 16<<10)
+ j.Truncated = r.Truncated || len(r.Stdout)+len(r.Stderr) > 16<<10
+ jobs.mu.Unlock()
+ close(j.done)
+ }()
+ return j
+}
+
+// forgetOldest keeps the book bounded; finished jobs go first. Called with the lock held.
+func (b *jobBook) forgetOldest() {
+ if len(b.jobs) <= keptJobs {
+ return
+ }
+ all := make([]*Job, 0, len(b.jobs))
+ for _, j := range b.jobs {
+ all = append(all, j)
+ }
+ sort.Slice(all, func(i, k int) bool { return all[i].Started.Before(all[k].Started) })
+ for _, j := range all {
+ if len(b.jobs) <= keptJobs {
+ return
+ }
+ if !j.Running {
+ delete(b.jobs, j.ID)
+ }
+ }
+}
+
+// awaitJob waits up to d for a job to finish and answers a copy of it as it then stands.
+func awaitJob(j *Job, d time.Duration) Job {
+ select {
+ case <-j.done:
+ case <-time.After(d):
+ }
+ return snapshot(j)
+}
+
+func snapshot(j *Job) Job {
+ jobs.mu.Lock()
+ defer jobs.mu.Unlock()
+ c := *j
+ c.done = nil
+ return c
+}
+
+// jobByID answers a job by its id, or says it is not known to this process.
+func jobByID(id string) (Job, error) {
+ jobs.mu.Lock()
+ j, ok := jobs.jobs[id]
+ jobs.mu.Unlock()
+ if !ok {
+ return Job{}, fmt.Errorf("no job %s in this process: it was never started here, was forgotten after %d newer ones, or the bundle has restarted since", id, keptJobs)
+ }
+ return snapshot(j), nil
+}
+
+// listJobs answers every job this process knows, newest first.
+func listJobs() []Job {
+ jobs.mu.Lock()
+ all := make([]*Job, 0, len(jobs.jobs))
+ for _, j := range jobs.jobs {
+ all = append(all, j)
+ }
+ jobs.mu.Unlock()
+ sort.Slice(all, func(i, k int) bool { return all[i].Started.After(all[k].Started) })
+ out := make([]Job, 0, len(all))
+ for _, j := range all {
+ out = append(out, snapshot(j))
+ }
+ return out
+}
+
+// actAsJob starts c and answers the job once it finishes or JobWait passes, whichever is first.
+// A finished job that failed is answered as an error, so a failed act is never read as success.
+func actAsJob(c Cmd) (Job, error) {
+ j := awaitJob(startJob(c), JobWait)
+ if !j.Running && j.Error != "" {
+ return j, fmt.Errorf("%s (job %s)", j.Error, j.ID)
+ }
+ return j, nil
+}
diff --git a/modules/flatpak/cmd/flatpak-tools/jobs_test.go b/modules/flatpak/cmd/flatpak-tools/jobs_test.go
new file mode 100644
index 0000000..46e1725
--- /dev/null
+++ b/modules/flatpak/cmd/flatpak-tools/jobs_test.go
@@ -0,0 +1,51 @@
+package main
+
+import (
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestJobsAFastActIsAnsweredFinishedAndAFailedOneAsAnError(t *testing.T) {
+ using(t, func(line string, c Cmd) Result {
+ if c.Timeout != JobLimit {
+ t.Errorf("a job is held to JobLimit, not %s", c.Timeout)
+ }
+ if strings.Contains(line, "bad") {
+ return Result{Status: 2, Stderr: "it broke"}
+ }
+ return ok("done")
+ })
+ j, err := actAsJob(Cmd{Name: "good"})
+ if err != nil || j.Running || j.Status == nil || *j.Status != 0 || j.Output != "done" {
+ t.Fatalf("%+v %v", j, err)
+ }
+ if _, err := actAsJob(Cmd{Name: "bad"}); err == nil || !strings.Contains(err.Error(), "it broke") {
+ t.Fatalf("a failed job: %v", err)
+ }
+ got, err := jobByID(j.ID)
+ if err != nil || got.ID != j.ID {
+ t.Fatalf("by id: %+v %v", got, err)
+ }
+ if _, err := jobByID("nope"); err == nil {
+ t.Fatal("an unknown job")
+ }
+ if len(listJobs()) < 2 {
+ t.Fatal("listed")
+ }
+}
+
+func TestJobsASlowActIsAnsweredRunningWithItsID(t *testing.T) {
+ release := make(chan struct{})
+ using(t, func(line string, c Cmd) Result { <-release; return ok("") })
+ j := awaitJob(startJob(Cmd{Name: "slow"}), 50*time.Millisecond)
+ if !j.Running || j.ID == "" {
+ t.Fatalf("%+v", j)
+ }
+ close(release)
+ time.Sleep(50 * time.Millisecond)
+ got, _ := jobByID(j.ID)
+ if got.Running {
+ t.Fatalf("finished afterwards: %+v", got)
+ }
+}
diff --git a/modules/flatpak/cmd/flatpak-tools/kit.go b/modules/flatpak/cmd/flatpak-tools/kit.go
new file mode 100644
index 0000000..adc5aac
--- /dev/null
+++ b/modules/flatpak/cmd/flatpak-tools/kit.go
@@ -0,0 +1,352 @@
+package main
+
+// kit.go is the same file in each of the workstations' tool bundles (fonts, docker-compose, snapd,
+// flatpak, cups, bluetooth, xclip, dmenu): how a tool runs a command, escalates, bounds what it
+// keeps, and names a failure. A module is built from its own directory, so the file is copied rather
+// than shared; a change to one copy is made to all eight.
+//
+// The rules it holds (novox/hq research 026/05, to-be 38 WP4):
+// - the node's tool runtime runs as the operator account, not root (ADR 0175 §4); a command that
+// needs root goes through `sudo -n`, never a prompt, and a refusal is named as such;
+// - one command gets 20 s, below the runtime's 30 s call limit, and is ended with everything it
+// started when it takes longer;
+// - each stream is kept to 256 KiB, and the answer says when it was cut;
+// - a failure is an error with what went wrong in it, never an empty answer.
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "os/exec"
+ "strings"
+ "syscall"
+ "time"
+)
+
+// Bounds every command is held to.
+const (
+ CallTimeout = 20 * time.Second
+ MostOutput = 256 << 10
+)
+
+// Cmd is one command a tool runs.
+type Cmd struct {
+ Name string
+ Args []string
+ // Stdin is written to the command's standard input when not empty.
+ Stdin string
+ // Env is added to this process's own environment.
+ Env []string
+ // Root says the command needs root: it is run through `sudo -n` when this process is not root.
+ Root bool
+ // Timeout replaces CallTimeout; only a background job (jobs.go) asks for longer.
+ Timeout time.Duration
+ // Detached is for a program that forks a child which outlives it, as xclip does to keep the
+ // selection: its streams go to files, because a pipe the child inherits would hold the call open
+ // until the child exits.
+ Detached bool
+}
+
+// Result is what a command did.
+type Result struct {
+ Stdout string `json:"stdout"`
+ Stderr string `json:"stderr"`
+ Status int `json:"status"`
+ // Error is why it did not run to an answer: "not-found" when the program is not there,
+ // "timeout" when it was ended for taking too long, else the spawn error.
+ Error string `json:"error,omitempty"`
+ Truncated bool `json:"truncated,omitempty"`
+}
+
+// Runner runs a command. Tests replace it; nothing else does.
+type Runner func(Cmd) Result
+
+var (
+ run Runner = execRun
+ euid = os.Geteuid
+)
+
+// argv is the command as it is run: through sudo without a prompt when it needs root and this
+// process is not root.
+func argv(c Cmd) (string, []string) {
+ if c.Root && euid() != 0 {
+ return "sudo", append([]string{"-n", c.Name}, c.Args...)
+ }
+ return c.Name, c.Args
+}
+
+// bounded keeps the first MostOutput bytes written to it and notes that more came.
+type bounded struct {
+ b bytes.Buffer
+ cut bool
+}
+
+func (w *bounded) Write(p []byte) (int, error) {
+ room := MostOutput - w.b.Len()
+ if room <= 0 {
+ w.cut = w.cut || len(p) > 0
+ return len(p), nil
+ }
+ if len(p) > room {
+ w.b.Write(p[:room])
+ w.cut = true
+ return len(p), nil
+ }
+ return w.b.Write(p)
+}
+
+func execRun(c Cmd) Result {
+ timeout := c.Timeout
+ if timeout <= 0 {
+ timeout = CallTimeout
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), timeout)
+ defer cancel()
+ name, args := argv(c)
+ cmd := exec.CommandContext(ctx, name, args...)
+ cmd.Env = append(append(os.Environ(), "LC_ALL=C"), c.Env...)
+ if !c.Detached {
+ // Its own process group, so that ending it on a timeout ends what it started too.
+ cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
+ cmd.Cancel = func() error {
+ if cmd.Process != nil {
+ _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
+ }
+ return nil
+ }
+ }
+ cmd.WaitDelay = 2 * time.Second
+ if c.Stdin != "" {
+ cmd.Stdin = strings.NewReader(c.Stdin)
+ }
+ var out, errs bounded
+ var outFile, errFile *os.File
+ if c.Detached {
+ var err error
+ if outFile, err = os.CreateTemp("", "mesh-tool-out-*"); err != nil {
+ return Result{Status: 127, Error: err.Error()}
+ }
+ defer os.Remove(outFile.Name())
+ defer outFile.Close()
+ if errFile, err = os.CreateTemp("", "mesh-tool-err-*"); err != nil {
+ return Result{Status: 127, Error: err.Error()}
+ }
+ defer os.Remove(errFile.Name())
+ defer errFile.Close()
+ cmd.Stdout, cmd.Stderr = outFile, errFile
+ } else {
+ cmd.Stdout, cmd.Stderr = &out, &errs
+ }
+ err := cmd.Run()
+ if c.Detached {
+ for _, f := range []struct {
+ file *os.File
+ into *bounded
+ }{{outFile, &out}, {errFile, &errs}} {
+ if _, e := f.file.Seek(0, io.SeekStart); e == nil {
+ _, _ = io.Copy(f.into, f.file)
+ }
+ }
+ }
+ r := Result{Stdout: out.b.String(), Stderr: errs.b.String(), Truncated: out.cut || errs.cut}
+ var exit *exec.ExitError
+ switch {
+ case err == nil:
+ case ctx.Err() == context.DeadlineExceeded:
+ r.Status, r.Error = 124, "timeout"
+ case errors.Is(err, exec.ErrNotFound) || errors.Is(err, os.ErrNotExist):
+ r.Status, r.Error = 127, "not-found"
+ case errors.As(err, &exit):
+ r.Status = exit.ExitCode()
+ default:
+ r.Status, r.Error = 127, err.Error()
+ }
+ return r
+}
+
+// call runs a command and answers its result, or an error naming what went wrong.
+func call(c Cmd) (Result, error) {
+ r := run(c)
+ if r.Status == 0 && r.Error == "" {
+ return r, nil
+ }
+ return r, failure(c, r)
+}
+
+// failure names how a command failed: not installed, refused escalation, too slow, or its exit
+// status with the end of what it said.
+func failure(c Cmd, r Result) error {
+ program, _ := argv(c)
+ switch {
+ case r.Error == "not-found" && program == "sudo":
+ return fmt.Errorf("%s needs root, and sudo is not installed here for the runtime's account to escalate with", c.Name)
+ case r.Error == "not-found":
+ if hint, ok := providedBy[c.Name]; ok {
+ return fmt.Errorf("%s is not installed on this machine (%s)", c.Name, hint)
+ }
+ return fmt.Errorf("%s is not installed on this machine", c.Name)
+ case r.Error == "timeout":
+ limit := c.Timeout
+ if limit <= 0 {
+ limit = CallTimeout
+ }
+ return fmt.Errorf("%s gave no answer within %s and was ended", c.Name, limit)
+ case r.Error != "":
+ return fmt.Errorf("%s did not run: %s", c.Name, r.Error)
+ case program == "sudo" && strings.Contains(r.Stderr, "command not found"):
+ if hint, ok := providedBy[c.Name]; ok {
+ return fmt.Errorf("%s is not installed on this machine (%s)", c.Name, hint)
+ }
+ return fmt.Errorf("%s is not installed on this machine", c.Name)
+ case program == "sudo" && strings.HasPrefix(strings.TrimSpace(r.Stderr), "sudo:"):
+ return fmt.Errorf("%s needs root, and sudo -n refused the runtime's account: %s (the escalation is the sudo module's to declare)",
+ c.Name, firstLine(r.Stderr))
+ }
+ said := tail(strings.TrimSpace(r.Stderr), 2000)
+ if said == "" {
+ said = tail(strings.TrimSpace(r.Stdout), 2000)
+ }
+ if said == "" {
+ said = "and said nothing"
+ }
+ return fmt.Errorf("%s %s exited %d: %s", c.Name, strings.Join(c.Args, " "), r.Status, said)
+}
+
+func firstLine(s string) string {
+ s = strings.TrimSpace(s)
+ if i := strings.IndexByte(s, '\n'); i >= 0 {
+ return s[:i]
+ }
+ return s
+}
+
+func tail(s string, n int) string {
+ if len(s) <= n {
+ return s
+ }
+ return "…" + s[len(s)-n:]
+}
+
+// lines are a command's output lines, blank ones dropped.
+func lines(s string) []string {
+ out := []string{}
+ for _, l := range strings.Split(s, "\n") {
+ if strings.TrimSpace(l) != "" {
+ out = append(out, strings.TrimRight(l, "\r"))
+ }
+ }
+ return out
+}
+
+// Arguments, read the way a tool's JSON arguments arrive.
+
+func text(args map[string]any, key string) (string, error) {
+ v, ok := args[key]
+ if !ok || v == nil {
+ return "", fmt.Errorf("%s is required", key)
+ }
+ s, ok := v.(string)
+ if !ok {
+ return "", fmt.Errorf("%s must be a string", key)
+ }
+ if strings.TrimSpace(s) == "" {
+ return "", fmt.Errorf("%s must not be empty", key)
+ }
+ return s, nil
+}
+
+func optText(args map[string]any, key, def string) (string, error) {
+ v, ok := args[key]
+ if !ok || v == nil {
+ return def, nil
+ }
+ s, ok := v.(string)
+ if !ok {
+ return "", fmt.Errorf("%s must be a string", key)
+ }
+ if strings.TrimSpace(s) == "" {
+ return def, nil
+ }
+ return s, nil
+}
+
+// optWhole reads a whole number, defaulted, refused below least and held to most.
+func optWhole(args map[string]any, key string, def, least, most int) (int, error) {
+ v, ok := args[key]
+ if !ok || v == nil {
+ return def, nil
+ }
+ f, ok := v.(float64)
+ if !ok {
+ if i, isInt := v.(int); isInt {
+ f = float64(i)
+ } else {
+ return 0, fmt.Errorf("%s must be a number", key)
+ }
+ }
+ if f != float64(int(f)) {
+ return 0, fmt.Errorf("%s must be a whole number", key)
+ }
+ n := int(f)
+ if n < least {
+ return 0, fmt.Errorf("%s must be at least %d", key, least)
+ }
+ if n > most {
+ n = most
+ }
+ return n, nil
+}
+
+func optFlag(args map[string]any, key string, def bool) (bool, error) {
+ v, ok := args[key]
+ if !ok || v == nil {
+ return def, nil
+ }
+ b, ok := v.(bool)
+ if !ok {
+ return false, fmt.Errorf("%s must be true or false", key)
+ }
+ return b, nil
+}
+
+func optList(args map[string]any, key string) ([]string, error) {
+ v, ok := args[key]
+ if !ok || v == nil {
+ return nil, nil
+ }
+ items, ok := v.([]any)
+ if !ok {
+ return nil, fmt.Errorf("%s must be a list of strings", key)
+ }
+ out := make([]string, 0, len(items))
+ for _, it := range items {
+ s, ok := it.(string)
+ if !ok || strings.TrimSpace(s) == "" {
+ return nil, fmt.Errorf("%s must be a list of non-empty strings", key)
+ }
+ out = append(out, s)
+ }
+ return out, nil
+}
+
+// oneOf refuses a value outside a closed set.
+func oneOf(key, value string, allowed ...string) error {
+ for _, a := range allowed {
+ if value == a {
+ return nil
+ }
+ }
+ return fmt.Errorf("%s must be one of %s, not %q", key, strings.Join(allowed, ", "), value)
+}
+
+// plainName refuses a name that could be read as an option or carries a path or a space: package,
+// snap, application and printer names never do.
+func plainName(key, value string) error {
+ if strings.HasPrefix(value, "-") || strings.ContainsAny(value, " \t\n/\\") {
+ return fmt.Errorf("%s %q is not a plain name", key, value)
+ }
+ return nil
+}
diff --git a/modules/flatpak/cmd/flatpak-tools/kit_test.go b/modules/flatpak/cmd/flatpak-tools/kit_test.go
new file mode 100644
index 0000000..c5d3557
--- /dev/null
+++ b/modules/flatpak/cmd/flatpak-tools/kit_test.go
@@ -0,0 +1,147 @@
+package main
+
+// Tests of kit.go, the same in each workstation module.
+
+import (
+ "strings"
+ "testing"
+ "time"
+)
+
+// fake records the commands asked and answers each from a function of the command line.
+type fake struct {
+ asked []Cmd
+ answer func(line string, c Cmd) Result
+}
+
+func (f *fake) runner() Runner {
+ return func(c Cmd) Result {
+ f.asked = append(f.asked, c)
+ name, args := argv(c)
+ line := strings.TrimSpace(name + " " + strings.Join(args, " "))
+ if f.answer == nil {
+ return Result{}
+ }
+ return f.answer(line, c)
+ }
+}
+
+func (f *fake) lines() []string {
+ out := []string{}
+ for _, c := range f.asked {
+ name, args := argv(c)
+ out = append(out, strings.TrimSpace(name+" "+strings.Join(args, " ")))
+ }
+ return out
+}
+
+// using installs a fake runner and a non-root uid for one test.
+func using(t *testing.T, answer func(line string, c Cmd) Result) *fake {
+ t.Helper()
+ f := &fake{answer: answer}
+ wasRun, wasUID := run, euid
+ run, euid = f.runner(), func() int { return 1000 }
+ t.Cleanup(func() { run, euid = wasRun, wasUID })
+ return f
+}
+
+func ok(stdout string) Result { return Result{Stdout: stdout} }
+
+func TestKitAnActThatNeedsRootGoesThroughSudoWithoutAPromptUnlessAlreadyRoot(t *testing.T) {
+ was := euid
+ defer func() { euid = was }()
+ euid = func() int { return 1000 }
+ if name, args := argv(Cmd{Name: "x", Args: []string{"a"}, Root: true}); name != "sudo" || strings.Join(args, " ") != "-n x a" {
+ t.Fatalf("not root: %s %v", name, args)
+ }
+ if name, _ := argv(Cmd{Name: "x"}); name != "x" {
+ t.Fatalf("a read is run as the account: %s", name)
+ }
+ euid = func() int { return 0 }
+ if name, _ := argv(Cmd{Name: "x", Root: true}); name != "x" {
+ t.Fatalf("as root no sudo: %s", name)
+ }
+}
+
+func TestKitAFailureIsNamedByHowItFailed(t *testing.T) {
+ was := euid
+ defer func() { euid = was }()
+ euid = func() int { return 1000 }
+ cases := []struct {
+ c Cmd
+ r Result
+ want string
+ }{
+ {Cmd{Name: "nothere"}, Result{Status: 127, Error: "not-found"}, "not installed"},
+ {Cmd{Name: "x", Root: true}, Result{Status: 127, Error: "not-found"}, "sudo is not installed"},
+ {Cmd{Name: "x", Root: true}, Result{Status: 1, Stderr: "sudo: a password is required\n"}, "sudo -n refused"},
+ {Cmd{Name: "x", Root: true}, Result{Status: 1, Stderr: "sudo: x: command not found\n"}, "x is not installed"},
+ {Cmd{Name: "x"}, Result{Status: 124, Error: "timeout"}, "within 20s"},
+ {Cmd{Name: "x", Args: []string{"y"}}, Result{Status: 3, Stderr: "boom\n"}, "x y exited 3: boom"},
+ {Cmd{Name: "x"}, Result{Status: 3}, "said nothing"},
+ }
+ for _, k := range cases {
+ err := failure(k.c, k.r)
+ if err == nil || !strings.Contains(err.Error(), k.want) {
+ t.Errorf("%+v: %v, want %q", k.r, err, k.want)
+ }
+ }
+}
+
+func TestKitOutputIsBoundedAndSaysSo(t *testing.T) {
+ var w bounded
+ big := strings.Repeat("a", MostOutput+10)
+ n, _ := w.Write([]byte(big))
+ if n != len(big) || w.b.Len() != MostOutput || !w.cut {
+ t.Fatalf("kept %d of %d, cut %v", w.b.Len(), len(big), w.cut)
+ }
+}
+
+func TestKitTheRealRunnerRunsEndsAndReportsAMissingProgram(t *testing.T) {
+ r := execRun(Cmd{Name: "sh", Args: []string{"-c", "echo out; echo err >&2; exit 3"}})
+ if r.Status != 3 || strings.TrimSpace(r.Stdout) != "out" || strings.TrimSpace(r.Stderr) != "err" {
+ t.Fatalf("%+v", r)
+ }
+ r = execRun(Cmd{Name: "sh", Args: []string{"-c", "sleep 5 & sleep 5"}, Timeout: 200 * time.Millisecond})
+ if r.Error != "timeout" {
+ t.Fatalf("a slow command: %+v", r)
+ }
+ r = execRun(Cmd{Name: "no-such-program-anywhere"})
+ if r.Error != "not-found" {
+ t.Fatalf("a missing program: %+v", r)
+ }
+ r = execRun(Cmd{Name: "cat", Stdin: "given"})
+ if r.Stdout != "given" {
+ t.Fatalf("stdin: %+v", r)
+ }
+ start := time.Now()
+ r = execRun(Cmd{Name: "sh", Args: []string{"-c", "echo kept; (sleep 3 &) ; exit 0"}, Detached: true})
+ if r.Status != 0 || strings.TrimSpace(r.Stdout) != "kept" || time.Since(start) > 2*time.Second {
+ t.Fatalf("a detached command returns when it exits, not when its child does: %+v after %s", r, time.Since(start))
+ }
+}
+
+func TestKitArgumentsAreReadStrictly(t *testing.T) {
+ args := map[string]any{"s": "x", "n": float64(5), "f": 1.5, "b": true, "l": []any{"a", "b"}}
+ if _, err := text(args, "missing"); err == nil {
+ t.Error("a missing required string")
+ }
+ if n, _ := optWhole(args, "n", 1, 1, 3); n != 3 {
+ t.Errorf("held to most: %d", n)
+ }
+ if _, err := optWhole(args, "n", 1, 6, 9); err == nil {
+ t.Error("below least")
+ }
+ if _, err := optWhole(args, "f", 1, 0, 9); err == nil {
+ t.Error("a fraction")
+ }
+ if l, _ := optList(args, "l"); len(l) != 2 {
+ t.Errorf("list: %v", l)
+ }
+ if b, _ := optFlag(args, "b", false); !b {
+ t.Error("flag")
+ }
+ if err := plainName("name", "--all"); err == nil {
+ t.Error("an option as a name")
+ }
+}
diff --git a/modules/flatpak/cmd/flatpak-tools/main.go b/modules/flatpak/cmd/flatpak-tools/main.go
new file mode 100644
index 0000000..b5278dd
--- /dev/null
+++ b/modules/flatpak/cmd/flatpak-tools/main.go
@@ -0,0 +1,213 @@
+// The flatpak module's tools (novox/hq research 027/02, 026/05): the applications and runtimes in
+// both installations, the remotes, pending updates, what nothing uses any more and the space it all
+// takes; and installing, removing and updating. A Go bundle the node's runtime launches over stdio
+// (ADR 0188, ADR 0193); it runs as the operator account. An act on the system installation goes
+// through `sudo -n`; one on the account's own installation does not.
+package main
+
+import (
+ "fmt"
+ "os"
+
+ stdio "git.novox.be/novox/mesh-sdk/go"
+)
+
+var providedBy = map[string]string{
+ "flatpak": "the flatpak package, which this module installs",
+ "du": "the coreutils package",
+}
+
+func main() {
+ if err := stdio.Serve("", tools()); err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+}
+
+var installationArg = map[string]any{"type": "string", "enum": []string{"system", "user"},
+ "description": "the system installation (default), or the account's own"}
+
+func installationOf(args map[string]any, def string) (string, error) {
+ i, err := optText(args, "installation", def)
+ if err != nil {
+ return "", err
+ }
+ if i == "" {
+ return "", nil
+ }
+ return i, oneOf("installation", i, "system", "user")
+}
+
+func refArg(args map[string]any, key string) (string, error) {
+ r, err := text(args, key)
+ if err != nil {
+ return "", err
+ }
+ return r, checkRef(r)
+}
+
+func tools() []stdio.Tool {
+ return []stdio.Tool{
+ {
+ Name: "flatpak_list",
+ Description: "The installed applications, in both installations or one: id, name, version, branch, origin, " +
+ "installation and size. (r)",
+ Input: map[string]any{"installation": installationArg},
+ Run: func(args map[string]any) (any, error) {
+ inst, err := installationOf(args, "")
+ if err != nil {
+ return nil, err
+ }
+ return List("app", inst)
+ },
+ },
+ {
+ Name: "flatpak_runtimes",
+ Description: "The installed runtimes and extensions, in both installations or one: id, branch, origin, installation and size. (r)",
+ Input: map[string]any{"installation": installationArg},
+ Run: func(args map[string]any) (any, error) {
+ inst, err := installationOf(args, "")
+ if err != nil {
+ return nil, err
+ }
+ return List("runtime", inst)
+ },
+ },
+ {
+ Name: "flatpak_remotes",
+ Description: "The remotes of both installations, with how many installed refs come from each, and findings: " +
+ "Flathub missing from the system installation, the same remote in both installations, a remote nothing " +
+ "is installed from. (r)",
+ Input: map[string]any{},
+ Run: func(map[string]any) (any, error) { return Remotes() },
+ },
+ {
+ Name: "flatpak_updates",
+ Description: "What an update would change: each ref with a newer commit on its remote. Asks the remotes, so it needs the network. (r)",
+ Input: map[string]any{"installation": installationArg},
+ Run: func(args map[string]any) (any, error) {
+ inst, err := installationOf(args, "")
+ if err != nil {
+ return nil, err
+ }
+ return Updates(inst)
+ },
+ },
+ {
+ Name: "flatpak_unused",
+ Description: "The runtimes and extensions no installed application needs any more, computed without changing " +
+ "anything: a runtime is used when an application names it as its runtime or SDK, when it fills an " +
+ "extension point of something used, or when it is pinned. With the space each takes. (r)",
+ Input: map[string]any{},
+ Run: func(map[string]any) (any, error) { return Unused() },
+ },
+ {
+ Name: "flatpak_disk_usage",
+ Description: "The space flatpak takes: each installation's directory on disk, and the applications and " +
+ "runtimes by size, largest first. (r)",
+ Input: map[string]any{},
+ Run: func(map[string]any) (any, error) { return DiskUsage() },
+ },
+ {
+ Name: "flatpak_install",
+ Description: "Install an application or runtime from a remote (default flathub), in the system installation " +
+ "(default) or the account's. Answers when finished, or after 18 s with a job to follow with flatpak_job. (a)",
+ Input: map[string]any{
+ "ref": map[string]any{"type": "string", "description": "an application id such as org.gimp.GIMP, or a full ref"},
+ "remote": map[string]any{"type": "string", "description": "the remote (default flathub)"},
+ "installation": installationArg,
+ },
+ Run: func(args map[string]any) (any, error) {
+ ref, err := refArg(args, "ref")
+ if err != nil {
+ return nil, err
+ }
+ remote, err := optText(args, "remote", "flathub")
+ if err != nil {
+ return nil, err
+ }
+ inst, err := installationOf(args, "system")
+ if err != nil {
+ return nil, err
+ }
+ return Install(ref, remote, inst)
+ },
+ },
+ {
+ Name: "flatpak_remove",
+ Description: "Uninstall an application or runtime, keeping its data unless delete_data is set. Answers when finished, or with a job to follow. (a)",
+ Input: map[string]any{
+ "ref": map[string]any{"type": "string", "description": "the application id or ref"},
+ "installation": installationArg,
+ "delete_data": map[string]any{"type": "boolean", "description": "remove the application's data in the home too"},
+ },
+ Run: func(args map[string]any) (any, error) {
+ ref, err := refArg(args, "ref")
+ if err != nil {
+ return nil, err
+ }
+ inst, err := installationOf(args, "system")
+ if err != nil {
+ return nil, err
+ }
+ del, err := optFlag(args, "delete_data", false)
+ if err != nil {
+ return nil, err
+ }
+ return Remove(ref, inst, del)
+ },
+ },
+ {
+ Name: "flatpak_update",
+ Description: "Update one ref, or everything in an installation when no ref is given. Answers when finished, or with a job to follow. (a)",
+ Input: map[string]any{
+ "ref": map[string]any{"type": "string", "description": "the application id or ref (default all)"},
+ "installation": installationArg,
+ },
+ Run: func(args map[string]any) (any, error) {
+ ref, err := optText(args, "ref", "")
+ if err != nil {
+ return nil, err
+ }
+ if ref != "" {
+ if err := checkRef(ref); err != nil {
+ return nil, err
+ }
+ }
+ inst, err := installationOf(args, "system")
+ if err != nil {
+ return nil, err
+ }
+ return Update(ref, inst)
+ },
+ },
+ {
+ Name: "flatpak_remove_unused",
+ Description: "Uninstall what flatpak itself finds unused in one installation (system by default). " +
+ "flatpak_unused shows what that is beforehand. Answers when finished, or with a job to follow. (a)",
+ Input: map[string]any{"installation": installationArg},
+ Run: func(args map[string]any) (any, error) {
+ inst, err := installationOf(args, "system")
+ if err != nil {
+ return nil, err
+ }
+ return RemoveUnused(inst)
+ },
+ },
+ {
+ Name: "flatpak_job",
+ Description: "A long act this module started: running or finished, its exit status and the end of its output. Without job, every act this process knows. (r)",
+ Input: map[string]any{"job": map[string]any{"type": "string", "description": "the job id an act answered"}},
+ Run: func(args map[string]any) (any, error) {
+ id, err := optText(args, "job", "")
+ if err != nil {
+ return nil, err
+ }
+ if id == "" {
+ return map[string]any{"jobs": listJobs()}, nil
+ }
+ return jobByID(id)
+ },
+ },
+ }
+}
diff --git a/modules/flatpak/cmd/flatpak-tools/manifest_kit_test.go b/modules/flatpak/cmd/flatpak-tools/manifest_kit_test.go
new file mode 100644
index 0000000..3e675b4
--- /dev/null
+++ b/modules/flatpak/cmd/flatpak-tools/manifest_kit_test.go
@@ -0,0 +1,107 @@
+package main
+
+// manifest_kit_test.go is the same file in each workstation module: it reads the module's
+// definition so the module's own tests can hold it to what it says.
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+ "testing"
+)
+
+type manifest struct {
+ Module string `json:"module"`
+ Capabilities []string `json:"capabilities"`
+ Claims []any `json:"claims"`
+ Seats []any `json:"seats"`
+ Tools []string `json:"tools"`
+ Resources []map[string]any `json:"resources"`
+ Build struct {
+ Artifacts []map[string]any `json:"artifacts"`
+ } `json:"build"`
+}
+
+func readManifest(t *testing.T) manifest {
+ t.Helper()
+ raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ var m manifest
+ if err := json.Unmarshal(raw, &m); err != nil {
+ t.Fatalf("module.json: %v", err)
+ }
+ return m
+}
+
+func (m manifest) resource(id string) map[string]any {
+ for _, r := range m.Resources {
+ if r["id"] == id {
+ return r
+ }
+ }
+ return nil
+}
+
+// packages are the packages the module installs, sorted.
+func (m manifest) packages() []string {
+ out := []string{}
+ for _, r := range m.Resources {
+ if r["type"] == "package" && r["absent"] != true {
+ out = append(out, r["package"].(string))
+ }
+ }
+ sort.Strings(out)
+ return out
+}
+
+// services are the units the module declares, by unit name.
+func (m manifest) services() map[string]map[string]any {
+ out := map[string]map[string]any{}
+ for _, r := range m.Resources {
+ if r["type"] == "service" {
+ out[r["unit"].(string)] = r
+ }
+ }
+ return out
+}
+
+// holdsTheBundle holds the manifest to the Go bundle this directory builds: every tool registered
+// is listed and nothing else, each named _…, and the artifact builds this command.
+func holdsTheBundle(t *testing.T, m manifest, prefix string) {
+ t.Helper()
+ registered := []string{}
+ for _, tool := range tools() {
+ registered = append(registered, tool.Name)
+ if !strings.HasPrefix(tool.Name, prefix+"_") {
+ t.Errorf("tool %s is not named %s_…", tool.Name, prefix)
+ }
+ if tool.Description == "" || tool.Run == nil || tool.Input == nil {
+ t.Errorf("tool %s is not described, runnable and given an input schema", tool.Name)
+ }
+ }
+ if strings.Join(registered, ",") != strings.Join(m.Tools, ",") {
+ t.Errorf("registered %v, listed %v", registered, m.Tools)
+ }
+ if len(m.Build.Artifacts) != 1 {
+ t.Fatalf("one artifact, got %d", len(m.Build.Artifacts))
+ }
+ cwd, _ := os.Getwd()
+ binary := filepath.Base(cwd)
+ a := m.Build.Artifacts[0]
+ want := map[string]any{"kind": "bundle", "language": "go", "system": "arch", "from": "cmd/" + binary, "binary": binary}
+ for k, v := range want {
+ if a[k] != v {
+ t.Errorf("artifact %s = %v, want %v", k, a[k], v)
+ }
+ }
+ if loads, _ := a["loads"].([]any); len(loads) != 1 || loads[0] != binary {
+ t.Errorf("artifact loads %v, want [%s]", a["loads"], binary)
+ }
+ if m.Claims != nil || m.Seats != nil {
+ t.Errorf("claims %v, seats %v: this module holds no seat", m.Claims, m.Seats)
+ }
+}
diff --git a/modules/flatpak/go.mod b/modules/flatpak/go.mod
new file mode 100644
index 0000000..7f34546
--- /dev/null
+++ b/modules/flatpak/go.mod
@@ -0,0 +1,5 @@
+module flatpak
+
+go 1.22
+
+require git.novox.be/novox/mesh-sdk/go v0.1.6
diff --git a/modules/flatpak/go.sum b/modules/flatpak/go.sum
new file mode 100644
index 0000000..0dd6061
--- /dev/null
+++ b/modules/flatpak/go.sum
@@ -0,0 +1,2 @@
+git.novox.be/novox/mesh-sdk/go v0.1.6 h1:9qzdYONYbJdWcu6sxQcq9v1LI0JxcfkiKYkMUzJSkVQ=
+git.novox.be/novox/mesh-sdk/go v0.1.6/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
diff --git a/modules/flatpak/module.json b/modules/flatpak/module.json
new file mode 100644
index 0000000..76d4721
--- /dev/null
+++ b/modules/flatpak/module.json
@@ -0,0 +1,42 @@
+{
+ "module": "flatpak",
+ "version": "1",
+ "capabilities": [
+ "package-manager"
+ ],
+ "tools": [
+ "flatpak_list",
+ "flatpak_runtimes",
+ "flatpak_remotes",
+ "flatpak_updates",
+ "flatpak_unused",
+ "flatpak_disk_usage",
+ "flatpak_install",
+ "flatpak_remove",
+ "flatpak_update",
+ "flatpak_remove_unused",
+ "flatpak_job"
+ ],
+ "resources": [
+ {
+ "id": "package",
+ "type": "package",
+ "package": "flatpak"
+ }
+ ],
+ "build": {
+ "artifacts": [
+ {
+ "name": "tools",
+ "kind": "bundle",
+ "language": "go",
+ "system": "arch",
+ "from": "cmd/flatpak-tools",
+ "binary": "flatpak-tools",
+ "loads": [
+ "flatpak-tools"
+ ]
+ }
+ ]
+ }
+}
diff --git a/modules/fonts/README.md b/modules/fonts/README.md
new file mode 100644
index 0000000..96d41c7
--- /dev/null
+++ b/modules/fonts/README.md
@@ -0,0 +1,99 @@
+# fonts
+
+The faces the workstations draw text with, as a module (novox/hq research 026/04, to-be 42 phase 2
+step 1). Assigned to the two workstations.
+
+## Owns
+
+| what | where | class (ADR 0182) |
+|---|---|---|
+| JetBrains Mono Nerd Font, for monospace: terminal, window manager, bar, launcher, prompt | package `ttf-jetbrains-mono-nerd` | package |
+| Inter, for the interface: GTK, Qt, notifications | package `inter-font` | package |
+| Nerd Fonts Symbols, the icon fallback for any face | package `ttf-nerd-fonts-symbols` | package |
+| Noto, for serif and every other script | package `noto-fonts` | package |
+| Noto Color Emoji | package `noto-fonts-emoji` | package |
+| what `monospace`, `sans-serif`, `system-ui`, `serif` and `emoji` mean | `~/.config/fontconfig/conf.d/50-mesh-fonts.conf` | owned, whole |
+
+All five packages are in the distribution's official repositories.
+
+**Why the account's fontconfig directory, not `/etc/fonts/conf.d`.** The choice of faces is the
+operator's taste on the operator's machine, and every program that draws for the operator runs as the
+account. The account's directory is read by fontconfig's stock `50-user.conf`, before the
+distribution's `60-latin.conf`, so the module's file decides the generic families without touching
+anything the distribution owns. Root's tools and any other account keep the distribution's defaults.
+A system file would need a symlink into `conf.d` the way the distribution does it, which the mesh
+never makes (ADR 0012).
+
+**The file in short.** Each generic family prefers the decided face, bound `same` as the request.
+Measured with fontconfig 2.18 on a workstation: a face added with the default (weak) binding loses to
+`Noto Sans Mono` for a plain `monospace` request, because the distribution's own rule decides first.
+The Nerd Fonts symbols and the colour emoji are appended last to every pattern, so an icon or emoji a
+face lacks is still drawn, and they never displace a face that has the character.
+
+## Improves
+
+- **Every program agrees on monospace.** Today `monospace` is `Noto Sans Mono`, while the terminal, the
+ window manager and the bar use a hand-copied Hack. After the push, all of them can say the family
+ and get one face.
+- **Configurations naming a font that is not installed stop falling back to a proportional face.** The
+ launcher's theme asks for `Iosevka Nerd Font` and its power menu for `JetBrains Mono Nerd Font`.
+ Neither is installed on the laptop, so fontconfig falls back to `sans-serif`. The file maps
+ `Hack Nerd Font`, `MesloLGS NF` and `Iosevka Nerd Font` to `monospace` and the old JetBrains name to
+ the new one, for as long as those families are not installed. Where one still is, it is used as
+ before.
+- **Packages instead of copies.** The hand-copied files can go (below), and an upgrade of a face is a
+ package upgrade.
+
+## Tools
+
+All answer JSON; `(r)` reads, `(a)` acts.
+
+| tool | what |
+|---|---|
+| `fonts_families` (r) | installed families: styles, file count, monospaced or not, and source (`package`, `account` for a hand copy, `other`) |
+| `fonts_match` (r) | what each generic family resolves to, beside the decided face, with `all_as_decided`; or any patterns given |
+| `fonts_glyph` (r) | which installed families have a character (given as itself or `U+F120`), and which face monospace and sans-serif draw it with |
+| `fonts_sources` (r) | every hand-copied font file with its family, duplicates, and whether a package now provides the family; marks each `remove` with the reasons; lists packaged families with their package. Removes nothing |
+| `fonts_config` (r) | whether the module's file is in place and loaded by fontconfig, and the account's other fontconfig files beside it |
+| `fonts_cache_rebuild` (a) | rebuild the account's font cache, or with `system: true` the system's (through `sudo -n`) |
+
+## The one-off steps for the operator (ADR 0182)
+
+The mesh removes nothing it did not place. After the first push that assigns this module, on each
+workstation:
+
+1. **Remove the hand-copied files** in `~/.local/share/fonts` that `fonts_sources` marks `remove`, then
+ run `fonts_cache_rebuild`. Measured on 2026-10-04:
+ - **laptop:** the four `HackNerdFont-*.ttf`; the four `MesloLGS NF *.ttf`; the four
+ `MesloLGS%20NF%20*.ttf`, which are the same bytes under URL-encoded names. Twelve files, 30 MB.
+ Nothing stays.
+ - **desktop:** the same twelve, plus `Iosevka-Nerd-Font-Complete.ttf` and
+ `JetBrains-Mono-Nerd-Font-Complete.ttf` (version-2 Nerd builds). `GrapeNuts-Regular.ttf` and
+ `Icomoon-Feather.ttf` came from a theme's repository. They are kept until the theme module decides
+ what it ships.
+ - Do this **after** the modules below name the new family, or the terminal and the bar fall back
+ through the file's aliases to `monospace`, which is the right face anyway.
+2. **Nothing else.** No fontconfig file of the account's own exists today on either workstation.
+
+## Modules that must name the family
+
+Fonts are not a seat (research 026/04): this module says what the generic families mean, and each
+desktop module names its family. Each switches to `JetBrainsMono Nerd Font` when it is written:
+
+| module | today, on both workstations |
+|---|---|
+| `xterm` | `~/.Xresources.d/xterm`: `*faceName: Hack Nerd Font` |
+| `i3` | `~/.config/i3/config`: `font pango: Hack Nerd Font 11`, three times (the window manager and both bars) |
+| the bar (`i3status-rust`) | takes the font i3's bar block names, above |
+| `rofi` | `theme.rasi`: `Iosevka Nerd Font 10`; `powermenu.rasi`: `JetBrains Mono Nerd Font 10` and `Hack Nerd Font bold 32` |
+| `dunst` | `font = Hack Nerd Font 10`, which becomes `Inter` (the interface face) |
+| the prompt (`powerlevel10k`) | nothing: it uses whichever Nerd Font the terminal has |
+
+The DPI fixed in an X resource is the display server's setting (issue 168), not this module's.
+
+## Leaves as found
+
+- `noto-fonts-cjk` and `noto-fonts-extra`, installed on both workstations, and every other font
+ package. The module does not remove a package it did not install.
+- The distribution's fontconfig files under `/etc/fonts`.
+- The theme's two fonts on the desktop, above.
diff --git a/modules/fonts/cmd/fonts-tools/fonts.go b/modules/fonts/cmd/fonts-tools/fonts.go
new file mode 100644
index 0000000..c3a2868
--- /dev/null
+++ b/modules/fonts/cmd/fonts-tools/fonts.go
@@ -0,0 +1,522 @@
+package main
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "fmt"
+ "io"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "sort"
+ "strconv"
+ "strings"
+ "time"
+ "unicode/utf8"
+)
+
+// ConfigFile is where the module's fontconfig file is placed, under the account's home. fontconfig's
+// stock 50-user.conf reads this directory, before the distribution's 60-latin.conf, so what it says
+// is what the generic families mean.
+const ConfigFile = ".config/fontconfig/conf.d/50-mesh-fonts.conf"
+
+// Decided is the face each generic family means (novox/hq research 026/04).
+var Decided = map[string]string{
+ "monospace": "JetBrainsMono Nerd Font",
+ "sans-serif": "Inter",
+ "system-ui": "Inter",
+ "serif": "Noto Serif",
+ "emoji": "Noto Color Emoji",
+}
+
+// generics is the order fonts_match answers them in.
+var generics = []string{"monospace", "sans-serif", "system-ui", "serif", "emoji"}
+
+// Retired are the monospace families the desktop's files named before the module; a hand-copied
+// file of one of them is replaced by the decided face.
+var Retired = []string{"Hack Nerd Font", "MesloLGS NF", "Iosevka Nerd Font", "JetBrains Mono Nerd Font", "JetBrainsMono Nerd Font"}
+
+// fontDirs are the account's own font directories, relative to its home.
+var fontDirs = []string{".local/share/fonts", ".fonts"}
+
+const packagedRoot = "/usr/share/fonts/"
+
+// Family is one installed family.
+type Family struct {
+ Family string `json:"family"`
+ Styles []string `json:"styles"`
+ Files int `json:"files"`
+ Monospace bool `json:"monospace"`
+ Source string `json:"source"`
+}
+
+// FamiliesAnswer is what fonts_families answers.
+type FamiliesAnswer struct {
+ Count int `json:"count"`
+ Families []Family `json:"families"`
+ Truncated bool `json:"truncated,omitempty"`
+}
+
+func sourceOf(file string) string {
+ home := accountHome()
+ switch {
+ case home != "" && strings.HasPrefix(file, strings.TrimRight(home, "/")+"/"):
+ return "account"
+ case strings.HasPrefix(file, packagedRoot):
+ return "package"
+ }
+ return "other"
+}
+
+func accountHome() string {
+ if h := strings.TrimSpace(os.Getenv("MESH_OPERATOR_HOME")); h != "" {
+ return h
+ }
+ h, _ := os.UserHomeDir()
+ return h
+}
+
+// Families lists what fc-list knows, by family.
+func Families(contains string, limit int) (FamiliesAnswer, error) {
+ r, err := call(Cmd{Name: "fc-list", Args: []string{"--format", "%{family[0]}\t%{style[0]}\t%{spacing}\t%{file}\n"}})
+ if err != nil {
+ return FamiliesAnswer{}, err
+ }
+ by := map[string]*Family{}
+ styles := map[string]map[string]bool{}
+ sources := map[string]map[string]bool{}
+ for _, l := range lines(r.Stdout) {
+ f := strings.Split(l, "\t")
+ if len(f) < 4 || f[0] == "" {
+ continue
+ }
+ name := f[0]
+ if contains != "" && !strings.Contains(strings.ToLower(name), strings.ToLower(contains)) {
+ continue
+ }
+ if by[name] == nil {
+ by[name] = &Family{Family: name, Monospace: true}
+ styles[name], sources[name] = map[string]bool{}, map[string]bool{}
+ }
+ fam := by[name]
+ fam.Files++
+ if f[1] != "" {
+ styles[name][f[1]] = true
+ }
+ // fontconfig's spacing: 100 mono, 110 charcell, 90 dual; absent is proportional.
+ if f[2] != "100" && f[2] != "110" && f[2] != "90" {
+ fam.Monospace = false
+ }
+ sources[name][sourceOf(f[3])] = true
+ }
+ out := FamiliesAnswer{Families: []Family{}}
+ for name, fam := range by {
+ fam.Styles = keys(styles[name])
+ fam.Source = strings.Join(keys(sources[name]), "+")
+ out.Families = append(out.Families, *fam)
+ }
+ sort.Slice(out.Families, func(i, k int) bool { return out.Families[i].Family < out.Families[k].Family })
+ out.Count = len(out.Families)
+ if len(out.Families) > limit {
+ out.Families, out.Truncated = out.Families[:limit], true
+ }
+ return out, nil
+}
+
+func keys(m map[string]bool) []string {
+ out := make([]string, 0, len(m))
+ for k := range m {
+ out = append(out, k)
+ }
+ sort.Strings(out)
+ return out
+}
+
+// Resolved is what one pattern resolves to.
+type Resolved struct {
+ Asked string `json:"asked"`
+ Family string `json:"family"`
+ Style string `json:"style"`
+ File string `json:"file"`
+ Source string `json:"source"`
+ Decided string `json:"decided,omitempty"`
+ AsDecided *bool `json:"as_decided,omitempty"`
+}
+
+func checkPattern(p string) error {
+ if strings.HasPrefix(p, "-") || strings.ContainsAny(p, "\n\t") {
+ return fmt.Errorf("%q is not a fontconfig pattern", p)
+ }
+ return nil
+}
+
+func resolve(pattern string) (Resolved, error) {
+ if err := checkPattern(pattern); err != nil {
+ return Resolved{}, err
+ }
+ r, err := call(Cmd{Name: "fc-match", Args: []string{"--format", "%{family[0]}\t%{style[0]}\t%{file}", pattern}})
+ if err != nil {
+ return Resolved{}, err
+ }
+ f := strings.Split(strings.TrimSpace(r.Stdout), "\t")
+ if len(f) < 3 || f[0] == "" {
+ return Resolved{}, fmt.Errorf("fc-match answered nothing usable for %q: %q", pattern, strings.TrimSpace(r.Stdout))
+ }
+ return Resolved{Asked: pattern, Family: f[0], Style: f[1], File: f[2], Source: sourceOf(f[2])}, nil
+}
+
+// MatchAnswer is what fonts_match answers.
+type MatchAnswer struct {
+ Resolved []Resolved `json:"resolved"`
+ // AllAsDecided is set when only the generics were asked, and says whether each is the decided face.
+ AllAsDecided *bool `json:"all_as_decided,omitempty"`
+}
+
+// Match resolves the generic families, or the patterns given.
+func Match(patterns []string) (MatchAnswer, error) {
+ generic := len(patterns) == 0
+ if generic {
+ patterns = generics
+ }
+ out := MatchAnswer{Resolved: []Resolved{}}
+ all := true
+ for _, p := range patterns {
+ r, err := resolve(p)
+ if err != nil {
+ return MatchAnswer{}, err
+ }
+ if want, ok := Decided[p]; ok {
+ agree := r.Family == want
+ r.Decided, r.AsDecided = want, &agree
+ all = all && agree
+ }
+ out.Resolved = append(out.Resolved, r)
+ }
+ if generic {
+ out.AllAsDecided = &all
+ }
+ return out, nil
+}
+
+// codePoint reads one character, or a code point written U+XXXX or 0xXXXX.
+func codePoint(s string) (rune, error) {
+ s = strings.TrimSpace(s)
+ if utf8.RuneCountInString(s) == 1 {
+ r, _ := utf8.DecodeRuneInString(s)
+ return r, nil
+ }
+ up := strings.ToUpper(s)
+ for _, prefix := range []string{"U+", "0X"} {
+ if strings.HasPrefix(up, prefix) {
+ n, err := strconv.ParseUint(up[len(prefix):], 16, 32)
+ if err != nil || n == 0 || n > utf8.MaxRune {
+ return 0, fmt.Errorf("%q is not a code point", s)
+ }
+ return rune(n), nil
+ }
+ }
+ return 0, fmt.Errorf("give one character, or its code point as U+XXXX or 0xXXXX, not %q", s)
+}
+
+// GlyphAnswer is what fonts_glyph answers.
+type GlyphAnswer struct {
+ CodePoint string `json:"code_point"`
+ Character string `json:"character"`
+ Count int `json:"count"`
+ Families []Family `json:"families"`
+ DrawnBy map[string]string `json:"drawn_by"`
+ Truncated bool `json:"truncated,omitempty"`
+}
+
+// Glyph answers which fonts have a character and which face the generics would draw it with.
+func Glyph(s string) (GlyphAnswer, error) {
+ cp, err := codePoint(s)
+ if err != nil {
+ return GlyphAnswer{}, err
+ }
+ hx := strconv.FormatInt(int64(cp), 16)
+ r, err := call(Cmd{Name: "fc-list", Args: []string{"--format", "%{family[0]}\t%{style[0]}\t%{spacing}\t%{file}\n", ":charset=" + hx}})
+ if err != nil {
+ return GlyphAnswer{}, err
+ }
+ by := map[string]*Family{}
+ for _, l := range lines(r.Stdout) {
+ f := strings.Split(l, "\t")
+ if len(f) < 4 || f[0] == "" {
+ continue
+ }
+ if by[f[0]] == nil {
+ by[f[0]] = &Family{Family: f[0], Monospace: f[2] == "100" || f[2] == "110" || f[2] == "90", Source: sourceOf(f[3]), Styles: []string{}}
+ }
+ by[f[0]].Files++
+ }
+ out := GlyphAnswer{CodePoint: fmt.Sprintf("U+%04X", cp), Character: string(cp), Families: []Family{}, DrawnBy: map[string]string{}}
+ for _, f := range by {
+ out.Families = append(out.Families, *f)
+ }
+ sort.Slice(out.Families, func(i, k int) bool { return out.Families[i].Family < out.Families[k].Family })
+ out.Count = len(out.Families)
+ if len(out.Families) > 200 {
+ out.Families, out.Truncated = out.Families[:200], true
+ }
+ for _, g := range []string{"monospace", "sans-serif"} {
+ res, err := resolve(g + ":charset=" + hx)
+ if err != nil {
+ return GlyphAnswer{}, err
+ }
+ out.DrawnBy[g] = res.Family
+ }
+ return out, nil
+}
+
+// HandFile is one font file copied into the account's home.
+type HandFile struct {
+ File string `json:"file"`
+ Family string `json:"family"`
+ Style string `json:"style"`
+ Bytes int64 `json:"bytes"`
+ Remove bool `json:"remove"`
+ Why []string `json:"why,omitempty"`
+ sum string
+}
+
+// Packaged is a family a package installs.
+type Packaged struct {
+ Family string `json:"family"`
+ Package string `json:"package"`
+}
+
+// SourcesAnswer is what fonts_sources answers.
+type SourcesAnswer struct {
+ Hand []HandFile `json:"hand_copied"`
+ HandBytes int64 `json:"hand_copied_bytes"`
+ RemoveCount int `json:"removable"`
+ Packaged []Packaged `json:"packaged"`
+ Note string `json:"note"`
+}
+
+var fontExt = map[string]bool{".ttf": true, ".otf": true, ".ttc": true, ".otc": true, ".pfb": true, ".pcf": true, ".woff": true, ".woff2": true, ".bdf": true}
+
+// handFiles walks the account's font directories.
+func handFiles(home string) ([]HandFile, error) {
+ out := []HandFile{}
+ for _, d := range fontDirs {
+ root := filepath.Join(home, d)
+ err := filepath.WalkDir(root, func(p string, e fs.DirEntry, err error) error {
+ if err != nil {
+ if p == root && os.IsNotExist(err) {
+ return filepath.SkipDir
+ }
+ return err
+ }
+ if e.IsDir() || !fontExt[strings.ToLower(filepath.Ext(p))] {
+ return nil
+ }
+ info, err := e.Info()
+ if err != nil {
+ return err
+ }
+ f, err := os.Open(p)
+ if err != nil {
+ return err
+ }
+ h := sha256.New()
+ _, err = io.Copy(h, f)
+ f.Close()
+ if err != nil {
+ return err
+ }
+ out = append(out, HandFile{File: p, Bytes: info.Size(), sum: hex.EncodeToString(h.Sum(nil))})
+ return nil
+ })
+ if err != nil && err != filepath.SkipDir {
+ return nil, fmt.Errorf("reading %s: %w", root, err)
+ }
+ }
+ sort.Slice(out, func(i, k int) bool { return out[i].File < out[k].File })
+ return out, nil
+}
+
+// Sources says which font files were copied by hand, which of them can go, and which families
+// packages install.
+func Sources() (SourcesAnswer, error) {
+ home := accountHome()
+ hand, err := handFiles(home)
+ if err != nil {
+ return SourcesAnswer{}, err
+ }
+ out := SourcesAnswer{Hand: hand, Packaged: []Packaged{},
+ Note: "Nothing is removed by this tool. The mesh removes nothing it did not place (ADR 0182): a file marked remove is for the operator to delete, once; then run fonts_cache_rebuild."}
+ if len(hand) > 0 {
+ args := []string{"--format", "%{file}\t%{family[0]}\t%{style[0]}\n"}
+ for _, h := range hand {
+ args = append(args, h.File)
+ }
+ r, err := call(Cmd{Name: "fc-scan", Args: args})
+ if err != nil {
+ return SourcesAnswer{}, err
+ }
+ named := map[string][2]string{}
+ for _, l := range lines(r.Stdout) {
+ f := strings.Split(l, "\t")
+ if len(f) >= 3 {
+ if _, seen := named[f[0]]; !seen {
+ named[f[0]] = [2]string{f[1], f[2]}
+ }
+ }
+ }
+ for i := range out.Hand {
+ n := named[out.Hand[i].File]
+ out.Hand[i].Family, out.Hand[i].Style = n[0], n[1]
+ }
+ }
+ // Families the packages install, each with one file to ask pacman about.
+ r, err := call(Cmd{Name: "fc-list", Args: []string{"--format", "%{family[0]}\t%{file}\n"}})
+ if err != nil {
+ return SourcesAnswer{}, err
+ }
+ fileOf := map[string]string{}
+ for _, l := range lines(r.Stdout) {
+ f := strings.Split(l, "\t")
+ if len(f) >= 2 && f[0] != "" && strings.HasPrefix(f[1], packagedRoot) {
+ if _, seen := fileOf[f[0]]; !seen {
+ fileOf[f[0]] = f[1]
+ }
+ }
+ }
+ owner := map[string]string{}
+ if len(fileOf) > 0 {
+ args := []string{"-Qo"}
+ for _, fam := range keys(boolSet(fileOf)) {
+ args = append(args, fileOf[fam])
+ }
+ // pacman exits 1 when one file is unowned and still answers the rest: read what it said.
+ r := run(Cmd{Name: "pacman", Args: args})
+ if r.Error != "" {
+ return SourcesAnswer{}, failure(Cmd{Name: "pacman", Args: []string{"-Qo"}}, r)
+ }
+ for _, l := range lines(r.Stdout) {
+ // "/usr/share/fonts/x.ttf is owned by noto-fonts 1:2026.08.01-1"
+ if i := strings.Index(l, " is owned by "); i > 0 {
+ pkg := strings.Fields(l[i+len(" is owned by "):])
+ if len(pkg) > 0 {
+ owner[l[:i]] = pkg[0]
+ }
+ }
+ }
+ }
+ for _, fam := range keys(boolSet(fileOf)) {
+ pkg := owner[fileOf[fam]]
+ if pkg == "" {
+ pkg = "(no package)"
+ }
+ out.Packaged = append(out.Packaged, Packaged{Family: fam, Package: pkg})
+ }
+ // Which hand-copied files can go, and why.
+ firstOf := map[string]string{}
+ for i := range out.Hand {
+ h := &out.Hand[i]
+ if prev, dup := firstOf[h.sum]; dup {
+ h.Why = append(h.Why, "the same bytes as "+filepath.Base(prev))
+ } else {
+ firstOf[h.sum] = h.File
+ }
+ if strings.Contains(filepath.Base(h.File), "%20") {
+ h.Why = append(h.Why, "a URL-encoded copy of a name")
+ }
+ if pkg := owner[fileOf[h.Family]]; h.Family != "" && pkg != "" {
+ h.Why = append(h.Why, "its family is installed by the package "+pkg)
+ }
+ for _, r := range Retired {
+ if h.Family == r {
+ h.Why = append(h.Why, "a monospace face the desktop named before the fonts module; "+Decided["monospace"]+" replaces it once the terminal, window manager, bar and launcher name that family")
+ break
+ }
+ }
+ h.Remove = len(h.Why) > 0
+ out.HandBytes += h.Bytes
+ if h.Remove {
+ out.RemoveCount++
+ }
+ }
+ return out, nil
+}
+
+func boolSet(m map[string]string) map[string]bool {
+ out := map[string]bool{}
+ for k := range m {
+ out[k] = true
+ }
+ return out
+}
+
+// ConfigAnswer is what fonts_config answers.
+type ConfigAnswer struct {
+ Path string `json:"path"`
+ Present bool `json:"present"`
+ Loaded bool `json:"loaded"`
+ Bytes int `json:"bytes,omitempty"`
+ Others []string `json:"account_files_beside_it"`
+ Note string `json:"note,omitempty"`
+}
+
+// Config says whether the module's file is in place and loaded.
+func Config() (ConfigAnswer, error) {
+ path := filepath.Join(accountHome(), ConfigFile)
+ out := ConfigAnswer{Path: path, Others: []string{}}
+ if b, err := os.ReadFile(path); err == nil {
+ out.Present, out.Bytes = true, len(b)
+ } else if !os.IsNotExist(err) {
+ return ConfigAnswer{}, fmt.Errorf("reading %s: %w", path, err)
+ }
+ entries, _ := os.ReadDir(filepath.Dir(path))
+ for _, e := range entries {
+ if p := filepath.Join(filepath.Dir(path), e.Name()); p != path {
+ out.Others = append(out.Others, p)
+ }
+ }
+ if b, err := os.ReadFile(filepath.Join(accountHome(), ".config/fontconfig/fonts.conf")); err == nil && len(b) > 0 {
+ out.Others = append(out.Others, filepath.Join(accountHome(), ".config/fontconfig/fonts.conf"))
+ }
+ r, err := call(Cmd{Name: "fc-conflist"})
+ if err != nil {
+ return ConfigAnswer{}, err
+ }
+ for _, l := range lines(r.Stdout) {
+ // "+ /path: description" for a file in use, "- /path" for one skipped
+ if strings.HasPrefix(l, "+ "+path+":") || strings.TrimSpace(l) == "+ "+path {
+ out.Loaded = true
+ }
+ }
+ if !out.Present {
+ out.Note = "the file is not in place: the fonts module is not assigned to this machine, or its push has not reached it"
+ } else if !out.Loaded {
+ out.Note = "the file is in place and fontconfig does not load it: check that /etc/fonts/conf.d/50-user.conf is enabled"
+ }
+ if len(out.Others) > 0 {
+ out.Note = strings.TrimSpace(out.Note + " The other files are the account's own: they are read too, and one sorting after 50-mesh-fonts.conf can override it.")
+ }
+ return out, nil
+}
+
+// RebuildAnswer is what fonts_cache_rebuild answers.
+type RebuildAnswer struct {
+ Scope string `json:"scope"`
+ ElapsedMS int64 `json:"elapsed_ms"`
+ Output string `json:"output,omitempty"`
+}
+
+// CacheRebuild rebuilds the account's font cache, or the system's.
+func CacheRebuild(system bool) (RebuildAnswer, error) {
+ c := Cmd{Name: "fc-cache", Args: []string{"-f"}}
+ scope := "account"
+ if system {
+ c, scope = Cmd{Name: "fc-cache", Args: []string{"-s", "-f"}, Root: true}, "system"
+ }
+ start := time.Now()
+ r, err := call(c)
+ if err != nil {
+ return RebuildAnswer{}, err
+ }
+ return RebuildAnswer{Scope: scope, ElapsedMS: time.Since(start).Milliseconds(), Output: tail(strings.TrimSpace(r.Stdout+r.Stderr), 4000)}, nil
+}
diff --git a/modules/fonts/cmd/fonts-tools/fonts_test.go b/modules/fonts/cmd/fonts-tools/fonts_test.go
new file mode 100644
index 0000000..8fe9b19
--- /dev/null
+++ b/modules/fonts/cmd/fonts-tools/fonts_test.go
@@ -0,0 +1,302 @@
+package main
+
+import (
+ "encoding/xml"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestTheManifestInstallsTheFiveDecidedFacesAndOwnsOneAccountFile(t *testing.T) {
+ m := readManifest(t)
+ holdsTheBundle(t, m, "fonts")
+ want := "inter-font,noto-fonts,noto-fonts-emoji,ttf-jetbrains-mono-nerd,ttf-nerd-fonts-symbols"
+ if got := strings.Join(m.packages(), ","); got != want {
+ t.Errorf("packages %s, want %s", got, want)
+ }
+ files := 0
+ for _, r := range m.Resources {
+ if r["type"] == "file" {
+ files++
+ }
+ if r["type"] == "service" || r["type"] == "directory" {
+ t.Errorf("a font needs no %s: %v", r["type"], r["id"])
+ }
+ }
+ f := m.resource("defaults")
+ if files != 1 || f == nil {
+ t.Fatalf("one owned file, got %d", files)
+ }
+ if f["path"] != "${machine:account-home}/"+ConfigFile || f["owner"] != "${machine:account}" || f["mode"] != "0644" {
+ t.Errorf("the file is the account's, at %s: %v", ConfigFile, f)
+ }
+ if _, into := f["into"]; into {
+ t.Error("the file is the module's whole, not written into")
+ }
+}
+
+// fontconfig's document, as far as the module's file uses it.
+type fcDoc struct {
+ Aliases []struct {
+ Binding string `xml:"binding,attr"`
+ Family string `xml:"family"`
+ Prefer []string `xml:"prefer>family"`
+ Accept []string `xml:"accept>family"`
+ } `xml:"alias"`
+ Matches []struct {
+ Target string `xml:"target,attr"`
+ Edits []struct {
+ Name string `xml:"name,attr"`
+ Mode string `xml:"mode,attr"`
+ String string `xml:"string"`
+ } `xml:"edit"`
+ } `xml:"match"`
+}
+
+func TestTheFontconfigFileMapsEachGenericToTheDecidedFaceStronglyAndFallsBackLast(t *testing.T) {
+ m := readManifest(t)
+ content, _ := m.resource("defaults")["content"].(string)
+ if !strings.HasPrefix(content, " 0 {
+ firstPrefer[a.Family] = a.Prefer[0]
+ if a.Binding != "same" {
+ t.Errorf("%s is bound %q: a weakly added face loses to the distribution's choice", a.Family, a.Binding)
+ }
+ }
+ }
+ for generic, face := range Decided {
+ if firstPrefer[generic] != face {
+ t.Errorf("%s prefers %q, decided %q", generic, firstPrefer[generic], face)
+ }
+ }
+ // The retired families lead to monospace, and are placed before the monospace rule.
+ mono := strings.Index(strings.Join(order, "|"), "|monospace|")
+ for _, r := range []string{"Hack Nerd Font", "MesloLGS NF", "Iosevka Nerd Font"} {
+ at := strings.Index(strings.Join(order, "|"), r)
+ if at < 0 || at > mono {
+ t.Errorf("%s is not mapped to monospace before the monospace rule", r)
+ }
+ }
+ if len(doc.Matches) != 1 || doc.Matches[0].Target != "pattern" {
+ t.Fatalf("one pattern match for the fallbacks: %+v", doc.Matches)
+ }
+ got := []string{}
+ for _, e := range doc.Matches[0].Edits {
+ if e.Name != "family" || e.Mode != "append_last" {
+ t.Errorf("a fallback is appended last, never prepended: %+v", e)
+ }
+ got = append(got, e.String)
+ }
+ if strings.Join(got, ",") != "Symbols Nerd Font,Noto Color Emoji" {
+ t.Errorf("fallbacks %v", got)
+ }
+}
+
+func TestFamiliesGroupsFilesAndTellsAHandCopiedFaceFromAPackagedOne(t *testing.T) {
+ t.Setenv("MESH_OPERATOR_HOME", "/home/op")
+ using(t, func(line string, c Cmd) Result {
+ return ok("Inter\tRegular\t\t/usr/share/fonts/inter/Inter.ttc\n" +
+ "Inter\tBold\t\t/usr/share/fonts/inter/Inter-Bold.ttc\n" +
+ "Hack Nerd Font\tRegular\t100\t/home/op/.local/share/fonts/Hack.ttf\n" +
+ "Noto Sans Mono\tRegular\t100\t/usr/share/fonts/noto/NotoSansMono.ttf\n")
+ })
+ got, err := Families("", 10)
+ if err != nil || got.Count != 3 {
+ t.Fatalf("%+v %v", got, err)
+ }
+ if f := got.Families[0]; f.Family != "Hack Nerd Font" || f.Source != "account" || !f.Monospace {
+ t.Errorf("%+v", f)
+ }
+ if f := got.Families[1]; f.Family != "Inter" || f.Files != 2 || f.Monospace || f.Source != "package" || len(f.Styles) != 2 {
+ t.Errorf("%+v", f)
+ }
+ got, _ = Families("noto", 10)
+ if got.Count != 1 {
+ t.Errorf("filtered: %+v", got)
+ }
+ got, _ = Families("", 1)
+ if !got.Truncated || len(got.Families) != 1 || got.Count != 3 {
+ t.Errorf("bounded: %+v", got)
+ }
+}
+
+func TestMatchSaysWhetherEachGenericIsTheDecidedFace(t *testing.T) {
+ f := using(t, func(line string, c Cmd) Result {
+ switch c.Args[len(c.Args)-1] {
+ case "monospace":
+ return ok("JetBrainsMono Nerd Font\tRegular\t/usr/share/fonts/TTF/JetBrainsMonoNerdFont-Regular.ttf")
+ case "sans-serif", "system-ui":
+ return ok("Noto Sans\tRegular\t/usr/share/fonts/noto/NotoSans-Regular.ttf")
+ case "serif":
+ return ok("Noto Serif\tRegular\t/usr/share/fonts/noto/NotoSerif-Regular.ttf")
+ }
+ return ok("Noto Color Emoji\tRegular\t/usr/share/fonts/noto/NotoColorEmoji.ttf")
+ })
+ got, err := Match(nil)
+ if err != nil || len(got.Resolved) != 5 || got.AllAsDecided == nil || *got.AllAsDecided {
+ t.Fatalf("%+v %v", got, err)
+ }
+ if r := got.Resolved[0]; !*r.AsDecided || r.Source != "package" {
+ t.Errorf("monospace: %+v", r)
+ }
+ if r := got.Resolved[1]; *r.AsDecided || r.Decided != "Inter" {
+ t.Errorf("sans-serif is not Inter here: %+v", r)
+ }
+ if len(f.asked) != 5 {
+ t.Errorf("one fc-match per generic: %v", f.lines())
+ }
+ got, _ = Match([]string{"Inter:bold"})
+ if got.AllAsDecided != nil || got.Resolved[0].AsDecided != nil {
+ t.Errorf("a pattern of the caller's own has no decided face: %+v", got)
+ }
+ if _, err := Match([]string{"--help"}); err == nil {
+ t.Error("an option as a pattern")
+ }
+}
+
+func TestMatchAnEmptyAnswerIsAnError(t *testing.T) {
+ using(t, func(string, Cmd) Result { return ok("") })
+ if _, err := Match(nil); err == nil {
+ t.Fatal("an empty fc-match answer was read as a face")
+ }
+}
+
+func TestGlyphReadsACharacterOrACodePointAndAsksForIt(t *testing.T) {
+ for in, want := range map[string]rune{"\uf120": 0xf120, "U+F120": 0xf120, "0x1f600": 0x1f600, "a": 'a', "é": 'é'} {
+ got, err := codePoint(in)
+ if err != nil || got != want {
+ t.Errorf("%q: %x %v", in, got, err)
+ }
+ }
+ for _, bad := range []string{"ab", "U+ZZ", "U+0", "120"} {
+ if _, err := codePoint(bad); err == nil {
+ t.Errorf("%q was read as a code point", bad)
+ }
+ }
+ f := using(t, func(line string, c Cmd) Result {
+ if c.Name == "fc-list" {
+ return ok("Symbols Nerd Font\tRegular\t100\t/usr/share/fonts/TTF/SymbolsNerdFont-Regular.ttf\n" +
+ "JetBrainsMono Nerd Font\tBold\t100\t/usr/share/fonts/TTF/a.ttf\nJetBrainsMono Nerd Font\tRegular\t100\t/usr/share/fonts/TTF/b.ttf\n")
+ }
+ return ok("JetBrainsMono Nerd Font\tRegular\t/usr/share/fonts/TTF/b.ttf")
+ })
+ got, err := Glyph("U+F120")
+ if err != nil || got.Count != 2 || got.CodePoint != "U+F120" || got.DrawnBy["monospace"] != "JetBrainsMono Nerd Font" {
+ t.Fatalf("%+v %v", got, err)
+ }
+ if !strings.Contains(f.lines()[0], ":charset=f120") || !strings.Contains(f.lines()[1], "monospace:charset=f120") {
+ t.Errorf("asked %v", f.lines())
+ }
+}
+
+func TestSourcesMarksDuplicatesEncodedNamesPackagedAndRetiredFacesForRemoval(t *testing.T) {
+ home := t.TempDir()
+ t.Setenv("MESH_OPERATOR_HOME", home)
+ dir := filepath.Join(home, ".local/share/fonts")
+ _ = os.MkdirAll(dir, 0o755)
+ write := func(name, body string) string {
+ p := filepath.Join(dir, name)
+ _ = os.WriteFile(p, []byte(body), 0o644)
+ return p
+ }
+ meslo := write("MesloLGS NF Regular.ttf", "meslo")
+ encoded := write("MesloLGS%20NF%20Regular.ttf", "meslo")
+ grape := write("GrapeNuts-Regular.ttf", "grape")
+ noto := write("NotoSans-Copy.ttf", "noto")
+ _ = os.WriteFile(filepath.Join(dir, ".uuid"), []byte("x"), 0o644)
+ f := using(t, func(line string, c Cmd) Result {
+ switch c.Name {
+ case "fc-scan":
+ return ok(meslo + "\tMesloLGS NF\tRegular\n" + encoded + "\tMesloLGS NF\tRegular\n" + grape + "\tGrape Nuts\tRegular\n" + noto + "\tNoto Sans\tRegular\n")
+ case "fc-list":
+ return ok("Noto Sans\t/usr/share/fonts/noto/NotoSans-Regular.ttf\nNoto Sans\t/usr/share/fonts/noto/NotoSans-Bold.ttf\nInter\t/usr/share/fonts/inter/Inter.ttc\nMesloLGS NF\t" + meslo + "\n")
+ case "pacman":
+ return Result{Status: 1, Stdout: "/usr/share/fonts/noto/NotoSans-Regular.ttf is owned by noto-fonts 1:2026.08.01-1\n", Stderr: "error: No package owns /usr/share/fonts/inter/Inter.ttc\n"}
+ }
+ return Result{Status: 9}
+ })
+ got, err := Sources()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(got.Hand) != 4 {
+ t.Fatalf("four font files, the .uuid is not one: %+v", got.Hand)
+ }
+ by := map[string]HandFile{}
+ for _, h := range got.Hand {
+ by[filepath.Base(h.File)] = h
+ }
+ if h := by["GrapeNuts-Regular.ttf"]; h.Remove || h.Family != "Grape Nuts" {
+ t.Errorf("a face nothing replaces is kept: %+v", h)
+ }
+ if h := by["MesloLGS%20NF%20Regular.ttf"]; !h.Remove || len(h.Why) < 3 {
+ t.Errorf("an encoded duplicate of a retired face: %+v", h)
+ }
+ if h := by["NotoSans-Copy.ttf"]; !h.Remove || !strings.Contains(strings.Join(h.Why, ";"), "noto-fonts") {
+ t.Errorf("a face a package installs: %+v", h)
+ }
+ if got.RemoveCount != 3 || got.HandBytes != int64(len("meslo")*2+len("grape")+len("noto")) {
+ t.Errorf("%d removable, %d bytes", got.RemoveCount, got.HandBytes)
+ }
+ pk := map[string]string{}
+ for _, p := range got.Packaged {
+ pk[p.Family] = p.Package
+ }
+ if pk["Noto Sans"] != "noto-fonts" || pk["Inter"] != "(no package)" || pk["MesloLGS NF"] != "" {
+ t.Errorf("packaged %v", pk)
+ }
+ for _, l := range f.lines() {
+ if strings.Contains(l, "rm ") || strings.HasPrefix(l, "sudo") {
+ t.Errorf("sources only reads: %s", l)
+ }
+ }
+}
+
+func TestConfigSaysWhetherTheFileIsInPlaceAndLoaded(t *testing.T) {
+ home := t.TempDir()
+ t.Setenv("MESH_OPERATOR_HOME", home)
+ path := filepath.Join(home, ConfigFile)
+ using(t, func(string, Cmd) Result { return ok("+ " + path + ": The mesh\n- /etc/fonts/conf.d/x.conf\n") })
+ got, err := Config()
+ if err != nil || got.Present || !strings.Contains(got.Note, "not in place") {
+ t.Fatalf("absent: %+v %v", got, err)
+ }
+ _ = os.MkdirAll(filepath.Dir(path), 0o755)
+ _ = os.WriteFile(path, []byte(""), 0o644)
+ _ = os.WriteFile(filepath.Join(filepath.Dir(path), "99-mine.conf"), []byte("x"), 0o644)
+ got, err = Config()
+ if err != nil || !got.Present || !got.Loaded || len(got.Others) != 1 {
+ t.Fatalf("present: %+v %v", got, err)
+ }
+}
+
+func TestCacheRebuildIsTheAccountsUnlessTheSystemIsAskedWhichNeedsRoot(t *testing.T) {
+ f := using(t, func(string, Cmd) Result { return ok("") })
+ if got, err := CacheRebuild(false); err != nil || got.Scope != "account" {
+ t.Fatal(got, err)
+ }
+ if got, err := CacheRebuild(true); err != nil || got.Scope != "system" {
+ t.Fatal(got, err)
+ }
+ if l := f.lines(); l[0] != "fc-cache -f" || l[1] != "sudo -n fc-cache -s -f" {
+ t.Errorf("%v", l)
+ }
+ using(t, func(string, Cmd) Result { return Result{Status: 1, Stderr: "sudo: a password is required"} })
+ if _, err := CacheRebuild(true); err == nil || !strings.Contains(err.Error(), "sudo -n refused") {
+ t.Errorf("a refused escalation: %v", err)
+ }
+}
diff --git a/modules/fonts/cmd/fonts-tools/kit.go b/modules/fonts/cmd/fonts-tools/kit.go
new file mode 100644
index 0000000..adc5aac
--- /dev/null
+++ b/modules/fonts/cmd/fonts-tools/kit.go
@@ -0,0 +1,352 @@
+package main
+
+// kit.go is the same file in each of the workstations' tool bundles (fonts, docker-compose, snapd,
+// flatpak, cups, bluetooth, xclip, dmenu): how a tool runs a command, escalates, bounds what it
+// keeps, and names a failure. A module is built from its own directory, so the file is copied rather
+// than shared; a change to one copy is made to all eight.
+//
+// The rules it holds (novox/hq research 026/05, to-be 38 WP4):
+// - the node's tool runtime runs as the operator account, not root (ADR 0175 §4); a command that
+// needs root goes through `sudo -n`, never a prompt, and a refusal is named as such;
+// - one command gets 20 s, below the runtime's 30 s call limit, and is ended with everything it
+// started when it takes longer;
+// - each stream is kept to 256 KiB, and the answer says when it was cut;
+// - a failure is an error with what went wrong in it, never an empty answer.
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "os/exec"
+ "strings"
+ "syscall"
+ "time"
+)
+
+// Bounds every command is held to.
+const (
+ CallTimeout = 20 * time.Second
+ MostOutput = 256 << 10
+)
+
+// Cmd is one command a tool runs.
+type Cmd struct {
+ Name string
+ Args []string
+ // Stdin is written to the command's standard input when not empty.
+ Stdin string
+ // Env is added to this process's own environment.
+ Env []string
+ // Root says the command needs root: it is run through `sudo -n` when this process is not root.
+ Root bool
+ // Timeout replaces CallTimeout; only a background job (jobs.go) asks for longer.
+ Timeout time.Duration
+ // Detached is for a program that forks a child which outlives it, as xclip does to keep the
+ // selection: its streams go to files, because a pipe the child inherits would hold the call open
+ // until the child exits.
+ Detached bool
+}
+
+// Result is what a command did.
+type Result struct {
+ Stdout string `json:"stdout"`
+ Stderr string `json:"stderr"`
+ Status int `json:"status"`
+ // Error is why it did not run to an answer: "not-found" when the program is not there,
+ // "timeout" when it was ended for taking too long, else the spawn error.
+ Error string `json:"error,omitempty"`
+ Truncated bool `json:"truncated,omitempty"`
+}
+
+// Runner runs a command. Tests replace it; nothing else does.
+type Runner func(Cmd) Result
+
+var (
+ run Runner = execRun
+ euid = os.Geteuid
+)
+
+// argv is the command as it is run: through sudo without a prompt when it needs root and this
+// process is not root.
+func argv(c Cmd) (string, []string) {
+ if c.Root && euid() != 0 {
+ return "sudo", append([]string{"-n", c.Name}, c.Args...)
+ }
+ return c.Name, c.Args
+}
+
+// bounded keeps the first MostOutput bytes written to it and notes that more came.
+type bounded struct {
+ b bytes.Buffer
+ cut bool
+}
+
+func (w *bounded) Write(p []byte) (int, error) {
+ room := MostOutput - w.b.Len()
+ if room <= 0 {
+ w.cut = w.cut || len(p) > 0
+ return len(p), nil
+ }
+ if len(p) > room {
+ w.b.Write(p[:room])
+ w.cut = true
+ return len(p), nil
+ }
+ return w.b.Write(p)
+}
+
+func execRun(c Cmd) Result {
+ timeout := c.Timeout
+ if timeout <= 0 {
+ timeout = CallTimeout
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), timeout)
+ defer cancel()
+ name, args := argv(c)
+ cmd := exec.CommandContext(ctx, name, args...)
+ cmd.Env = append(append(os.Environ(), "LC_ALL=C"), c.Env...)
+ if !c.Detached {
+ // Its own process group, so that ending it on a timeout ends what it started too.
+ cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
+ cmd.Cancel = func() error {
+ if cmd.Process != nil {
+ _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
+ }
+ return nil
+ }
+ }
+ cmd.WaitDelay = 2 * time.Second
+ if c.Stdin != "" {
+ cmd.Stdin = strings.NewReader(c.Stdin)
+ }
+ var out, errs bounded
+ var outFile, errFile *os.File
+ if c.Detached {
+ var err error
+ if outFile, err = os.CreateTemp("", "mesh-tool-out-*"); err != nil {
+ return Result{Status: 127, Error: err.Error()}
+ }
+ defer os.Remove(outFile.Name())
+ defer outFile.Close()
+ if errFile, err = os.CreateTemp("", "mesh-tool-err-*"); err != nil {
+ return Result{Status: 127, Error: err.Error()}
+ }
+ defer os.Remove(errFile.Name())
+ defer errFile.Close()
+ cmd.Stdout, cmd.Stderr = outFile, errFile
+ } else {
+ cmd.Stdout, cmd.Stderr = &out, &errs
+ }
+ err := cmd.Run()
+ if c.Detached {
+ for _, f := range []struct {
+ file *os.File
+ into *bounded
+ }{{outFile, &out}, {errFile, &errs}} {
+ if _, e := f.file.Seek(0, io.SeekStart); e == nil {
+ _, _ = io.Copy(f.into, f.file)
+ }
+ }
+ }
+ r := Result{Stdout: out.b.String(), Stderr: errs.b.String(), Truncated: out.cut || errs.cut}
+ var exit *exec.ExitError
+ switch {
+ case err == nil:
+ case ctx.Err() == context.DeadlineExceeded:
+ r.Status, r.Error = 124, "timeout"
+ case errors.Is(err, exec.ErrNotFound) || errors.Is(err, os.ErrNotExist):
+ r.Status, r.Error = 127, "not-found"
+ case errors.As(err, &exit):
+ r.Status = exit.ExitCode()
+ default:
+ r.Status, r.Error = 127, err.Error()
+ }
+ return r
+}
+
+// call runs a command and answers its result, or an error naming what went wrong.
+func call(c Cmd) (Result, error) {
+ r := run(c)
+ if r.Status == 0 && r.Error == "" {
+ return r, nil
+ }
+ return r, failure(c, r)
+}
+
+// failure names how a command failed: not installed, refused escalation, too slow, or its exit
+// status with the end of what it said.
+func failure(c Cmd, r Result) error {
+ program, _ := argv(c)
+ switch {
+ case r.Error == "not-found" && program == "sudo":
+ return fmt.Errorf("%s needs root, and sudo is not installed here for the runtime's account to escalate with", c.Name)
+ case r.Error == "not-found":
+ if hint, ok := providedBy[c.Name]; ok {
+ return fmt.Errorf("%s is not installed on this machine (%s)", c.Name, hint)
+ }
+ return fmt.Errorf("%s is not installed on this machine", c.Name)
+ case r.Error == "timeout":
+ limit := c.Timeout
+ if limit <= 0 {
+ limit = CallTimeout
+ }
+ return fmt.Errorf("%s gave no answer within %s and was ended", c.Name, limit)
+ case r.Error != "":
+ return fmt.Errorf("%s did not run: %s", c.Name, r.Error)
+ case program == "sudo" && strings.Contains(r.Stderr, "command not found"):
+ if hint, ok := providedBy[c.Name]; ok {
+ return fmt.Errorf("%s is not installed on this machine (%s)", c.Name, hint)
+ }
+ return fmt.Errorf("%s is not installed on this machine", c.Name)
+ case program == "sudo" && strings.HasPrefix(strings.TrimSpace(r.Stderr), "sudo:"):
+ return fmt.Errorf("%s needs root, and sudo -n refused the runtime's account: %s (the escalation is the sudo module's to declare)",
+ c.Name, firstLine(r.Stderr))
+ }
+ said := tail(strings.TrimSpace(r.Stderr), 2000)
+ if said == "" {
+ said = tail(strings.TrimSpace(r.Stdout), 2000)
+ }
+ if said == "" {
+ said = "and said nothing"
+ }
+ return fmt.Errorf("%s %s exited %d: %s", c.Name, strings.Join(c.Args, " "), r.Status, said)
+}
+
+func firstLine(s string) string {
+ s = strings.TrimSpace(s)
+ if i := strings.IndexByte(s, '\n'); i >= 0 {
+ return s[:i]
+ }
+ return s
+}
+
+func tail(s string, n int) string {
+ if len(s) <= n {
+ return s
+ }
+ return "…" + s[len(s)-n:]
+}
+
+// lines are a command's output lines, blank ones dropped.
+func lines(s string) []string {
+ out := []string{}
+ for _, l := range strings.Split(s, "\n") {
+ if strings.TrimSpace(l) != "" {
+ out = append(out, strings.TrimRight(l, "\r"))
+ }
+ }
+ return out
+}
+
+// Arguments, read the way a tool's JSON arguments arrive.
+
+func text(args map[string]any, key string) (string, error) {
+ v, ok := args[key]
+ if !ok || v == nil {
+ return "", fmt.Errorf("%s is required", key)
+ }
+ s, ok := v.(string)
+ if !ok {
+ return "", fmt.Errorf("%s must be a string", key)
+ }
+ if strings.TrimSpace(s) == "" {
+ return "", fmt.Errorf("%s must not be empty", key)
+ }
+ return s, nil
+}
+
+func optText(args map[string]any, key, def string) (string, error) {
+ v, ok := args[key]
+ if !ok || v == nil {
+ return def, nil
+ }
+ s, ok := v.(string)
+ if !ok {
+ return "", fmt.Errorf("%s must be a string", key)
+ }
+ if strings.TrimSpace(s) == "" {
+ return def, nil
+ }
+ return s, nil
+}
+
+// optWhole reads a whole number, defaulted, refused below least and held to most.
+func optWhole(args map[string]any, key string, def, least, most int) (int, error) {
+ v, ok := args[key]
+ if !ok || v == nil {
+ return def, nil
+ }
+ f, ok := v.(float64)
+ if !ok {
+ if i, isInt := v.(int); isInt {
+ f = float64(i)
+ } else {
+ return 0, fmt.Errorf("%s must be a number", key)
+ }
+ }
+ if f != float64(int(f)) {
+ return 0, fmt.Errorf("%s must be a whole number", key)
+ }
+ n := int(f)
+ if n < least {
+ return 0, fmt.Errorf("%s must be at least %d", key, least)
+ }
+ if n > most {
+ n = most
+ }
+ return n, nil
+}
+
+func optFlag(args map[string]any, key string, def bool) (bool, error) {
+ v, ok := args[key]
+ if !ok || v == nil {
+ return def, nil
+ }
+ b, ok := v.(bool)
+ if !ok {
+ return false, fmt.Errorf("%s must be true or false", key)
+ }
+ return b, nil
+}
+
+func optList(args map[string]any, key string) ([]string, error) {
+ v, ok := args[key]
+ if !ok || v == nil {
+ return nil, nil
+ }
+ items, ok := v.([]any)
+ if !ok {
+ return nil, fmt.Errorf("%s must be a list of strings", key)
+ }
+ out := make([]string, 0, len(items))
+ for _, it := range items {
+ s, ok := it.(string)
+ if !ok || strings.TrimSpace(s) == "" {
+ return nil, fmt.Errorf("%s must be a list of non-empty strings", key)
+ }
+ out = append(out, s)
+ }
+ return out, nil
+}
+
+// oneOf refuses a value outside a closed set.
+func oneOf(key, value string, allowed ...string) error {
+ for _, a := range allowed {
+ if value == a {
+ return nil
+ }
+ }
+ return fmt.Errorf("%s must be one of %s, not %q", key, strings.Join(allowed, ", "), value)
+}
+
+// plainName refuses a name that could be read as an option or carries a path or a space: package,
+// snap, application and printer names never do.
+func plainName(key, value string) error {
+ if strings.HasPrefix(value, "-") || strings.ContainsAny(value, " \t\n/\\") {
+ return fmt.Errorf("%s %q is not a plain name", key, value)
+ }
+ return nil
+}
diff --git a/modules/fonts/cmd/fonts-tools/kit_test.go b/modules/fonts/cmd/fonts-tools/kit_test.go
new file mode 100644
index 0000000..c5d3557
--- /dev/null
+++ b/modules/fonts/cmd/fonts-tools/kit_test.go
@@ -0,0 +1,147 @@
+package main
+
+// Tests of kit.go, the same in each workstation module.
+
+import (
+ "strings"
+ "testing"
+ "time"
+)
+
+// fake records the commands asked and answers each from a function of the command line.
+type fake struct {
+ asked []Cmd
+ answer func(line string, c Cmd) Result
+}
+
+func (f *fake) runner() Runner {
+ return func(c Cmd) Result {
+ f.asked = append(f.asked, c)
+ name, args := argv(c)
+ line := strings.TrimSpace(name + " " + strings.Join(args, " "))
+ if f.answer == nil {
+ return Result{}
+ }
+ return f.answer(line, c)
+ }
+}
+
+func (f *fake) lines() []string {
+ out := []string{}
+ for _, c := range f.asked {
+ name, args := argv(c)
+ out = append(out, strings.TrimSpace(name+" "+strings.Join(args, " ")))
+ }
+ return out
+}
+
+// using installs a fake runner and a non-root uid for one test.
+func using(t *testing.T, answer func(line string, c Cmd) Result) *fake {
+ t.Helper()
+ f := &fake{answer: answer}
+ wasRun, wasUID := run, euid
+ run, euid = f.runner(), func() int { return 1000 }
+ t.Cleanup(func() { run, euid = wasRun, wasUID })
+ return f
+}
+
+func ok(stdout string) Result { return Result{Stdout: stdout} }
+
+func TestKitAnActThatNeedsRootGoesThroughSudoWithoutAPromptUnlessAlreadyRoot(t *testing.T) {
+ was := euid
+ defer func() { euid = was }()
+ euid = func() int { return 1000 }
+ if name, args := argv(Cmd{Name: "x", Args: []string{"a"}, Root: true}); name != "sudo" || strings.Join(args, " ") != "-n x a" {
+ t.Fatalf("not root: %s %v", name, args)
+ }
+ if name, _ := argv(Cmd{Name: "x"}); name != "x" {
+ t.Fatalf("a read is run as the account: %s", name)
+ }
+ euid = func() int { return 0 }
+ if name, _ := argv(Cmd{Name: "x", Root: true}); name != "x" {
+ t.Fatalf("as root no sudo: %s", name)
+ }
+}
+
+func TestKitAFailureIsNamedByHowItFailed(t *testing.T) {
+ was := euid
+ defer func() { euid = was }()
+ euid = func() int { return 1000 }
+ cases := []struct {
+ c Cmd
+ r Result
+ want string
+ }{
+ {Cmd{Name: "nothere"}, Result{Status: 127, Error: "not-found"}, "not installed"},
+ {Cmd{Name: "x", Root: true}, Result{Status: 127, Error: "not-found"}, "sudo is not installed"},
+ {Cmd{Name: "x", Root: true}, Result{Status: 1, Stderr: "sudo: a password is required\n"}, "sudo -n refused"},
+ {Cmd{Name: "x", Root: true}, Result{Status: 1, Stderr: "sudo: x: command not found\n"}, "x is not installed"},
+ {Cmd{Name: "x"}, Result{Status: 124, Error: "timeout"}, "within 20s"},
+ {Cmd{Name: "x", Args: []string{"y"}}, Result{Status: 3, Stderr: "boom\n"}, "x y exited 3: boom"},
+ {Cmd{Name: "x"}, Result{Status: 3}, "said nothing"},
+ }
+ for _, k := range cases {
+ err := failure(k.c, k.r)
+ if err == nil || !strings.Contains(err.Error(), k.want) {
+ t.Errorf("%+v: %v, want %q", k.r, err, k.want)
+ }
+ }
+}
+
+func TestKitOutputIsBoundedAndSaysSo(t *testing.T) {
+ var w bounded
+ big := strings.Repeat("a", MostOutput+10)
+ n, _ := w.Write([]byte(big))
+ if n != len(big) || w.b.Len() != MostOutput || !w.cut {
+ t.Fatalf("kept %d of %d, cut %v", w.b.Len(), len(big), w.cut)
+ }
+}
+
+func TestKitTheRealRunnerRunsEndsAndReportsAMissingProgram(t *testing.T) {
+ r := execRun(Cmd{Name: "sh", Args: []string{"-c", "echo out; echo err >&2; exit 3"}})
+ if r.Status != 3 || strings.TrimSpace(r.Stdout) != "out" || strings.TrimSpace(r.Stderr) != "err" {
+ t.Fatalf("%+v", r)
+ }
+ r = execRun(Cmd{Name: "sh", Args: []string{"-c", "sleep 5 & sleep 5"}, Timeout: 200 * time.Millisecond})
+ if r.Error != "timeout" {
+ t.Fatalf("a slow command: %+v", r)
+ }
+ r = execRun(Cmd{Name: "no-such-program-anywhere"})
+ if r.Error != "not-found" {
+ t.Fatalf("a missing program: %+v", r)
+ }
+ r = execRun(Cmd{Name: "cat", Stdin: "given"})
+ if r.Stdout != "given" {
+ t.Fatalf("stdin: %+v", r)
+ }
+ start := time.Now()
+ r = execRun(Cmd{Name: "sh", Args: []string{"-c", "echo kept; (sleep 3 &) ; exit 0"}, Detached: true})
+ if r.Status != 0 || strings.TrimSpace(r.Stdout) != "kept" || time.Since(start) > 2*time.Second {
+ t.Fatalf("a detached command returns when it exits, not when its child does: %+v after %s", r, time.Since(start))
+ }
+}
+
+func TestKitArgumentsAreReadStrictly(t *testing.T) {
+ args := map[string]any{"s": "x", "n": float64(5), "f": 1.5, "b": true, "l": []any{"a", "b"}}
+ if _, err := text(args, "missing"); err == nil {
+ t.Error("a missing required string")
+ }
+ if n, _ := optWhole(args, "n", 1, 1, 3); n != 3 {
+ t.Errorf("held to most: %d", n)
+ }
+ if _, err := optWhole(args, "n", 1, 6, 9); err == nil {
+ t.Error("below least")
+ }
+ if _, err := optWhole(args, "f", 1, 0, 9); err == nil {
+ t.Error("a fraction")
+ }
+ if l, _ := optList(args, "l"); len(l) != 2 {
+ t.Errorf("list: %v", l)
+ }
+ if b, _ := optFlag(args, "b", false); !b {
+ t.Error("flag")
+ }
+ if err := plainName("name", "--all"); err == nil {
+ t.Error("an option as a name")
+ }
+}
diff --git a/modules/fonts/cmd/fonts-tools/main.go b/modules/fonts/cmd/fonts-tools/main.go
new file mode 100644
index 0000000..4d00345
--- /dev/null
+++ b/modules/fonts/cmd/fonts-tools/main.go
@@ -0,0 +1,117 @@
+// The fonts module's tools (novox/hq research 026/04, 026/05): what faces the account has, what the
+// generic families resolve to, which face draws a character, which font files were copied by hand
+// and may go, and rebuilding the font cache. A Go bundle the node's runtime launches and speaks MCP
+// to over stdio (ADR 0188, ADR 0193); it runs as the operator account.
+package main
+
+import (
+ "fmt"
+ "os"
+
+ stdio "git.novox.be/novox/mesh-sdk/go"
+)
+
+// providedBy names what installs a program the tools run, for a failure that says so.
+var providedBy = map[string]string{
+ "fc-list": "the fontconfig package",
+ "fc-match": "the fontconfig package",
+ "fc-scan": "the fontconfig package",
+ "fc-cache": "the fontconfig package",
+ "fc-conflist": "the fontconfig package",
+ "pacman": "this is not an Arch machine",
+}
+
+func main() {
+ if err := stdio.Serve("", tools()); err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+}
+
+func tools() []stdio.Tool {
+ return []stdio.Tool{
+ {
+ Name: "fonts_families",
+ Description: "The font families installed for the operator account, each with its styles, how many files, " +
+ "whether it is monospaced, and where it comes from: package (under /usr/share/fonts), account (copied into " +
+ "the home by hand) or other. (r)",
+ Input: map[string]any{
+ "contains": map[string]any{"type": "string", "description": "only families whose name contains this, any case"},
+ "limit": map[string]any{"type": "integer", "description": "at most this many families (default 500, at most 2000)"},
+ },
+ Run: func(args map[string]any) (any, error) {
+ contains, err := optText(args, "contains", "")
+ if err != nil {
+ return nil, err
+ }
+ limit, err := optWhole(args, "limit", 500, 1, 2000)
+ if err != nil {
+ return nil, err
+ }
+ return Families(contains, limit)
+ },
+ },
+ {
+ Name: "fonts_match",
+ Description: "What the generic families resolve to for this account: monospace, sans-serif, system-ui, serif and " +
+ "emoji by default, or the patterns given. Each answer names the face, its file, the face the fonts module " +
+ "decided on, and whether they agree. (r)",
+ Input: map[string]any{
+ "patterns": map[string]any{"type": "array", "items": map[string]any{"type": "string"},
+ "description": "fontconfig patterns to resolve instead, such as \"monospace:bold\" or \"Inter\""},
+ },
+ Run: func(args map[string]any) (any, error) {
+ patterns, err := optList(args, "patterns")
+ if err != nil {
+ return nil, err
+ }
+ return Match(patterns)
+ },
+ },
+ {
+ Name: "fonts_glyph",
+ Description: "Which installed fonts have a given character, and which face monospace and sans-serif would draw " +
+ "it with. The character is given as itself or as a code point (U+F120, 0xF120). (r)",
+ Input: map[string]any{
+ "character": map[string]any{"type": "string", "description": "one character, or its code point as U+XXXX or 0xXXXX"},
+ },
+ Run: func(args map[string]any) (any, error) {
+ ch, err := text(args, "character")
+ if err != nil {
+ return nil, err
+ }
+ return Glyph(ch)
+ },
+ },
+ {
+ Name: "fonts_sources",
+ Description: "Every font file copied into the account's font directories by hand, with its family, whether it " +
+ "duplicates another, and whether a package now provides that family; and the families installed by " +
+ "packages, with the package. Says which hand-copied files can be removed and why; removes nothing. (r)",
+ Input: map[string]any{},
+ Run: func(map[string]any) (any, error) { return Sources() },
+ },
+ {
+ Name: "fonts_config",
+ Description: "The fontconfig file the fonts module owns: whether it is in place, whether fontconfig loads it, " +
+ "and the account's other fontconfig files beside it. (r)",
+ Input: map[string]any{},
+ Run: func(map[string]any) (any, error) { return Config() },
+ },
+ {
+ Name: "fonts_cache_rebuild",
+ Description: "Rebuild the font cache: the account's (default), or the system's with system: true, which needs " +
+ "root and goes through sudo without a prompt. Answers how long it took. (a)",
+ Input: map[string]any{
+ "system": map[string]any{"type": "boolean", "description": "rebuild the system cache instead of the account's"},
+ },
+ Run: func(args map[string]any) (any, error) {
+ system, err := optFlag(args, "system", false)
+ if err != nil {
+ return nil, err
+ }
+ return CacheRebuild(system)
+ },
+ },
+ }
+}
diff --git a/modules/fonts/cmd/fonts-tools/manifest_kit_test.go b/modules/fonts/cmd/fonts-tools/manifest_kit_test.go
new file mode 100644
index 0000000..3e675b4
--- /dev/null
+++ b/modules/fonts/cmd/fonts-tools/manifest_kit_test.go
@@ -0,0 +1,107 @@
+package main
+
+// manifest_kit_test.go is the same file in each workstation module: it reads the module's
+// definition so the module's own tests can hold it to what it says.
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+ "testing"
+)
+
+type manifest struct {
+ Module string `json:"module"`
+ Capabilities []string `json:"capabilities"`
+ Claims []any `json:"claims"`
+ Seats []any `json:"seats"`
+ Tools []string `json:"tools"`
+ Resources []map[string]any `json:"resources"`
+ Build struct {
+ Artifacts []map[string]any `json:"artifacts"`
+ } `json:"build"`
+}
+
+func readManifest(t *testing.T) manifest {
+ t.Helper()
+ raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ var m manifest
+ if err := json.Unmarshal(raw, &m); err != nil {
+ t.Fatalf("module.json: %v", err)
+ }
+ return m
+}
+
+func (m manifest) resource(id string) map[string]any {
+ for _, r := range m.Resources {
+ if r["id"] == id {
+ return r
+ }
+ }
+ return nil
+}
+
+// packages are the packages the module installs, sorted.
+func (m manifest) packages() []string {
+ out := []string{}
+ for _, r := range m.Resources {
+ if r["type"] == "package" && r["absent"] != true {
+ out = append(out, r["package"].(string))
+ }
+ }
+ sort.Strings(out)
+ return out
+}
+
+// services are the units the module declares, by unit name.
+func (m manifest) services() map[string]map[string]any {
+ out := map[string]map[string]any{}
+ for _, r := range m.Resources {
+ if r["type"] == "service" {
+ out[r["unit"].(string)] = r
+ }
+ }
+ return out
+}
+
+// holdsTheBundle holds the manifest to the Go bundle this directory builds: every tool registered
+// is listed and nothing else, each named _…, and the artifact builds this command.
+func holdsTheBundle(t *testing.T, m manifest, prefix string) {
+ t.Helper()
+ registered := []string{}
+ for _, tool := range tools() {
+ registered = append(registered, tool.Name)
+ if !strings.HasPrefix(tool.Name, prefix+"_") {
+ t.Errorf("tool %s is not named %s_…", tool.Name, prefix)
+ }
+ if tool.Description == "" || tool.Run == nil || tool.Input == nil {
+ t.Errorf("tool %s is not described, runnable and given an input schema", tool.Name)
+ }
+ }
+ if strings.Join(registered, ",") != strings.Join(m.Tools, ",") {
+ t.Errorf("registered %v, listed %v", registered, m.Tools)
+ }
+ if len(m.Build.Artifacts) != 1 {
+ t.Fatalf("one artifact, got %d", len(m.Build.Artifacts))
+ }
+ cwd, _ := os.Getwd()
+ binary := filepath.Base(cwd)
+ a := m.Build.Artifacts[0]
+ want := map[string]any{"kind": "bundle", "language": "go", "system": "arch", "from": "cmd/" + binary, "binary": binary}
+ for k, v := range want {
+ if a[k] != v {
+ t.Errorf("artifact %s = %v, want %v", k, a[k], v)
+ }
+ }
+ if loads, _ := a["loads"].([]any); len(loads) != 1 || loads[0] != binary {
+ t.Errorf("artifact loads %v, want [%s]", a["loads"], binary)
+ }
+ if m.Claims != nil || m.Seats != nil {
+ t.Errorf("claims %v, seats %v: this module holds no seat", m.Claims, m.Seats)
+ }
+}
diff --git a/modules/fonts/go.mod b/modules/fonts/go.mod
new file mode 100644
index 0000000..9341c24
--- /dev/null
+++ b/modules/fonts/go.mod
@@ -0,0 +1,5 @@
+module fonts
+
+go 1.22
+
+require git.novox.be/novox/mesh-sdk/go v0.1.6
diff --git a/modules/fonts/go.sum b/modules/fonts/go.sum
new file mode 100644
index 0000000..0dd6061
--- /dev/null
+++ b/modules/fonts/go.sum
@@ -0,0 +1,2 @@
+git.novox.be/novox/mesh-sdk/go v0.1.6 h1:9qzdYONYbJdWcu6sxQcq9v1LI0JxcfkiKYkMUzJSkVQ=
+git.novox.be/novox/mesh-sdk/go v0.1.6/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
diff --git a/modules/fonts/module.json b/modules/fonts/module.json
new file mode 100644
index 0000000..e668440
--- /dev/null
+++ b/modules/fonts/module.json
@@ -0,0 +1,65 @@
+{
+ "module": "fonts",
+ "version": "1",
+ "capabilities": [
+ "package-manager"
+ ],
+ "tools": [
+ "fonts_families",
+ "fonts_match",
+ "fonts_glyph",
+ "fonts_sources",
+ "fonts_config",
+ "fonts_cache_rebuild"
+ ],
+ "resources": [
+ {
+ "id": "monospace",
+ "type": "package",
+ "package": "ttf-jetbrains-mono-nerd"
+ },
+ {
+ "id": "interface",
+ "type": "package",
+ "package": "inter-font"
+ },
+ {
+ "id": "symbols",
+ "type": "package",
+ "package": "ttf-nerd-fonts-symbols"
+ },
+ {
+ "id": "noto",
+ "type": "package",
+ "package": "noto-fonts"
+ },
+ {
+ "id": "emoji",
+ "type": "package",
+ "package": "noto-fonts-emoji"
+ },
+ {
+ "id": "defaults",
+ "type": "file",
+ "path": "${machine:account-home}/.config/fontconfig/conf.d/50-mesh-fonts.conf",
+ "owner": "${machine:account}",
+ "mode": "0644",
+ "content": "\n\n\n\n The mesh: the faces monospace, sans-serif, serif and emoji mean\n\n \n Hack Nerd Fontmonospace\n MesloLGS NFmonospace\n Iosevka Nerd Fontmonospace\n JetBrains Mono Nerd FontJetBrainsMono Nerd Font\n\n \n monospaceJetBrainsMono Nerd Font\n sans-serifInterNoto Sans\n system-uiInter\n serifNoto Serif\n emojiNoto Color Emoji\n\n \n \n Symbols Nerd Font\n Noto Color Emoji\n \n\n"
+ }
+ ],
+ "build": {
+ "artifacts": [
+ {
+ "name": "tools",
+ "kind": "bundle",
+ "language": "go",
+ "system": "arch",
+ "from": "cmd/fonts-tools",
+ "binary": "fonts-tools",
+ "loads": [
+ "fonts-tools"
+ ]
+ }
+ ]
+ }
+}
diff --git a/modules/snapd/README.md b/modules/snapd/README.md
new file mode 100644
index 0000000..c6c6345
--- /dev/null
+++ b/modules/snapd/README.md
@@ -0,0 +1,71 @@
+# snapd
+
+Snaps on the two workstations (novox/hq research 027/02: "`snapd` and `flatpak` are modules, on the
+two workstations only"; to-be 42 phase 2 step 9).
+
+## Status: tools only, the package blocked
+
+**snapd is not in the distribution's official repositories.** It is a user-repository (AUR) package:
+on the desktop it is installed as a foreign package, and `pacman -Si snapd` finds nothing. The host's
+`package` shape installs from the official repositories only, so this module cannot declare it.
+
+Neither form of ADR 0205 fits either. snapd is a daemon in compiled code with setuid helpers, a socket,
+services and a system mount, so it is not a pinned archive of plain files. The way out is research 027
+question 1, option P2: the build machine builds user-repository packages into a package repository the
+mesh serves. Until that exists:
+
+- **The module declares no resources.** It does not even declare the units snapd brings
+ (`snapd.socket`, `snapd.apparmor.service`). On a workstation without the package, the laptop today,
+ those units do not exist, and the host would fail the module there. A declaration that cannot hold
+ on every machine the module is assigned to is not written.
+- **The tools are written and work wherever snapd is installed.** Where it is not, every tool says
+ that, rather than answering an empty list.
+
+When the repository exists, the module gains, in one change:
+
+1. the package `snapd`;
+2. `snapd.socket` enabled and running;
+3. `snapd.apparmor.service` enabled only if the kernel runs AppArmor (below);
+4. its tests.
+
+## Improves (once it owns the package)
+
+- **The disabled revisions become visible.** snapd keeps old revisions for rollback. On the desktop on
+ 2026-10-04 that was 1.7 GB of snaps, of which about 0.7 GB were disabled revisions: the previous
+ `code`, `core18`, `core20` and `snapd`. `snapd_disk_usage` answers it.
+- **A unit that does nothing is named.** On the desktop `snapd.apparmor.service` is enabled, but the
+ kernel's security modules are `capability,landlock,lockdown,yama,bpf`. There is no AppArmor, so the
+ profiles it would load are enforced by nothing, and strict snaps run unconfined. `snapd_status` says
+ so. Turning AppArmor on is a kernel command-line change, which is the `kernel` module's, and is the
+ operator's choice.
+
+## Tools
+
+All answer JSON; `(r)` reads, `(a)` acts. Reads run as the operator account; acts go through `sudo -n`.
+Acts use `--no-wait`: snapd carries them out in the background, and the answer is snapd's change id,
+followed with `snapd_changes`. No act outlasts the 20 s a call has.
+
+| tool | what |
+|---|---|
+| `snapd_status` (r) | installed or not, version, the four units' enabled and active states, AppArmor in the kernel, `/snap` present, and findings |
+| `snapd_list` (r) | every revision: version, revision, tracking, publisher, notes, disabled |
+| `snapd_info` (r) | one snap: fields, commands, channels |
+| `snapd_updates` (r) | what a refresh would change |
+| `snapd_disk_usage` (r) | bytes per snap and revision, the disabled revisions' share, the total |
+| `snapd_services` (r) | the services snaps provide |
+| `snapd_changes` (r) | recent changes, or one change's tasks |
+| `snapd_install` (a) | install, optionally from a channel and in classic confinement |
+| `snapd_remove` (a) | remove, keeping a snapshot unless `purge` |
+| `snapd_refresh` (a) | refresh one snap, or all |
+
+## What changes when it is assigned
+
+Nothing on disk, on either workstation: the module declares nothing.
+
+- **desktop:** the tools answer for its snaps: `code` (classic), its bases, `gtk-common-themes`,
+ `gnome-3-28-1804`, `snapd`.
+- **laptop:** snapd is not installed, and every tool says so.
+
+## Leaves as found
+
+Everything: the package, its units, `/snap`, the installed snaps and their data.
diff --git a/modules/snapd/cmd/snapd-tools/kit.go b/modules/snapd/cmd/snapd-tools/kit.go
new file mode 100644
index 0000000..adc5aac
--- /dev/null
+++ b/modules/snapd/cmd/snapd-tools/kit.go
@@ -0,0 +1,352 @@
+package main
+
+// kit.go is the same file in each of the workstations' tool bundles (fonts, docker-compose, snapd,
+// flatpak, cups, bluetooth, xclip, dmenu): how a tool runs a command, escalates, bounds what it
+// keeps, and names a failure. A module is built from its own directory, so the file is copied rather
+// than shared; a change to one copy is made to all eight.
+//
+// The rules it holds (novox/hq research 026/05, to-be 38 WP4):
+// - the node's tool runtime runs as the operator account, not root (ADR 0175 §4); a command that
+// needs root goes through `sudo -n`, never a prompt, and a refusal is named as such;
+// - one command gets 20 s, below the runtime's 30 s call limit, and is ended with everything it
+// started when it takes longer;
+// - each stream is kept to 256 KiB, and the answer says when it was cut;
+// - a failure is an error with what went wrong in it, never an empty answer.
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "os/exec"
+ "strings"
+ "syscall"
+ "time"
+)
+
+// Bounds every command is held to.
+const (
+ CallTimeout = 20 * time.Second
+ MostOutput = 256 << 10
+)
+
+// Cmd is one command a tool runs.
+type Cmd struct {
+ Name string
+ Args []string
+ // Stdin is written to the command's standard input when not empty.
+ Stdin string
+ // Env is added to this process's own environment.
+ Env []string
+ // Root says the command needs root: it is run through `sudo -n` when this process is not root.
+ Root bool
+ // Timeout replaces CallTimeout; only a background job (jobs.go) asks for longer.
+ Timeout time.Duration
+ // Detached is for a program that forks a child which outlives it, as xclip does to keep the
+ // selection: its streams go to files, because a pipe the child inherits would hold the call open
+ // until the child exits.
+ Detached bool
+}
+
+// Result is what a command did.
+type Result struct {
+ Stdout string `json:"stdout"`
+ Stderr string `json:"stderr"`
+ Status int `json:"status"`
+ // Error is why it did not run to an answer: "not-found" when the program is not there,
+ // "timeout" when it was ended for taking too long, else the spawn error.
+ Error string `json:"error,omitempty"`
+ Truncated bool `json:"truncated,omitempty"`
+}
+
+// Runner runs a command. Tests replace it; nothing else does.
+type Runner func(Cmd) Result
+
+var (
+ run Runner = execRun
+ euid = os.Geteuid
+)
+
+// argv is the command as it is run: through sudo without a prompt when it needs root and this
+// process is not root.
+func argv(c Cmd) (string, []string) {
+ if c.Root && euid() != 0 {
+ return "sudo", append([]string{"-n", c.Name}, c.Args...)
+ }
+ return c.Name, c.Args
+}
+
+// bounded keeps the first MostOutput bytes written to it and notes that more came.
+type bounded struct {
+ b bytes.Buffer
+ cut bool
+}
+
+func (w *bounded) Write(p []byte) (int, error) {
+ room := MostOutput - w.b.Len()
+ if room <= 0 {
+ w.cut = w.cut || len(p) > 0
+ return len(p), nil
+ }
+ if len(p) > room {
+ w.b.Write(p[:room])
+ w.cut = true
+ return len(p), nil
+ }
+ return w.b.Write(p)
+}
+
+func execRun(c Cmd) Result {
+ timeout := c.Timeout
+ if timeout <= 0 {
+ timeout = CallTimeout
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), timeout)
+ defer cancel()
+ name, args := argv(c)
+ cmd := exec.CommandContext(ctx, name, args...)
+ cmd.Env = append(append(os.Environ(), "LC_ALL=C"), c.Env...)
+ if !c.Detached {
+ // Its own process group, so that ending it on a timeout ends what it started too.
+ cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
+ cmd.Cancel = func() error {
+ if cmd.Process != nil {
+ _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
+ }
+ return nil
+ }
+ }
+ cmd.WaitDelay = 2 * time.Second
+ if c.Stdin != "" {
+ cmd.Stdin = strings.NewReader(c.Stdin)
+ }
+ var out, errs bounded
+ var outFile, errFile *os.File
+ if c.Detached {
+ var err error
+ if outFile, err = os.CreateTemp("", "mesh-tool-out-*"); err != nil {
+ return Result{Status: 127, Error: err.Error()}
+ }
+ defer os.Remove(outFile.Name())
+ defer outFile.Close()
+ if errFile, err = os.CreateTemp("", "mesh-tool-err-*"); err != nil {
+ return Result{Status: 127, Error: err.Error()}
+ }
+ defer os.Remove(errFile.Name())
+ defer errFile.Close()
+ cmd.Stdout, cmd.Stderr = outFile, errFile
+ } else {
+ cmd.Stdout, cmd.Stderr = &out, &errs
+ }
+ err := cmd.Run()
+ if c.Detached {
+ for _, f := range []struct {
+ file *os.File
+ into *bounded
+ }{{outFile, &out}, {errFile, &errs}} {
+ if _, e := f.file.Seek(0, io.SeekStart); e == nil {
+ _, _ = io.Copy(f.into, f.file)
+ }
+ }
+ }
+ r := Result{Stdout: out.b.String(), Stderr: errs.b.String(), Truncated: out.cut || errs.cut}
+ var exit *exec.ExitError
+ switch {
+ case err == nil:
+ case ctx.Err() == context.DeadlineExceeded:
+ r.Status, r.Error = 124, "timeout"
+ case errors.Is(err, exec.ErrNotFound) || errors.Is(err, os.ErrNotExist):
+ r.Status, r.Error = 127, "not-found"
+ case errors.As(err, &exit):
+ r.Status = exit.ExitCode()
+ default:
+ r.Status, r.Error = 127, err.Error()
+ }
+ return r
+}
+
+// call runs a command and answers its result, or an error naming what went wrong.
+func call(c Cmd) (Result, error) {
+ r := run(c)
+ if r.Status == 0 && r.Error == "" {
+ return r, nil
+ }
+ return r, failure(c, r)
+}
+
+// failure names how a command failed: not installed, refused escalation, too slow, or its exit
+// status with the end of what it said.
+func failure(c Cmd, r Result) error {
+ program, _ := argv(c)
+ switch {
+ case r.Error == "not-found" && program == "sudo":
+ return fmt.Errorf("%s needs root, and sudo is not installed here for the runtime's account to escalate with", c.Name)
+ case r.Error == "not-found":
+ if hint, ok := providedBy[c.Name]; ok {
+ return fmt.Errorf("%s is not installed on this machine (%s)", c.Name, hint)
+ }
+ return fmt.Errorf("%s is not installed on this machine", c.Name)
+ case r.Error == "timeout":
+ limit := c.Timeout
+ if limit <= 0 {
+ limit = CallTimeout
+ }
+ return fmt.Errorf("%s gave no answer within %s and was ended", c.Name, limit)
+ case r.Error != "":
+ return fmt.Errorf("%s did not run: %s", c.Name, r.Error)
+ case program == "sudo" && strings.Contains(r.Stderr, "command not found"):
+ if hint, ok := providedBy[c.Name]; ok {
+ return fmt.Errorf("%s is not installed on this machine (%s)", c.Name, hint)
+ }
+ return fmt.Errorf("%s is not installed on this machine", c.Name)
+ case program == "sudo" && strings.HasPrefix(strings.TrimSpace(r.Stderr), "sudo:"):
+ return fmt.Errorf("%s needs root, and sudo -n refused the runtime's account: %s (the escalation is the sudo module's to declare)",
+ c.Name, firstLine(r.Stderr))
+ }
+ said := tail(strings.TrimSpace(r.Stderr), 2000)
+ if said == "" {
+ said = tail(strings.TrimSpace(r.Stdout), 2000)
+ }
+ if said == "" {
+ said = "and said nothing"
+ }
+ return fmt.Errorf("%s %s exited %d: %s", c.Name, strings.Join(c.Args, " "), r.Status, said)
+}
+
+func firstLine(s string) string {
+ s = strings.TrimSpace(s)
+ if i := strings.IndexByte(s, '\n'); i >= 0 {
+ return s[:i]
+ }
+ return s
+}
+
+func tail(s string, n int) string {
+ if len(s) <= n {
+ return s
+ }
+ return "…" + s[len(s)-n:]
+}
+
+// lines are a command's output lines, blank ones dropped.
+func lines(s string) []string {
+ out := []string{}
+ for _, l := range strings.Split(s, "\n") {
+ if strings.TrimSpace(l) != "" {
+ out = append(out, strings.TrimRight(l, "\r"))
+ }
+ }
+ return out
+}
+
+// Arguments, read the way a tool's JSON arguments arrive.
+
+func text(args map[string]any, key string) (string, error) {
+ v, ok := args[key]
+ if !ok || v == nil {
+ return "", fmt.Errorf("%s is required", key)
+ }
+ s, ok := v.(string)
+ if !ok {
+ return "", fmt.Errorf("%s must be a string", key)
+ }
+ if strings.TrimSpace(s) == "" {
+ return "", fmt.Errorf("%s must not be empty", key)
+ }
+ return s, nil
+}
+
+func optText(args map[string]any, key, def string) (string, error) {
+ v, ok := args[key]
+ if !ok || v == nil {
+ return def, nil
+ }
+ s, ok := v.(string)
+ if !ok {
+ return "", fmt.Errorf("%s must be a string", key)
+ }
+ if strings.TrimSpace(s) == "" {
+ return def, nil
+ }
+ return s, nil
+}
+
+// optWhole reads a whole number, defaulted, refused below least and held to most.
+func optWhole(args map[string]any, key string, def, least, most int) (int, error) {
+ v, ok := args[key]
+ if !ok || v == nil {
+ return def, nil
+ }
+ f, ok := v.(float64)
+ if !ok {
+ if i, isInt := v.(int); isInt {
+ f = float64(i)
+ } else {
+ return 0, fmt.Errorf("%s must be a number", key)
+ }
+ }
+ if f != float64(int(f)) {
+ return 0, fmt.Errorf("%s must be a whole number", key)
+ }
+ n := int(f)
+ if n < least {
+ return 0, fmt.Errorf("%s must be at least %d", key, least)
+ }
+ if n > most {
+ n = most
+ }
+ return n, nil
+}
+
+func optFlag(args map[string]any, key string, def bool) (bool, error) {
+ v, ok := args[key]
+ if !ok || v == nil {
+ return def, nil
+ }
+ b, ok := v.(bool)
+ if !ok {
+ return false, fmt.Errorf("%s must be true or false", key)
+ }
+ return b, nil
+}
+
+func optList(args map[string]any, key string) ([]string, error) {
+ v, ok := args[key]
+ if !ok || v == nil {
+ return nil, nil
+ }
+ items, ok := v.([]any)
+ if !ok {
+ return nil, fmt.Errorf("%s must be a list of strings", key)
+ }
+ out := make([]string, 0, len(items))
+ for _, it := range items {
+ s, ok := it.(string)
+ if !ok || strings.TrimSpace(s) == "" {
+ return nil, fmt.Errorf("%s must be a list of non-empty strings", key)
+ }
+ out = append(out, s)
+ }
+ return out, nil
+}
+
+// oneOf refuses a value outside a closed set.
+func oneOf(key, value string, allowed ...string) error {
+ for _, a := range allowed {
+ if value == a {
+ return nil
+ }
+ }
+ return fmt.Errorf("%s must be one of %s, not %q", key, strings.Join(allowed, ", "), value)
+}
+
+// plainName refuses a name that could be read as an option or carries a path or a space: package,
+// snap, application and printer names never do.
+func plainName(key, value string) error {
+ if strings.HasPrefix(value, "-") || strings.ContainsAny(value, " \t\n/\\") {
+ return fmt.Errorf("%s %q is not a plain name", key, value)
+ }
+ return nil
+}
diff --git a/modules/snapd/cmd/snapd-tools/kit_test.go b/modules/snapd/cmd/snapd-tools/kit_test.go
new file mode 100644
index 0000000..c5d3557
--- /dev/null
+++ b/modules/snapd/cmd/snapd-tools/kit_test.go
@@ -0,0 +1,147 @@
+package main
+
+// Tests of kit.go, the same in each workstation module.
+
+import (
+ "strings"
+ "testing"
+ "time"
+)
+
+// fake records the commands asked and answers each from a function of the command line.
+type fake struct {
+ asked []Cmd
+ answer func(line string, c Cmd) Result
+}
+
+func (f *fake) runner() Runner {
+ return func(c Cmd) Result {
+ f.asked = append(f.asked, c)
+ name, args := argv(c)
+ line := strings.TrimSpace(name + " " + strings.Join(args, " "))
+ if f.answer == nil {
+ return Result{}
+ }
+ return f.answer(line, c)
+ }
+}
+
+func (f *fake) lines() []string {
+ out := []string{}
+ for _, c := range f.asked {
+ name, args := argv(c)
+ out = append(out, strings.TrimSpace(name+" "+strings.Join(args, " ")))
+ }
+ return out
+}
+
+// using installs a fake runner and a non-root uid for one test.
+func using(t *testing.T, answer func(line string, c Cmd) Result) *fake {
+ t.Helper()
+ f := &fake{answer: answer}
+ wasRun, wasUID := run, euid
+ run, euid = f.runner(), func() int { return 1000 }
+ t.Cleanup(func() { run, euid = wasRun, wasUID })
+ return f
+}
+
+func ok(stdout string) Result { return Result{Stdout: stdout} }
+
+func TestKitAnActThatNeedsRootGoesThroughSudoWithoutAPromptUnlessAlreadyRoot(t *testing.T) {
+ was := euid
+ defer func() { euid = was }()
+ euid = func() int { return 1000 }
+ if name, args := argv(Cmd{Name: "x", Args: []string{"a"}, Root: true}); name != "sudo" || strings.Join(args, " ") != "-n x a" {
+ t.Fatalf("not root: %s %v", name, args)
+ }
+ if name, _ := argv(Cmd{Name: "x"}); name != "x" {
+ t.Fatalf("a read is run as the account: %s", name)
+ }
+ euid = func() int { return 0 }
+ if name, _ := argv(Cmd{Name: "x", Root: true}); name != "x" {
+ t.Fatalf("as root no sudo: %s", name)
+ }
+}
+
+func TestKitAFailureIsNamedByHowItFailed(t *testing.T) {
+ was := euid
+ defer func() { euid = was }()
+ euid = func() int { return 1000 }
+ cases := []struct {
+ c Cmd
+ r Result
+ want string
+ }{
+ {Cmd{Name: "nothere"}, Result{Status: 127, Error: "not-found"}, "not installed"},
+ {Cmd{Name: "x", Root: true}, Result{Status: 127, Error: "not-found"}, "sudo is not installed"},
+ {Cmd{Name: "x", Root: true}, Result{Status: 1, Stderr: "sudo: a password is required\n"}, "sudo -n refused"},
+ {Cmd{Name: "x", Root: true}, Result{Status: 1, Stderr: "sudo: x: command not found\n"}, "x is not installed"},
+ {Cmd{Name: "x"}, Result{Status: 124, Error: "timeout"}, "within 20s"},
+ {Cmd{Name: "x", Args: []string{"y"}}, Result{Status: 3, Stderr: "boom\n"}, "x y exited 3: boom"},
+ {Cmd{Name: "x"}, Result{Status: 3}, "said nothing"},
+ }
+ for _, k := range cases {
+ err := failure(k.c, k.r)
+ if err == nil || !strings.Contains(err.Error(), k.want) {
+ t.Errorf("%+v: %v, want %q", k.r, err, k.want)
+ }
+ }
+}
+
+func TestKitOutputIsBoundedAndSaysSo(t *testing.T) {
+ var w bounded
+ big := strings.Repeat("a", MostOutput+10)
+ n, _ := w.Write([]byte(big))
+ if n != len(big) || w.b.Len() != MostOutput || !w.cut {
+ t.Fatalf("kept %d of %d, cut %v", w.b.Len(), len(big), w.cut)
+ }
+}
+
+func TestKitTheRealRunnerRunsEndsAndReportsAMissingProgram(t *testing.T) {
+ r := execRun(Cmd{Name: "sh", Args: []string{"-c", "echo out; echo err >&2; exit 3"}})
+ if r.Status != 3 || strings.TrimSpace(r.Stdout) != "out" || strings.TrimSpace(r.Stderr) != "err" {
+ t.Fatalf("%+v", r)
+ }
+ r = execRun(Cmd{Name: "sh", Args: []string{"-c", "sleep 5 & sleep 5"}, Timeout: 200 * time.Millisecond})
+ if r.Error != "timeout" {
+ t.Fatalf("a slow command: %+v", r)
+ }
+ r = execRun(Cmd{Name: "no-such-program-anywhere"})
+ if r.Error != "not-found" {
+ t.Fatalf("a missing program: %+v", r)
+ }
+ r = execRun(Cmd{Name: "cat", Stdin: "given"})
+ if r.Stdout != "given" {
+ t.Fatalf("stdin: %+v", r)
+ }
+ start := time.Now()
+ r = execRun(Cmd{Name: "sh", Args: []string{"-c", "echo kept; (sleep 3 &) ; exit 0"}, Detached: true})
+ if r.Status != 0 || strings.TrimSpace(r.Stdout) != "kept" || time.Since(start) > 2*time.Second {
+ t.Fatalf("a detached command returns when it exits, not when its child does: %+v after %s", r, time.Since(start))
+ }
+}
+
+func TestKitArgumentsAreReadStrictly(t *testing.T) {
+ args := map[string]any{"s": "x", "n": float64(5), "f": 1.5, "b": true, "l": []any{"a", "b"}}
+ if _, err := text(args, "missing"); err == nil {
+ t.Error("a missing required string")
+ }
+ if n, _ := optWhole(args, "n", 1, 1, 3); n != 3 {
+ t.Errorf("held to most: %d", n)
+ }
+ if _, err := optWhole(args, "n", 1, 6, 9); err == nil {
+ t.Error("below least")
+ }
+ if _, err := optWhole(args, "f", 1, 0, 9); err == nil {
+ t.Error("a fraction")
+ }
+ if l, _ := optList(args, "l"); len(l) != 2 {
+ t.Errorf("list: %v", l)
+ }
+ if b, _ := optFlag(args, "b", false); !b {
+ t.Error("flag")
+ }
+ if err := plainName("name", "--all"); err == nil {
+ t.Error("an option as a name")
+ }
+}
diff --git a/modules/snapd/cmd/snapd-tools/main.go b/modules/snapd/cmd/snapd-tools/main.go
new file mode 100644
index 0000000..be6a51b
--- /dev/null
+++ b/modules/snapd/cmd/snapd-tools/main.go
@@ -0,0 +1,153 @@
+// The snapd module's tools (novox/hq research 027/02, 026/05): the snaps on this machine, their
+// revisions and the space they take, the store's pending updates, snapd's changes, and installing,
+// removing and refreshing a snap. A Go bundle the node's runtime launches over stdio (ADR 0188,
+// ADR 0193); it runs as the operator account, and an act goes through `sudo -n`.
+//
+// The module installs nothing: snapd is not in the distribution's official repositories (README).
+package main
+
+import (
+ "fmt"
+ "os"
+
+ stdio "git.novox.be/novox/mesh-sdk/go"
+)
+
+var providedBy = map[string]string{
+ "snap": "snapd is not installed; it is not in the official repositories, and this module does not install it (see its README)",
+ "systemctl": "the systemd package",
+}
+
+func main() {
+ if err := stdio.Serve("", tools()); err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+}
+
+var nameArg = map[string]any{"type": "string", "description": "the snap's name"}
+
+func tools() []stdio.Tool {
+ return []stdio.Tool{
+ {
+ Name: "snapd_status",
+ Description: "Whether snapd is here and working: its version, its units (socket, service, AppArmor loader), " +
+ "whether the kernel runs AppArmor (without it strict snaps are not confined), and whether /snap exists " +
+ "for classic snaps. (r)",
+ Input: map[string]any{},
+ Run: func(map[string]any) (any, error) { return Status() },
+ },
+ {
+ Name: "snapd_list",
+ Description: "Every installed snap and revision: version, revision, channel tracked, publisher, notes, and " +
+ "whether the revision is disabled (kept for rollback, taking space). (r)",
+ Input: map[string]any{},
+ Run: func(map[string]any) (any, error) { return List() },
+ },
+ {
+ Name: "snapd_info",
+ Description: "One snap as the store and snapd describe it: summary, publisher, licence, commands, tracking, and the channels with their versions. (r)",
+ Input: map[string]any{"name": nameArg},
+ Run: func(args map[string]any) (any, error) {
+ name, err := snapName(args)
+ if err != nil {
+ return nil, err
+ }
+ return Info(name)
+ },
+ },
+ {
+ Name: "snapd_updates",
+ Description: "What a refresh would change: each snap with an update, its new version, revision and size. (r)",
+ Input: map[string]any{},
+ Run: func(map[string]any) (any, error) { return Updates() },
+ },
+ {
+ Name: "snapd_disk_usage",
+ Description: "The space each snap's revisions take in /var/lib/snapd/snaps, which of it is disabled revisions " +
+ "kept for rollback, and the total. (r)",
+ Input: map[string]any{},
+ Run: func(map[string]any) (any, error) { return DiskUsage() },
+ },
+ {
+ Name: "snapd_services",
+ Description: "The services installed snaps provide, with whether each starts at boot and runs now. (r)",
+ Input: map[string]any{},
+ Run: func(map[string]any) (any, error) { return Services() },
+ },
+ {
+ Name: "snapd_changes",
+ Description: "snapd's recent changes (installs, refreshes, removals): id, status, when, summary; or, with id, " +
+ "one change's tasks. An act answers the change id to follow here. (r)",
+ Input: map[string]any{"id": map[string]any{"type": "string", "description": "one change's id, for its tasks"}},
+ Run: func(args map[string]any) (any, error) {
+ id, err := optText(args, "id", "")
+ if err != nil {
+ return nil, err
+ }
+ return Changes(id)
+ },
+ },
+ {
+ Name: "snapd_install",
+ Description: "Install a snap from the store, optionally from a channel and in classic confinement. snapd " +
+ "carries it out in the background; the answer is the change id to follow with snapd_changes. (a)",
+ Input: map[string]any{
+ "name": nameArg,
+ "channel": map[string]any{"type": "string", "description": "a channel such as latest/stable or 3.x/edge"},
+ "classic": map[string]any{"type": "boolean", "description": "classic confinement, for a snap that asks for it"},
+ },
+ Run: func(args map[string]any) (any, error) {
+ name, err := snapName(args)
+ if err != nil {
+ return nil, err
+ }
+ channel, err := optText(args, "channel", "")
+ if err != nil {
+ return nil, err
+ }
+ classic, err := optFlag(args, "classic", false)
+ if err != nil {
+ return nil, err
+ }
+ return Install(name, channel, classic)
+ },
+ },
+ {
+ Name: "snapd_remove",
+ Description: "Remove a snap, keeping a snapshot of its data unless purge is set. Answers the change id. (a)",
+ Input: map[string]any{
+ "name": nameArg,
+ "purge": map[string]any{"type": "boolean", "description": "remove its data too, without a snapshot"},
+ },
+ Run: func(args map[string]any) (any, error) {
+ name, err := snapName(args)
+ if err != nil {
+ return nil, err
+ }
+ purge, err := optFlag(args, "purge", false)
+ if err != nil {
+ return nil, err
+ }
+ return Remove(name, purge)
+ },
+ },
+ {
+ Name: "snapd_refresh",
+ Description: "Refresh one snap, or every snap when no name is given. Answers the change id, or that nothing needed it. (a)",
+ Input: map[string]any{"name": map[string]any{"type": "string", "description": "the snap to refresh (default all)"}},
+ Run: func(args map[string]any) (any, error) {
+ name, err := optText(args, "name", "")
+ if err != nil {
+ return nil, err
+ }
+ if name != "" {
+ if err := checkSnapName(name); err != nil {
+ return nil, err
+ }
+ }
+ return Refresh(name)
+ },
+ },
+ }
+}
diff --git a/modules/snapd/cmd/snapd-tools/manifest_kit_test.go b/modules/snapd/cmd/snapd-tools/manifest_kit_test.go
new file mode 100644
index 0000000..3e675b4
--- /dev/null
+++ b/modules/snapd/cmd/snapd-tools/manifest_kit_test.go
@@ -0,0 +1,107 @@
+package main
+
+// manifest_kit_test.go is the same file in each workstation module: it reads the module's
+// definition so the module's own tests can hold it to what it says.
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+ "testing"
+)
+
+type manifest struct {
+ Module string `json:"module"`
+ Capabilities []string `json:"capabilities"`
+ Claims []any `json:"claims"`
+ Seats []any `json:"seats"`
+ Tools []string `json:"tools"`
+ Resources []map[string]any `json:"resources"`
+ Build struct {
+ Artifacts []map[string]any `json:"artifacts"`
+ } `json:"build"`
+}
+
+func readManifest(t *testing.T) manifest {
+ t.Helper()
+ raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ var m manifest
+ if err := json.Unmarshal(raw, &m); err != nil {
+ t.Fatalf("module.json: %v", err)
+ }
+ return m
+}
+
+func (m manifest) resource(id string) map[string]any {
+ for _, r := range m.Resources {
+ if r["id"] == id {
+ return r
+ }
+ }
+ return nil
+}
+
+// packages are the packages the module installs, sorted.
+func (m manifest) packages() []string {
+ out := []string{}
+ for _, r := range m.Resources {
+ if r["type"] == "package" && r["absent"] != true {
+ out = append(out, r["package"].(string))
+ }
+ }
+ sort.Strings(out)
+ return out
+}
+
+// services are the units the module declares, by unit name.
+func (m manifest) services() map[string]map[string]any {
+ out := map[string]map[string]any{}
+ for _, r := range m.Resources {
+ if r["type"] == "service" {
+ out[r["unit"].(string)] = r
+ }
+ }
+ return out
+}
+
+// holdsTheBundle holds the manifest to the Go bundle this directory builds: every tool registered
+// is listed and nothing else, each named _…, and the artifact builds this command.
+func holdsTheBundle(t *testing.T, m manifest, prefix string) {
+ t.Helper()
+ registered := []string{}
+ for _, tool := range tools() {
+ registered = append(registered, tool.Name)
+ if !strings.HasPrefix(tool.Name, prefix+"_") {
+ t.Errorf("tool %s is not named %s_…", tool.Name, prefix)
+ }
+ if tool.Description == "" || tool.Run == nil || tool.Input == nil {
+ t.Errorf("tool %s is not described, runnable and given an input schema", tool.Name)
+ }
+ }
+ if strings.Join(registered, ",") != strings.Join(m.Tools, ",") {
+ t.Errorf("registered %v, listed %v", registered, m.Tools)
+ }
+ if len(m.Build.Artifacts) != 1 {
+ t.Fatalf("one artifact, got %d", len(m.Build.Artifacts))
+ }
+ cwd, _ := os.Getwd()
+ binary := filepath.Base(cwd)
+ a := m.Build.Artifacts[0]
+ want := map[string]any{"kind": "bundle", "language": "go", "system": "arch", "from": "cmd/" + binary, "binary": binary}
+ for k, v := range want {
+ if a[k] != v {
+ t.Errorf("artifact %s = %v, want %v", k, a[k], v)
+ }
+ }
+ if loads, _ := a["loads"].([]any); len(loads) != 1 || loads[0] != binary {
+ t.Errorf("artifact loads %v, want [%s]", a["loads"], binary)
+ }
+ if m.Claims != nil || m.Seats != nil {
+ t.Errorf("claims %v, seats %v: this module holds no seat", m.Claims, m.Seats)
+ }
+}
diff --git a/modules/snapd/cmd/snapd-tools/snapd.go b/modules/snapd/cmd/snapd-tools/snapd.go
new file mode 100644
index 0000000..9b0ef1b
--- /dev/null
+++ b/modules/snapd/cmd/snapd-tools/snapd.go
@@ -0,0 +1,437 @@
+package main
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "regexp"
+ "sort"
+ "strconv"
+ "strings"
+)
+
+// snapNames are what the store accepts as a snap's name, optionally with an instance key.
+var snapNames = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,39}(_[a-z0-9]{1,10})?$`)
+
+func checkSnapName(name string) error {
+ if !snapNames.MatchString(name) {
+ return fmt.Errorf("%q is not a snap name", name)
+ }
+ return nil
+}
+
+func snapName(args map[string]any) (string, error) {
+ name, err := text(args, "name")
+ if err != nil {
+ return "", err
+ }
+ return name, checkSnapName(name)
+}
+
+var plain = []string{"--unicode=never", "--color=never"}
+
+// Where snapd keeps what it knows. Tests point these elsewhere.
+var (
+ snapsDir = "/var/lib/snapd/snaps"
+ lsmFile = "/sys/kernel/security/lsm"
+ snapRoot = "/snap"
+)
+
+// UnitState is one unit's state.
+type UnitState struct {
+ Unit string `json:"unit"`
+ Enabled string `json:"enabled"`
+ Active string `json:"active"`
+}
+
+// StatusAnswer is what snapd_status answers.
+type StatusAnswer struct {
+ Installed bool `json:"installed"`
+ Version string `json:"version,omitempty"`
+ Units []UnitState `json:"units"`
+ AppArmor bool `json:"kernel_apparmor"`
+ ClassicRoot bool `json:"classic_root"`
+ Findings []string `json:"findings"`
+}
+
+// Status says whether snapd is here and working.
+func Status() (StatusAnswer, error) {
+ out := StatusAnswer{Units: []UnitState{}, Findings: []string{}}
+ r := run(Cmd{Name: "snap", Args: []string{"version"}})
+ if r.Error == "not-found" {
+ out.Findings = append(out.Findings, "snapd is not installed on this machine")
+ return out, nil
+ }
+ if r.Status != 0 || r.Error != "" {
+ return out, failure(Cmd{Name: "snap", Args: []string{"version"}}, r)
+ }
+ out.Installed = true
+ for _, l := range lines(r.Stdout) {
+ if f := strings.Fields(l); len(f) >= 2 && f[0] == "snapd" {
+ out.Version = f[1]
+ }
+ }
+ for _, u := range []string{"snapd.socket", "snapd.service", "snapd.apparmor.service", "apparmor.service"} {
+ // is-enabled and is-active answer on stdout and exit non-zero for "disabled" and "inactive":
+ // a state, not a failure.
+ en := run(Cmd{Name: "systemctl", Args: []string{"is-enabled", u}})
+ ac := run(Cmd{Name: "systemctl", Args: []string{"is-active", u}})
+ if en.Error != "" || ac.Error != "" {
+ return out, failure(Cmd{Name: "systemctl", Args: []string{"is-enabled", u}}, en)
+ }
+ out.Units = append(out.Units, UnitState{Unit: u, Enabled: firstLine(en.Stdout), Active: firstLine(ac.Stdout)})
+ }
+ if b, err := os.ReadFile(lsmFile); err == nil {
+ for _, m := range strings.Split(strings.TrimSpace(string(b)), ",") {
+ out.AppArmor = out.AppArmor || m == "apparmor"
+ }
+ }
+ _, err := os.Stat(snapRoot)
+ out.ClassicRoot = err == nil
+ if out.Units[0].Enabled != "enabled" {
+ out.Findings = append(out.Findings, "snapd.socket is not enabled: snapd does not start on demand")
+ }
+ if !out.AppArmor {
+ out.Findings = append(out.Findings, "the kernel does not run AppArmor: strict snaps run without their confinement")
+ }
+ if out.Units[2].Enabled == "enabled" && !out.AppArmor {
+ out.Findings = append(out.Findings, "snapd.apparmor.service is enabled with no AppArmor in the kernel: it loads profiles nothing enforces")
+ }
+ if !out.ClassicRoot {
+ out.Findings = append(out.Findings, "/snap does not exist: classic snaps cannot run")
+ }
+ return out, nil
+}
+
+// Snap is one installed revision.
+type Snap struct {
+ Name string `json:"name"`
+ Version string `json:"version"`
+ Revision string `json:"revision"`
+ Tracking string `json:"tracking"`
+ Publisher string `json:"publisher"`
+ Notes []string `json:"notes"`
+ Disabled bool `json:"disabled"`
+}
+
+// ListAnswer is what snapd_list answers.
+type ListAnswer struct {
+ Snaps []Snap `json:"snaps"`
+ Active int `json:"active"`
+ Disabled int `json:"disabled_revisions"`
+}
+
+// columns reads a table snap prints: a header line, then whitespace-separated columns, the last
+// taking the rest of the line.
+func columns(s string, n int) [][]string {
+ out := [][]string{}
+ for i, l := range lines(s) {
+ if i == 0 {
+ continue
+ }
+ f := strings.Fields(l)
+ if len(f) < n {
+ continue
+ }
+ if len(f) > n {
+ f = append(f[:n-1], strings.Join(f[n-1:], " "))
+ }
+ out = append(out, f)
+ }
+ return out
+}
+
+// List answers every installed snap and revision.
+func List() (ListAnswer, error) {
+ r, err := call(Cmd{Name: "snap", Args: append([]string{"list", "--all"}, plain...)})
+ if err != nil {
+ return ListAnswer{}, err
+ }
+ out := ListAnswer{Snaps: []Snap{}}
+ for _, f := range columns(r.Stdout, 6) {
+ s := Snap{Name: f[0], Version: f[1], Revision: f[2], Tracking: f[3], Publisher: strings.TrimRight(f[4], "*"), Notes: []string{}}
+ if f[5] != "-" {
+ s.Notes = strings.Split(f[5], ",")
+ }
+ for _, n := range s.Notes {
+ s.Disabled = s.Disabled || n == "disabled"
+ }
+ if s.Disabled {
+ out.Disabled++
+ } else {
+ out.Active++
+ }
+ out.Snaps = append(out.Snaps, s)
+ }
+ return out, nil
+}
+
+// InfoAnswer is what snapd_info answers.
+type InfoAnswer struct {
+ Name string `json:"name"`
+ Fields map[string]string `json:"fields"`
+ Commands []string `json:"commands"`
+ Channels map[string]string `json:"channels"`
+}
+
+// Info reads snap info's YAML-like answer: top-level key: value lines, and the commands and
+// channels blocks.
+func Info(name string) (InfoAnswer, error) {
+ r, err := call(Cmd{Name: "snap", Args: append([]string{"info"}, append(plain, name)...)})
+ if err != nil {
+ return InfoAnswer{}, err
+ }
+ out := InfoAnswer{Name: name, Fields: map[string]string{}, Commands: []string{}, Channels: map[string]string{}}
+ block := ""
+ for _, l := range strings.Split(r.Stdout, "\n") {
+ if strings.TrimSpace(l) == "" {
+ continue
+ }
+ if !strings.HasPrefix(l, " ") {
+ block = ""
+ k, v, found := strings.Cut(l, ":")
+ if !found {
+ continue
+ }
+ v = strings.TrimSpace(v)
+ switch {
+ case v == "" || v == "|":
+ block = k
+ default:
+ out.Fields[k] = v
+ }
+ continue
+ }
+ t := strings.TrimSpace(l)
+ switch block {
+ case "commands":
+ out.Commands = append(out.Commands, strings.TrimPrefix(t, "- "))
+ case "channels":
+ if k, v, found := strings.Cut(t, ":"); found {
+ out.Channels[k] = strings.Join(strings.Fields(v), " ")
+ }
+ case "description":
+ out.Fields["description"] = strings.TrimSpace(out.Fields["description"] + " " + t)
+ }
+ }
+ return out, nil
+}
+
+// Update is one pending refresh.
+type Update struct {
+ Name string `json:"name"`
+ Version string `json:"version"`
+ Revision string `json:"revision"`
+ Size string `json:"size"`
+ Publisher string `json:"publisher"`
+}
+
+// Updates answers what a refresh would change.
+func Updates() (map[string]any, error) {
+ r, err := call(Cmd{Name: "snap", Args: append([]string{"refresh", "--list"}, plain...)})
+ if err != nil {
+ return nil, err
+ }
+ out := []Update{}
+ if !strings.Contains(r.Stdout+r.Stderr, "All snaps up to date") {
+ for _, f := range columns(r.Stdout, 6) {
+ out = append(out, Update{Name: f[0], Version: f[1], Revision: f[2], Size: f[3], Publisher: strings.TrimRight(f[4], "*")})
+ }
+ }
+ return map[string]any{"updates": out, "count": len(out)}, nil
+}
+
+// Revision is one revision's file.
+type Revision struct {
+ Revision string `json:"revision"`
+ Bytes int64 `json:"bytes"`
+ Disabled bool `json:"disabled"`
+}
+
+// SnapUsage is the space one snap's revisions take.
+type SnapUsage struct {
+ Name string `json:"name"`
+ Bytes int64 `json:"bytes"`
+ Revisions []Revision `json:"revisions"`
+}
+
+// DiskAnswer is what snapd_disk_usage answers.
+type DiskAnswer struct {
+ Dir string `json:"dir"`
+ TotalBytes int64 `json:"total_bytes"`
+ Reclaimable int64 `json:"disabled_revisions_bytes"`
+ Snaps []SnapUsage `json:"snaps"`
+ Note string `json:"note"`
+}
+
+// DiskUsage measures the snap files and marks the disabled revisions.
+func DiskUsage() (DiskAnswer, error) {
+ listed, err := List()
+ if err != nil {
+ return DiskAnswer{}, err
+ }
+ disabled := map[string]bool{}
+ for _, s := range listed.Snaps {
+ if s.Disabled {
+ disabled[s.Name+"_"+s.Revision] = true
+ }
+ }
+ files, err := filepath.Glob(filepath.Join(snapsDir, "*.snap"))
+ if err != nil {
+ return DiskAnswer{}, err
+ }
+ out := DiskAnswer{Dir: snapsDir, Snaps: []SnapUsage{},
+ Note: "A disabled revision is kept by snapd for rollback (refresh.retain); removing one is `snap remove --revision`, which no tool here does."}
+ by := map[string]*SnapUsage{}
+ for _, f := range files {
+ info, err := os.Stat(f)
+ if err != nil {
+ return DiskAnswer{}, err
+ }
+ base := strings.TrimSuffix(filepath.Base(f), ".snap")
+ i := strings.LastIndex(base, "_")
+ if i <= 0 {
+ continue
+ }
+ name, rev := base[:i], base[i+1:]
+ if by[name] == nil {
+ by[name] = &SnapUsage{Name: name, Revisions: []Revision{}}
+ }
+ d := disabled[base]
+ by[name].Revisions = append(by[name].Revisions, Revision{Revision: rev, Bytes: info.Size(), Disabled: d})
+ by[name].Bytes += info.Size()
+ out.TotalBytes += info.Size()
+ if d {
+ out.Reclaimable += info.Size()
+ }
+ }
+ for _, u := range by {
+ sort.Slice(u.Revisions, func(i, k int) bool {
+ a, _ := strconv.Atoi(u.Revisions[i].Revision)
+ b, _ := strconv.Atoi(u.Revisions[k].Revision)
+ return a < b
+ })
+ out.Snaps = append(out.Snaps, *u)
+ }
+ sort.Slice(out.Snaps, func(i, k int) bool { return out.Snaps[i].Bytes > out.Snaps[k].Bytes })
+ return out, nil
+}
+
+// Services answers the snaps' services.
+func Services() (map[string]any, error) {
+ r, err := call(Cmd{Name: "snap", Args: append([]string{"services"}, plain...)})
+ if err != nil {
+ return nil, err
+ }
+ out := []map[string]string{}
+ if !strings.Contains(r.Stdout+r.Stderr, "no services") {
+ for _, f := range columns(r.Stdout, 4) {
+ out = append(out, map[string]string{"service": f[0], "startup": f[1], "current": f[2], "notes": f[3]})
+ }
+ }
+ return map[string]any{"services": out}, nil
+}
+
+// Change is one of snapd's changes, or one task of a change.
+type Change struct {
+ ID string `json:"id,omitempty"`
+ Status string `json:"status"`
+ Spawn string `json:"spawn"`
+ Ready string `json:"ready,omitempty"`
+ Summary string `json:"summary"`
+}
+
+var changeID = regexp.MustCompile(`^[0-9]+$`)
+
+// Changes answers snapd's recent changes, or one change's tasks.
+func Changes(id string) (map[string]any, error) {
+ args := append([]string{"changes", "--abs-time"}, plain...)
+ n := 5
+ if id != "" {
+ if !changeID.MatchString(id) {
+ return nil, fmt.Errorf("%q is not a change id", id)
+ }
+ args, n = append([]string{"tasks", "--abs-time"}, append(plain, id)...), 4
+ }
+ r := run(Cmd{Name: "snap", Args: args})
+ if strings.Contains(r.Stdout+r.Stderr, "no changes found") {
+ return map[string]any{"changes": []Change{}}, nil
+ }
+ if r.Status != 0 || r.Error != "" {
+ return nil, failure(Cmd{Name: "snap", Args: args}, r)
+ }
+ out := []Change{}
+ for _, f := range columns(r.Stdout, n) {
+ c := Change{}
+ if n == 5 {
+ c.ID, f = f[0], f[1:]
+ }
+ c.Status, c.Spawn, c.Ready, c.Summary = f[0], f[1], f[2], f[3]
+ if c.Ready == "-" {
+ c.Ready = ""
+ }
+ out = append(out, c)
+ }
+ if id != "" {
+ return map[string]any{"id": id, "tasks": out}, nil
+ }
+ return map[string]any{"changes": out}, nil
+}
+
+// ActAnswer is what an act answers: the change snapd carries it out in.
+type ActAnswer struct {
+ Act string `json:"act"`
+ Snap string `json:"snap,omitempty"`
+ Change string `json:"change,omitempty"`
+ Said string `json:"said,omitempty"`
+ Follow string `json:"follow,omitempty"`
+}
+
+// act runs one snap act with --no-wait, which answers snapd's change id at once.
+func act(verb, name string, extra ...string) (ActAnswer, error) {
+ args := append([]string{verb, "--no-wait"}, extra...)
+ if name != "" {
+ args = append(args, name)
+ }
+ r, err := call(Cmd{Name: "snap", Args: args, Root: true})
+ if err != nil {
+ return ActAnswer{}, err
+ }
+ out := ActAnswer{Act: verb, Snap: name}
+ if id := strings.TrimSpace(r.Stdout); changeID.MatchString(id) {
+ out.Change, out.Follow = id, "snapd_changes with id "+id
+ } else {
+ out.Said = strings.TrimSpace(r.Stdout + "\n" + r.Stderr)
+ }
+ return out, nil
+}
+
+var channelName = regexp.MustCompile(`^[a-z0-9][a-z0-9./_-]*$`)
+
+// Install installs a snap.
+func Install(name, channel string, classic bool) (ActAnswer, error) {
+ extra := []string{}
+ if channel != "" {
+ if !channelName.MatchString(channel) {
+ return ActAnswer{}, fmt.Errorf("%q is not a channel", channel)
+ }
+ extra = append(extra, "--channel="+channel)
+ }
+ if classic {
+ extra = append(extra, "--classic")
+ }
+ return act("install", name, extra...)
+}
+
+// Remove removes a snap.
+func Remove(name string, purge bool) (ActAnswer, error) {
+ if purge {
+ return act("remove", name, "--purge")
+ }
+ return act("remove", name)
+}
+
+// Refresh refreshes one snap, or all.
+func Refresh(name string) (ActAnswer, error) {
+ return act("refresh", name)
+}
diff --git a/modules/snapd/cmd/snapd-tools/snapd_test.go b/modules/snapd/cmd/snapd-tools/snapd_test.go
new file mode 100644
index 0000000..98b523a
--- /dev/null
+++ b/modules/snapd/cmd/snapd-tools/snapd_test.go
@@ -0,0 +1,203 @@
+package main
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestTheManifestDeclaresNothingTheHostCouldNotInstall(t *testing.T) {
+ m := readManifest(t)
+ holdsTheBundle(t, m, "snapd")
+ // snapd is not in the official repositories: no package, and no unit that only that package
+ // brings, until the mesh's package repository builds it (research 027 question 1).
+ if len(m.Resources) != 0 {
+ t.Errorf("resources %v", m.Resources)
+ }
+}
+
+const listAll = `Name Version Rev Tracking Publisher Notes
+bare 1.0 5 latest/stable canonical** base
+code 04c0d99f 266 latest/stable vscode** disabled,classic
+code 07f806f9 267 latest/stable vscode** classic
+gtk-common-themes 0.1-81-g442e511 1535 latest/stable canonical** -
+`
+
+func TestListReadsEachRevisionAndMarksTheDisabledOnes(t *testing.T) {
+ f := using(t, func(string, Cmd) Result { return ok(listAll) })
+ got, err := List()
+ if err != nil || len(got.Snaps) != 4 || got.Disabled != 1 || got.Active != 3 {
+ t.Fatalf("%+v %v", got, err)
+ }
+ if s := got.Snaps[1]; s.Name != "code" || s.Revision != "266" || !s.Disabled || s.Publisher != "vscode" || strings.Join(s.Notes, ",") != "disabled,classic" {
+ t.Errorf("%+v", s)
+ }
+ if s := got.Snaps[3]; len(s.Notes) != 0 || s.Disabled {
+ t.Errorf("%+v", s)
+ }
+ if f.lines()[0] != "snap list --all --unicode=never --color=never" {
+ t.Errorf("%v", f.lines())
+ }
+}
+
+func TestToolsSayWhenSnapdIsNotInstalled(t *testing.T) {
+ using(t, func(string, Cmd) Result { return Result{Status: 127, Error: "not-found"} })
+ if _, err := List(); err == nil || !strings.Contains(err.Error(), "not in the official repositories") {
+ t.Fatalf("%v", err)
+ }
+ got, err := Status()
+ if err != nil || got.Installed || !strings.Contains(strings.Join(got.Findings, ";"), "not installed") {
+ t.Fatalf("%+v %v", got, err)
+ }
+}
+
+func TestStatusNamesWhatIsWrongWithTheInstallation(t *testing.T) {
+ dir := t.TempDir()
+ wasLSM, wasRoot := lsmFile, snapRoot
+ lsmFile, snapRoot = filepath.Join(dir, "lsm"), filepath.Join(dir, "snap")
+ defer func() { lsmFile, snapRoot = wasLSM, wasRoot }()
+ _ = os.WriteFile(lsmFile, []byte("capability,landlock,lockdown,yama,bpf\n"), 0o644)
+ _ = os.Mkdir(snapRoot, 0o755)
+ using(t, func(line string, c Cmd) Result {
+ switch {
+ case line == "snap version":
+ return ok("snap 2.76.2-2\nsnapd 2.76.2-2\nseries 16\n")
+ case strings.HasSuffix(line, "is-enabled snapd.service"), strings.HasSuffix(line, "is-enabled apparmor.service"):
+ return Result{Status: 1, Stdout: "disabled\n"}
+ case strings.Contains(line, "is-enabled"):
+ return ok("enabled\n")
+ case strings.Contains(line, "is-active snapd.service"):
+ return ok("active\n")
+ }
+ return Result{Status: 3, Stdout: "inactive\n"}
+ })
+ got, err := Status()
+ if err != nil || !got.Installed || got.Version != "2.76.2-2" || got.AppArmor || !got.ClassicRoot || len(got.Units) != 4 {
+ t.Fatalf("%+v %v", got, err)
+ }
+ if got.Units[1].Enabled != "disabled" || got.Units[1].Active != "active" {
+ t.Errorf("socket-activated service: %+v", got.Units[1])
+ }
+ all := strings.Join(got.Findings, ";")
+ if !strings.Contains(all, "without their confinement") || !strings.Contains(all, "nothing enforces") || strings.Contains(all, "/snap does not exist") {
+ t.Errorf("%s", all)
+ }
+}
+
+func TestInfoReadsFieldsCommandsAndChannels(t *testing.T) {
+ using(t, func(string, Cmd) Result {
+ return ok(`name: code
+summary: Code editing. Redefined.
+publisher: Visual Studio Code (vscode**)
+license: unset
+description: |
+ Visual Studio Code is a new choice
+ of tool.
+commands:
+ - code
+ - code.url-handler
+tracking: latest/stable
+channels:
+ latest/stable: 07f806f9 2026-09-30 (267) 543MB classic
+ latest/candidate: ^
+`)
+ })
+ got, err := Info("code")
+ if err != nil || got.Fields["summary"] != "Code editing. Redefined." || len(got.Commands) != 2 || got.Fields["tracking"] != "latest/stable" {
+ t.Fatalf("%+v %v", got, err)
+ }
+ if got.Channels["latest/stable"] != "07f806f9 2026-09-30 (267) 543MB classic" || got.Fields["description"] != "Visual Studio Code is a new choice of tool." {
+ t.Errorf("%+v", got)
+ }
+}
+
+func TestUpdatesAndChangesReadNothingToDoAsEmpty(t *testing.T) {
+ using(t, func(line string, c Cmd) Result {
+ if strings.Contains(line, "refresh --list") {
+ return Result{Stderr: "All snaps up to date.\n"}
+ }
+ return Result{Status: 1, Stderr: "error: no changes found\n"}
+ })
+ u, err := Updates()
+ if err != nil || u["count"] != 0 {
+ t.Fatalf("%v %v", u, err)
+ }
+ c, err := Changes("")
+ if err != nil || len(c["changes"].([]Change)) != 0 {
+ t.Fatalf("%v %v", c, err)
+ }
+ if _, err := Changes("12; reboot"); err == nil {
+ t.Error("a change id that is not a number")
+ }
+}
+
+func TestChangesAndTasksAreReadByColumn(t *testing.T) {
+ using(t, func(line string, c Cmd) Result {
+ if strings.Contains(line, "tasks") {
+ return ok("Status Spawn Ready Summary\nDone 2026-10-01T18:57:00+02:00 2026-10-01T18:57:10+02:00 Download snap \"code\" (267)\n")
+ }
+ return ok("ID Status Spawn Ready Summary\n42 Doing 2026-10-04T10:00:00+02:00 - Install \"hello\" snap\n")
+ })
+ c, err := Changes("")
+ ch := c["changes"].([]Change)
+ if err != nil || len(ch) != 1 || ch[0].ID != "42" || ch[0].Ready != "" || ch[0].Summary != `Install "hello" snap` {
+ t.Fatalf("%+v %v", c, err)
+ }
+ c, _ = Changes("42")
+ if tasks := c["tasks"].([]Change); len(tasks) != 1 || tasks[0].Status != "Done" || tasks[0].Summary != `Download snap "code" (267)` {
+ t.Errorf("%+v", c)
+ }
+}
+
+func TestActsGoThroughSudoWithoutWaitingAndAnswerTheChange(t *testing.T) {
+ f := using(t, func(line string, c Cmd) Result {
+ if strings.Contains(line, "refresh") {
+ return Result{Stderr: "All snaps up to date.\n"}
+ }
+ return ok("57\n")
+ })
+ got, err := Install("hello-world", "latest/edge", true)
+ if err != nil || got.Change != "57" || !strings.Contains(got.Follow, "57") {
+ t.Fatalf("%+v %v", got, err)
+ }
+ if _, err := Remove("hello-world", true); err != nil {
+ t.Fatal(err)
+ }
+ r, err := Refresh("")
+ if err != nil || r.Change != "" || !strings.Contains(r.Said, "up to date") {
+ t.Fatalf("%+v %v", r, err)
+ }
+ want := "sudo -n snap install --no-wait --channel=latest/edge --classic hello-world\n" +
+ "sudo -n snap remove --no-wait --purge hello-world\n" +
+ "sudo -n snap refresh --no-wait"
+ if got := strings.Join(f.lines(), "\n"); got != want {
+ t.Errorf("asked\n%s", got)
+ }
+ for _, bad := range []string{"--classic", "Hello", "a b", "../x"} {
+ if err := checkSnapName(bad); err == nil {
+ t.Errorf("%q accepted as a snap name", bad)
+ }
+ }
+ if _, err := Install("x", "--dangerous", false); err == nil {
+ t.Error("an option as a channel")
+ }
+}
+
+func TestDiskUsageMeasuresEachRevisionAndWhatDisabledOnesTake(t *testing.T) {
+ dir := t.TempDir()
+ was := snapsDir
+ snapsDir = dir
+ defer func() { snapsDir = was }()
+ for name, size := range map[string]int{"code_266.snap": 500, "code_267.snap": 510, "bare_5.snap": 4} {
+ _ = os.WriteFile(filepath.Join(dir, name), make([]byte, size), 0o600)
+ }
+ using(t, func(string, Cmd) Result { return ok(listAll) })
+ got, err := DiskUsage()
+ if err != nil || got.TotalBytes != 1014 || got.Reclaimable != 500 || len(got.Snaps) != 2 {
+ t.Fatalf("%+v %v", got, err)
+ }
+ if c := got.Snaps[0]; c.Name != "code" || c.Bytes != 1010 || !c.Revisions[0].Disabled || c.Revisions[1].Disabled {
+ t.Errorf("%+v", c)
+ }
+}
diff --git a/modules/snapd/go.mod b/modules/snapd/go.mod
new file mode 100644
index 0000000..fdb3da4
--- /dev/null
+++ b/modules/snapd/go.mod
@@ -0,0 +1,5 @@
+module snapd
+
+go 1.22
+
+require git.novox.be/novox/mesh-sdk/go v0.1.6
diff --git a/modules/snapd/go.sum b/modules/snapd/go.sum
new file mode 100644
index 0000000..0dd6061
--- /dev/null
+++ b/modules/snapd/go.sum
@@ -0,0 +1,2 @@
+git.novox.be/novox/mesh-sdk/go v0.1.6 h1:9qzdYONYbJdWcu6sxQcq9v1LI0JxcfkiKYkMUzJSkVQ=
+git.novox.be/novox/mesh-sdk/go v0.1.6/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
diff --git a/modules/snapd/module.json b/modules/snapd/module.json
new file mode 100644
index 0000000..93ad730
--- /dev/null
+++ b/modules/snapd/module.json
@@ -0,0 +1,31 @@
+{
+ "module": "snapd",
+ "version": "1",
+ "tools": [
+ "snapd_status",
+ "snapd_list",
+ "snapd_info",
+ "snapd_updates",
+ "snapd_disk_usage",
+ "snapd_services",
+ "snapd_changes",
+ "snapd_install",
+ "snapd_remove",
+ "snapd_refresh"
+ ],
+ "build": {
+ "artifacts": [
+ {
+ "name": "tools",
+ "kind": "bundle",
+ "language": "go",
+ "system": "arch",
+ "from": "cmd/snapd-tools",
+ "binary": "snapd-tools",
+ "loads": [
+ "snapd-tools"
+ ]
+ }
+ ]
+ }
+}
diff --git a/modules/xclip/README.md b/modules/xclip/README.md
new file mode 100644
index 0000000..f31253e
--- /dev/null
+++ b/modules/xclip/README.md
@@ -0,0 +1,68 @@
+# xclip
+
+The command-line X clipboard, as a module (novox/hq research 026/04: "`xclip` is a module of its own,
+a package and nothing else. It is the tool scripts depend on, and a module that needs it requires
+it"; to-be 42 phase 2 step 7).
+
+## Owns
+
+| what | where |
+|---|---|
+| `xclip` | package `xclip` (official repositories) |
+
+Nothing else. The clipboard's history is the clipboard manager's (`node-clipboard`, a later module).
+This one is the plain clipboard, without a manager.
+
+## Improves
+
+- **A declared dependency.** The window manager's screenshot script copies through `xclip` on both
+ workstations, and nothing said it needed it. A module that ships such a script requires this one.
+- **The clipboard from the mesh.** Text can be put on the operator's clipboard from any machine, for
+ example a command or a link prepared elsewhere, and read back. That is safe with a clipboard manager
+ too: the manager takes what `xclip` offers into its history.
+
+## Reaching the operator's session
+
+The node's tool runtime is a system service running as the operator account. It is given no session
+words: no `DISPLAY`, no `XAUTHORITY`. Measured on both workstations on 2026-10-04:
+
+- the runtime runs as the account, in the machine's own mount namespace, with no private `/tmp`;
+- the X server's socket is `/tmp/.X11-unix/X1`;
+- the cookie is in `~/.Xauthority`, readable by the account;
+- the window manager's environment names both.
+
+So a child of the runtime **can** reach the session. `session.go` finds it in this order:
+
+1. the process's own `DISPLAY`;
+2. else the `DISPLAY` and `XAUTHORITY` of the account's running processes, read from
+ `/proc//environ` (the window manager's by preference), whose socket exists;
+3. else the only X socket, with `~/.Xauthority`.
+
+Checked from a shell with both variables unset: it found `:1` through the window manager's process,
+and the server answered. With no session (nobody logged in to the desktop), every tool answers that no
+graphical session of the account is running, and runs nothing. A server that refuses the cookie is
+named with the display and how it was found.
+
+**What it does not cover.** A Wayland session: there `wl-clipboard` is the tool, and a Wayland module
+carries it (research 026/04). Also, a copy is held by `xclip`'s own background process, a child of the
+tool bundle. If the runtime restarts the bundle before another program takes the selection, the copied
+text is gone.
+
+## Tools
+
+All answer JSON. `(r)` reads; `(d)` acts in the operator's session.
+
+| tool | what |
+|---|---|
+| `xclip_copy` (d) | put text (at most 1 MiB) on the clipboard, primary or secondary selection, as a given type (default UTF-8 text) |
+| `xclip_paste` (r) | what a selection holds: text, or base64 for a type that is not text; `empty` when there is nothing; cut at 256 KiB |
+| `xclip_targets` (r) | the types a selection is offered as, without reading it |
+| `xclip_session` (r) | the session the tools reach and how it was found, or why there is none, and whether the server answers |
+
+## What changes when it is assigned
+
+Nothing on disk on either workstation: `xclip` is installed explicitly on both.
+
+## Leaves as found
+
+The scripts that call `xclip` (the window manager's screenshot script), which are their own modules'.
diff --git a/modules/xclip/cmd/xclip-tools/kit.go b/modules/xclip/cmd/xclip-tools/kit.go
new file mode 100644
index 0000000..adc5aac
--- /dev/null
+++ b/modules/xclip/cmd/xclip-tools/kit.go
@@ -0,0 +1,352 @@
+package main
+
+// kit.go is the same file in each of the workstations' tool bundles (fonts, docker-compose, snapd,
+// flatpak, cups, bluetooth, xclip, dmenu): how a tool runs a command, escalates, bounds what it
+// keeps, and names a failure. A module is built from its own directory, so the file is copied rather
+// than shared; a change to one copy is made to all eight.
+//
+// The rules it holds (novox/hq research 026/05, to-be 38 WP4):
+// - the node's tool runtime runs as the operator account, not root (ADR 0175 §4); a command that
+// needs root goes through `sudo -n`, never a prompt, and a refusal is named as such;
+// - one command gets 20 s, below the runtime's 30 s call limit, and is ended with everything it
+// started when it takes longer;
+// - each stream is kept to 256 KiB, and the answer says when it was cut;
+// - a failure is an error with what went wrong in it, never an empty answer.
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "os/exec"
+ "strings"
+ "syscall"
+ "time"
+)
+
+// Bounds every command is held to.
+const (
+ CallTimeout = 20 * time.Second
+ MostOutput = 256 << 10
+)
+
+// Cmd is one command a tool runs.
+type Cmd struct {
+ Name string
+ Args []string
+ // Stdin is written to the command's standard input when not empty.
+ Stdin string
+ // Env is added to this process's own environment.
+ Env []string
+ // Root says the command needs root: it is run through `sudo -n` when this process is not root.
+ Root bool
+ // Timeout replaces CallTimeout; only a background job (jobs.go) asks for longer.
+ Timeout time.Duration
+ // Detached is for a program that forks a child which outlives it, as xclip does to keep the
+ // selection: its streams go to files, because a pipe the child inherits would hold the call open
+ // until the child exits.
+ Detached bool
+}
+
+// Result is what a command did.
+type Result struct {
+ Stdout string `json:"stdout"`
+ Stderr string `json:"stderr"`
+ Status int `json:"status"`
+ // Error is why it did not run to an answer: "not-found" when the program is not there,
+ // "timeout" when it was ended for taking too long, else the spawn error.
+ Error string `json:"error,omitempty"`
+ Truncated bool `json:"truncated,omitempty"`
+}
+
+// Runner runs a command. Tests replace it; nothing else does.
+type Runner func(Cmd) Result
+
+var (
+ run Runner = execRun
+ euid = os.Geteuid
+)
+
+// argv is the command as it is run: through sudo without a prompt when it needs root and this
+// process is not root.
+func argv(c Cmd) (string, []string) {
+ if c.Root && euid() != 0 {
+ return "sudo", append([]string{"-n", c.Name}, c.Args...)
+ }
+ return c.Name, c.Args
+}
+
+// bounded keeps the first MostOutput bytes written to it and notes that more came.
+type bounded struct {
+ b bytes.Buffer
+ cut bool
+}
+
+func (w *bounded) Write(p []byte) (int, error) {
+ room := MostOutput - w.b.Len()
+ if room <= 0 {
+ w.cut = w.cut || len(p) > 0
+ return len(p), nil
+ }
+ if len(p) > room {
+ w.b.Write(p[:room])
+ w.cut = true
+ return len(p), nil
+ }
+ return w.b.Write(p)
+}
+
+func execRun(c Cmd) Result {
+ timeout := c.Timeout
+ if timeout <= 0 {
+ timeout = CallTimeout
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), timeout)
+ defer cancel()
+ name, args := argv(c)
+ cmd := exec.CommandContext(ctx, name, args...)
+ cmd.Env = append(append(os.Environ(), "LC_ALL=C"), c.Env...)
+ if !c.Detached {
+ // Its own process group, so that ending it on a timeout ends what it started too.
+ cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
+ cmd.Cancel = func() error {
+ if cmd.Process != nil {
+ _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
+ }
+ return nil
+ }
+ }
+ cmd.WaitDelay = 2 * time.Second
+ if c.Stdin != "" {
+ cmd.Stdin = strings.NewReader(c.Stdin)
+ }
+ var out, errs bounded
+ var outFile, errFile *os.File
+ if c.Detached {
+ var err error
+ if outFile, err = os.CreateTemp("", "mesh-tool-out-*"); err != nil {
+ return Result{Status: 127, Error: err.Error()}
+ }
+ defer os.Remove(outFile.Name())
+ defer outFile.Close()
+ if errFile, err = os.CreateTemp("", "mesh-tool-err-*"); err != nil {
+ return Result{Status: 127, Error: err.Error()}
+ }
+ defer os.Remove(errFile.Name())
+ defer errFile.Close()
+ cmd.Stdout, cmd.Stderr = outFile, errFile
+ } else {
+ cmd.Stdout, cmd.Stderr = &out, &errs
+ }
+ err := cmd.Run()
+ if c.Detached {
+ for _, f := range []struct {
+ file *os.File
+ into *bounded
+ }{{outFile, &out}, {errFile, &errs}} {
+ if _, e := f.file.Seek(0, io.SeekStart); e == nil {
+ _, _ = io.Copy(f.into, f.file)
+ }
+ }
+ }
+ r := Result{Stdout: out.b.String(), Stderr: errs.b.String(), Truncated: out.cut || errs.cut}
+ var exit *exec.ExitError
+ switch {
+ case err == nil:
+ case ctx.Err() == context.DeadlineExceeded:
+ r.Status, r.Error = 124, "timeout"
+ case errors.Is(err, exec.ErrNotFound) || errors.Is(err, os.ErrNotExist):
+ r.Status, r.Error = 127, "not-found"
+ case errors.As(err, &exit):
+ r.Status = exit.ExitCode()
+ default:
+ r.Status, r.Error = 127, err.Error()
+ }
+ return r
+}
+
+// call runs a command and answers its result, or an error naming what went wrong.
+func call(c Cmd) (Result, error) {
+ r := run(c)
+ if r.Status == 0 && r.Error == "" {
+ return r, nil
+ }
+ return r, failure(c, r)
+}
+
+// failure names how a command failed: not installed, refused escalation, too slow, or its exit
+// status with the end of what it said.
+func failure(c Cmd, r Result) error {
+ program, _ := argv(c)
+ switch {
+ case r.Error == "not-found" && program == "sudo":
+ return fmt.Errorf("%s needs root, and sudo is not installed here for the runtime's account to escalate with", c.Name)
+ case r.Error == "not-found":
+ if hint, ok := providedBy[c.Name]; ok {
+ return fmt.Errorf("%s is not installed on this machine (%s)", c.Name, hint)
+ }
+ return fmt.Errorf("%s is not installed on this machine", c.Name)
+ case r.Error == "timeout":
+ limit := c.Timeout
+ if limit <= 0 {
+ limit = CallTimeout
+ }
+ return fmt.Errorf("%s gave no answer within %s and was ended", c.Name, limit)
+ case r.Error != "":
+ return fmt.Errorf("%s did not run: %s", c.Name, r.Error)
+ case program == "sudo" && strings.Contains(r.Stderr, "command not found"):
+ if hint, ok := providedBy[c.Name]; ok {
+ return fmt.Errorf("%s is not installed on this machine (%s)", c.Name, hint)
+ }
+ return fmt.Errorf("%s is not installed on this machine", c.Name)
+ case program == "sudo" && strings.HasPrefix(strings.TrimSpace(r.Stderr), "sudo:"):
+ return fmt.Errorf("%s needs root, and sudo -n refused the runtime's account: %s (the escalation is the sudo module's to declare)",
+ c.Name, firstLine(r.Stderr))
+ }
+ said := tail(strings.TrimSpace(r.Stderr), 2000)
+ if said == "" {
+ said = tail(strings.TrimSpace(r.Stdout), 2000)
+ }
+ if said == "" {
+ said = "and said nothing"
+ }
+ return fmt.Errorf("%s %s exited %d: %s", c.Name, strings.Join(c.Args, " "), r.Status, said)
+}
+
+func firstLine(s string) string {
+ s = strings.TrimSpace(s)
+ if i := strings.IndexByte(s, '\n'); i >= 0 {
+ return s[:i]
+ }
+ return s
+}
+
+func tail(s string, n int) string {
+ if len(s) <= n {
+ return s
+ }
+ return "…" + s[len(s)-n:]
+}
+
+// lines are a command's output lines, blank ones dropped.
+func lines(s string) []string {
+ out := []string{}
+ for _, l := range strings.Split(s, "\n") {
+ if strings.TrimSpace(l) != "" {
+ out = append(out, strings.TrimRight(l, "\r"))
+ }
+ }
+ return out
+}
+
+// Arguments, read the way a tool's JSON arguments arrive.
+
+func text(args map[string]any, key string) (string, error) {
+ v, ok := args[key]
+ if !ok || v == nil {
+ return "", fmt.Errorf("%s is required", key)
+ }
+ s, ok := v.(string)
+ if !ok {
+ return "", fmt.Errorf("%s must be a string", key)
+ }
+ if strings.TrimSpace(s) == "" {
+ return "", fmt.Errorf("%s must not be empty", key)
+ }
+ return s, nil
+}
+
+func optText(args map[string]any, key, def string) (string, error) {
+ v, ok := args[key]
+ if !ok || v == nil {
+ return def, nil
+ }
+ s, ok := v.(string)
+ if !ok {
+ return "", fmt.Errorf("%s must be a string", key)
+ }
+ if strings.TrimSpace(s) == "" {
+ return def, nil
+ }
+ return s, nil
+}
+
+// optWhole reads a whole number, defaulted, refused below least and held to most.
+func optWhole(args map[string]any, key string, def, least, most int) (int, error) {
+ v, ok := args[key]
+ if !ok || v == nil {
+ return def, nil
+ }
+ f, ok := v.(float64)
+ if !ok {
+ if i, isInt := v.(int); isInt {
+ f = float64(i)
+ } else {
+ return 0, fmt.Errorf("%s must be a number", key)
+ }
+ }
+ if f != float64(int(f)) {
+ return 0, fmt.Errorf("%s must be a whole number", key)
+ }
+ n := int(f)
+ if n < least {
+ return 0, fmt.Errorf("%s must be at least %d", key, least)
+ }
+ if n > most {
+ n = most
+ }
+ return n, nil
+}
+
+func optFlag(args map[string]any, key string, def bool) (bool, error) {
+ v, ok := args[key]
+ if !ok || v == nil {
+ return def, nil
+ }
+ b, ok := v.(bool)
+ if !ok {
+ return false, fmt.Errorf("%s must be true or false", key)
+ }
+ return b, nil
+}
+
+func optList(args map[string]any, key string) ([]string, error) {
+ v, ok := args[key]
+ if !ok || v == nil {
+ return nil, nil
+ }
+ items, ok := v.([]any)
+ if !ok {
+ return nil, fmt.Errorf("%s must be a list of strings", key)
+ }
+ out := make([]string, 0, len(items))
+ for _, it := range items {
+ s, ok := it.(string)
+ if !ok || strings.TrimSpace(s) == "" {
+ return nil, fmt.Errorf("%s must be a list of non-empty strings", key)
+ }
+ out = append(out, s)
+ }
+ return out, nil
+}
+
+// oneOf refuses a value outside a closed set.
+func oneOf(key, value string, allowed ...string) error {
+ for _, a := range allowed {
+ if value == a {
+ return nil
+ }
+ }
+ return fmt.Errorf("%s must be one of %s, not %q", key, strings.Join(allowed, ", "), value)
+}
+
+// plainName refuses a name that could be read as an option or carries a path or a space: package,
+// snap, application and printer names never do.
+func plainName(key, value string) error {
+ if strings.HasPrefix(value, "-") || strings.ContainsAny(value, " \t\n/\\") {
+ return fmt.Errorf("%s %q is not a plain name", key, value)
+ }
+ return nil
+}
diff --git a/modules/xclip/cmd/xclip-tools/kit_test.go b/modules/xclip/cmd/xclip-tools/kit_test.go
new file mode 100644
index 0000000..c5d3557
--- /dev/null
+++ b/modules/xclip/cmd/xclip-tools/kit_test.go
@@ -0,0 +1,147 @@
+package main
+
+// Tests of kit.go, the same in each workstation module.
+
+import (
+ "strings"
+ "testing"
+ "time"
+)
+
+// fake records the commands asked and answers each from a function of the command line.
+type fake struct {
+ asked []Cmd
+ answer func(line string, c Cmd) Result
+}
+
+func (f *fake) runner() Runner {
+ return func(c Cmd) Result {
+ f.asked = append(f.asked, c)
+ name, args := argv(c)
+ line := strings.TrimSpace(name + " " + strings.Join(args, " "))
+ if f.answer == nil {
+ return Result{}
+ }
+ return f.answer(line, c)
+ }
+}
+
+func (f *fake) lines() []string {
+ out := []string{}
+ for _, c := range f.asked {
+ name, args := argv(c)
+ out = append(out, strings.TrimSpace(name+" "+strings.Join(args, " ")))
+ }
+ return out
+}
+
+// using installs a fake runner and a non-root uid for one test.
+func using(t *testing.T, answer func(line string, c Cmd) Result) *fake {
+ t.Helper()
+ f := &fake{answer: answer}
+ wasRun, wasUID := run, euid
+ run, euid = f.runner(), func() int { return 1000 }
+ t.Cleanup(func() { run, euid = wasRun, wasUID })
+ return f
+}
+
+func ok(stdout string) Result { return Result{Stdout: stdout} }
+
+func TestKitAnActThatNeedsRootGoesThroughSudoWithoutAPromptUnlessAlreadyRoot(t *testing.T) {
+ was := euid
+ defer func() { euid = was }()
+ euid = func() int { return 1000 }
+ if name, args := argv(Cmd{Name: "x", Args: []string{"a"}, Root: true}); name != "sudo" || strings.Join(args, " ") != "-n x a" {
+ t.Fatalf("not root: %s %v", name, args)
+ }
+ if name, _ := argv(Cmd{Name: "x"}); name != "x" {
+ t.Fatalf("a read is run as the account: %s", name)
+ }
+ euid = func() int { return 0 }
+ if name, _ := argv(Cmd{Name: "x", Root: true}); name != "x" {
+ t.Fatalf("as root no sudo: %s", name)
+ }
+}
+
+func TestKitAFailureIsNamedByHowItFailed(t *testing.T) {
+ was := euid
+ defer func() { euid = was }()
+ euid = func() int { return 1000 }
+ cases := []struct {
+ c Cmd
+ r Result
+ want string
+ }{
+ {Cmd{Name: "nothere"}, Result{Status: 127, Error: "not-found"}, "not installed"},
+ {Cmd{Name: "x", Root: true}, Result{Status: 127, Error: "not-found"}, "sudo is not installed"},
+ {Cmd{Name: "x", Root: true}, Result{Status: 1, Stderr: "sudo: a password is required\n"}, "sudo -n refused"},
+ {Cmd{Name: "x", Root: true}, Result{Status: 1, Stderr: "sudo: x: command not found\n"}, "x is not installed"},
+ {Cmd{Name: "x"}, Result{Status: 124, Error: "timeout"}, "within 20s"},
+ {Cmd{Name: "x", Args: []string{"y"}}, Result{Status: 3, Stderr: "boom\n"}, "x y exited 3: boom"},
+ {Cmd{Name: "x"}, Result{Status: 3}, "said nothing"},
+ }
+ for _, k := range cases {
+ err := failure(k.c, k.r)
+ if err == nil || !strings.Contains(err.Error(), k.want) {
+ t.Errorf("%+v: %v, want %q", k.r, err, k.want)
+ }
+ }
+}
+
+func TestKitOutputIsBoundedAndSaysSo(t *testing.T) {
+ var w bounded
+ big := strings.Repeat("a", MostOutput+10)
+ n, _ := w.Write([]byte(big))
+ if n != len(big) || w.b.Len() != MostOutput || !w.cut {
+ t.Fatalf("kept %d of %d, cut %v", w.b.Len(), len(big), w.cut)
+ }
+}
+
+func TestKitTheRealRunnerRunsEndsAndReportsAMissingProgram(t *testing.T) {
+ r := execRun(Cmd{Name: "sh", Args: []string{"-c", "echo out; echo err >&2; exit 3"}})
+ if r.Status != 3 || strings.TrimSpace(r.Stdout) != "out" || strings.TrimSpace(r.Stderr) != "err" {
+ t.Fatalf("%+v", r)
+ }
+ r = execRun(Cmd{Name: "sh", Args: []string{"-c", "sleep 5 & sleep 5"}, Timeout: 200 * time.Millisecond})
+ if r.Error != "timeout" {
+ t.Fatalf("a slow command: %+v", r)
+ }
+ r = execRun(Cmd{Name: "no-such-program-anywhere"})
+ if r.Error != "not-found" {
+ t.Fatalf("a missing program: %+v", r)
+ }
+ r = execRun(Cmd{Name: "cat", Stdin: "given"})
+ if r.Stdout != "given" {
+ t.Fatalf("stdin: %+v", r)
+ }
+ start := time.Now()
+ r = execRun(Cmd{Name: "sh", Args: []string{"-c", "echo kept; (sleep 3 &) ; exit 0"}, Detached: true})
+ if r.Status != 0 || strings.TrimSpace(r.Stdout) != "kept" || time.Since(start) > 2*time.Second {
+ t.Fatalf("a detached command returns when it exits, not when its child does: %+v after %s", r, time.Since(start))
+ }
+}
+
+func TestKitArgumentsAreReadStrictly(t *testing.T) {
+ args := map[string]any{"s": "x", "n": float64(5), "f": 1.5, "b": true, "l": []any{"a", "b"}}
+ if _, err := text(args, "missing"); err == nil {
+ t.Error("a missing required string")
+ }
+ if n, _ := optWhole(args, "n", 1, 1, 3); n != 3 {
+ t.Errorf("held to most: %d", n)
+ }
+ if _, err := optWhole(args, "n", 1, 6, 9); err == nil {
+ t.Error("below least")
+ }
+ if _, err := optWhole(args, "f", 1, 0, 9); err == nil {
+ t.Error("a fraction")
+ }
+ if l, _ := optList(args, "l"); len(l) != 2 {
+ t.Errorf("list: %v", l)
+ }
+ if b, _ := optFlag(args, "b", false); !b {
+ t.Error("flag")
+ }
+ if err := plainName("name", "--all"); err == nil {
+ t.Error("an option as a name")
+ }
+}
diff --git a/modules/xclip/cmd/xclip-tools/main.go b/modules/xclip/cmd/xclip-tools/main.go
new file mode 100644
index 0000000..07c8fc0
--- /dev/null
+++ b/modules/xclip/cmd/xclip-tools/main.go
@@ -0,0 +1,102 @@
+// The xclip module's tools (novox/hq research 026/04, 026/05): the plain X clipboard without a
+// manager. Copy puts text on a selection, paste reads one, targets says what a selection offers. A Go
+// bundle the node's runtime launches over stdio (ADR 0188, ADR 0193). It runs as the operator account
+// and reaches the account's X session as session.go finds it; with no session, every tool says so.
+package main
+
+import (
+ "fmt"
+ "os"
+
+ stdio "git.novox.be/novox/mesh-sdk/go"
+)
+
+var providedBy = map[string]string{
+ "xclip": "the xclip package, which this module installs",
+}
+
+func main() {
+ if err := stdio.Serve("", tools()); err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+}
+
+var selectionArg = map[string]any{"type": "string", "enum": Selections, "description": "which selection: clipboard (default), primary or secondary"}
+
+func selectionOf(args map[string]any) (string, error) {
+ s, err := optText(args, "selection", "clipboard")
+ if err != nil {
+ return "", err
+ }
+ return s, oneOf("selection", s, Selections...)
+}
+
+func tools() []stdio.Tool {
+ return []stdio.Tool{
+ {
+ Name: "xclip_copy",
+ Description: "Put text on the operator's clipboard (or the primary or secondary selection), as a given type " +
+ "(default plain UTF-8 text). Needs the operator's graphical session. (d)",
+ Input: map[string]any{
+ "text": map[string]any{"type": "string", "description": "what to copy, at most 1 MiB"},
+ "selection": selectionArg,
+ "type": map[string]any{"type": "string", "description": "the content's type, such as text/html (default UTF8_STRING)"},
+ },
+ Run: func(args map[string]any) (any, error) {
+ t, ok := args["text"].(string)
+ if !ok {
+ return nil, fmt.Errorf("text is required, as a string (it may be empty)")
+ }
+ sel, err := selectionOf(args)
+ if err != nil {
+ return nil, err
+ }
+ typ, err := optText(args, "type", "")
+ if err != nil {
+ return nil, err
+ }
+ return Copy(t, sel, typ)
+ },
+ },
+ {
+ Name: "xclip_paste",
+ Description: "What the operator's clipboard (or another selection) holds now, as text, or as base64 for a " +
+ "type that is not text. Cut at 256 KiB. Needs the operator's graphical session. (r)",
+ Input: map[string]any{
+ "selection": selectionArg,
+ "type": map[string]any{"type": "string", "description": "the type to ask for, such as text/html or image/png (default UTF8_STRING)"},
+ },
+ Run: func(args map[string]any) (any, error) {
+ sel, err := selectionOf(args)
+ if err != nil {
+ return nil, err
+ }
+ typ, err := optText(args, "type", "")
+ if err != nil {
+ return nil, err
+ }
+ return Paste(sel, typ)
+ },
+ },
+ {
+ Name: "xclip_targets",
+ Description: "The types a selection is offered as right now (text, HTML, an image…), without reading the content. (r)",
+ Input: map[string]any{"selection": selectionArg},
+ Run: func(args map[string]any) (any, error) {
+ sel, err := selectionOf(args)
+ if err != nil {
+ return nil, err
+ }
+ return Targets(sel)
+ },
+ },
+ {
+ Name: "xclip_session",
+ Description: "Which X session the clipboard tools reach and how it was found, or why there is none; and " +
+ "whether the X server answers. (r)",
+ Input: map[string]any{},
+ Run: func(map[string]any) (any, error) { return SessionCheck() },
+ },
+ }
+}
diff --git a/modules/xclip/cmd/xclip-tools/manifest_kit_test.go b/modules/xclip/cmd/xclip-tools/manifest_kit_test.go
new file mode 100644
index 0000000..3e675b4
--- /dev/null
+++ b/modules/xclip/cmd/xclip-tools/manifest_kit_test.go
@@ -0,0 +1,107 @@
+package main
+
+// manifest_kit_test.go is the same file in each workstation module: it reads the module's
+// definition so the module's own tests can hold it to what it says.
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+ "testing"
+)
+
+type manifest struct {
+ Module string `json:"module"`
+ Capabilities []string `json:"capabilities"`
+ Claims []any `json:"claims"`
+ Seats []any `json:"seats"`
+ Tools []string `json:"tools"`
+ Resources []map[string]any `json:"resources"`
+ Build struct {
+ Artifacts []map[string]any `json:"artifacts"`
+ } `json:"build"`
+}
+
+func readManifest(t *testing.T) manifest {
+ t.Helper()
+ raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ var m manifest
+ if err := json.Unmarshal(raw, &m); err != nil {
+ t.Fatalf("module.json: %v", err)
+ }
+ return m
+}
+
+func (m manifest) resource(id string) map[string]any {
+ for _, r := range m.Resources {
+ if r["id"] == id {
+ return r
+ }
+ }
+ return nil
+}
+
+// packages are the packages the module installs, sorted.
+func (m manifest) packages() []string {
+ out := []string{}
+ for _, r := range m.Resources {
+ if r["type"] == "package" && r["absent"] != true {
+ out = append(out, r["package"].(string))
+ }
+ }
+ sort.Strings(out)
+ return out
+}
+
+// services are the units the module declares, by unit name.
+func (m manifest) services() map[string]map[string]any {
+ out := map[string]map[string]any{}
+ for _, r := range m.Resources {
+ if r["type"] == "service" {
+ out[r["unit"].(string)] = r
+ }
+ }
+ return out
+}
+
+// holdsTheBundle holds the manifest to the Go bundle this directory builds: every tool registered
+// is listed and nothing else, each named _…, and the artifact builds this command.
+func holdsTheBundle(t *testing.T, m manifest, prefix string) {
+ t.Helper()
+ registered := []string{}
+ for _, tool := range tools() {
+ registered = append(registered, tool.Name)
+ if !strings.HasPrefix(tool.Name, prefix+"_") {
+ t.Errorf("tool %s is not named %s_…", tool.Name, prefix)
+ }
+ if tool.Description == "" || tool.Run == nil || tool.Input == nil {
+ t.Errorf("tool %s is not described, runnable and given an input schema", tool.Name)
+ }
+ }
+ if strings.Join(registered, ",") != strings.Join(m.Tools, ",") {
+ t.Errorf("registered %v, listed %v", registered, m.Tools)
+ }
+ if len(m.Build.Artifacts) != 1 {
+ t.Fatalf("one artifact, got %d", len(m.Build.Artifacts))
+ }
+ cwd, _ := os.Getwd()
+ binary := filepath.Base(cwd)
+ a := m.Build.Artifacts[0]
+ want := map[string]any{"kind": "bundle", "language": "go", "system": "arch", "from": "cmd/" + binary, "binary": binary}
+ for k, v := range want {
+ if a[k] != v {
+ t.Errorf("artifact %s = %v, want %v", k, a[k], v)
+ }
+ }
+ if loads, _ := a["loads"].([]any); len(loads) != 1 || loads[0] != binary {
+ t.Errorf("artifact loads %v, want [%s]", a["loads"], binary)
+ }
+ if m.Claims != nil || m.Seats != nil {
+ t.Errorf("claims %v, seats %v: this module holds no seat", m.Claims, m.Seats)
+ }
+}
diff --git a/modules/xclip/cmd/xclip-tools/session.go b/modules/xclip/cmd/xclip-tools/session.go
new file mode 100644
index 0000000..43ba119
--- /dev/null
+++ b/modules/xclip/cmd/xclip-tools/session.go
@@ -0,0 +1,177 @@
+package main
+
+// session.go is the same file in the bundles whose tools act in the operator's graphical session
+// (xclip, dmenu): how a process the node's tool runtime launched reaches that session.
+//
+// The runtime is a system service running as the operator account (novox/hq ADR 0175 §4), in the
+// machine's own mount namespace, and is given no session words: no DISPLAY, no XAUTHORITY. An X
+// server accepts a client that names its display and presents the cookie in the authority file, and
+// both are the account's: the display's socket is in /tmp/.X11-unix, and the cookie file is
+// readable by the account. So the session is found, not configured:
+//
+// 1. the process's own DISPLAY, when the runtime happens to have one;
+// 2. else the DISPLAY and XAUTHORITY of the account's own running processes, read from
+// /proc//environ (the window manager's, by preference), whose socket exists;
+// 3. else the only X socket there is, with the authority file in the account's home.
+//
+// When none is found the tool says that no graphical session of the account is running, and does
+// nothing.
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "sort"
+ "strconv"
+ "strings"
+ "syscall"
+)
+
+// Session is the operator's X session as a tool reaches it.
+type Session struct {
+ Display string `json:"display"`
+ XAuthority string `json:"xauthority,omitempty"`
+ // FoundBy says how: "environment", "process ()" or "socket".
+ FoundBy string `json:"found_by"`
+}
+
+// Env is what a command needs to reach the session.
+func (s Session) Env() []string {
+ env := []string{"DISPLAY=" + s.Display}
+ if s.XAuthority != "" {
+ env = append(env, "XAUTHORITY="+s.XAuthority)
+ }
+ return env
+}
+
+// Where the session is looked for. Tests point these at a tree of their own.
+var (
+ procRoot = "/proc"
+ x11Sockets = "/tmp/.X11-unix"
+ getenv = os.Getenv
+ myUID = os.Getuid
+)
+
+// sessionWMs are the programs whose environment is the session's own, preferred over any other
+// process's (a terminal's child may carry a stale or forwarded DISPLAY).
+var sessionWMs = map[string]bool{"i3": true, "sway": true, "xinit": true, "i3bar": true, "picom": true, "dunst": true}
+
+func accountHome() string {
+ if h := strings.TrimSpace(getenv("MESH_OPERATOR_HOME")); h != "" {
+ return h
+ }
+ if h := strings.TrimSpace(getenv("HOME")); h != "" {
+ return h
+ }
+ h, _ := os.UserHomeDir()
+ return h
+}
+
+// socketOf is the local socket of a display such as ":1" or ":1.0", or "" for a remote one.
+func socketOf(display string) string {
+ if !strings.HasPrefix(display, ":") {
+ return ""
+ }
+ n := strings.TrimPrefix(display, ":")
+ if i := strings.IndexByte(n, '.'); i >= 0 {
+ n = n[:i]
+ }
+ if _, err := strconv.Atoi(n); err != nil {
+ return ""
+ }
+ return filepath.Join(x11Sockets, "X"+n)
+}
+
+func exists(p string) bool {
+ _, err := os.Stat(p)
+ return err == nil
+}
+
+// findSession answers the account's X session, or an error saying there is none.
+func findSession() (Session, error) {
+ if d := strings.TrimSpace(getenv("DISPLAY")); d != "" {
+ if s := socketOf(d); s == "" || exists(s) {
+ return Session{Display: d, XAuthority: getenv("XAUTHORITY"), FoundBy: "environment"}, nil
+ }
+ }
+ type seen struct {
+ Session
+ wm bool
+ count int
+ }
+ found := map[string]*seen{}
+ entries, _ := os.ReadDir(procRoot)
+ for _, e := range entries {
+ pid, err := strconv.Atoi(e.Name())
+ if err != nil || !e.IsDir() {
+ continue
+ }
+ dir := filepath.Join(procRoot, e.Name())
+ info, err := os.Stat(dir)
+ if err != nil {
+ continue
+ }
+ if st, ok := info.Sys().(*syscall.Stat_t); !ok || int(st.Uid) != myUID() {
+ continue
+ }
+ raw, err := os.ReadFile(filepath.Join(dir, "environ"))
+ if err != nil {
+ continue
+ }
+ var display, auth string
+ for _, kv := range strings.Split(string(raw), "\x00") {
+ switch {
+ case strings.HasPrefix(kv, "DISPLAY="):
+ display = strings.TrimPrefix(kv, "DISPLAY=")
+ case strings.HasPrefix(kv, "XAUTHORITY="):
+ auth = strings.TrimPrefix(kv, "XAUTHORITY=")
+ }
+ }
+ if display == "" {
+ continue
+ }
+ if s := socketOf(display); s == "" || !exists(s) {
+ continue
+ }
+ comm, _ := os.ReadFile(filepath.Join(dir, "comm"))
+ name := strings.TrimSpace(string(comm))
+ key := display + "\x00" + auth
+ if found[key] == nil {
+ found[key] = &seen{Session: Session{Display: display, XAuthority: auth, FoundBy: fmt.Sprintf("process %d (%s)", pid, name)}}
+ }
+ f := found[key]
+ f.count++
+ if sessionWMs[name] && !f.wm {
+ f.wm = true
+ f.FoundBy = fmt.Sprintf("process %d (%s)", pid, name)
+ }
+ }
+ if len(found) > 0 {
+ all := make([]*seen, 0, len(found))
+ for _, f := range found {
+ all = append(all, f)
+ }
+ sort.Slice(all, func(i, k int) bool {
+ if all[i].wm != all[k].wm {
+ return all[i].wm
+ }
+ if all[i].count != all[k].count {
+ return all[i].count > all[k].count
+ }
+ return all[i].Display < all[k].Display
+ })
+ return all[0].Session, nil
+ }
+ sockets, _ := filepath.Glob(filepath.Join(x11Sockets, "X*"))
+ if len(sockets) == 1 {
+ s := Session{Display: ":" + strings.TrimPrefix(filepath.Base(sockets[0]), "X"), FoundBy: "socket"}
+ if a := filepath.Join(accountHome(), ".Xauthority"); exists(a) {
+ s.XAuthority = a
+ }
+ return s, nil
+ }
+ if len(sockets) > 1 {
+ return Session{}, fmt.Errorf("no process of this account names its X display, and there are %d X sockets in %s: which one is the operator's session cannot be told", len(sockets), x11Sockets)
+ }
+ return Session{}, fmt.Errorf("no graphical session of this account is running on this machine: no process of the account has DISPLAY set, and there is no X socket in %s. A desktop tool acts only while the operator is logged in to the graphical session", x11Sockets)
+}
diff --git a/modules/xclip/cmd/xclip-tools/session_test.go b/modules/xclip/cmd/xclip-tools/session_test.go
new file mode 100644
index 0000000..707e754
--- /dev/null
+++ b/modules/xclip/cmd/xclip-tools/session_test.go
@@ -0,0 +1,94 @@
+package main
+
+import (
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "testing"
+)
+
+// aMachine gives findSession a /proc and an X socket directory of the test's own.
+func aMachine(t *testing.T, env map[string]string) (proc, sockets string) {
+ t.Helper()
+ root := t.TempDir()
+ proc, sockets = filepath.Join(root, "proc"), filepath.Join(root, "x11")
+ for _, d := range []string{proc, sockets} {
+ if err := os.MkdirAll(d, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ }
+ wasProc, wasX, wasEnv := procRoot, x11Sockets, getenv
+ procRoot, x11Sockets = proc, sockets
+ getenv = func(k string) string { return env[k] }
+ t.Cleanup(func() { procRoot, x11Sockets, getenv = wasProc, wasX, wasEnv })
+ return proc, sockets
+}
+
+func aProcess(t *testing.T, proc string, pid int, comm string, env ...string) {
+ t.Helper()
+ dir := filepath.Join(proc, strconv.Itoa(pid))
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ _ = os.WriteFile(filepath.Join(dir, "comm"), []byte(comm+"\n"), 0o644)
+ _ = os.WriteFile(filepath.Join(dir, "environ"), []byte(strings.Join(env, "\x00")+"\x00"), 0o644)
+}
+
+func aSocket(t *testing.T, dir, name string) {
+ t.Helper()
+ if err := os.WriteFile(filepath.Join(dir, name), nil, 0o644); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestSessionTheWindowManagersDisplayAndCookieAreTheSessions(t *testing.T) {
+ proc, sockets := aMachine(t, map[string]string{"MESH_OPERATOR_HOME": "/home/op"})
+ aSocket(t, sockets, "X1")
+ aProcess(t, proc, 3, "bash", "DISPLAY=:9", "XAUTHORITY=/stale")
+ aProcess(t, proc, 4, "kitty", "DISPLAY=:1", "XAUTHORITY=/home/op/.Xauthority")
+ aProcess(t, proc, 5, "i3", "DISPLAY=:1.0", "XAUTHORITY=/home/op/.Xauthority")
+ aProcess(t, proc, 6, "sshd", "PATH=/bin")
+ s, err := findSession()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if s.Display != ":1.0" || s.XAuthority != "/home/op/.Xauthority" || !strings.Contains(s.FoundBy, "i3") {
+ t.Fatalf("%+v: a display without a socket (:9) is skipped, and the window manager's is preferred", s)
+ }
+ if got := strings.Join(s.Env(), " "); got != "DISPLAY=:1.0 XAUTHORITY=/home/op/.Xauthority" {
+ t.Fatalf("env %s", got)
+ }
+}
+
+func TestSessionTheOnlySocketWithTheHomesCookieIsTheFallback(t *testing.T) {
+ home := t.TempDir()
+ _ = os.WriteFile(filepath.Join(home, ".Xauthority"), []byte("c"), 0o600)
+ _, sockets := aMachine(t, map[string]string{"MESH_OPERATOR_HOME": home})
+ aSocket(t, sockets, "X0")
+ s, err := findSession()
+ if err != nil || s.Display != ":0" || s.XAuthority != filepath.Join(home, ".Xauthority") || s.FoundBy != "socket" {
+ t.Fatalf("%+v %v", s, err)
+ }
+}
+
+func TestSessionNoSessionIsSaidNotGuessed(t *testing.T) {
+ _, sockets := aMachine(t, map[string]string{})
+ if _, err := findSession(); err == nil || !strings.Contains(err.Error(), "no graphical session") {
+ t.Fatalf("none: %v", err)
+ }
+ aSocket(t, sockets, "X0")
+ aSocket(t, sockets, "X1")
+ if _, err := findSession(); err == nil || !strings.Contains(err.Error(), "2 X sockets") {
+ t.Fatalf("two: %v", err)
+ }
+}
+
+func TestSessionTheProcessesOwnDisplayComesFirst(t *testing.T) {
+ _, sockets := aMachine(t, map[string]string{"DISPLAY": ":2", "XAUTHORITY": "/a"})
+ aSocket(t, sockets, "X2")
+ s, err := findSession()
+ if err != nil || s.Display != ":2" || s.FoundBy != "environment" {
+ t.Fatalf("%+v %v", s, err)
+ }
+}
diff --git a/modules/xclip/cmd/xclip-tools/xclip.go b/modules/xclip/cmd/xclip-tools/xclip.go
new file mode 100644
index 0000000..27aa661
--- /dev/null
+++ b/modules/xclip/cmd/xclip-tools/xclip.go
@@ -0,0 +1,160 @@
+package main
+
+import (
+ "encoding/base64"
+ "fmt"
+ "regexp"
+ "strings"
+ "unicode/utf8"
+)
+
+// Selections are the X selections xclip reaches.
+var Selections = []string{"clipboard", "primary", "secondary"}
+
+// MostCopy bounds what a caller may put on the clipboard.
+const MostCopy = 1 << 20
+
+var targetName = regexp.MustCompile(`^[A-Za-z0-9_][A-Za-z0-9_.+/;=-]{0,127}$`)
+
+func checkTarget(t string) error {
+ if t != "" && !targetName.MatchString(t) {
+ return fmt.Errorf("%q is not a type a selection can be offered as", t)
+ }
+ return nil
+}
+
+// xclip runs xclip in the session, naming an unreachable display as such.
+func xclip(s Session, c Cmd) (Result, error) {
+ c.Name, c.Env = "xclip", s.Env()
+ r := run(c)
+ said := r.Stderr + r.Stdout
+ switch {
+ case r.Error != "":
+ return r, failure(c, r)
+ case strings.Contains(said, "Can't open display"):
+ return r, fmt.Errorf("the X session at %s (found by %s) refused the connection: the display is gone, or the cookie in %s is not the server's", s.Display, s.FoundBy, orNone(s.XAuthority))
+ case r.Status != 0:
+ return r, failure(c, r)
+ }
+ return r, nil
+}
+
+func orNone(s string) string {
+ if s == "" {
+ return "(no authority file)"
+ }
+ return s
+}
+
+// CopyAnswer is what xclip_copy answers.
+type CopyAnswer struct {
+ Selection string `json:"selection"`
+ Type string `json:"type"`
+ Bytes int `json:"bytes"`
+ Session Session `json:"session"`
+ Note string `json:"note"`
+}
+
+// Copy puts text on a selection. xclip forks a process that holds the selection until another
+// program takes it, so the command runs detached.
+func Copy(text, selection, typ string) (CopyAnswer, error) {
+ if len(text) > MostCopy {
+ return CopyAnswer{}, fmt.Errorf("text is %d bytes; at most %d are copied", len(text), MostCopy)
+ }
+ if err := checkTarget(typ); err != nil {
+ return CopyAnswer{}, err
+ }
+ s, err := findSession()
+ if err != nil {
+ return CopyAnswer{}, err
+ }
+ args := []string{"-selection", selection, "-in"}
+ if typ != "" {
+ args = append(args, "-t", typ)
+ }
+ if _, err := xclip(s, Cmd{Args: args, Stdin: text, Detached: true}); err != nil {
+ return CopyAnswer{}, err
+ }
+ if typ == "" {
+ typ = "UTF8_STRING"
+ }
+ return CopyAnswer{Selection: selection, Type: typ, Bytes: len(text), Session: s,
+ Note: "xclip holds the selection until another program takes it; a clipboard manager may copy it into its history"}, nil
+}
+
+// PasteAnswer is what xclip_paste answers.
+type PasteAnswer struct {
+ Selection string `json:"selection"`
+ Type string `json:"type"`
+ Empty bool `json:"empty"`
+ Text string `json:"text,omitempty"`
+ Base64 string `json:"base64,omitempty"`
+ Bytes int `json:"bytes"`
+ Truncated bool `json:"truncated,omitempty"`
+}
+
+// Paste reads a selection.
+func Paste(selection, typ string) (PasteAnswer, error) {
+ if err := checkTarget(typ); err != nil {
+ return PasteAnswer{}, err
+ }
+ s, err := findSession()
+ if err != nil {
+ return PasteAnswer{}, err
+ }
+ args := []string{"-selection", selection, "-out"}
+ if typ != "" {
+ args = append(args, "-t", typ)
+ } else {
+ typ = "UTF8_STRING"
+ }
+ out := PasteAnswer{Selection: selection, Type: typ}
+ r, err := xclip(s, Cmd{Args: args})
+ if err != nil {
+ // "Error: target … not available": the selection is empty, or not offered as that type.
+ if strings.Contains(r.Stderr, "not available") {
+ out.Empty = true
+ return out, nil
+ }
+ return PasteAnswer{}, err
+ }
+ out.Bytes, out.Truncated = len(r.Stdout), r.Truncated
+ if utf8.ValidString(r.Stdout) {
+ out.Text = r.Stdout
+ } else {
+ out.Base64 = base64.StdEncoding.EncodeToString([]byte(r.Stdout))
+ }
+ out.Empty = out.Bytes == 0
+ return out, nil
+}
+
+// Targets answers the types a selection is offered as.
+func Targets(selection string) (map[string]any, error) {
+ s, err := findSession()
+ if err != nil {
+ return nil, err
+ }
+ r, err := xclip(s, Cmd{Args: []string{"-selection", selection, "-out", "-t", "TARGETS"}})
+ if err != nil {
+ if strings.Contains(r.Stderr, "not available") {
+ return map[string]any{"selection": selection, "targets": []string{}, "empty": true}, nil
+ }
+ return nil, err
+ }
+ return map[string]any{"selection": selection, "targets": lines(r.Stdout), "empty": strings.TrimSpace(r.Stdout) == ""}, nil
+}
+
+// SessionCheck answers the session and whether its X server answers.
+func SessionCheck() (map[string]any, error) {
+ s, err := findSession()
+ if err != nil {
+ return map[string]any{"found": false, "why": err.Error()}, nil
+ }
+ out := map[string]any{"found": true, "session": s}
+ if _, err := Targets("clipboard"); err != nil {
+ out["answers"], out["why"] = false, err.Error()
+ } else {
+ out["answers"] = true
+ }
+ return out, nil
+}
diff --git a/modules/xclip/cmd/xclip-tools/xclip_test.go b/modules/xclip/cmd/xclip-tools/xclip_test.go
new file mode 100644
index 0000000..2a455c6
--- /dev/null
+++ b/modules/xclip/cmd/xclip-tools/xclip_test.go
@@ -0,0 +1,106 @@
+package main
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestTheManifestIsThePackageAndNothingElse(t *testing.T) {
+ m := readManifest(t)
+ holdsTheBundle(t, m, "xclip")
+ if got := strings.Join(m.packages(), ","); got != "xclip" || len(m.Resources) != 1 {
+ t.Errorf("packages %s, resources %v", got, m.Resources)
+ }
+}
+
+// aSession gives the tools an X session on :1 with the account's cookie.
+func aSession(t *testing.T) {
+ t.Helper()
+ _, sockets := aMachine(t, map[string]string{"DISPLAY": ":1", "XAUTHORITY": "/home/op/.Xauthority"})
+ aSocket(t, sockets, "X1")
+}
+
+func TestCopyRunsDetachedInTheSessionWithTheTextOnItsInput(t *testing.T) {
+ aSession(t)
+ f := using(t, func(string, Cmd) Result { return ok("") })
+ got, err := Copy("hello", "clipboard", "")
+ if err != nil || got.Bytes != 5 || got.Type != "UTF8_STRING" || got.Session.Display != ":1" {
+ t.Fatalf("%+v %v", got, err)
+ }
+ c := f.asked[0]
+ if f.lines()[0] != "xclip -selection clipboard -in" || c.Stdin != "hello" || !c.Detached {
+ t.Errorf("%v %+v", f.lines(), c)
+ }
+ if strings.Join(c.Env, " ") != "DISPLAY=:1 XAUTHORITY=/home/op/.Xauthority" {
+ t.Errorf("env %v", c.Env)
+ }
+ if _, err := Copy("x", "primary", "text/html"); err != nil || f.lines()[1] != "xclip -selection primary -in -t text/html" {
+ t.Errorf("%v %v", f.lines(), err)
+ }
+ if _, err := Copy(strings.Repeat("a", MostCopy+1), "clipboard", ""); err == nil {
+ t.Error("more than 1 MiB")
+ }
+ if _, err := Copy("x", "clipboard", "-o"); err == nil {
+ t.Error("an option as a type")
+ }
+}
+
+func TestPasteAnswersTextBase64OrEmpty(t *testing.T) {
+ aSession(t)
+ answer := Result{Stdout: "some text"}
+ f := using(t, func(string, Cmd) Result { return answer })
+ got, err := Paste("clipboard", "")
+ if err != nil || got.Text != "some text" || got.Bytes != 9 || got.Empty {
+ t.Fatalf("%+v %v", got, err)
+ }
+ if f.lines()[0] != "xclip -selection clipboard -out" || f.asked[0].Detached {
+ t.Errorf("%v", f.lines())
+ }
+ answer = Result{Stdout: "\x89PNG\r\n\x1a\n\xff"}
+ got, _ = Paste("clipboard", "image/png")
+ if got.Text != "" || got.Base64 == "" {
+ t.Errorf("binary: %+v", got)
+ }
+ answer = Result{Status: 1, Stderr: "Error: target UTF8_STRING not available\n"}
+ got, err = Paste("clipboard", "")
+ if err != nil || !got.Empty {
+ t.Errorf("an empty clipboard is an answer: %+v %v", got, err)
+ }
+}
+
+func TestADisplayThatRefusesIsSaidWithHowItWasFound(t *testing.T) {
+ aSession(t)
+ using(t, func(string, Cmd) Result { return Result{Status: 1, Stderr: "Error: Can't open display: :1\n"} })
+ _, err := Paste("clipboard", "")
+ if err == nil || !strings.Contains(err.Error(), "refused the connection") || !strings.Contains(err.Error(), "environment") {
+ t.Fatalf("%v", err)
+ }
+}
+
+func TestWithoutASessionNothingRunsAndTheToolSaysWhy(t *testing.T) {
+ aMachine(t, map[string]string{})
+ f := using(t, func(string, Cmd) Result { return ok("") })
+ if _, err := Copy("x", "clipboard", ""); err == nil || !strings.Contains(err.Error(), "no graphical session") {
+ t.Fatalf("%v", err)
+ }
+ got, err := SessionCheck()
+ if err != nil || got["found"] != false || !strings.Contains(got["why"].(string), "logged in") {
+ t.Fatalf("%v %v", got, err)
+ }
+ if len(f.asked) != 0 {
+ t.Errorf("nothing runs without a session: %v", f.lines())
+ }
+}
+
+func TestTargetsListWhatASelectionOffers(t *testing.T) {
+ aSession(t)
+ using(t, func(string, Cmd) Result { return ok("TIMESTAMP\nTARGETS\nUTF8_STRING\ntext/html\n") })
+ got, err := Targets("clipboard")
+ if err != nil || len(got["targets"].([]string)) != 4 {
+ t.Fatalf("%v %v", got, err)
+ }
+ s, _ := SessionCheck()
+ if s["answers"] != true {
+ t.Errorf("%v", s)
+ }
+}
diff --git a/modules/xclip/go.mod b/modules/xclip/go.mod
new file mode 100644
index 0000000..9284445
--- /dev/null
+++ b/modules/xclip/go.mod
@@ -0,0 +1,5 @@
+module xclip
+
+go 1.22
+
+require git.novox.be/novox/mesh-sdk/go v0.1.6
diff --git a/modules/xclip/go.sum b/modules/xclip/go.sum
new file mode 100644
index 0000000..0dd6061
--- /dev/null
+++ b/modules/xclip/go.sum
@@ -0,0 +1,2 @@
+git.novox.be/novox/mesh-sdk/go v0.1.6 h1:9qzdYONYbJdWcu6sxQcq9v1LI0JxcfkiKYkMUzJSkVQ=
+git.novox.be/novox/mesh-sdk/go v0.1.6/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
diff --git a/modules/xclip/module.json b/modules/xclip/module.json
new file mode 100644
index 0000000..bf06f6f
--- /dev/null
+++ b/modules/xclip/module.json
@@ -0,0 +1,35 @@
+{
+ "module": "xclip",
+ "version": "1",
+ "capabilities": [
+ "package-manager"
+ ],
+ "tools": [
+ "xclip_copy",
+ "xclip_paste",
+ "xclip_targets",
+ "xclip_session"
+ ],
+ "resources": [
+ {
+ "id": "package",
+ "type": "package",
+ "package": "xclip"
+ }
+ ],
+ "build": {
+ "artifacts": [
+ {
+ "name": "tools",
+ "kind": "bundle",
+ "language": "go",
+ "system": "arch",
+ "from": "cmd/xclip-tools",
+ "binary": "xclip-tools",
+ "loads": [
+ "xclip-tools"
+ ]
+ }
+ ]
+ }
+}